@walkhi/code-relax 0.1.0-beta.1 → 0.1.0-beta.11

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.
Files changed (80) hide show
  1. package/README.md +53 -64
  2. package/dist/bin/codex-remote.mjs +90 -71
  3. package/dist/bin/self-relay-admin.mjs +8 -0
  4. package/dist/bin/self-relay-demo.mjs +1 -1
  5. package/dist/bin/self-relay-server.mjs +5 -2
  6. package/dist/shared/app-server-events.cjs +64 -24
  7. package/dist/shared/file-mentions.cjs +35 -0
  8. package/dist/shared/p2p-data-channel.cjs +50 -0
  9. package/dist/skills/code-relax/SKILL.md +1 -1
  10. package/dist/src/desktop-launcher.mjs +16 -21
  11. package/dist/src/file-upload-session.mjs +124 -0
  12. package/dist/src/lan-socket-server.mjs +59 -10
  13. package/dist/src/managed-app-server.mjs +36 -14
  14. package/dist/src/message-images.mjs +0 -18
  15. package/dist/src/onboarding.mjs +6 -8
  16. package/dist/src/platform/README.md +1 -1
  17. package/dist/src/platform/windows/IsolatedProcess.cs +7 -3
  18. package/dist/src/platform/windows/background-process.mjs +7 -2
  19. package/dist/src/platform/windows/codex-runtime.mjs +117 -0
  20. package/dist/src/platform/windows/desktop-host.mjs +17 -3
  21. package/dist/src/platform/windows/desktop-info.ps1 +2 -4
  22. package/dist/src/platform/windows/desktop-shortcuts.ps1 +7 -14
  23. package/dist/src/platform/windows/launch-worker.mjs +42 -0
  24. package/dist/src/platform/windows/resident-startup.ps1 +58 -18
  25. package/dist/src/platform/windows/service-host.mjs +4 -16
  26. package/dist/src/platform/windows/start-hidden-console.ps1 +18 -8
  27. package/dist/src/platform/windows/stop-desktop.ps1 +7 -4
  28. package/dist/src/runtime-discovery.mjs +43 -0
  29. package/dist/src/self-relay/admin-cloud.mjs +101 -0
  30. package/dist/src/self-relay/admin-page.mjs +71 -0
  31. package/dist/src/self-relay/admin-server.mjs +61 -0
  32. package/dist/src/self-relay/admin-state.mjs +122 -0
  33. package/dist/src/self-relay/client.mjs +2 -0
  34. package/dist/src/self-relay/connector.mjs +107 -20
  35. package/dist/src/self-relay/demo.mjs +4 -5
  36. package/dist/src/self-relay/lifecycle.mjs +58 -70
  37. package/dist/src/self-relay/p2p-probe.mjs +123 -83
  38. package/dist/src/self-relay/server.mjs +224 -29
  39. package/dist/src/server.mjs +616 -1299
  40. package/dist/src/service-doctor.mjs +14 -26
  41. package/dist/src/service-install.mjs +1 -4
  42. package/dist/src/service-lifecycle.mjs +12 -12
  43. package/dist/src/shared-app-server.mjs +206 -46
  44. package/dist/src/shared-catalog.mjs +5 -25
  45. package/dist/src/shared-recovery.mjs +11 -9
  46. package/dist/src/shared-thread-stream.mjs +1 -1
  47. package/dist/src/skill-install.mjs +0 -13
  48. package/package.json +16 -18
  49. package/tools/postinstall.mjs +140 -3
  50. package/dist/src/app-server-client.mjs +0 -468
  51. package/dist/src/app-server-tasks.mjs +0 -355
  52. package/dist/src/debug-ui.mjs +0 -131
  53. package/dist/src/platform/windows/desktop-monitor.mjs +0 -34
  54. package/dist/src/platform/windows/desktop-shortcut-run.ps1 +0 -5
  55. package/dist/src/platform/windows/desktop-tools.mjs +0 -48
  56. package/dist/src/platform/windows/launch-worker.ps1 +0 -13
  57. package/dist/src/thread-catalog.mjs +0 -42
  58. package/dist/tools/find-desktop-pipe.mjs +0 -10
  59. package/dist/web/approval-ui.js +0 -188
  60. package/dist/web/capabilities.js +0 -57
  61. package/dist/web/chat-transport.js +0 -196
  62. package/dist/web/chat.css +0 -736
  63. package/dist/web/chat.js +0 -5149
  64. package/dist/web/client-platform.js +0 -11
  65. package/dist/web/harmony-platform.js +0 -51
  66. package/dist/web/highlight-powershell.min.js +0 -39
  67. package/dist/web/highlight.min.js +0 -1262
  68. package/dist/web/history-cache.js +0 -146
  69. package/dist/web/index.html +0 -222
  70. package/dist/web/lan-session.js +0 -122
  71. package/dist/web/p2p-diagnostics.js +0 -28
  72. package/dist/web/p2p-probe.js +0 -83
  73. package/dist/web/resources.json +0 -1
  74. package/dist/web/self-relay-session.js +0 -119
  75. package/dist/web/server-requests.js +0 -67
  76. package/dist/web/timeline-sync.js +0 -67
  77. package/dist/web/vendor/README.md +0 -14
  78. package/dist/web/vendor/highlight-powershell.min.js +0 -39
  79. package/dist/web/vendor/highlight.LICENSE.txt +0 -29
  80. package/dist/web/vendor/highlight.min.js +0 -1262
@@ -1,151 +1,131 @@
1
1
  import crypto from 'node:crypto';
2
2
  import fs from 'node:fs';
3
3
  import http from 'node:http';
4
- import net from 'node:net';
5
4
  import path from 'node:path';
6
5
  import os from 'node:os';
7
6
  import { fileURLToPath } from 'node:url';
8
- import vm from 'node:vm';
9
- import { attachLanSocket } from './lan-socket-server.mjs';
10
- import { validateInlineImage, persistLegacyImage } from './message-images.mjs';
7
+ import vm from 'node:vm';
8
+ import sharp from 'sharp';
9
+ import { attachLanSocket } from './lan-socket-server.mjs';
10
+ import fileMentions from '../shared/file-mentions.cjs';
11
+ import { validateInlineImage } from './message-images.mjs';
11
12
  import { ThreadWriterConflictError } from './shared-app-server.mjs';
12
13
  import { ManagedAppServer, managedAppServerUrl } from './managed-app-server.mjs';
14
+ import { RuntimeDiscovery } from './runtime-discovery.mjs';
13
15
  import { streamSharedThread } from './shared-thread-stream.mjs';
14
- import { AppServerClient } from './app-server-client.mjs';
15
- import { AppServerTaskStore, AppServerTaskTransport } from './app-server-tasks.mjs';
16
- import { supplementThreadCatalog } from './thread-catalog.mjs';
17
16
  import appServerEvents from '../shared/app-server-events.cjs';
18
17
  import { runtimePaths } from './platform/windows/runtime-paths.mjs';
19
18
  import { openControl } from './service-control.mjs';
20
19
  import { writeRecord, readRecord } from './service-lifecycle.mjs';
21
20
  import { ExecutionHealth } from './execution-health.mjs';
22
- import { activeDebugPort, serveDebugResource } from './debug-ui.mjs';
23
21
  import { authorizeLanDevice } from './self-relay/lan-authorizations.mjs';
24
22
  import { generateAndSetThreadTitle } from './thread-title-generation.mjs';
25
- import { DesktopMonitor } from './platform/windows/desktop-monitor.mjs';
26
23
  import { desktopHost } from './platform/windows/desktop-host.mjs';
27
24
  import { SharedCatalog } from './shared-catalog.mjs';
28
25
  import { requestRecovery, recoveryStatus } from './shared-recovery.mjs';
29
26
  import { requestServiceRestart } from './service-restart.mjs';
30
27
 
31
- const MAX_FRAME_BYTES = 32 * 1024 * 1024;
32
28
  const MAX_ATTACHMENT_BYTES = 16 * 1024 * 1024;
33
29
  const MAX_CACHED_ATTACHMENTS = 32;
34
30
  const MAX_CACHED_FILES = 64;
35
31
  const MAX_CONTEXT_TAIL_BYTES = 4 * 1024 * 1024;
36
- const MAX_LIVE_ACTIVITY_TAIL_BYTES = 1024 * 1024;
32
+ const MAX_LIVE_ACTIVITY_TAIL_BYTES = 1024 * 1024;
33
+ const SHARED_HISTORY_PAGE_BYTES = 500 * 1024;
34
+ const SHARED_HISTORY_PAGE_TURNS = 8;
37
35
  const MAX_SETTINGS_SCAN_BYTES = 64 * 1024 * 1024;
38
- const ALLOWED_MODELS = new Set([
39
- 'gpt-6-astra',
36
+ const ALLOWED_MODELS = new Set([
37
+ 'gpt-6-astra',
38
+ 'gpt-6-sol',
40
39
  'gpt-5.6-sol',
41
40
  'gpt-5.6-terra',
42
41
  'gpt-5.6-luna',
43
42
  'gpt-5.5',
44
43
  'gpt-5.3-codex-spark',
45
44
  ]);
46
- const ALLOWED_THINKING = new Set(['low', 'medium', 'high', 'xhigh', 'max', 'ultra']);
47
- const ALLOWED_TOOLS = new Set([
48
- 'create_thread',
49
- 'get_usage_limits',
50
- 'list_projects',
51
- 'list_threads',
52
- 'navigate_to_codex_page',
53
- 'read_thread',
54
- 'send_message_to_thread',
55
- 'wait_threads',
56
- ]);
57
-
58
- const options = parseOptions(process.argv.slice(2));
59
- const pipePath = process.env.CODEX_APP_TOOLS_PIPE_PATH?.trim();
60
- let sourceThreadId = (process.env.CODEX_THREAD_ID || process.env.CODEX_SESSION_ID)?.trim();
45
+ const ALLOWED_THINKING = new Set(['low', 'medium', 'high', 'xhigh', 'max', 'ultra']);
46
+
47
+ class RequestError extends Error {
48
+ constructor(statusCode, message, details) {
49
+ super(message);
50
+ this.statusCode = statusCode;
51
+ this.details = details;
52
+ this.code = details?.code;
53
+ }
54
+ }
55
+
56
+ function readRuntimeVersion() {
57
+ let directory = path.dirname(fileURLToPath(import.meta.url));
58
+ while (true) {
59
+ const manifest = readRecord(path.join(directory, 'package.json'));
60
+ if (manifest?.name === '@walkhi/code-relax') return manifest.version || '未知';
61
+ const parent = path.dirname(directory);
62
+ if (parent === directory) return '未知';
63
+ directory = parent;
64
+ }
65
+ }
66
+
67
+ const options = parseOptions(process.argv.slice(2));
68
+ const runtimeVersion = readRuntimeVersion();
61
69
 
62
70
  const webRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'web');
63
- const repositoryRoot = path.resolve(webRoot, '..', '..');
64
71
  const projectRoot = os.homedir();
65
72
  const { codexDataRoot, stateRoot, sessionsRoot, uploadsRoot } = runtimePaths();
66
73
  const markdownItScript = fs.readFileSync(path.join(webRoot, 'vendor', 'markdown-it.umd.min.js'));
67
74
  const markdownSandbox = { atob };
68
75
  vm.runInNewContext(markdownItScript.toString(), markdownSandbox);
69
76
  const imageMarkdown = markdownSandbox.markdownit({ html: false });
70
- let nextRequestId = 0;
71
- const attachmentCache = new Map();
72
- const fileCache = new Map();
73
- const fileCapabilitySecret = crypto.randomBytes(32);
74
- const legacyUploadsRoot = path.resolve(webRoot, '..', 'uploads');
75
- const allowedUploadsRoots = [uploadsRoot, legacyUploadsRoot,
76
- ...JSON.parse(process.env.CODEX_REMOTE_LEGACY_UPLOADS_ROOTS || '[]')];
77
+ const attachmentCache = new Map();
78
+ const fileCache = new Map();
79
+ const fileCapabilitySecret = crypto.randomBytes(32);
80
+ const imageCapabilityKey = crypto.createHash('sha256')
81
+ .update('code-relax-image-capability\0').update(options.token || fileCapabilitySecret).digest();
77
82
  const sessionPathCache = new Map();
78
83
  const sessionSettingsCache = new Map();
79
84
  const liveActivityCache = new Map();
80
- const messageQueues = new Map();
81
85
  let sharedServer = null;
82
- const desktopMonitor = new DesktopMonitor({
83
- read: (tool, args, pipe) => callTool(tool, args, 5000, pipe),
84
- });
85
86
  const managedAppServer = new ManagedAppServer({ stateRoot, host: desktopHost });
87
+ const runtimeDiscovery = new RuntimeDiscovery({ stateRoot, host: desktopHost, url: managedAppServerUrl });
86
88
  let sharedCatalog = null, executionMode = 'ordinary';
87
89
  function selectSharedExecution(enabled) {
88
90
  const next = enabled ? managedAppServer.client : null;
89
91
  if (sharedServer === next) return;
90
92
  sharedServer = next;
91
- sharedCatalog = sharedServer ? new SharedCatalog(sharedServer, desktopMonitor) : null;
93
+ sharedCatalog = sharedServer ? new SharedCatalog(sharedServer) : null;
92
94
  }
93
95
  const executionHealth = new ExecutionHealth(async () => {
94
96
  await managedAppServer.ensure();
95
- const [desktop, attachedUrl] = await Promise.all([desktopHost.inspect(), desktopHost.sharedServiceUrl()]);
96
- const attached = Boolean(desktop.running && attachedUrl === managedAppServerUrl);
97
97
  const managed = managedAppServer.snapshot();
98
98
  // The managed app-server owns execution. Desktop is another client of the
99
99
  // same service, so closing Desktop must not disable phone writes.
100
100
  selectSharedExecution(managed.ready);
101
101
  executionMode = sharedServer ? 'shared' : 'ordinary';
102
- const basicReady = Boolean(desktop.running && sourceThreadId && desktopMonitor.pipe);
103
102
  const managedRestarting = managed.state === 'restarting';
104
- const ready = !managedRestarting && (sharedServer ? sharedServer.ready === true : basicReady);
103
+ const ready = !managedRestarting && sharedServer?.ready === true;
105
104
  const state = managedRestarting ? 'restarting'
106
- : ready ? (sharedServer ? 'full' : 'basic') : !desktop.running ? 'offline' : 'unavailable';
107
- return { state, ready, canWrite: ready, mode: executionMode,
108
- desktop: { state: !desktop.running ? 'stopped' : attached ? 'managed' : 'ordinary', attachedToManagedServer: attached } };
105
+ : ready ? 'full' : 'unavailable';
106
+ return { state, ready, canWrite: ready, mode: executionMode };
109
107
  });
110
108
  async function refreshExecution() {
111
- sourceThreadId = readRecord(path.join(stateRoot, 'bridge-startup-context.json'))?.sourceThreadId || sourceThreadId;
112
109
  await executionHealth.refresh();
113
110
  return { mode: executionMode };
114
111
  }
115
112
  async function requireExecutionWritable(threadId) {
116
- const recovery = readRecord(path.join(stateRoot, 'shared-recovery.json'));
117
- if (['scheduled', 'running'].includes(recovery?.status)) {
118
- throw new RequestError(409, 'Codex 正在重新启动,当前不能提交写操作。', { code: 'desktop_recovering' });
119
- }
120
113
  const execution = await executionHealth.refresh();
121
114
  if (!execution.canWrite) {
122
- const message = execution.mode === 'shared'
123
- ? 'Codex 内核尚未建立可写连接,请稍后重新检测。'
124
- : 'Codex Desktop 尚未建立基础控制连接,请稍后重新检测。';
125
- throw new RequestError(409, message, { code: 'execution_not_writable', execution });
115
+ throw new RequestError(503, 'Codex 内核未就绪。请重启工作站服务;若仍失败,运行 relax doctor。',
116
+ { code: 'full_control_required', execution });
126
117
  }
127
118
  if (threadId && sharedServer) sharedServer.requireWritable(threadId);
128
119
  return execution;
129
120
  }
130
- managedAppServer.client.on('subscription-error', error => console.error('完整控制任务取消订阅失败:', error.message));
131
- const queueWorkers = new Map();
132
- const appServer = new AppServerClient({ cwd: projectRoot });
133
- const appServerTasks = new AppServerTaskTransport({
134
- client: appServer,
135
- store: new AppServerTaskStore(path.join(stateRoot, 'app-server-tasks.json')),
136
- });
137
- appServer.on('protocol-error', error => console.error(`app-server 协议错误:${error.message}`));
138
- appServer.on('server-request', request => {
139
- console.log(`app-server 等待客户端处理反向请求:${request.method} (${request.id})`);
140
- });
121
+ managedAppServer.client.on('subscription-error', error => console.error('Codex 内核任务取消订阅失败:', error.message));
141
122
 
142
123
  async function handleRequest(request, response) {
143
124
  try {
144
125
  const url = new URL(request.url || '/', `http://${request.headers.host || 'localhost'}`);
145
126
 
146
127
  if (!request.socketRequest) {
147
- if (serveDebugResource(request, response, stateRoot, options.port)) return;
148
- sendJson(response, 404, { error: 'Bridge 调试页面未开启,或请求的资源不存在。' });
128
+ sendJson(response, 404, { error: '此端口不提供网页。' });
149
129
  return;
150
130
  }
151
131
 
@@ -170,7 +150,7 @@ async function handleRequest(request, response) {
170
150
  }
171
151
  if (request.method === 'POST' && url.pathname === '/api/shared-recovery') {
172
152
  const body = await readJsonBody(request);
173
- if (body.confirmed !== true || typeof body.operationId !== 'string') throw new RequestError(400, '请确认重新打开当前 Codex 并启用完整控制后再继续。');
153
+ if (body.confirmed !== true || typeof body.operationId !== 'string') throw new RequestError(400, '请确认重新打开当前 Codex 并接入 Codex 内核后再继续。');
174
154
  sendJson(response, 202, await requestRecovery(stateRoot, body)); return;
175
155
  }
176
156
  if (request.method === 'POST' && url.pathname === '/api/service/restart') {
@@ -192,49 +172,65 @@ async function handleRequest(request, response) {
192
172
  return;
193
173
  }
194
174
 
175
+ if (request.method === 'GET' && url.pathname === '/api/official-remote/status') {
176
+ if (!managedAppServer.client.ready) throw new RequestError(503, 'Codex 内核未就绪,无法读取官方工作站状态。');
177
+ const result = await managedAppServer.client.request('remoteControl/status/read', {});
178
+ sendJson(response, 200, { status: result.status, environmentId: result.environmentId });
179
+ return;
180
+ }
181
+ if (request.method === 'POST' && url.pathname === '/api/official-remote/pairing') {
182
+ if (!managedAppServer.client.ready) throw new RequestError(503, 'Codex 内核未就绪,无法生成官方配对码。');
183
+ const status = await managedAppServer.client.request('remoteControl/status/read', {});
184
+ if (status.status !== 'connected') throw new RequestError(409, '当前 Codex 内核未连接 OpenAI Remote,无法配对。');
185
+ const result = await managedAppServer.client.request('remoteControl/pairing/start', { manualCode: true });
186
+ if (!result.manualPairingCode || !result.environmentId) throw new RequestError(502, 'Codex 内核未返回有效的官方配对码。');
187
+ // One-time code: return only to the authenticated station controller; never persist or log it.
188
+ sendJson(response, 200, { manualPairingCode: result.manualPairingCode,
189
+ environmentId: result.environmentId, expiresAt: result.expiresAt });
190
+ return;
191
+ }
192
+
195
193
  if (request.method === 'GET' && url.pathname === '/api/health') {
196
194
  // Status readers consume the workstation's cached sample. A phone query
197
195
  // must not trigger PowerShell, process-identity, or app-server checks.
198
- const recovery = readRecord(path.join(stateRoot, 'shared-recovery.json'));
199
- const recovering = ['scheduled', 'running'].includes(recovery?.status);
200
- sendJson(response, 200, {
201
- status: 'ok', resident: true,
202
- execution: { ...executionHealth.state, ready: executionHealth.state.ready && (!sharedServer || sharedServer.ready),
203
- ...(recovering ? { state: 'recovering' } : {}), mode: executionMode },
196
+ sendJson(response, 200, {
197
+ status: 'ok', resident: true, version: runtimeVersion,
198
+ execution: { ...executionHealth.state, ready: executionHealth.state.ready && sharedServer?.ready === true,
199
+ mode: executionMode },
204
200
  managedAppServer: managedAppServer.snapshot(),
205
- desktop: executionHealth.state.desktop || { state: 'stopped', attachedToManagedServer: false },
206
- desktopRestartRecommended: executionHealth.state.desktop?.state === 'ordinary' && managedAppServer.snapshot().ready,
207
- recovery: recovery ? { operationId: recovery.operationId, status: recovery.status, stage: recovery.stage, error: recovery.error } : null,
208
- debugUiPort: await activeDebugPort(stateRoot, options.port),
209
- desktopConnected: Boolean(desktopMonitor.pipe),
210
- sharedAppServerConnected: sharedServer?.ready === true,
211
- sharedRecoveryAvailable: true,
212
- desktopOrderingReady: Boolean(desktopMonitor.snapshot),
213
- toolCount: 0,
201
+ ...runtimeDiscovery.state,
202
+ sharedAppServerConnected: sharedServer?.ready === true,
203
+ sharedRecoveryAvailable: runtimeDiscovery.state.desktop?.installed === true,
214
204
  mode: options.lan ? 'lan' : 'loopback',
215
205
  tokenRequired: Boolean(options.token),
216
206
  nativeImageTransport: sharedServer ? 'shared-app-server' : null,
217
- transportMode: sharedServer ? 'lan-shared' : 'lan-legacy',
218
- appServer: {
219
- state: appServer.state,
220
- cliVersion: appServer.cliVersion,
221
- taskCount: appServerTasks.store.list().length,
222
- },
207
+ transportMode: sharedServer?.ready === true ? 'lan-shared' : 'unavailable',
223
208
  });
224
209
  return;
225
- }
226
-
227
-
228
- const imageMatch = /^\/api\/images\/([a-f0-9]{64})$/.exec(url.pathname);
229
- if (request.method === 'GET' && imageMatch) {
230
- const image = attachmentCache.get(imageMatch[1]);
231
- if (!image) throw new RequestError(404, '图片缓存已失效,请重新读取对话。');
232
- sendJson(response, 200, { url: `data:${image.mimeType};base64,${image.buffer.toString('base64')}` });
210
+ }
211
+
212
+ const imageMatch = /^\/api\/images\/([a-f0-9]{64})$/.exec(url.pathname);
213
+ if (request.method === 'GET' && imageMatch) {
214
+ let image = attachmentCache.get(imageMatch[1])
215
+ || recoverLocalImage(imageMatch[1], url.searchParams.get('recovery') || '');
216
+ const contextThreadId = url.searchParams.get('threadId') || '';
217
+ const contextTurnId = url.searchParams.get('turnId') || '';
218
+ if (!image && sharedServer && contextThreadId && contextTurnId
219
+ && contextThreadId.length <= 128 && contextTurnId.length <= 128) {
220
+ try {
221
+ normalizeTurn(await sharedServer.turnContext(contextThreadId, contextTurnId));
222
+ image = attachmentCache.get(imageMatch[1]);
223
+ } catch {}
224
+ }
225
+ if (!image) throw new RequestError(404, '这张图片已失效,无法从工作站重新读取。');
226
+ const thumbnail = url.searchParams.get('variant') === 'thumbnail';
227
+ const asset = thumbnail ? await attachmentThumbnail(image) : { buffer: image.buffer, mimeType: image.mimeType };
228
+ sendJson(response, 200, { url: `data:${asset.mimeType};base64,${asset.buffer.toString('base64')}` });
233
229
  return;
234
230
  }
235
231
 
236
232
  const fileMatch = /^\/api\/files\/([a-f0-9]{64})$/.exec(url.pathname);
237
- if (request.method === 'GET' && fileMatch) {
233
+ if (request.method === 'GET' && fileMatch) {
238
234
  const file = fileCache.get(fileMatch[1]);
239
235
  if (!file) throw new RequestError(404, '文件授权已失效,请重新读取对话。');
240
236
  let stat;
@@ -245,67 +241,47 @@ async function handleRequest(request, response) {
245
241
  }
246
242
  if (stat.size > MAX_ATTACHMENT_BYTES) throw new RequestError(413, '暂只支持下载不超过 16 MiB 的文件。');
247
243
  sendJson(response, 200, { name: file.name, size: file.size, dataBase64: fs.readFileSync(file.path).toString('base64') });
248
- return;
249
- }
250
-
251
- if (request.method === 'GET' && url.pathname === '/api/models') {
252
- if (!usesSharedTask({ hostId: url.searchParams.get('hostId') })) throw new RequestError(400, '当前连接不支持即时模型设置。');
253
- sendJson(response, 200, await sharedServer.models());
254
- return;
255
- }
244
+ return;
245
+ }
246
+
247
+ if (url.pathname.startsWith('/api/app-server/') || url.pathname === '/api/wait') {
248
+ throw new RequestError(410, '此连接方式已停用。请检查 Codex 内核状态。', { code: 'unsupported_control' });
249
+ }
250
+ const taskPath = /^\/api\/threads\/([^/]+)/.exec(url.pathname);
251
+ if (taskPath && (url.pathname.endsWith('/takeover') || url.pathname === `/api/threads/${taskPath[1]}`)) {
252
+ throw new RequestError(410, '此连接方式已停用。', { code: 'unsupported_control' });
253
+ }
254
+ if (url.searchParams.has('hostId') && url.searchParams.get('hostId') !== 'local') {
255
+ throw new RequestError(410, '此对话的连接方式已停用,请检查 Codex 内核状态。', { code: 'unsupported_control' });
256
+ }
257
+ if (!executionHealth.state.ready || sharedServer?.ready !== true) {
258
+ throw new RequestError(503, 'Codex 内核未就绪。请重启工作站服务;若仍失败,运行 relax doctor。',
259
+ { code: 'full_control_required' });
260
+ }
261
+
262
+ if (request.method === 'GET' && url.pathname === '/api/models') {
263
+ sendJson(response, 200, await sharedServer.models());
264
+ return;
265
+ }
256
266
 
257
267
  if (request.method === 'GET' && url.pathname === '/api/threads') {
258
268
  const limit = boundedInteger(url.searchParams.get('limit'), 20, 1, 200);
259
269
  const archived = url.searchParams.get('archived') === 'true';
260
- const catalog = sharedCatalog;
261
- const sharedSnapshot = Boolean(catalog);
262
- let desktopCatalog;
263
- if (catalog) desktopCatalog = await catalog.threads(limit, archived);
264
- else {
265
- if (archived) throw new RequestError(400, '当前连接不支持归档对话。请启用完整控制。');
266
- const listing = await callTool('list_threads', { limit: Math.min(limit, 50) });
267
- if (!listing.success) { sendToolResult(response, listing); return; }
268
- const projects = await callTool('list_projects', {});
269
- if (!projects.success) { sendToolResult(response, projects); return; }
270
- const catalogProjects = projects.data.projects || [];
271
- desktopCatalog = { ...supplementThreadCatalog(listing.data, catalogProjects, codexDataRoot, limit),
272
- projects: catalogProjects };
273
- }
274
- let appServerThreads = archived ? [] : appServerTasks.store.list().map(record => ({
275
- id: record.id,
276
- kind: 'codex',
277
- hostId: 'app-server',
278
- title: record.title,
279
- cwd: record.cwd,
280
- projectId: record.projectId,
281
- updatedAt: record.updatedAt,
282
- status: 'unknown',
283
- transport: 'app-server',
284
- }));
285
- if (appServerThreads.length) {
286
- try { appServerThreads = await appServerTasks.listTasks(); } catch {}
287
- }
288
- sendJson(response, 200, mergeThreadCatalog(desktopCatalog, appServerThreads, limit, sharedSnapshot));
270
+ sendJson(response, 200, await sharedCatalog.threads(limit, archived));
289
271
  return;
290
272
  }
291
273
 
292
274
  if (request.method === 'GET' && url.pathname === '/api/projects') {
293
- if (sharedCatalog) {
294
- sendJson(response, 200, { projects: (await sharedCatalog.projects()).projects });
295
- return;
296
- }
297
- const result = await callTool('list_projects', {});
298
- sendToolResult(response, result);
275
+ sendJson(response, 200, { projects: (await sharedCatalog.projects()).projects });
299
276
  return;
300
277
  }
301
278
 
302
279
  if (request.method === 'GET' && url.pathname === '/api/usage-limits') {
303
- if (sharedServer) sendJson(response, 200, await sharedServer.usage());
304
- else sendToolResult(response, await callTool('get_usage_limits', {}));
280
+ sendJson(response, 200, await sharedServer.usage());
305
281
  return;
306
282
  }
307
283
 
308
- if (request.method === 'GET' && url.pathname === '/api/usage-events' && sharedServer) {
284
+ if (request.method === 'GET' && url.pathname === '/api/usage-events') {
309
285
  const execution = sharedServer;
310
286
  response.startEvents();
311
287
  const emit = value => { if (!response.destroyed) response.event('usage', value); };
@@ -317,196 +293,107 @@ async function handleRequest(request, response) {
317
293
  return;
318
294
  }
319
295
 
320
- if (request.method === 'GET' && url.pathname === '/api/app-server/status') {
321
- sendJson(response, 200, appServerBridgeStatus());
322
- return;
323
- }
324
-
325
- if (request.method === 'POST' && url.pathname === '/api/app-server/start') {
326
- await requireExecutionWritable();
327
- await appServer.start();
328
- sendJson(response, 200, appServerBridgeStatus());
329
- return;
330
- }
331
-
332
296
  const createThreadMatch = /^\/api\/projects\/([^/]+)\/threads$/.exec(url.pathname);
333
297
  if (request.method === 'POST' && createThreadMatch) {
334
298
  await requireExecutionWritable();
335
299
  const projectId = decodeURIComponent(createThreadMatch[1]);
336
- const body = createThreadBody(await readJsonBody(request));
337
- let projects = [];
338
- if (body.transport !== 'app-server' || projectId !== '__unassigned__') {
339
- const projectsResult = sharedCatalog
340
- ? { success: true, data: { projects: (await sharedCatalog.projects()).projects } }
341
- : await callTool('list_projects', {});
342
- if (!projectsResult.success) {
343
- sendJson(response, 502, { error: '无法读取 Codex Desktop 项目。', details: projectsResult.data });
344
- return;
345
- }
346
- projects = Array.isArray(projectsResult.data?.projects) ? projectsResult.data.projects : [];
347
- }
348
- if (body.transport === 'app-server') {
349
- const target = appServerProjectTarget(projectId, projects);
350
- const result = await appServerTasks.createTask({
351
- cwd: target.cwd,
352
- projectId: target.projectId,
353
- input: appServerTurnArguments('', body).input,
354
- });
355
- sendJson(response, 201, {
356
- threadId: result.record.id,
357
- hostId: 'app-server',
358
- transport: 'app-server',
359
- turnId: result.turn.id,
360
- });
361
- return;
362
- }
363
- const project = projects.find(item => item.projectId === projectId);
364
- if (usesSharedTask({ hostId:project?.hostId || body.hostId })) {
365
- if (projectId !== '__unassigned__' && !project) throw new RequestError(404, '没有找到对应项目。');
366
- const execution = sharedServer;
367
- await execution.connect();
368
- const rpc = (method, params) => execution.request(method, params);
369
- let target = appServerEvents.projectlessThreadTarget(projectRoot);
370
- if (project) {
371
- const nativeProjects = await appServerEvents.readProjects(rpc);
372
- const nativeId = nativeProjects.find(item => item.id === project.projectId)?.id;
373
- if (!nativeId) throw new RequestError(404, '完整控制中没有找到对应项目,请刷新项目列表。');
374
- target = { projectId:nativeId, cwd:project.path };
375
- }
376
- const { input } = appServerTurnArguments('', body);
377
- const created = await appServerEvents.createThread(rpc, target, input, body);
378
- const prompt = input.filter(part => part.type === 'text').map(part => part.text).join('\n');
379
- void generateAndSetThreadTitle({
380
- request: rpc,
381
- subscribe: listener => {
382
- execution.on('notification', listener);
383
- return () => execution.off('notification', listener);
384
- },
385
- threadId: created.threadId,
386
- prompt,
387
- cwd: target.cwd || null,
388
- }).catch(error => console.error(`生成对话标题失败:${error.message}`));
389
- sendJson(response, 201, { ...created, hostId:'local' });
390
- return;
391
- }
392
- const argumentsValue = await createThreadArguments(projectId, body, projects);
393
- sendToolResult(response, await callTool('create_thread', argumentsValue, 90_000));
394
- return;
300
+ const body = await readJsonBody(request);
301
+ if (body.transport === 'app-server' || (body.hostId && body.hostId !== 'local')) {
302
+ throw new RequestError(410, '此连接方式已停用,请检查 Codex 内核状态。', { code: 'unsupported_control' });
303
+ }
304
+ const projects = (await sharedCatalog.projects()).projects;
305
+ const project = projects.find(item => item.projectId === projectId);
306
+ if (projectId !== '__unassigned__' && !project) throw new RequestError(404, '没有找到对应项目。');
307
+ const execution = sharedServer;
308
+ await execution.connect();
309
+ const rpc = (method, params) => execution.request(method, params);
310
+ let target = appServerEvents.projectlessThreadTarget(projectRoot);
311
+ if (project) {
312
+ const nativeProjects = await appServerEvents.readProjects(rpc);
313
+ const nativeId = nativeProjects.find(item => item.id === project.projectId)?.id;
314
+ if (!nativeId) throw new RequestError(404, 'Codex 内核中没有找到对应项目,请刷新项目列表。');
315
+ target = { projectId:nativeId, cwd:project.path };
316
+ }
317
+ const { input } = appServerTurnArguments('', body);
318
+ const created = await appServerEvents.createThread(rpc, target, input, body);
319
+ const prompt = input.filter(part => part.type === 'text').map(part => part.text).join('\n');
320
+ void generateAndSetThreadTitle({
321
+ request: rpc,
322
+ subscribe: listener => {
323
+ execution.on('notification', listener);
324
+ return () => execution.off('notification', listener);
325
+ },
326
+ threadId: created.threadId,
327
+ prompt,
328
+ cwd: target.cwd || null,
329
+ }).catch(error => console.error(`生成对话标题失败:${error.message}`));
330
+ sendJson(response, 201, { ...created, hostId:'local' });
331
+ return;
395
332
  }
396
333
 
397
- const timelineMatch = /^\/api\/threads\/([^/]+)\/timeline$/.exec(url.pathname);
334
+ const timelineMatch = /^\/api\/threads\/([^/]+)\/timeline$/.exec(url.pathname);
398
335
  if (request.method === 'GET' && timelineMatch) {
399
336
  const threadId = decodeURIComponent(timelineMatch[1]);
400
- if (!appServerTasks.owns(threadId) && usesSharedTask({ hostId: url.searchParams.get('hostId') })) {
401
- const timeline = normalizeSharedTimeline(await sharedServer.timeline(threadId));
402
- const knownUpdatedAt = Number(url.searchParams.get('knownUpdatedAt'));
403
- const knownTurnId = url.searchParams.get('knownTurnId') || '';
404
- const knownTurnStatus = url.searchParams.get('knownTurnStatus') || '';
405
- const latest = timeline.turns.at(-1);
406
- const knownIndex = knownTurnId ? timeline.turns.findIndex(turn => turn.id === knownTurnId) : -1;
407
- const unchanged = knownIndex === timeline.turns.length - 1
408
- && timeline.thread.status === 'idle' && latest?.status !== 'inProgress'
409
- && knownTurnStatus && knownTurnStatus !== 'inProgress'
410
- && (!Number.isFinite(knownUpdatedAt) || knownUpdatedAt <= 0 || timeline.thread.updatedAt === knownUpdatedAt);
411
- const partial = !unchanged && knownIndex >= 0
412
- ? { ...timeline, turns: timeline.turns.slice(knownIndex), partialFromTurnId: knownTurnId }
413
- : timeline;
414
- sendJson(response, 200, unchanged ? { ...timeline, turns: [], notModified: true } : partial);
415
- return;
416
- }
417
- if (appServerTasks.owns(threadId)) {
418
- const thread = await appServerTasks.readTask(threadId, {
419
- includeTurns: true,
420
- turnLimit: boundedInteger(url.searchParams.get('turnLimit'), 10, 1, 10),
421
- });
422
- sendJson(response, 200, normalizeAppServerTimeline(thread));
423
- return;
424
- }
425
- const argumentsValue = readThreadArguments(url, threadId, 10);
426
- const result = await callTool('read_thread', argumentsValue);
427
- if (result.success) {
428
- const timeline = normalizeTimeline(result.data);
429
- enrichTimelineWithLocalActivity(timeline, threadId);
430
- sendJson(response, 200, timeline);
431
- } else {
432
- sendJson(response, 502, { error: 'Codex Desktop 工具返回失败。', details: result.data });
433
- }
434
- return;
435
- }
337
+ const timeline = normalizeSharedTimeline(await sharedServer.timeline(threadId, sharedHistoryPageOptions()));
338
+ const knownUpdatedAt = Number(url.searchParams.get('knownUpdatedAt'));
339
+ const knownTurnId = url.searchParams.get('knownTurnId') || '';
340
+ const knownTurnStatus = url.searchParams.get('knownTurnStatus') || '';
341
+ const knownHistoryView = url.searchParams.get('knownHistoryView') || '';
342
+ const latest = timeline.turns.at(-1);
343
+ const knownIndex = knownTurnId ? timeline.turns.findIndex(turn => turn.id === knownTurnId) : -1;
344
+ const sameHistoryView = knownHistoryView === timeline.historyView;
345
+ const unchanged = sameHistoryView
346
+ && knownIndex === timeline.turns.length - 1
347
+ && timeline.thread.status === 'idle' && latest?.status !== 'inProgress'
348
+ && knownTurnStatus && knownTurnStatus !== 'inProgress'
349
+ && (!Number.isFinite(knownUpdatedAt) || knownUpdatedAt <= 0 || timeline.thread.updatedAt === knownUpdatedAt);
350
+ const partial = !unchanged && sameHistoryView && knownIndex >= 0
351
+ ? { ...timeline, turns: timeline.turns.slice(knownIndex), partialFromTurnId: knownTurnId }
352
+ : timeline;
353
+ sendJson(response, 200, unchanged ? { ...timeline, turns: [], notModified: true } : partial);
354
+ return;
355
+ }
436
356
 
437
357
  const approvalMatch = /^\/api\/threads\/([^/]+)\/approvals$/.exec(url.pathname);
438
358
  if (request.method === 'POST' && approvalMatch) {
439
359
  const threadId = decodeURIComponent(approvalMatch[1]);
440
360
  await requireExecutionWritable(threadId);
441
- if (!usesSharedTask({ hostId: url.searchParams.get('hostId') }) || appServerTasks.owns(threadId)) {
442
- sendJson(response, 409, { error: '当前执行模式不支持手机审批,请在工作站处理。' }); return;
443
- }
444
- const body = await readJsonBody(request);
361
+ const body = await readJsonBody(request);
445
362
  sendJson(response, 200, await sharedServer.respondApproval(threadId, body)); return;
446
363
  }
447
364
 
448
365
  const eventsMatch = /^\/api\/threads\/([^/]+)\/events$/.exec(url.pathname);
449
- if (request.method === 'GET' && eventsMatch) {
450
- const threadId = decodeURIComponent(eventsMatch[1]);
451
- if (!appServerTasks.owns(threadId) && usesSharedTask({ hostId: url.searchParams.get('hostId') })) {
452
- const after = Number(url.searchParams.get('afterSeq'));
453
- const requestedSync = url.searchParams.get('syncId') && url.searchParams.get('syncEpoch') && Number.isSafeInteger(after) && after >= 0
454
- ? { id: url.searchParams.get('syncId'), epoch: url.searchParams.get('syncEpoch'), after } : undefined;
455
- const knownUpdatedAt = Number(url.searchParams.get('knownUpdatedAt'));
456
- const known = url.searchParams.get('knownTurnId') ? {
457
- updatedAt: Number.isFinite(knownUpdatedAt) ? knownUpdatedAt : 0,
458
- turnId: url.searchParams.get('knownTurnId'),
459
- turnStatus: url.searchParams.get('knownTurnStatus') || '',
460
- } : undefined;
461
- await streamSharedThread(response, threadId, sharedServer, normalizeSharedTimeline, requestedSync, known);
462
- return;
463
- }
464
- if (appServerTasks.owns(threadId)) {
465
- await streamAppServerThreadEvents(request, response, threadId);
466
- return;
467
- }
468
- await streamThreadEvents(
469
- request,
470
- response,
471
- threadId,
472
- url.searchParams.get('hostId')?.trim(),
473
- );
474
- return;
475
- }
476
-
477
- const navigateMatch = /^\/api\/threads\/([^/]+)\/navigate$/.exec(url.pathname);
478
- if (request.method === 'POST' && navigateMatch) {
479
- const threadId = decodeURIComponent(navigateMatch[1]);
480
- if (sharedServer && !desktopMonitor.pipe) {
481
- sendJson(response, 200, { opened: false, reason: 'Desktop 工具通道尚未就绪' });
482
- return;
483
- }
484
- if (appServerTasks.owns(threadId)) {
485
- sendJson(response, 200, { opened: false, transport: 'app-server' });
486
- return;
487
- }
488
- sendToolResult(response, await callTool('navigate_to_codex_page', {
489
- threadId,
490
- }, 15_000));
491
- return;
492
- }
366
+ if (request.method === 'GET' && eventsMatch) {
367
+ const threadId = decodeURIComponent(eventsMatch[1]);
368
+ const after = Number(url.searchParams.get('afterSeq'));
369
+ const requestedSync = url.searchParams.get('syncId') && url.searchParams.get('syncEpoch') && Number.isSafeInteger(after) && after >= 0
370
+ ? { id: url.searchParams.get('syncId'), epoch: url.searchParams.get('syncEpoch'), after } : undefined;
371
+ const knownUpdatedAt = Number(url.searchParams.get('knownUpdatedAt'));
372
+ const known = url.searchParams.get('knownTurnId') ? {
373
+ updatedAt: Number.isFinite(knownUpdatedAt) ? knownUpdatedAt : 0,
374
+ turnId: url.searchParams.get('knownTurnId'),
375
+ turnStatus: url.searchParams.get('knownTurnStatus') || '',
376
+ } : undefined;
377
+ await streamSharedThread(response, threadId, sharedServer,
378
+ normalizeSharedTimeline, requestedSync, known);
379
+ return;
380
+ }
493
381
 
494
382
  const contextMatch = /^\/api\/threads\/([^/]+)\/context$/.exec(url.pathname);
495
383
  if (request.method === 'GET' && contextMatch) {
496
384
  const threadId = decodeURIComponent(contextMatch[1]);
497
385
  const current = readContextUsage(threadId);
498
- const native = usesSharedTask({ hostId: url.searchParams.get('hostId') }) ? await sharedServer.readContext(threadId) : {};
386
+ const native = await sharedServer.readContext(threadId);
499
387
  sendJson(response, 200, { ...current, ...(native.model ? { model: native.model, thinking: native.thinking, serviceTier: native.serviceTier,
500
388
  permissionMode: native.permissionMode, permissionModes: native.permissionModes } : {}), ...(native.available ? native : {}) });
501
389
  return;
502
390
  }
503
391
 
504
392
  const sharedAction = /^\/api\/threads\/([^/]+)\/(history|settings|permissions)$/.exec(url.pathname);
505
- if (sharedAction) {
393
+ if (sharedAction) {
506
394
  const id = decodeURIComponent(sharedAction[1]);
507
- if (!usesSharedTask({ hostId: url.searchParams.get('hostId') }) || appServerTasks.owns(id)) throw new RequestError(400, '此连接不支持该操作。');
508
395
  if (request.method === 'GET' && sharedAction[2] === 'history') {
509
- sendJson(response, 200, normalizeSharedTimeline(await sharedServer.history(id, url.searchParams.get('cursor'))));
396
+ sendJson(response, 200, normalizeSharedTimeline(await sharedServer.history(id, url.searchParams.get('cursor'), sharedHistoryPageOptions())));
510
397
  return;
511
398
  }
512
399
  if (request.method === 'POST' && sharedAction[2] === 'settings') {
@@ -520,194 +407,73 @@ async function handleRequest(request, response) {
520
407
  const body = await readJsonBody(request);
521
408
  sendJson(response, 200, await sharedServer.permissions(id, body.mode));
522
409
  return;
523
- }
524
- }
525
-
526
- const archiveMatch = /^\/api\/threads\/([^/]+)\/(archive|unarchive)$/.exec(url.pathname);
410
+ }
411
+ }
412
+
413
+ const sharedOperationGroup = /^\/api\/threads\/([^/]+)\/turns\/([^/]+)\/groups\/([^/]+)\/items$/.exec(url.pathname);
414
+ if (request.method === 'GET' && sharedOperationGroup) {
415
+ const threadId = decodeURIComponent(sharedOperationGroup[1]);
416
+ const turnId = decodeURIComponent(sharedOperationGroup[2]);
417
+ const groupId = decodeURIComponent(sharedOperationGroup[3]);
418
+ const page = await sharedServer.operationGroupItems(threadId, turnId, groupId, url.searchParams.get('offset'));
419
+ sendJson(response, 200, { turnId: page.turnId, groupId: page.groupId,
420
+ entries: page.items.map(normalizeItem).filter(Boolean), nextOffset: page.nextOffset, total: page.total });
421
+ return;
422
+ }
423
+
424
+ const archiveMatch = /^\/api\/threads\/([^/]+)\/(archive|unarchive)$/.exec(url.pathname);
527
425
  if (request.method === 'POST' && archiveMatch) {
528
426
  const threadId = decodeURIComponent(archiveMatch[1]);
529
427
  await requireExecutionWritable(threadId);
530
- if (!usesSharedTask({ hostId: url.searchParams.get('hostId') }) || appServerTasks.owns(threadId)) {
531
- throw new RequestError(400, '当前连接不支持归档对话。请启用完整控制。');
532
- }
533
428
  await sharedServer.request(`thread/${archiveMatch[2]}`, { threadId });
534
429
  sendJson(response, 200, { threadId, archived: archiveMatch[2] === 'archive' });
535
430
  return;
536
431
  }
537
432
 
538
- const readMatch = /^\/api\/threads\/([^/]+)$/.exec(url.pathname);
539
- if (request.method === 'DELETE' && readMatch) {
540
- const threadId = decodeURIComponent(readMatch[1]);
541
- await requireExecutionWritable(threadId);
542
- if (!appServerTasks.owns(threadId)) {
543
- throw new RequestError(400, '仅支持删除由独立 app-server 通道创建的任务。');
544
- }
545
- await appServerTasks.deleteTask(threadId);
546
- messageQueues.delete(threadId);
547
- sendJson(response, 200, { deleted: true, threadId, transport: 'app-server' });
548
- return;
549
- }
550
- if (request.method === 'GET' && readMatch) {
551
- const threadId = decodeURIComponent(readMatch[1]);
552
- if (appServerTasks.owns(threadId)) {
553
- sendJson(response, 200, { thread: await appServerTasks.readTask(threadId, {
554
- includeTurns: true,
555
- turnLimit: boundedInteger(url.searchParams.get('turnLimit'), 10, 1, 10),
556
- }) });
557
- return;
558
- }
559
- const argumentsValue = readThreadArguments(url, threadId, 10, 12_000);
560
- sendToolResult(response, await callTool('read_thread', argumentsValue));
561
- return;
562
- }
563
-
564
- const queueMatch = /^\/api\/threads\/([^/]+)\/queue$/.exec(url.pathname);
565
- if (queueMatch) {
566
- const threadId = decodeURIComponent(queueMatch[1]);
567
- if (!appServerTasks.owns(threadId) && usesSharedTask({ hostId: url.searchParams.get('hostId') })) {
568
- await sharedServer.connect();
569
- const rpc = (method, params) => sharedServer.request(method, params);
570
- if (request.method === 'GET') { sendJson(response, 200, await appServerEvents.readQueue(rpc, threadId)); return; }
571
- if (request.method === 'POST') {
572
- await requireExecutionWritable(threadId);
573
- const args = appServerTurnArguments(threadId, await readJsonBody(request));
574
- sendJson(response, 202, await appServerEvents.addQueue(rpc, threadId, args.input, crypto.randomUUID())); return;
575
- }
576
- }
577
- if (request.method === 'GET') {
578
- sendJson(response, 200, { items: publicQueue(threadId) });
579
- return;
580
- }
433
+ const queueMatch = /^\/api\/threads\/([^/]+)\/queue$/.exec(url.pathname);
434
+ if (queueMatch) {
435
+ const threadId = decodeURIComponent(queueMatch[1]);
436
+ await sharedServer.connect();
437
+ const rpc = (method, params) => sharedServer.request(method, params);
438
+ if (request.method === 'GET') { sendJson(response, 200, await appServerEvents.readQueue(rpc, threadId)); return; }
581
439
  if (request.method === 'POST') {
582
440
  await requireExecutionWritable(threadId);
583
- const body = await readJsonBody(request);
584
- const argumentsValue = appServerTasks.owns(threadId)
585
- ? appServerTurnArguments(threadId, body)
586
- : await messageArguments(threadId, body);
587
- const item = {
588
- id: crypto.randomUUID(),
589
- ...argumentsValue,
590
- transport: appServerTasks.owns(threadId) ? 'app-server' : 'desktop-tools',
591
- displayPrompt: messageDisplayText(body),
592
- text: typeof body.prompt === 'string' ? body.prompt.trim() : '',
593
- attachments: (body.images || []).map(id => ({ type:'image', available:true, url:id.startsWith('data:') ? id : `/api/attachments/${id}`, thumbnailUrl:id.startsWith('data:') ? id : `/api/attachments/${id}/thumbnail` })),
594
- createdAt: new Date().toISOString(),
595
- error: '',
596
- };
597
- const queue = messageQueues.get(threadId) || [];
598
- queue.push(item);
599
- messageQueues.set(threadId, queue);
600
- ensureQueueWorker(threadId);
601
- sendJson(response, 202, { items: publicQueue(threadId) });
602
- return;
603
- }
604
- }
441
+ const args = appServerTurnArguments(threadId, await readJsonBody(request));
442
+ sendJson(response, 202, await appServerEvents.addQueue(rpc, threadId, args.input, crypto.randomUUID())); return;
443
+ }
444
+ }
605
445
 
606
446
  const queueItemMatch = /^\/api\/threads\/([^/]+)\/queue\/([a-f0-9-]+)(?:\/(steer))?$/.exec(url.pathname);
607
447
  if (queueItemMatch) {
608
448
  const threadId = decodeURIComponent(queueItemMatch[1]);
609
449
  const itemId = queueItemMatch[2];
610
- if (!appServerTasks.owns(threadId) && usesSharedTask({ hostId: url.searchParams.get('hostId') })) {
611
- await sharedServer.connect();
612
- const action = request.method === 'DELETE' ? 'delete' : queueItemMatch[3] === 'steer' ? 'steer' : null;
613
- if (!action) throw new RequestError(405, '不支持的队列操作');
614
- await requireExecutionWritable(threadId);
615
- sendJson(response, 200, await appServerEvents.changeQueue((method, params) => sharedServer.request(method, params), threadId, itemId, action)); return;
616
- }
617
- const queue = messageQueues.get(threadId) || [];
618
- const index = queue.findIndex(item => item.id === itemId);
619
- if (index < 0) {
620
- sendJson(response, 404, { error: '没有找到该排队消息。' });
621
- return;
622
- }
623
- if (request.method === 'DELETE' && !queueItemMatch[3]) {
624
- await requireExecutionWritable(threadId);
625
- queue.splice(index, 1);
626
- sendJson(response, 200, { items: publicQueue(threadId) });
627
- return;
628
- }
629
- if (request.method === 'POST' && queueItemMatch[3] === 'steer') {
630
- await requireExecutionWritable(threadId);
631
- const [item] = queue.splice(index, 1);
632
- const result = await steerQueuedItem(threadId, item);
633
- if (!result.success) {
634
- queue.splice(index, 0, item);
635
- sendJson(response, 502, { error: '插队发送失败。', details: result.data });
636
- return;
637
- }
638
- sendJson(response, 200, { items: publicQueue(threadId) });
639
- return;
640
- }
641
- }
642
-
643
- const takeoverMatch = /^\/api\/threads\/([^/]+)\/takeover$/.exec(url.pathname);
644
- if (request.method === 'POST' && takeoverMatch) {
645
- const threadId = decodeURIComponent(takeoverMatch[1]);
450
+ await sharedServer.connect();
451
+ const action = request.method === 'DELETE' ? 'delete' : queueItemMatch[3] === 'steer' ? 'steer' : null;
452
+ if (!action) throw new RequestError(405, '不支持的队列操作');
646
453
  await requireExecutionWritable(threadId);
647
- if (appServerTasks.owns(threadId)) {
648
- sendJson(response, 200, { thread: (await appServerTasks.listTasks()).find(t => t.id === threadId) });
649
- return;
650
- }
651
- if (messageQueues.get(threadId)?.length || queueWorkers.has(threadId)) throw new RequestError(409, '请先处理此聊天的排队消息。');
652
- const result = await callTool('read_thread', { threadId, turnLimit: 1, includeOutputs: false });
653
- if (!result.success) throw new RequestError(502, '无法确认 Desktop 聊天状态。');
654
- const timeline = normalizeTimeline(result.data);
655
- const thread = timeline.thread;
656
- if (thread.hostId && thread.hostId !== 'local') throw new RequestError(400, '只支持本机聊天。');
657
- if (!['idle', 'notLoaded'].includes(thread.status) || timeline.turns.some(t => t.status === 'inProgress')) throw new RequestError(409, '只允许接管已空闲的聊天,请等当前轮次结束。');
658
- if (!findSessionPath(threadId)) throw new RequestError(400, '未找到此聊天的本机历史。');
659
- sendJson(response, 200, { thread: await appServerTasks.adoptTask({ ...thread, id: threadId }) });
660
- return;
661
- }
454
+ sendJson(response, 200, await appServerEvents.changeQueue((method, params) => sharedServer.request(method, params), threadId, itemId, action)); return;
455
+ }
662
456
 
663
- const interruptMatch = /^\/api\/threads\/([^/]+)\/interrupt$/.exec(url.pathname);
457
+ const interruptMatch = /^\/api\/threads\/([^/]+)\/interrupt$/.exec(url.pathname);
664
458
  if (request.method === 'POST' && interruptMatch) {
665
459
  const threadId = decodeURIComponent(interruptMatch[1]);
666
460
  await requireExecutionWritable(threadId);
667
- if (!appServerTasks.owns(threadId) && usesSharedTask({ hostId: url.searchParams.get('hostId') })) {
668
- const body = await readJsonBody(request);
669
- if (!body.turnId) throw new RequestError(400, '缺少活动轮次 ID。');
670
- await sharedServer.interrupt(threadId, body.turnId);
671
- sendJson(response, 200, { requested: true });
672
- return;
673
- }
674
- if (!appServerTasks.owns(threadId)) throw new RequestError(409, '请先接管空闲聊天。');
675
- const body = await readJsonBody(request);
676
- const thread = await appServerTasks.readTask(threadId, { turnLimit: 1 });
677
- const active = thread.turns?.at(-1);
678
- if (!body.turnId || active?.id !== body.turnId || statusText(active.status) !== 'inProgress') throw new RequestError(409, '此轮次已经结束或发生变化。');
679
- // Stop also clears pending messages so the queue cannot restart work.
680
- messageQueues.delete(threadId);
681
- await appServerTasks.interruptTurn(threadId, body.turnId);
682
- sendJson(response, 200, { requested: true });
683
- return;
461
+ const body = await readJsonBody(request);
462
+ if (!body.turnId) throw new RequestError(400, '缺少活动轮次 ID。');
463
+ await sharedServer.interrupt(threadId, body.turnId);
464
+ sendJson(response, 200, { requested: true });
465
+ return;
684
466
  }
685
467
 
686
468
  const messageMatch = /^\/api\/threads\/([^/]+)\/messages$/.exec(url.pathname);
687
469
  if (request.method === 'POST' && messageMatch) {
688
470
  const threadId = decodeURIComponent(messageMatch[1]);
689
471
  await requireExecutionWritable(threadId);
690
- const body = await readJsonBody(request);
691
- if (!appServerTasks.owns(threadId) && usesSharedTask(body)) {
692
- sendJson(response, 200, { success: true, data: await sharedServer.send(appServerTurnArguments(threadId, body)) });
693
- } else if (appServerTasks.owns(threadId)) {
694
- const args = appServerTurnArguments(threadId, body);
695
- const current = await appServerTasks.readTask(threadId, { turnLimit: 1 });
696
- const active = current.turns?.at(-1);
697
- sendJson(response, 200, statusText(active?.status) === 'inProgress'
698
- ? await appServerTasks.steerTurn(threadId, active.id, args.input)
699
- : await appServerTasks.startTurn(threadId, args));
700
- } else {
701
- sendToolResult(response, await callTool('send_message_to_thread', await messageArguments(threadId, body)));
702
- }
703
- return;
704
- }
705
-
706
- if (request.method === 'POST' && url.pathname === '/api/wait') {
707
- const body = await readJsonBody(request);
708
- sendToolResult(response, await callTool('wait_threads', body, 130_000));
709
- return;
710
- }
472
+ const body = await readJsonBody(request);
473
+ if (body.hostId && body.hostId !== 'local') throw new RequestError(410, '此对话的连接方式已停用。', { code: 'unsupported_control' });
474
+ sendJson(response, 200, { success: true, data: await sharedServer.send(appServerTurnArguments(threadId, body)) });
475
+ return;
476
+ }
711
477
 
712
478
  sendJson(response, 404, { error: '未找到请求的资源。' });
713
479
  } catch (error) {
@@ -727,23 +493,15 @@ async function handleRequest(request, response) {
727
493
  }
728
494
 
729
495
  const server = http.createServer(handleRequest);
730
- const lanSocket = attachLanSocket(server, { token: options.token, dispatch: handleRequest,
731
- authorizeDevice: (clientId, credential) => authorizeLanDevice(stateRoot, clientId, credential),
496
+ const lanSocket = attachLanSocket(server, { token: options.token, dispatch: handleRequest,
497
+ uploadsRoot,
498
+ authorizeDevice: (clientId, credential) => authorizeLanDevice(stateRoot, clientId, credential),
732
499
  });
733
500
 
734
- if (options.appServer) {
735
- try {
736
- await appServer.start();
737
- } catch (error) {
738
- console.error(`app-server 启动失败:${error instanceof Error ? error.message : String(error)}`);
739
- process.exit(2);
740
- }
741
- }
742
-
743
501
  let control;
744
- desktopMonitor.start();
745
502
  void managedAppServer.start();
746
503
  executionHealth.start();
504
+ runtimeDiscovery.start();
747
505
  server.on('error', error => {
748
506
  console.error('Bridge 监听失败:', error.message);
749
507
  process.exit(1);
@@ -751,8 +509,14 @@ server.on('error', error => {
751
509
  server.listen(options.port, options.lan ? '0.0.0.0' : '127.0.0.1', async () => {
752
510
  try {
753
511
  if (process.env.CODEX_REMOTE_INSTANCE && process.env.CODEX_REMOTE_CONTROL_SECRET && process.env.CODEX_REMOTE_READY_PATH) {
754
- control = await openControl({ instanceId: process.env.CODEX_REMOTE_INSTANCE,
755
- secret: process.env.CODEX_REMOTE_CONTROL_SECRET, shutdown: shutDown, actions: { 'refresh-execution': refreshExecution } });
512
+ control = await openControl({ instanceId: process.env.CODEX_REMOTE_INSTANCE,
513
+ secret: process.env.CODEX_REMOTE_CONTROL_SECRET, shutdown: shutDown, actions: {
514
+ 'refresh-execution': refreshExecution,
515
+ 'restart-managed-app-server': () => {
516
+ const state = managedAppServer.snapshot();
517
+ return state.launcherRestartRequired ? managedAppServer.restart(crypto.randomUUID()) : state;
518
+ },
519
+ } });
756
520
  writeRecord(process.env.CODEX_REMOTE_READY_PATH, { instanceId: process.env.CODEX_REMOTE_INSTANCE,
757
521
  pid: process.pid, controlPort: control.port });
758
522
  }
@@ -772,147 +536,15 @@ async function shutDown() {
772
536
  shuttingDown = true;
773
537
  lanSocket.close();
774
538
  server.close();
775
- desktopMonitor?.stop();
776
539
  executionHealth?.stop();
540
+ runtimeDiscovery.stop();
777
541
  managedAppServer.close();
778
- await appServer.close();
779
542
  await control?.close();
780
543
  process.exit(0);
781
544
  }
782
545
  process.on('SIGTERM', shutDown);
783
546
  process.on('SIGINT', shutDown);
784
547
 
785
- async function callTool(tool, argumentsValue, timeoutMs = 60_000, targetPipe) {
786
- if (!sourceThreadId) throw new RequestError(503, '基础控制尚未绑定调用上下文;请在 Codex 对话启动一次 Code Relax。电脑管理连接仍可使用。');
787
- if (!ALLOWED_TOOLS.has(tool)) {
788
- throw new RequestError(403, `工具 ${tool} 不在 Bridge 允许列表中。`);
789
- }
790
-
791
- const invocationId = crypto.randomUUID();
792
- const rpc = await callDesktop('tools/call', {
793
- arguments: argumentsValue,
794
- callId: `codex-remote-${invocationId}`,
795
- namespace: 'codex_app',
796
- threadId: sourceThreadId,
797
- tool,
798
- turnId: `codex-remote-turn-${invocationId}`,
799
- }, timeoutMs, targetPipe);
800
-
801
- const result = rpc.result;
802
- if (!result || typeof result.success !== 'boolean') {
803
- throw new BridgeError('Codex Desktop 没有返回有效的工具结果。', rpc);
804
- }
805
-
806
- const items = Array.isArray(result.contentItems) ? result.contentItems : [];
807
- let data = items;
808
- if (items.length === 1 && items[0]?.type === 'inputText' && typeof items[0].text === 'string') {
809
- try {
810
- data = JSON.parse(items[0].text);
811
- } catch {
812
- data = items[0].text;
813
- }
814
- }
815
-
816
- return { success: result.success, data };
817
- }
818
-
819
- function callDesktop(method, params, timeoutMs, targetPipe = desktopMonitor?.pipe || pipePath) {
820
- if (!targetPipe) return Promise.reject(new RequestError(503, 'Desktop 工具通道尚未就绪。'));
821
- return new Promise((resolve, reject) => {
822
- const socket = net.createConnection(targetPipe);
823
- const requestId = ++nextRequestId;
824
- const payload = Buffer.from(JSON.stringify({ jsonrpc: '2.0', id: requestId, method, params }), 'utf8');
825
- const header = Buffer.allocUnsafe(4);
826
- header.writeUInt32LE(payload.length, 0);
827
- let pending = Buffer.alloc(0);
828
- let expectedLength = null;
829
- let settled = false;
830
-
831
- const finish = (callback, value) => {
832
- if (settled) return;
833
- settled = true;
834
- clearTimeout(timer);
835
- socket.destroy();
836
- callback(value);
837
- };
838
-
839
- const timer = setTimeout(() => {
840
- finish(reject, new BridgeError('等待 Codex Desktop 响应超时。'));
841
- }, timeoutMs);
842
-
843
- socket.once('connect', () => socket.write(Buffer.concat([header, payload])));
844
- socket.on('data', chunk => {
845
- pending = Buffer.concat([pending, chunk]);
846
- if (expectedLength === null && pending.length >= 4) {
847
- expectedLength = pending.readUInt32LE(0);
848
- pending = pending.subarray(4);
849
- if (expectedLength <= 0 || expectedLength > MAX_FRAME_BYTES) {
850
- finish(reject, new BridgeError(`Codex Desktop 返回了无效帧长度:${expectedLength}。`));
851
- return;
852
- }
853
- }
854
-
855
- if (expectedLength !== null && pending.length >= expectedLength) {
856
- try {
857
- const responseValue = JSON.parse(pending.subarray(0, expectedLength).toString('utf8'));
858
- if (responseValue.error) {
859
- finish(reject, new BridgeError('Codex Desktop 工具调用失败。', responseValue.error));
860
- } else if (responseValue.id !== requestId) {
861
- finish(reject, new BridgeError('Codex Desktop 返回了不匹配的请求 ID。'));
862
- } else {
863
- finish(resolve, responseValue);
864
- }
865
- } catch (error) {
866
- finish(reject, new BridgeError('无法解析 Codex Desktop 响应。', undefined, error));
867
- }
868
- }
869
- });
870
- socket.once('error', error => finish(reject, new BridgeError(
871
- '无法连接 Codex Desktop Pipe。桌面版可能已重启,请重新启动 Bridge。',
872
- undefined,
873
- error,
874
- )));
875
- socket.once('close', () => {
876
- if (!settled) finish(reject, new BridgeError('Codex Desktop Pipe 在返回结果前关闭。'));
877
- });
878
- });
879
- }
880
-
881
- function sendToolResult(response, result) {
882
- if (result.success) {
883
- sendJson(response, 200, result.data);
884
- } else {
885
- sendJson(response, 502, { error: 'Codex Desktop 工具返回失败。', details: result.data });
886
- }
887
- }
888
-
889
- function appServerBridgeStatus() {
890
- return {
891
- ...appServer.snapshot(),
892
- platformFamily: appServer.initializeResult?.platformFamily || null,
893
- platformOs: appServer.initializeResult?.platformOs || null,
894
- taskCount: appServerTasks.store.list().length,
895
- ownershipError: appServerTasks.store.loadError?.message || null,
896
- };
897
- }
898
-
899
- function readThreadArguments(url, threadId, defaultTurnLimit, defaultOutputLimit = 4_000) {
900
- const argumentsValue = {
901
- threadId,
902
- turnLimit: boundedInteger(url.searchParams.get('turnLimit'), defaultTurnLimit, 1, 10),
903
- includeOutputs: booleanValue(url.searchParams.get('includeOutputs'), true),
904
- maxOutputCharsPerItem: boundedInteger(
905
- url.searchParams.get('maxOutputCharsPerItem'),
906
- defaultOutputLimit,
907
- 500,
908
- 100_000,
909
- ),
910
- };
911
- const hostId = url.searchParams.get('hostId')?.trim();
912
- if (hostId) argumentsValue.hostId = hostId;
913
- return argumentsValue;
914
- }
915
-
916
548
  function readContextUsage(threadId) {
917
549
  if (!/^[0-9a-f]{8}-[0-9a-f-]{20,55}$/i.test(threadId)) {
918
550
  return { available: false };
@@ -1035,108 +667,6 @@ function findSessionPath(threadId) {
1035
667
  return null;
1036
668
  }
1037
669
 
1038
- function enrichTimelineWithLocalActivity(timeline, threadId) {
1039
- const turn = timeline.turns.at(-1);
1040
- if (!turn || turn.status !== 'inProgress') return;
1041
- if (turn.entries.some(entry => entry?.type === 'activity' && entry.status === 'inProgress')) return;
1042
- const pending = readPendingLocalActivity(threadId);
1043
- if (!pending || (pending.turnId && pending.turnId !== turn.id)) return;
1044
- turn.entries.push(pending.entry);
1045
- }
1046
-
1047
- function readPendingLocalActivity(threadId) {
1048
- const sessionPath = findSessionPath(threadId);
1049
- if (!sessionPath) return null;
1050
- let handle;
1051
- try {
1052
- const stat = fs.statSync(sessionPath);
1053
- const cacheKey = `${stat.size}:${stat.mtimeMs}`;
1054
- const cached = liveActivityCache.get(sessionPath);
1055
- if (cached?.key === cacheKey) return cached.value;
1056
- const readLength = Math.min(stat.size, MAX_LIVE_ACTIVITY_TAIL_BYTES);
1057
- const buffer = Buffer.allocUnsafe(readLength);
1058
- handle = fs.openSync(sessionPath, 'r');
1059
- fs.readSync(handle, buffer, 0, readLength, stat.size - readLength);
1060
- const lines = buffer.toString('utf8').split(/\r?\n/);
1061
- if (readLength < stat.size) lines.shift();
1062
- const pending = new Map();
1063
- for (const line of lines) {
1064
- if (!line.includes('"custom_tool_call')) continue;
1065
- let event;
1066
- try { event = JSON.parse(line); } catch { continue; }
1067
- if (event?.type !== 'response_item') continue;
1068
- const payload = event.payload;
1069
- const callId = payload?.call_id;
1070
- if (!callId) continue;
1071
- if (payload.type === 'custom_tool_call') pending.set(callId, payload);
1072
- if (payload.type === 'custom_tool_call_output') pending.delete(callId);
1073
- }
1074
- const payload = [...pending.values()].at(-1);
1075
- const value = payload ? pendingActivity(payload) : null;
1076
- liveActivityCache.set(sessionPath, { key: cacheKey, value });
1077
- return value;
1078
- } catch {
1079
- liveActivityCache.delete(sessionPath);
1080
- return null;
1081
- } finally {
1082
- if (handle !== undefined) fs.closeSync(handle);
1083
- }
1084
- }
1085
-
1086
- function pendingActivity(payload) {
1087
- const input = typeof payload.input === 'string' ? payload.input : '';
1088
- const nestedTool = input.match(/tools\.([A-Za-z0-9_]+)\s*\(/)?.[1] || payload.name || 'tool';
1089
- const id = `live:${payload.call_id}`;
1090
- const common = { type: 'activity', id, status: 'inProgress', transient: true };
1091
- const turnId = payload.internal_chat_message_metadata_passthrough?.turn_id || '';
1092
- if (nestedTool === 'exec_command') {
1093
- return {
1094
- turnId,
1095
- entry: {
1096
- ...common,
1097
- kind: 'command',
1098
- title: '命令',
1099
- command: jsonStringField(input, 'cmd'),
1100
- cwd: jsonStringField(input, 'workdir'),
1101
- },
1102
- };
1103
- }
1104
- if (nestedTool === 'apply_patch') {
1105
- return { turnId, entry: { ...common, kind: 'fileChange', title: '文件修改', changes: [] } };
1106
- }
1107
- if (nestedTool === 'web__run') {
1108
- return {
1109
- turnId,
1110
- entry: { ...common, kind: 'webSearch', title: '网页搜索', query: jsonStringField(input, 'q') },
1111
- };
1112
- }
1113
- if (nestedTool === 'view_image') {
1114
- return {
1115
- turnId,
1116
- entry: { ...common, kind: 'imageView', title: '查看图片', path: jsonStringField(input, 'path') },
1117
- };
1118
- }
1119
- const parts = nestedTool.replace(/^mcp__/, '').split('__');
1120
- return {
1121
- turnId,
1122
- entry: {
1123
- ...common,
1124
- kind: 'tool',
1125
- title: '工具',
1126
- server: parts.length > 1 ? parts.shift() : 'functions',
1127
- tool: parts.join('/') || nestedTool,
1128
- arguments: {},
1129
- },
1130
- };
1131
- }
1132
-
1133
- function jsonStringField(source, field) {
1134
- const pattern = new RegExp(`(?:["']${field}["']|${field})\\s*:\\s*("(?:\\\\.|[^"\\\\])*")`);
1135
- const match = pattern.exec(source);
1136
- if (!match) return '';
1137
- try { return JSON.parse(match[1]); } catch { return ''; }
1138
- }
1139
-
1140
670
  function messagePayload(body) {
1141
671
  const prompt = typeof body.prompt === 'string' ? body.prompt.trim() : '';
1142
672
  if (body.images !== undefined && (!Array.isArray(body.images) || body.images.length > 4)) {
@@ -1173,26 +703,6 @@ function messagePayload(body) {
1173
703
  return payload;
1174
704
  }
1175
705
 
1176
- async function messageArguments(threadId, body) {
1177
- const payload = messagePayload(body);
1178
- payload.images = await Promise.all(payload.images.map(image => image.startsWith('data:') ? persistLegacyImage(image, uploadsRoot) : image));
1179
- let prompt = payload.prompt;
1180
- if (payload.images.length) {
1181
- prompt += (prompt ? '\n\n' : '') + payload.images
1182
- .map((file, index) => `![手机图片 ${index + 1}](${file.replaceAll('\\', '/')})`)
1183
- .join('\n\n');
1184
- }
1185
- const argumentsValue = { threadId, prompt };
1186
- if (payload.hostId) argumentsValue.hostId = payload.hostId;
1187
- if (payload.model) argumentsValue.model = payload.model;
1188
- if (payload.thinking) argumentsValue.thinking = payload.thinking;
1189
- return argumentsValue;
1190
- }
1191
-
1192
- function usesSharedTask(body) {
1193
- return Boolean(sharedServer && (!body.hostId || body.hostId === 'local'));
1194
- }
1195
-
1196
706
  function appServerTurnArguments(threadId, body) {
1197
707
  const payload = messagePayload(body);
1198
708
  const input = [];
@@ -1205,332 +715,23 @@ function appServerTurnArguments(threadId, body) {
1205
715
  return value;
1206
716
  }
1207
717
 
1208
- function messageDisplayText(body) {
1209
- const prompt = typeof body.prompt === 'string' ? body.prompt.trim() : '';
1210
- const imageCount = Array.isArray(body.images) ? body.images.length : 0;
1211
- return prompt || (imageCount ? `[${imageCount} 张图片]` : '');
1212
- }
1213
-
1214
- function createThreadBody(body) {
1215
- const payload = messagePayload(body);
1216
- return { ...body, prompt: payload.prompt, transport: body.transport === 'app-server' ? 'app-server' : 'desktop-tools' };
1217
- }
1218
-
1219
- function appServerProjectTarget(projectId, projects) {
1220
- if (projectId === '__unassigned__') return appServerEvents.projectlessThreadTarget(projectRoot);
1221
- const project = projects.find(item => item?.projectId === projectId);
1222
- if (!project) throw new RequestError(404, '没有找到对应项目。');
1223
- if (project.hostId && project.hostId !== 'local') {
1224
- throw new RequestError(400, '独立 app-server 当前只支持本机项目。');
1225
- }
1226
- if (typeof project.path !== 'string' || !path.isAbsolute(project.path)) {
1227
- throw new RequestError(400, '项目没有可用的本机路径。');
1228
- }
1229
- return { projectId, cwd: project.path };
1230
- }
1231
-
1232
- function mergeThreadCatalog(catalog, appThreads, limit, sharedSnapshot = Boolean(sharedServer)) {
1233
- const owned = new Map(appThreads.map(thread => [thread.id, thread]));
1234
- const pinnedThreads = (catalog.pinnedThreads || []).map(thread => owned.get(thread.id) || thread);
1235
- const pinnedIds = new Set(pinnedThreads.map(thread => thread.id));
1236
- if (sharedSnapshot) {
1237
- const listed = new Set([...pinnedIds, ...(catalog.threads || []).map(thread => thread.id)]);
1238
- return { ...catalog, pinnedThreads, threads: [
1239
- ...(catalog.threads || []).map(thread => owned.get(thread.id) || thread),
1240
- ...appThreads.filter(thread => !listed.has(thread.id)),
1241
- ].slice(0, limit) };
1242
- }
1243
- const desktopThreads = (catalog.threads || []).filter(thread => !owned.has(thread.id));
1244
- const merged = [...desktopThreads, ...appThreads.filter(thread => !pinnedIds.has(thread.id))]
1245
- .sort((left, right) => Number(right.updatedAt || 0) - Number(left.updatedAt || 0))
1246
- .slice(0, limit);
1247
- return { ...catalog, pinnedThreads, threads: merged };
1248
- }
1249
-
1250
- async function createThreadArguments(projectId, body, projects) {
1251
- const { threadId, hostId, ...message } = await messageArguments('', body);
1252
- if (projectId === '__unassigned__') return { ...message, target: { type:'projectless' } };
1253
- const project = projects.find(item => item?.projectId === projectId);
1254
- if (!project) throw new RequestError(404, '没有找到对应项目。');
1255
- return { ...message, target:{ type:'project', projectId, environment:{ type:'local' } } };
1256
- }
1257
-
1258
- function publicQueue(threadId) {
1259
- return (messageQueues.get(threadId) || []).map(item => ({
1260
- id: item.id,
1261
- prompt: item.displayPrompt || item.prompt,
1262
- text: item.text, attachments:item.attachments || [],
1263
- model: item.model || '',
1264
- thinking: item.thinking || item.effort || '',
1265
- createdAt: item.createdAt,
1266
- error: item.error || '',
1267
- }));
1268
- }
1269
-
1270
- function queueArguments(item) {
1271
- const value = { threadId: item.threadId, prompt: item.prompt };
1272
- if (item.hostId) value.hostId = item.hostId;
1273
- if (item.model) value.model = item.model;
1274
- if (item.thinking) value.thinking = item.thinking;
1275
- return value;
1276
- }
1277
-
1278
- async function sendQueuedItem(threadId, item) {
1279
- await requireExecutionWritable(threadId);
1280
- if (item.transport === 'app-server') {
1281
- try {
1282
- return { success: true, data: await appServerTasks.startTurn(threadId, item) };
1283
- } catch (error) {
1284
- return { success: false, data: error instanceof Error ? error.message : String(error) };
1285
- }
1286
- }
1287
- return callTool('send_message_to_thread', queueArguments(item));
1288
- }
1289
-
1290
- async function steerQueuedItem(threadId, item) {
1291
- if (item.transport !== 'app-server') return callTool('send_message_to_thread', queueArguments(item));
1292
- try {
1293
- const thread = await appServerTasks.readTask(threadId, { includeTurns: true, turnLimit: 1 });
1294
- const activeTurn = thread.turns?.at(-1);
1295
- if (!activeTurn || statusText(activeTurn.status) !== 'inProgress') {
1296
- throw new Error('当前没有可引导的 app-server 活动轮次。');
1297
- }
1298
- return {
1299
- success: true,
1300
- data: await appServerTasks.steerTurn(threadId, activeTurn.id, item.input),
1301
- };
1302
- } catch (error) {
1303
- return { success: false, data: error instanceof Error ? error.message : String(error) };
1304
- }
1305
- }
1306
-
1307
- function ensureQueueWorker(threadId) {
1308
- if (queueWorkers.has(threadId)) return;
1309
- const worker = runQueueWorker(threadId)
1310
- .catch(error => {
1311
- const item = messageQueues.get(threadId)?.[0];
1312
- if (item) item.error = error instanceof Error ? error.message : String(error);
1313
- })
1314
- .finally(() => queueWorkers.delete(threadId));
1315
- queueWorkers.set(threadId, worker);
1316
- }
1317
-
1318
- async function runQueueWorker(threadId) {
1319
- while (true) {
1320
- const queue = messageQueues.get(threadId) || [];
1321
- const item = queue[0];
1322
- if (!item || item.error) return;
1323
- let timeline;
1324
- if (item.transport === 'app-server') {
1325
- const thread = await appServerTasks.readTask(threadId, { includeTurns: true, turnLimit: 1 });
1326
- timeline = normalizeAppServerTimeline(thread);
1327
- } else {
1328
- const readArguments = {
1329
- threadId,
1330
- turnLimit: 1,
1331
- includeOutputs: false,
1332
- maxOutputCharsPerItem: 500,
1333
- };
1334
- if (item.hostId) readArguments.hostId = item.hostId;
1335
- const threadResult = await callTool('read_thread', readArguments, 15_000);
1336
- if (!threadResult.success) throw new BridgeError('读取排队任务状态失败。', threadResult.data);
1337
- timeline = normalizeTimeline(threadResult.data);
1338
- }
1339
- const active = timeline.thread.status === 'active' || timeline.turns.at(-1)?.status === 'inProgress';
1340
- if (active) {
1341
- await delay(2_000);
1342
- continue;
1343
- }
1344
- if (messageQueues.get(threadId) !== queue || queue[0]?.id !== item.id) continue;
1345
- const sendResult = await sendQueuedItem(threadId, item);
1346
- if (!sendResult.success) throw new BridgeError('发送排队消息失败。', sendResult.data);
1347
- if (queue[0]?.id === item.id) queue.shift();
1348
- }
1349
- }
1350
-
1351
- async function streamAppServerThreadEvents(request, response, threadId) {
1352
- response.startEvents();
1353
-
1354
- let closed = false;
1355
- let pushPending = false;
1356
- let pushing = false;
1357
- let previousHash = '';
1358
- let updateTimer = null;
1359
- let resolveClosed;
1360
- const closedPromise = new Promise(resolve => { resolveClosed = resolve; });
1361
- response.once('close', () => { closed = true; resolveClosed(); });
1362
-
1363
- const pushUpdate = async () => {
1364
- if (closed) return;
1365
- if (pushing) { pushPending = true; return; }
1366
- pushing = true;
1367
- try {
1368
- const thread = await appServerTasks.readTask(threadId, { includeTurns: true, turnLimit: 2 });
1369
- const timeline = normalizeAppServerTimeline(thread);
1370
- const update = {
1371
- thread: timeline.thread,
1372
- turns: timeline.turns.slice(-2),
1373
- turn: timeline.turns.at(-1) || null,
1374
- queue: publicQueue(threadId),
1375
- };
1376
- const serialized = JSON.stringify(update);
1377
- const hash = crypto.createHash('sha256').update(serialized).digest('base64url');
1378
- if (hash !== previousHash) {
1379
- sendEvent(response, 'update', update);
1380
- previousHash = hash;
1381
- }
1382
- } catch (error) {
1383
- sendEvent(response, 'bridge-error', { error: error instanceof Error ? error.message : String(error) });
1384
- } finally {
1385
- pushing = false;
1386
- if (pushPending && !closed) {
1387
- pushPending = false;
1388
- void pushUpdate();
1389
- }
1390
- }
1391
- };
1392
- const scheduleUpdate = event => {
1393
- if (closed || event.threadId !== threadId || updateTimer) return;
1394
- updateTimer = setTimeout(() => {
1395
- updateTimer = null;
1396
- void pushUpdate();
1397
- }, 60);
1398
- };
1399
- appServerTasks.on('thread-event', scheduleUpdate);
1400
- await pushUpdate();
1401
-
1402
- await closedPromise;
1403
- closed = true;
1404
- if (updateTimer) clearTimeout(updateTimer);
1405
- appServerTasks.off('thread-event', scheduleUpdate);
1406
- if (!response.writableEnded) response.end();
1407
- }
1408
-
1409
- async function streamThreadEvents(request, response, threadId, hostId) {
1410
- response.startEvents();
1411
-
1412
- let closed = false;
1413
- let previousHash = '';
1414
- response.once('close', () => { closed = true; });
1415
-
1416
- while (!closed) {
1417
- let intervalMs = 2_500;
1418
- try {
1419
- const argumentsValue = {
1420
- threadId,
1421
- turnLimit: 2,
1422
- includeOutputs: true,
1423
- maxOutputCharsPerItem: 4_000,
1424
- };
1425
- if (hostId) argumentsValue.hostId = hostId;
1426
- const result = await callTool('read_thread', argumentsValue);
1427
- if (!result.success) {
1428
- throw new BridgeError('Codex Desktop 工具返回失败。', result.data);
1429
- }
1430
-
1431
- const timeline = normalizeTimeline(result.data);
1432
- enrichTimelineWithLocalActivity(timeline, threadId);
1433
- const update = {
1434
- thread: timeline.thread,
1435
- turns: timeline.turns.slice(-2),
1436
- turn: timeline.turns.at(-1) || null,
1437
- queue: publicQueue(threadId),
1438
- };
1439
- const serialized = JSON.stringify(update);
1440
- const hash = crypto.createHash('sha256').update(serialized).digest('base64url');
1441
- if (hash !== previousHash) {
1442
- sendEvent(response, 'update', update);
1443
- previousHash = hash;
1444
- }
1445
- if (timeline.thread.status === 'active' || update.turn?.status === 'inProgress') {
1446
- intervalMs = 1_000;
1447
- }
1448
- } catch (error) {
1449
- sendEvent(response, 'bridge-error', {
1450
- error: error instanceof Error ? error.message : String(error),
1451
- });
1452
- intervalMs = 3_000;
1453
- }
1454
-
1455
- await delay(intervalMs);
1456
- }
1457
-
1458
- if (!response.writableEnded) response.end();
1459
- }
1460
-
1461
- function sendEvent(response, eventName, value) {
1462
- if (response.destroyed || response.writableEnded) return;
1463
- response.event(eventName, value);
1464
- }
1465
-
1466
- function normalizeTimeline(data, reverseTurns = true) {
1467
- const source = data && typeof data === 'object' ? data : {};
1468
- const sourceTurns = Array.isArray(source.turns) ? source.turns : source.thread?.turns;
1469
- const turns = Array.isArray(sourceTurns) ? sourceTurns.map(normalizeTurn) : [];
1470
- if (reverseTurns) turns.reverse();
1471
- if (reverseTurns) restoreEmptyTurnMessages(source.thread?.id, turns);
1472
- return {
718
+ function normalizeTimeline(data) {
719
+ const source = data && typeof data === 'object' ? data : {};
720
+ const sourceTurns = Array.isArray(source.turns) ? source.turns : source.thread?.turns;
721
+ const turns = Array.isArray(sourceTurns) ? sourceTurns.map(normalizeTurn) : [];
722
+ return {
1473
723
  schemaVersion: 1,
1474
724
  thread: normalizeThread(source.thread),
1475
725
  turns,
1476
726
  };
1477
727
  }
1478
728
 
1479
- function normalizeAppServerTimeline(thread) {
1480
- const record = appServerTasks.store.get(thread?.id);
1481
- return normalizeTimeline({
1482
- thread: {
1483
- ...thread,
1484
- title: thread?.name || thread?.title || record?.title || '',
1485
- projectId: record?.projectId || '',
1486
- hostId: 'app-server',
1487
- transport: 'app-server',
1488
- },
1489
- }, false);
1490
- }
1491
-
1492
- function normalizeSharedTimeline({ thread, nextCursor, sync }) {
1493
- return { ...normalizeTimeline({ thread: { ...thread, title: thread.name || thread.title || '', hostId: 'local', transport: 'shared-app-server' } }, false),
1494
- ...(nextCursor !== undefined ? { nextCursor } : {}), ...(sync ? { sync } : {}) };
1495
- }
1496
-
1497
- function restoreEmptyTurnMessages(threadId, turns) {
1498
- const missing = new Map(turns.filter(turn => turn.entries.length === 0).map(turn => [turn.id, turn]));
1499
- if (!missing.size || !threadId) return;
1500
- const sessionPath = findSessionPath(threadId);
1501
- if (!sessionPath) return;
1502
- const handle = fs.openSync(sessionPath, 'r');
1503
- try {
1504
- const size = fs.fstatSync(handle).size;
1505
- const length = Math.min(size, MAX_SETTINGS_SCAN_BYTES);
1506
- const buffer = Buffer.allocUnsafe(length);
1507
- fs.readSync(handle, buffer, 0, length, size - length);
1508
- const lines = buffer.toString('utf8').split(/\r?\n/);
1509
- if (length < size) lines.shift();
1510
- let turnId;
1511
- for (const line of lines) {
1512
- if (!line.trim()) continue;
1513
- let event;
1514
- try { event = JSON.parse(line); } catch { continue; }
1515
- const item = event.payload;
1516
- if (event.type === 'event_msg' && item?.type === 'task_started') turnId = item.turn_id;
1517
- if (event.type === 'turn_context') turnId = item?.turn_id;
1518
- const turn = missing.get(turnId);
1519
- if (!turn || event.type !== 'response_item') continue;
1520
- let entry;
1521
- if (item.type === 'function_call_output' && item.namespace === 'codex_app' && ['create_thread', 'send_message_to_thread'].includes(item.name)) {
1522
- const text = extractDelegatedInput(item.output);
1523
- if (text) entry = { type: 'message', id: item.id, role: 'user', source: 'remote', ...splitUploadedImages(text) };
1524
- } else if (item.type === 'message' && ['user', 'assistant'].includes(item.role) && item.channel !== 'analysis') {
1525
- const text = (item.content || []).filter(part => ['input_text', 'output_text'].includes(part.type)).map(part => part.text || '').join('\n');
1526
- if (text) entry = item.role === 'user'
1527
- ? normalizeItem({ type: 'userMessage', id: item.id, content: item.content.map(part => ({ ...part, type: part.type === 'input_text' ? 'text' : part.type })) })
1528
- : normalizeItem({ type: 'agentMessage', id: item.id, phase: item.phase || item.channel, text });
1529
- }
1530
- if (entry) turn.entries.push(entry);
1531
- }
1532
- } finally { fs.closeSync(handle); }
1533
- }
729
+ function normalizeSharedTimeline({ thread, nextCursor, sync }) {
730
+ const timeline = normalizeTimeline({ thread: { ...thread, title: thread.name || thread.title || '', hostId: 'local', transport: 'shared-app-server' } });
731
+ return { ...timeline,
732
+ historyView: 'grouped-lazy-skeleton-v12',
733
+ ...(nextCursor !== undefined ? { nextCursor } : {}), ...(sync ? { sync } : {}) };
734
+ }
1534
735
 
1535
736
  function normalizeThread(thread) {
1536
737
  const source = thread && typeof thread === 'object' ? thread : {};
@@ -1539,30 +740,48 @@ function normalizeThread(thread) {
1539
740
  title: source.name || source.title || source.preview || '',
1540
741
  status: statusText(source.status),
1541
742
  updatedAt: Number(source.updatedAt) || 0,
743
+ recencyAt: Number(source.recencyAt ?? source.updatedAt) || 0,
1542
744
  projectId: source.projectId || '',
1543
745
  cwd: source.cwd || '',
1544
746
  hostId: source.hostId || '',
1545
- transport: source.transport || (source.hostId === 'app-server' ? 'app-server' : 'desktop-tools'),
747
+ transport: source.transport || 'shared-app-server',
1546
748
  readOnly: source.readOnly === true,
1547
749
  readOnlyReason: source.readOnlyReason || '',
1548
750
  };
1549
751
  }
1550
752
 
1551
- function normalizeTurn(turn) {
1552
- const source = turn && typeof turn === 'object' ? turn : {};
1553
- return {
1554
- id: source.id || '',
1555
- status: statusText(source.status),
753
+ function normalizeTurn(turn) {
754
+ const source = turn && typeof turn === 'object' ? turn : {};
755
+ const turnId = source.id || '';
756
+ const entries = collapseSubAgentEntries(Array.isArray(source.items) ? source.items.map(normalizeItem).filter(Boolean) : []);
757
+ for (const entry of entries) {
758
+ const images = [...(entry.attachments || []), entry.image, ...Object.values(entry.imageAssets || {})].filter(Boolean);
759
+ for (const image of images) if (image.remotePath && !image.recoveryTurnId) image.recoveryTurnId = turnId;
760
+ }
761
+ return {
762
+ id: turnId,
763
+ status: statusText(source.status),
1556
764
  startedAt: source.startedAt || null,
1557
765
  completedAt: source.completedAt || null,
1558
- durationMs: source.durationMs ?? null,
1559
- error: source.error ?? null,
1560
- entries: Array.isArray(source.items) ? source.items.map(normalizeItem).filter(Boolean) : [],
1561
- };
1562
- }
1563
-
1564
- function normalizeItem(item) {
1565
- if (!item || typeof item !== 'object') return null;
766
+ durationMs: source.durationMs ?? null,
767
+ error: source.error ?? null,
768
+ entries,
769
+ };
770
+ }
771
+
772
+ function normalizeItem(item) {
773
+ if (!item || typeof item !== 'object') return null;
774
+
775
+ if (item.type === 'lazyOperationGroup') {
776
+ return {
777
+ type: 'activity',
778
+ id: item.id || '',
779
+ kind: 'lazyOperationGroup',
780
+ turnId: item.turnId || '',
781
+ groupId: item.groupId || '',
782
+ operationTypes: Array.isArray(item.operationTypes) ? item.operationTypes : [],
783
+ };
784
+ }
1566
785
 
1567
786
  if (item.type === 'contextCompaction') {
1568
787
  return {
@@ -1588,9 +807,9 @@ function normalizeItem(item) {
1588
807
  }
1589
808
  }
1590
809
 
1591
- if (item.type === 'userMessage') {
1592
- const text = extractUserText(item.content);
1593
- const attachments = normalizeAttachments(item.content);
810
+ if (item.type === 'userMessage') {
811
+ const { text, files } = extractUserText(item.content);
812
+ const attachments = [...normalizeAttachments(item.content), ...files];
1594
813
  if (!text && !attachments.length) return null;
1595
814
  return {
1596
815
  clientId: item.clientId,
@@ -1616,132 +835,151 @@ function normalizeItem(item) {
1616
835
  };
1617
836
  }
1618
837
 
1619
- if (item.type === 'reasoning') {
1620
- const detail = Array.isArray(item.summary)
1621
- ? item.summary.map(summaryText).filter(Boolean).join('\n')
1622
- : summaryText(item.summary);
1623
- return {
1624
- type: 'activity',
1625
- id: item.id || '',
1626
- kind: 'reasoning',
1627
- title: '思考摘要',
1628
- detail,
1629
- };
1630
- }
1631
-
1632
- if (item.type === 'commandExecution') {
1633
- return {
1634
- type: 'activity',
1635
- id: item.id || '',
1636
- kind: 'command',
1637
- title: '命令',
1638
- status: statusText(item.status),
1639
- command: item.command || '',
1640
- cwd: item.cwd || '',
1641
- exitCode: item.exitCode ?? null,
1642
- durationMs: item.durationMs ?? null,
1643
- output: item.aggregatedOutput || item.output?.text || '',
1644
- };
1645
- }
838
+ if (item.type === 'reasoning') {
839
+ const detail = Array.isArray(item.summary)
840
+ ? item.summary.map(summaryText).filter(Boolean).join('\n')
841
+ : summaryText(item.summary);
842
+ return {
843
+ type: 'activity',
844
+ id: item.id || '',
845
+ kind: 'reasoning',
846
+ title: '思考摘要',
847
+ detail,
848
+ };
849
+ }
1646
850
 
851
+ if (item.type === 'commandExecution') {
852
+ return {
853
+ type: 'activity',
854
+ id: item.id || '',
855
+ kind: 'command',
856
+ title: '命令',
857
+ titleOnly: true,
858
+ status: statusText(item.status),
859
+ command: item.command,
860
+ cwd: item.cwd,
861
+ exitCode: item.exitCode,
862
+ durationMs: item.durationMs,
863
+ };
864
+ }
865
+
1647
866
  if (item.type === 'mcpToolCall') {
1648
- const result = normalizeToolResult(item.result, item.error);
1649
867
  return {
1650
868
  type: 'activity',
1651
869
  id: item.id || '',
1652
- kind: 'tool',
1653
- title: '工具',
1654
- status: statusText(item.status),
1655
- server: item.server || '',
1656
- tool: item.tool || '',
1657
- arguments: item.arguments || {},
1658
- resultText: result.text,
1659
- errorText: result.error,
1660
- resources: result.resources,
1661
- mediaCount: result.mediaCount,
1662
- durationMs: item.durationMs ?? null,
870
+ kind: 'tool',
871
+ title: '工具',
872
+ titleOnly: true,
873
+ status: statusText(item.status),
874
+ server: item.server,
875
+ tool: item.tool,
876
+ arguments: item.arguments,
877
+ durationMs: item.durationMs,
878
+ error: item.error,
879
+ };
880
+ }
881
+
882
+ if (item.type === 'dynamicToolCall') {
883
+ return {
884
+ type: 'activity',
885
+ id: item.id || '',
886
+ kind: 'tool',
887
+ title: '工具',
888
+ titleOnly: true,
889
+ status: statusText(item.status),
890
+ namespace: item.namespace,
891
+ tool: item.tool,
892
+ arguments: item.arguments,
893
+ durationMs: item.durationMs,
894
+ success: item.success,
895
+ };
896
+ }
897
+
898
+ if (item.type === 'fileChange') {
899
+ return {
900
+ type: 'activity',
901
+ id: item.id || '',
902
+ kind: 'fileChange',
903
+ title: '文件修改',
904
+ titleOnly: true,
905
+ status: statusText(item.status),
906
+ changes: Array.isArray(item.changes) ? item.changes : [],
1663
907
  };
1664
908
  }
1665
909
 
1666
- if (item.type === 'fileChange') {
1667
- return {
1668
- type: 'activity',
1669
- id: item.id || '',
1670
- kind: 'fileChange',
1671
- title: '文件修改',
1672
- status: statusText(item.status),
1673
- changes: Array.isArray(item.changes) ? item.changes : [],
1674
- };
1675
- }
1676
-
1677
- if (item.type === 'webSearch') {
1678
- return {
1679
- type: 'activity',
1680
- id: item.id || '',
1681
- kind: 'webSearch',
1682
- title: '网页搜索',
1683
- query: item.query || item.action?.query || '',
1684
- };
1685
- }
1686
-
1687
- if (item.type === 'imageView') {
1688
- return {
1689
- type: 'activity',
1690
- id: item.id || '',
1691
- kind: 'imageView',
1692
- title: '查看图片',
1693
- path: item.path || '',
1694
- image: typeof item.path === 'string' && item.path
1695
- ? normalizeAttachment({ type: 'localImage', path: item.path })
1696
- : null,
1697
- };
1698
- }
1699
-
1700
- if (item.type === 'imageGeneration') {
1701
- let image = typeof item.savedPath === 'string' && item.savedPath
1702
- ? normalizeAttachment({ type: 'localImage', path: item.savedPath, name: '生成的图片' })
1703
- : null;
1704
- if ((!image || !image.available) && typeof item.result === 'string' && item.result.trim()) {
1705
- const result = item.result.trim();
1706
- const url = result.startsWith('data:image/') ? result : `data:image/png;base64,${result}`;
1707
- image = normalizeAttachment({ type: 'image', url, name: '生成的图片' });
1708
- }
1709
- return {
1710
- type: 'activity',
1711
- id: item.id || '',
1712
- kind: 'imageGeneration',
1713
- title: '生成图片',
1714
- status: statusText(item.status),
1715
- image,
1716
- failure: item.failure || null,
1717
- transparentBackground: item.transparentBackground === true,
1718
- };
1719
- }
1720
-
1721
- if (item.type === 'subAgentActivity') {
1722
- return {
1723
- type: 'activity',
1724
- id: item.id || '',
1725
- kind: 'subAgent',
1726
- title: '子任务',
1727
- status: statusText(item.kind),
1728
- agentPath: item.agentPath || '',
1729
- agentThreadId: item.agentThreadId || '',
1730
- };
1731
- }
1732
-
1733
- if (item.type === 'collabAgentToolCall') {
1734
- return {
1735
- type: 'activity',
1736
- id: item.id || '',
1737
- kind: 'collaboration',
1738
- title: '任务协作',
1739
- status: statusText(item.status),
1740
- tool: item.tool || '',
1741
- prompt: item.prompt || '',
1742
- receiverThreadIds: Array.isArray(item.receiverThreadIds) ? item.receiverThreadIds : [],
1743
- };
1744
- }
910
+ if (item.type === 'webSearch') {
911
+ return {
912
+ type: 'activity',
913
+ id: item.id || '',
914
+ kind: 'webSearch',
915
+ title: '网页搜索',
916
+ titleOnly: true,
917
+ query: item.query || item.action?.query || '',
918
+ ...(item.action && typeof item.action === 'object' ? { action: item.action } : {}),
919
+ };
920
+ }
921
+
922
+ if (item.type === 'imageView') {
923
+ return {
924
+ type: 'activity',
925
+ id: item.id || '',
926
+ kind: 'imageView',
927
+ title: '查看图片',
928
+ titleOnly: true,
929
+ path: item.path || '',
930
+ image: typeof item.path === 'string' && item.path
931
+ ? normalizeAttachment({ type: 'localImage', path: item.path })
932
+ : null,
933
+ };
934
+ }
935
+
936
+ if (item.type === 'imageGeneration') {
937
+ const image = typeof item.savedPath === 'string' && item.savedPath
938
+ ? normalizeAttachment({ type: 'localImage', path: item.savedPath, name: '生成的图片' })
939
+ : null;
940
+ return {
941
+ type: 'activity',
942
+ id: item.id || '',
943
+ kind: 'imageGeneration',
944
+ title: '生成图片',
945
+ titleOnly: true,
946
+ status: statusText(item.status),
947
+ image,
948
+ failure: item.failure || null,
949
+ transparentBackground: item.transparentBackground === true,
950
+ };
951
+ }
952
+
953
+ if (item.type === 'subAgentActivity') {
954
+ return {
955
+ type: 'activity',
956
+ id: item.id || '',
957
+ kind: 'subAgent',
958
+ title: '子任务',
959
+ titleOnly: true,
960
+ status: statusText(item.kind),
961
+ agentPath: item.agentPath || '',
962
+ agentThreadId: item.agentThreadId || '',
963
+ };
964
+ }
965
+
966
+ if (item.type === 'collabAgentToolCall') {
967
+ return {
968
+ type: 'activity',
969
+ id: item.id || '',
970
+ kind: 'collaboration',
971
+ title: '任务协作',
972
+ titleOnly: true,
973
+ status: statusText(item.status),
974
+ tool: item.tool || '',
975
+ prompt: item.prompt || '',
976
+ senderThreadId: item.senderThreadId || '',
977
+ receiverThreadIds: Array.isArray(item.receiverThreadIds) ? item.receiverThreadIds : [],
978
+ model: item.model || '',
979
+ reasoningEffort: item.reasoningEffort || '',
980
+ agentsStates: item.agentsStates && typeof item.agentsStates === 'object' ? item.agentsStates : {},
981
+ };
982
+ }
1745
983
 
1746
984
  return {
1747
985
  type: 'activity',
@@ -1753,44 +991,92 @@ function normalizeItem(item) {
1753
991
  };
1754
992
  }
1755
993
 
1756
- function normalizeToolResult(result, fallbackError) {
1757
- const text = [];
1758
- const resources = [];
1759
- let mediaCount = 0;
1760
- const seen = new Set();
1761
- const addText = value => {
1762
- const normalized = typeof value === 'string' ? value.trim() : '';
1763
- if (normalized && !seen.has(normalized)) { seen.add(normalized); text.push(normalized); }
994
+ function collapseSubAgentEntries(entries) {
995
+ const agents = new Map();
996
+ let firstIndex = -1;
997
+ const retained = [];
998
+ const ensureAgent = threadId => {
999
+ if (!threadId) return null;
1000
+ let agent = agents.get(threadId);
1001
+ if (!agent) {
1002
+ agent = { threadId, name: '', status: 'pendingInit', objective: '', model: '', reasoningEffort: '' };
1003
+ agents.set(threadId, agent);
1004
+ }
1005
+ return agent;
1764
1006
  };
1765
- const visit = (value, depth = 0) => {
1766
- if (value == null || depth > 4) return;
1767
- if (typeof value === 'string') { addText(value); return; }
1768
- if (Array.isArray(value)) { for (const item of value) visit(item, depth + 1); return; }
1769
- if (typeof value !== 'object') return;
1770
- if (value.type === 'image' || value.type === 'audio') { mediaCount += 1; return; }
1771
- if (value.type === 'resource_link') {
1772
- resources.push({ name: value.title || value.name || '资源', uri: value.uri || '', description: value.description || '' });
1773
- return;
1007
+ const setProtocolStatus = (agent, status) => {
1008
+ if (!agent || !status || status === 'unknown') return;
1009
+ const terminal = ['interrupted', 'completed', 'errored', 'shutdown', 'notFound'];
1010
+ if (terminal.includes(agent.status) && ['pendingInit', 'running'].includes(status)) return;
1011
+ if (status === 'shutdown' && agent.status === 'completed') return;
1012
+ agent.status = status;
1013
+ };
1014
+ for (const entry of entries) {
1015
+ if (entry.kind === 'subAgent') {
1016
+ if (firstIndex < 0) firstIndex = retained.length;
1017
+ const agent = ensureAgent(entry.agentThreadId);
1018
+ if (!agent) continue;
1019
+ agent.name = subAgentDisplayName(entry.agentPath) || agent.name;
1020
+ const activityStatus = {
1021
+ started: 'running',
1022
+ interacted: 'running',
1023
+ interrupted: 'interrupted',
1024
+ completed: 'completed',
1025
+ }[entry.status];
1026
+ agent.activity = entry.status;
1027
+ setProtocolStatus(agent, activityStatus);
1028
+ continue;
1774
1029
  }
1775
- if (value.type === 'resource' && value.resource) {
1776
- const resource = value.resource;
1777
- if (typeof resource.text === 'string') addText(resource.text);
1778
- else if (resource.uri) resources.push({ name: resource.title || resource.name || '资源', uri: resource.uri, description: resource.description || '' });
1779
- return;
1030
+ if (entry.kind === 'collaboration') {
1031
+ const receiverIds = entry.receiverThreadIds || [];
1032
+ const createsAgent = entry.tool === 'spawnAgent';
1033
+ const updatesKnownAgent = receiverIds.some(threadId => agents.has(threadId));
1034
+ if (!createsAgent && !updatesKnownAgent) {
1035
+ retained.push(entry);
1036
+ continue;
1037
+ }
1038
+ if (firstIndex < 0) firstIndex = retained.length;
1039
+ for (const threadId of receiverIds) {
1040
+ const agent = ensureAgent(threadId);
1041
+ if (!agent) continue;
1042
+ if (entry.prompt) agent.objective = entry.prompt;
1043
+ if (entry.model) agent.model = entry.model;
1044
+ if (entry.reasoningEffort) agent.reasoningEffort = entry.reasoningEffort;
1045
+ setProtocolStatus(agent, statusText(entry.agentsStates?.[threadId]?.status));
1046
+ if (entry.status === 'failed' && !entry.agentsStates?.[threadId]?.status) agent.status = 'errored';
1047
+ if (entry.status === 'interrupted' && !entry.agentsStates?.[threadId]?.status) agent.status = 'interrupted';
1048
+ }
1049
+ continue;
1780
1050
  }
1781
- if (typeof value.text === 'string') addText(value.text);
1782
- if (typeof value.message === 'string') addText(value.message);
1783
- if (value.content) visit(value.content, depth + 1);
1784
- if (value.raw) visit(value.raw, depth + 1);
1051
+ retained.push(entry);
1052
+ }
1053
+ if (!agents.size) return retained;
1054
+ retained.splice(firstIndex < 0 ? retained.length : firstIndex, 0, {
1055
+ type: 'activity',
1056
+ id: `subagents:${[...agents.keys()].join(':')}`,
1057
+ kind: 'subAgentGroup',
1058
+ title: '子 Agent',
1059
+ agents: [...agents.values()],
1060
+ });
1061
+ return retained;
1062
+ }
1063
+
1064
+ function subAgentDisplayName(agentPath) {
1065
+ const segment = String(agentPath || '').split('/').map(value => value.trim()).filter(value => value && value !== 'root').at(-1);
1066
+ if (!segment) return '';
1067
+ const text = segment.replace(/[_-]+/g, ' ').replace(/\s+/g, ' ').trim().toLowerCase();
1068
+ return text ? text[0].toUpperCase() + text.slice(1) : '';
1069
+ }
1070
+
1071
+ function sharedHistoryPageOptions() {
1072
+ return {
1073
+ maxTurns: SHARED_HISTORY_PAGE_TURNS,
1074
+ maxBytes: SHARED_HISTORY_PAGE_BYTES,
1075
+ measure: value => Buffer.byteLength(JSON.stringify(normalizeSharedTimeline(value))),
1785
1076
  };
1786
- visit(result);
1787
- const resultError = result?.type === 'error' ? result.error : null;
1788
- const errorValue = fallbackError || resultError;
1789
- const error = typeof errorValue === 'string' ? errorValue : errorValue?.message || errorValue?.text || '';
1790
- return { text: text.join('\n\n'), error, resources, mediaCount };
1791
1077
  }
1792
-
1793
- function normalizeAttachments(content) {
1078
+
1079
+ function normalizeAttachments(content) {
1794
1080
  if (!Array.isArray(content)) return [];
1795
1081
  const items = content.filter(item => item && !['text', 'input_text'].includes(item.type)).map(item => ({ ...item }));
1796
1082
  const text = content.filter(item => ['text', 'input_text'].includes(item?.type)).map(item => item.text || '').join('\n');
@@ -1833,18 +1119,19 @@ function markdownImageAssets(text) {
1833
1119
  return assets;
1834
1120
  }
1835
1121
 
1836
- function markdownFileAssets(text) {
1122
+ function markdownFileAssets(text) {
1837
1123
  const assets = Object.create(null);
1838
1124
  function visit(tokens) {
1839
1125
  for (const token of tokens) {
1840
1126
  if (token.type === 'link_open') {
1841
1127
  const value = token.attrGet('href');
1842
1128
  if (value && !Object.hasOwn(assets, value)) {
1843
- let localPath;
1844
- try { localPath = decodeURIComponent(value); } catch { localPath = ''; }
1845
- if (/^\/?[a-z]:[\\/]/i.test(localPath)) {
1846
- localPath = localPath.replace(/^\/(?=[a-z]:)/i, '');
1847
- const asset = rememberLocalFile(localPath);
1129
+ let localPath;
1130
+ try { localPath = decodeURIComponent(value); } catch { localPath = ''; }
1131
+ if (/^\/?[a-z]:[\\/]/i.test(localPath)) {
1132
+ localPath = localPath.replace(/^\/(?=[a-z]:)/i, '');
1133
+ localPath = localPath.replace(/:(\d+)(?::\d+)?$/, '');
1134
+ const asset = rememberLocalFile(localPath);
1848
1135
  if (asset) assets[value] = { remotePath: asset.id, name: asset.name, size: asset.size };
1849
1136
  }
1850
1137
  }
@@ -1887,9 +1174,9 @@ function splitUploadedImages(text) {
1887
1174
  const paths = children.map(token => {
1888
1175
  try { return path.resolve(decodeURIComponent(token.attrGet('src') || '')); } catch { return ''; }
1889
1176
  });
1890
- if (paths.some(file => !allowedUploadsRoots.some(root => path.dirname(file).toLowerCase() === root.toLowerCase())
1177
+ if (paths.some(file => path.dirname(file).toLowerCase() !== uploadsRoot.toLowerCase()
1891
1178
  || !/^[a-f0-9]{64}\.(png|webp)$/.test(path.basename(file)))) continue;
1892
- for (const file of paths) attachments.push(normalizeAttachment({ type: 'localImage', path: file, name: '手机图片' }));
1179
+ for (const file of paths) attachments.push(normalizeAttachment({ type: 'localImage', path: file, name: '移动端图片' }));
1893
1180
  for (let line = block.map[0]; line < block.map[1]; line++) removed.add(line);
1894
1181
  }
1895
1182
  const visibleText = lines.filter((line, index) => !removed.has(index)).join('\n').trim();
@@ -1914,18 +1201,23 @@ function normalizeAttachment(item) {
1914
1201
  name,
1915
1202
  mimeType: cached?.mimeType || item.mimeType || '',
1916
1203
  available: Boolean(cached),
1917
- remotePath: cached ? cached.id : '',
1204
+ remotePath: cached ? cached.id : '',
1205
+ recoveryToken: cached?.recoveryToken || '',
1918
1206
  url: '',
1919
1207
  thumbnailUrl: '',
1920
1208
  };
1921
1209
  }
1922
1210
 
1923
- function cacheLocalImage(filePath, name) {
1924
- try {
1925
- const stat = fs.statSync(filePath);
1926
- const mimeType = imageMimeType(filePath);
1927
- if (!stat.isFile() || !mimeType || stat.size > MAX_ATTACHMENT_BYTES) return null;
1928
- return rememberAttachment(fs.readFileSync(filePath), mimeType, name);
1211
+ function cacheLocalImage(filePath, name) {
1212
+ try {
1213
+ const resolved = path.resolve(filePath);
1214
+ const stat = fs.statSync(resolved);
1215
+ const mimeType = imageMimeType(resolved);
1216
+ if (!stat.isFile() || !mimeType || stat.size > MAX_ATTACHMENT_BYTES) return null;
1217
+ const cached = rememberAttachment(fs.readFileSync(resolved), mimeType, name,
1218
+ localImageRecoveryToken(resolved, stat));
1219
+ void attachmentThumbnail(cached).catch(() => {});
1220
+ return cached;
1929
1221
  } catch {
1930
1222
  return null;
1931
1223
  }
@@ -1943,16 +1235,65 @@ function cacheDataImage(dataUrl, name) {
1943
1235
  }
1944
1236
  }
1945
1237
 
1946
- function rememberAttachment(buffer, mimeType, name) {
1947
- const id = crypto.createHash('sha256').update(buffer).digest('hex');
1948
- if (!attachmentCache.has(id)) {
1238
+ function rememberAttachment(buffer, mimeType, name, recoveryToken = '') {
1239
+ const id = crypto.createHash('sha256').update(buffer).digest('hex');
1240
+ if (!attachmentCache.has(id)) {
1949
1241
  while (attachmentCache.size >= MAX_CACHED_ATTACHMENTS) {
1950
1242
  attachmentCache.delete(attachmentCache.keys().next().value);
1951
- }
1952
- attachmentCache.set(id, { id, buffer, mimeType, name });
1953
- }
1954
- return attachmentCache.get(id);
1955
- }
1243
+ }
1244
+ attachmentCache.set(id, { id, buffer, mimeType, name, recoveryToken });
1245
+ } else if (recoveryToken && !attachmentCache.get(id).recoveryToken) {
1246
+ attachmentCache.get(id).recoveryToken = recoveryToken;
1247
+ }
1248
+ return attachmentCache.get(id);
1249
+ }
1250
+
1251
+ function localImageRecoveryToken(filePath, stat) {
1252
+ const iv = crypto.randomBytes(12);
1253
+ const cipher = crypto.createCipheriv('aes-256-gcm', imageCapabilityKey, iv);
1254
+ const payload = Buffer.from(JSON.stringify([filePath, stat.size, stat.mtimeMs]));
1255
+ const encrypted = Buffer.concat([cipher.update(payload), cipher.final()]);
1256
+ return Buffer.concat([iv, cipher.getAuthTag(), encrypted]).toString('base64url');
1257
+ }
1258
+
1259
+ function recoverLocalImage(id, token) {
1260
+ if (!token || token.length > 2048) return null;
1261
+ try {
1262
+ const value = Buffer.from(token, 'base64url');
1263
+ if (value.length < 29) return null;
1264
+ const decipher = crypto.createDecipheriv('aes-256-gcm', imageCapabilityKey, value.subarray(0, 12));
1265
+ decipher.setAuthTag(value.subarray(12, 28));
1266
+ const [filePath, size, mtimeMs] = JSON.parse(Buffer.concat([
1267
+ decipher.update(value.subarray(28)), decipher.final(),
1268
+ ]).toString('utf8'));
1269
+ if (typeof filePath !== 'string' || !path.isAbsolute(filePath)
1270
+ || !Number.isSafeInteger(size) || size <= 0 || size > MAX_ATTACHMENT_BYTES
1271
+ || typeof mtimeMs !== 'number' || !Number.isFinite(mtimeMs)) return null;
1272
+ const stat = fs.statSync(filePath);
1273
+ const mimeType = imageMimeType(filePath);
1274
+ if (!stat.isFile() || stat.size !== size || stat.mtimeMs !== mtimeMs || !mimeType) return null;
1275
+ const buffer = fs.readFileSync(filePath);
1276
+ if (crypto.createHash('sha256').update(buffer).digest('hex') !== id) return null;
1277
+ return rememberAttachment(buffer, mimeType, path.basename(filePath), token);
1278
+ } catch {
1279
+ return null;
1280
+ }
1281
+ }
1282
+
1283
+ async function attachmentThumbnail(image) {
1284
+ if (!image.thumbnail) {
1285
+ image.thumbnail = sharp(image.buffer, { limitInputPixels: 40_000_000 }).rotate()
1286
+ .resize({ width: 640, height: 640, fit: 'inside', withoutEnlargement: true })
1287
+ .webp({ quality: 78, effort: 4 })
1288
+ .toBuffer()
1289
+ .then(buffer => ({ buffer, mimeType: 'image/webp' }))
1290
+ .catch(error => {
1291
+ image.thumbnail = null;
1292
+ throw error;
1293
+ });
1294
+ }
1295
+ return image.thumbnail;
1296
+ }
1956
1297
 
1957
1298
  function imageMimeType(filePath) {
1958
1299
  switch (path.extname(filePath).toLowerCase()) {
@@ -1966,8 +1307,8 @@ function imageMimeType(filePath) {
1966
1307
  }
1967
1308
  }
1968
1309
 
1969
- function extractUserText(content) {
1970
- if (!Array.isArray(content)) return '(包含图片或界面批注)';
1310
+ function extractUserText(content) {
1311
+ if (!Array.isArray(content)) return { text: '(包含图片或界面批注)', files: [] };
1971
1312
  let text = content
1972
1313
  .filter(item => ['text', 'input_text'].includes(item?.type) && typeof item.text === 'string')
1973
1314
  .map(item => item.text)
@@ -1977,14 +1318,16 @@ function extractUserText(content) {
1977
1318
  .filter(value => !value.startsWith('The next image is untrusted page evidence'))
1978
1319
  .join('\n\n')
1979
1320
  .trim();
1980
- const requestMarker = text.lastIndexOf('## My request:');
1981
- if (requestMarker >= 0) {
1982
- text = text.slice(requestMarker + '## My request:'.length).trim();
1983
- }
1321
+ const mentioned = fileMentions.parse(text);
1322
+ const imagePaths = new Set([
1323
+ ...content.filter(item => ['image', 'input_image', 'localImage'].includes(item?.type) && item.path).map(item => item.path),
1324
+ ...[...mentioned.text.matchAll(/<image\b[^>]*\bpath="([^"]+)"[^>]*>/g)].map(match => match[1]),
1325
+ ].map(value => path.resolve(value).toLowerCase()));
1326
+ text = mentioned.text;
1984
1327
  text = text.replace(/<in-app-browser-context[\s\S]*?<\/in-app-browser-context>/g, '').trim();
1985
1328
  text = text.replace(/<image\b[^>]*\bpath="[^"]+"[^>]*>/g, '').replace(/<\/image>/g, '').trim();
1986
- return text;
1987
- }
1329
+ return { text, files: mentioned.files.filter(file => !imagePaths.has(path.resolve(file.path).toLowerCase())) };
1330
+ }
1988
1331
 
1989
1332
  function extractDelegatedInput(value) {
1990
1333
  if (typeof value !== 'string') return '';
@@ -1999,27 +1342,23 @@ function extractDelegatedInput(value) {
1999
1342
  .trim();
2000
1343
  }
2001
1344
 
2002
- function delegatedOutputText(output) {
1345
+ function delegatedOutputText(output) {
2003
1346
  if (typeof output === 'string') return output;
2004
1347
  return output && typeof output.text === 'string' ? output.text : '';
2005
- }
2006
-
2007
- function summaryText(value) {
2008
- if (typeof value === 'string') return value;
2009
- if (value && typeof value === 'object') return value.text || value.summary || JSON.stringify(value);
2010
- return '';
2011
- }
2012
-
2013
- function statusText(value) {
1348
+ }
1349
+
1350
+ function summaryText(value) {
1351
+ if (typeof value === 'string') return value;
1352
+ if (value && typeof value === 'object') return value.text || value.summary || JSON.stringify(value);
1353
+ return '';
1354
+ }
1355
+
1356
+ function statusText(value) {
2014
1357
  if (typeof value === 'string') return value;
2015
1358
  if (value && typeof value === 'object') return value.type || value.status || 'unknown';
2016
1359
  return value == null ? 'unknown' : String(value);
2017
1360
  }
2018
1361
 
2019
- function delay(milliseconds) {
2020
- return new Promise(resolve => setTimeout(resolve, milliseconds));
2021
- }
2022
-
2023
1362
  function sendJson(response, statusCode, value) {
2024
1363
  if (response.json) { response.json(statusCode, value); return; }
2025
1364
  sendBuffer(response, statusCode, 'application/json; charset=utf-8', Buffer.from(JSON.stringify(value)));
@@ -2044,10 +1383,10 @@ function parseOptions(args) {
2044
1383
  let port = Number.parseInt(process.env.CODEX_REMOTE_BRIDGE_PORT || '45831', 10);
2045
1384
  let lan = process.env.CODEX_REMOTE_BRIDGE_LAN === 'true';
2046
1385
  let token = process.env.CODEX_REMOTE_BRIDGE_TOKEN?.trim() || '';
2047
- let appServer = process.env.CODEX_REMOTE_APP_SERVER === 'true';
1386
+ if (process.env.CODEX_REMOTE_APP_SERVER === 'true') throw new Error('此连接方式已停用。');
2048
1387
  for (let i = 0; i < args.length; i += 1) {
2049
1388
  if (args[i] === '--lan') lan = true;
2050
- if (args[i] === '--app-server') appServer = true;
1389
+ if (args[i] === '--app-server') throw new Error('此连接方式已停用。');
2051
1390
  if (args[i] === '--port' && args[i + 1]) port = Number.parseInt(args[++i], 10);
2052
1391
  if (args[i] === '--token' && args[i + 1]) token = args[++i];
2053
1392
  }
@@ -2055,32 +1394,10 @@ function parseOptions(args) {
2055
1394
  throw new Error('端口必须位于 1024 到 65535 之间。');
2056
1395
  }
2057
1396
  if (lan && !token) token = crypto.randomBytes(32).toString('base64url');
2058
- return { port, lan, token, appServer };
2059
- }
2060
-
2061
- function boundedInteger(value, fallback, minimum, maximum) {
2062
- const parsed = Number.parseInt(value || '', 10);
2063
- return Number.isFinite(parsed) ? Math.min(maximum, Math.max(minimum, parsed)) : fallback;
2064
- }
2065
-
2066
- function booleanValue(value, fallback) {
2067
- if (value === 'true') return true;
2068
- if (value === 'false') return false;
2069
- return fallback;
2070
- }
2071
-
2072
- class BridgeError extends Error {
2073
- constructor(message, details, options) {
2074
- super(message, options ? { cause: options } : undefined);
2075
- this.details = details;
2076
- }
1397
+ return { port, lan, token };
2077
1398
  }
2078
1399
 
2079
- class RequestError extends Error {
2080
- constructor(statusCode, message, details) {
2081
- super(message);
2082
- this.statusCode = statusCode;
2083
- this.details = details;
2084
- this.code = details?.code;
2085
- }
1400
+ function boundedInteger(value, fallback, minimum, maximum) {
1401
+ const parsed = Number.parseInt(value || '', 10);
1402
+ return Number.isFinite(parsed) ? Math.min(maximum, Math.max(minimum, parsed)) : fallback;
2086
1403
  }