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

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 (91) hide show
  1. package/README.md +14 -15
  2. package/dist/bin/codex-remote.mjs +81 -26
  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 +50 -22
  7. package/dist/shared/p2p-data-channel.cjs +50 -0
  8. package/dist/skills/code-relax/SKILL.md +1 -1
  9. package/dist/src/desktop-launcher.mjs +11 -11
  10. package/dist/src/lan-socket-server.mjs +8 -2
  11. package/dist/src/managed-app-server.mjs +27 -7
  12. package/dist/src/message-images.mjs +0 -18
  13. package/dist/src/onboarding.mjs +3 -5
  14. package/dist/src/platform/README.md +1 -1
  15. package/dist/src/platform/windows/IsolatedProcess.cs +7 -3
  16. package/dist/src/platform/windows/background-process.mjs +7 -2
  17. package/dist/src/platform/windows/codex-runtime.mjs +117 -0
  18. package/dist/src/platform/windows/desktop-host.mjs +16 -1
  19. package/dist/src/platform/windows/desktop-info.ps1 +2 -4
  20. package/dist/src/platform/windows/desktop-shortcut-run.ps1 +1 -1
  21. package/dist/src/platform/windows/desktop-shortcuts.ps1 +1 -1
  22. package/dist/src/platform/windows/launch-worker.mjs +42 -0
  23. package/dist/src/platform/windows/resident-startup.ps1 +58 -18
  24. package/dist/src/platform/windows/service-host.mjs +4 -7
  25. package/dist/src/platform/windows/start-hidden-console.ps1 +18 -8
  26. package/dist/src/platform/windows/stop-desktop.ps1 +7 -4
  27. package/dist/src/runtime-discovery.mjs +43 -0
  28. package/dist/src/self-relay/admin-cloud.mjs +101 -0
  29. package/dist/src/self-relay/admin-page.mjs +71 -0
  30. package/dist/src/self-relay/admin-server.mjs +61 -0
  31. package/dist/src/self-relay/admin-state.mjs +122 -0
  32. package/dist/src/self-relay/client.mjs +2 -0
  33. package/dist/src/self-relay/connector.mjs +23 -8
  34. package/dist/src/self-relay/demo.mjs +4 -5
  35. package/dist/src/self-relay/lifecycle.mjs +53 -49
  36. package/dist/src/self-relay/p2p-probe.mjs +123 -83
  37. package/dist/src/self-relay/server.mjs +157 -17
  38. package/dist/src/server.mjs +484 -1274
  39. package/dist/src/service-doctor.mjs +13 -25
  40. package/dist/src/service-lifecycle.mjs +12 -12
  41. package/dist/src/shared-app-server.mjs +192 -34
  42. package/dist/src/shared-catalog.mjs +4 -14
  43. package/dist/src/shared-recovery.mjs +8 -6
  44. package/dist/src/shared-thread-stream.mjs +1 -1
  45. package/dist/web/activity-view.js +283 -0
  46. package/dist/web/capabilities.js +2 -9
  47. package/dist/web/chat-transport.js +21 -21
  48. package/dist/web/chat.css +148 -106
  49. package/dist/web/chat.js +1454 -2788
  50. package/dist/web/community.css +82 -0
  51. package/dist/web/community.html +36 -0
  52. package/dist/web/composer-controller.js +97 -0
  53. package/dist/web/conversation-controller.js +99 -0
  54. package/dist/web/disclosure-state-controller.js +95 -0
  55. package/dist/web/draft-controller.js +103 -0
  56. package/dist/web/harmony-platform.js +3 -2
  57. package/dist/web/history-cache.js +112 -18
  58. package/dist/web/history-controller.js +167 -0
  59. package/dist/web/index.html +113 -47
  60. package/dist/web/link-action-controller.js +212 -0
  61. package/dist/web/message-send-controller.js +177 -0
  62. package/dist/web/message-view.js +98 -0
  63. package/dist/web/p2p-data-channel.js +50 -0
  64. package/dist/web/p2p-probe.js +67 -27
  65. package/dist/web/page-resume.js +28 -0
  66. package/dist/web/pending-message-store.js +108 -0
  67. package/dist/web/queue-controller.js +82 -0
  68. package/dist/web/resources.json +1 -1
  69. package/dist/web/self-relay-session.js +28 -18
  70. package/dist/web/station-connection-controller.js +75 -0
  71. package/dist/web/task-list-view.js +289 -0
  72. package/dist/web/thread-attention-controller.js +124 -0
  73. package/dist/web/thread-context-controller.js +30 -0
  74. package/dist/web/thread-list-controller.js +61 -0
  75. package/dist/web/thread-list-sync.js +86 -0
  76. package/dist/web/thread-title-controller.js +57 -0
  77. package/dist/web/timeline-formatters.js +249 -0
  78. package/dist/web/timeline-reducer.js +77 -0
  79. package/dist/web/timeline-renderer.js +161 -0
  80. package/dist/web/timeline-scroll-controller.js +50 -0
  81. package/dist/web/usage-controller.js +258 -0
  82. package/dist/web/vendor/lucide.LICENSE.txt +17 -0
  83. package/package.json +16 -18
  84. package/tools/postinstall.mjs +140 -3
  85. package/dist/src/app-server-client.mjs +0 -468
  86. package/dist/src/app-server-tasks.mjs +0 -355
  87. package/dist/src/platform/windows/desktop-monitor.mjs +0 -34
  88. package/dist/src/platform/windows/desktop-tools.mjs +0 -48
  89. package/dist/src/platform/windows/launch-worker.ps1 +0 -13
  90. package/dist/src/thread-catalog.mjs +0 -42
  91. package/dist/tools/find-desktop-pipe.mjs +0 -10
@@ -1,19 +1,17 @@
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';
7
+ import vm from 'node:vm';
8
+ import sharp from 'sharp';
9
9
  import { attachLanSocket } from './lan-socket-server.mjs';
10
- import { validateInlineImage, persistLegacyImage } from './message-images.mjs';
10
+ import { validateInlineImage } from './message-images.mjs';
11
11
  import { ThreadWriterConflictError } from './shared-app-server.mjs';
12
12
  import { ManagedAppServer, managedAppServerUrl } from './managed-app-server.mjs';
13
+ import { RuntimeDiscovery } from './runtime-discovery.mjs';
13
14
  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
15
  import appServerEvents from '../shared/app-server-events.cjs';
18
16
  import { runtimePaths } from './platform/windows/runtime-paths.mjs';
19
17
  import { openControl } from './service-control.mjs';
@@ -22,18 +20,18 @@ import { ExecutionHealth } from './execution-health.mjs';
22
20
  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
36
  const ALLOWED_MODELS = new Set([
39
37
  'gpt-6-astra',
@@ -44,20 +42,20 @@ const ALLOWED_MODELS = new Set([
44
42
  'gpt-5.3-codex-spark',
45
43
  ]);
46
44
  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
+
46
+ function readRuntimeVersion() {
47
+ let directory = path.dirname(fileURLToPath(import.meta.url));
48
+ while (true) {
49
+ const manifest = readRecord(path.join(directory, 'package.json'));
50
+ if (manifest?.name === '@walkhi/code-relax') return manifest.version || '未知';
51
+ const parent = path.dirname(directory);
52
+ if (parent === directory) return '未知';
53
+ directory = parent;
54
+ }
55
+ }
56
+
57
+ const options = parseOptions(process.argv.slice(2));
58
+ const runtimeVersion = readRuntimeVersion();
61
59
 
62
60
  const webRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'web');
63
61
  const repositoryRoot = path.resolve(webRoot, '..', '..');
@@ -67,77 +65,54 @@ const markdownItScript = fs.readFileSync(path.join(webRoot, 'vendor', 'markdown-
67
65
  const markdownSandbox = { atob };
68
66
  vm.runInNewContext(markdownItScript.toString(), markdownSandbox);
69
67
  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);
68
+ const attachmentCache = new Map();
69
+ const fileCache = new Map();
70
+ const fileCapabilitySecret = crypto.randomBytes(32);
71
+ const imageCapabilityKey = crypto.createHash('sha256')
72
+ .update('code-relax-image-capability\0').update(options.token || fileCapabilitySecret).digest();
74
73
  const legacyUploadsRoot = path.resolve(webRoot, '..', 'uploads');
75
74
  const allowedUploadsRoots = [uploadsRoot, legacyUploadsRoot,
76
75
  ...JSON.parse(process.env.CODEX_REMOTE_LEGACY_UPLOADS_ROOTS || '[]')];
77
76
  const sessionPathCache = new Map();
78
77
  const sessionSettingsCache = new Map();
79
78
  const liveActivityCache = new Map();
80
- const messageQueues = new Map();
81
79
  let sharedServer = null;
82
- const desktopMonitor = new DesktopMonitor({
83
- read: (tool, args, pipe) => callTool(tool, args, 5000, pipe),
84
- });
85
80
  const managedAppServer = new ManagedAppServer({ stateRoot, host: desktopHost });
81
+ const runtimeDiscovery = new RuntimeDiscovery({ stateRoot, host: desktopHost, url: managedAppServerUrl });
86
82
  let sharedCatalog = null, executionMode = 'ordinary';
87
83
  function selectSharedExecution(enabled) {
88
84
  const next = enabled ? managedAppServer.client : null;
89
85
  if (sharedServer === next) return;
90
86
  sharedServer = next;
91
- sharedCatalog = sharedServer ? new SharedCatalog(sharedServer, desktopMonitor) : null;
87
+ sharedCatalog = sharedServer ? new SharedCatalog(sharedServer) : null;
92
88
  }
93
89
  const executionHealth = new ExecutionHealth(async () => {
94
90
  await managedAppServer.ensure();
95
- const [desktop, attachedUrl] = await Promise.all([desktopHost.inspect(), desktopHost.sharedServiceUrl()]);
96
- const attached = Boolean(desktop.running && attachedUrl === managedAppServerUrl);
97
91
  const managed = managedAppServer.snapshot();
98
92
  // The managed app-server owns execution. Desktop is another client of the
99
93
  // same service, so closing Desktop must not disable phone writes.
100
94
  selectSharedExecution(managed.ready);
101
95
  executionMode = sharedServer ? 'shared' : 'ordinary';
102
- const basicReady = Boolean(desktop.running && sourceThreadId && desktopMonitor.pipe);
103
96
  const managedRestarting = managed.state === 'restarting';
104
- const ready = !managedRestarting && (sharedServer ? sharedServer.ready === true : basicReady);
97
+ const ready = !managedRestarting && sharedServer?.ready === true;
105
98
  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 } };
99
+ : ready ? 'full' : 'unavailable';
100
+ return { state, ready, canWrite: ready, mode: executionMode };
109
101
  });
110
102
  async function refreshExecution() {
111
- sourceThreadId = readRecord(path.join(stateRoot, 'bridge-startup-context.json'))?.sourceThreadId || sourceThreadId;
112
103
  await executionHealth.refresh();
113
104
  return { mode: executionMode };
114
105
  }
115
106
  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
107
  const execution = await executionHealth.refresh();
121
108
  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 });
109
+ throw new RequestError(503, 'Codex 内核未就绪。请重启工作站服务;若仍失败,运行 relax doctor。',
110
+ { code: 'full_control_required', execution });
126
111
  }
127
112
  if (threadId && sharedServer) sharedServer.requireWritable(threadId);
128
113
  return execution;
129
114
  }
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
- });
115
+ managedAppServer.client.on('subscription-error', error => console.error('Codex 内核任务取消订阅失败:', error.message));
141
116
 
142
117
  async function handleRequest(request, response) {
143
118
  try {
@@ -170,7 +145,7 @@ async function handleRequest(request, response) {
170
145
  }
171
146
  if (request.method === 'POST' && url.pathname === '/api/shared-recovery') {
172
147
  const body = await readJsonBody(request);
173
- if (body.confirmed !== true || typeof body.operationId !== 'string') throw new RequestError(400, '请确认重新打开当前 Codex 并启用完整控制后再继续。');
148
+ if (body.confirmed !== true || typeof body.operationId !== 'string') throw new RequestError(400, '请确认重新打开当前 Codex 并接入 Codex 内核后再继续。');
174
149
  sendJson(response, 202, await requestRecovery(stateRoot, body)); return;
175
150
  }
176
151
  if (request.method === 'POST' && url.pathname === '/api/service/restart') {
@@ -195,46 +170,45 @@ async function handleRequest(request, response) {
195
170
  if (request.method === 'GET' && url.pathname === '/api/health') {
196
171
  // Status readers consume the workstation's cached sample. A phone query
197
172
  // 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 },
173
+ sendJson(response, 200, {
174
+ status: 'ok', resident: true, version: runtimeVersion,
175
+ execution: { ...executionHealth.state, ready: executionHealth.state.ready && sharedServer?.ready === true,
176
+ mode: executionMode },
204
177
  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,
178
+ ...runtimeDiscovery.state,
208
179
  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,
180
+ sharedAppServerConnected: sharedServer?.ready === true,
181
+ sharedRecoveryAvailable: runtimeDiscovery.state.desktop?.installed === true,
214
182
  mode: options.lan ? 'lan' : 'loopback',
215
183
  tokenRequired: Boolean(options.token),
216
184
  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
- },
185
+ transportMode: sharedServer?.ready === true ? 'lan-shared' : 'unavailable',
223
186
  });
224
187
  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')}` });
188
+ }
189
+
190
+ const imageMatch = /^\/api\/images\/([a-f0-9]{64})$/.exec(url.pathname);
191
+ if (request.method === 'GET' && imageMatch) {
192
+ let image = attachmentCache.get(imageMatch[1])
193
+ || recoverLocalImage(imageMatch[1], url.searchParams.get('recovery') || '');
194
+ const contextThreadId = url.searchParams.get('threadId') || '';
195
+ const contextTurnId = url.searchParams.get('turnId') || '';
196
+ if (!image && sharedServer && contextThreadId && contextTurnId
197
+ && contextThreadId.length <= 128 && contextTurnId.length <= 128) {
198
+ try {
199
+ normalizeTurn(await sharedServer.turnContext(contextThreadId, contextTurnId));
200
+ image = attachmentCache.get(imageMatch[1]);
201
+ } catch {}
202
+ }
203
+ if (!image) throw new RequestError(404, '这张图片已失效,无法从工作站重新读取。');
204
+ const thumbnail = url.searchParams.get('variant') === 'thumbnail';
205
+ const asset = thumbnail ? await attachmentThumbnail(image) : { buffer: image.buffer, mimeType: image.mimeType };
206
+ sendJson(response, 200, { url: `data:${asset.mimeType};base64,${asset.buffer.toString('base64')}` });
233
207
  return;
234
208
  }
235
209
 
236
210
  const fileMatch = /^\/api\/files\/([a-f0-9]{64})$/.exec(url.pathname);
237
- if (request.method === 'GET' && fileMatch) {
211
+ if (request.method === 'GET' && fileMatch) {
238
212
  const file = fileCache.get(fileMatch[1]);
239
213
  if (!file) throw new RequestError(404, '文件授权已失效,请重新读取对话。');
240
214
  let stat;
@@ -245,67 +219,47 @@ async function handleRequest(request, response) {
245
219
  }
246
220
  if (stat.size > MAX_ATTACHMENT_BYTES) throw new RequestError(413, '暂只支持下载不超过 16 MiB 的文件。');
247
221
  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
- }
222
+ return;
223
+ }
224
+
225
+ if (url.pathname.startsWith('/api/app-server/') || url.pathname === '/api/wait') {
226
+ throw new RequestError(410, '此连接方式已停用。请检查 Codex 内核状态。', { code: 'unsupported_control' });
227
+ }
228
+ const taskPath = /^\/api\/threads\/([^/]+)/.exec(url.pathname);
229
+ if (taskPath && (url.pathname.endsWith('/takeover') || url.pathname === `/api/threads/${taskPath[1]}`)) {
230
+ throw new RequestError(410, '此连接方式已停用。', { code: 'unsupported_control' });
231
+ }
232
+ if (url.searchParams.has('hostId') && url.searchParams.get('hostId') !== 'local') {
233
+ throw new RequestError(410, '此对话的连接方式已停用,请检查 Codex 内核状态。', { code: 'unsupported_control' });
234
+ }
235
+ if (!executionHealth.state.ready || sharedServer?.ready !== true) {
236
+ throw new RequestError(503, 'Codex 内核未就绪。请重启工作站服务;若仍失败,运行 relax doctor。',
237
+ { code: 'full_control_required' });
238
+ }
239
+
240
+ if (request.method === 'GET' && url.pathname === '/api/models') {
241
+ sendJson(response, 200, await sharedServer.models());
242
+ return;
243
+ }
256
244
 
257
245
  if (request.method === 'GET' && url.pathname === '/api/threads') {
258
246
  const limit = boundedInteger(url.searchParams.get('limit'), 20, 1, 200);
259
247
  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));
248
+ sendJson(response, 200, await sharedCatalog.threads(limit, archived));
289
249
  return;
290
250
  }
291
251
 
292
252
  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);
253
+ sendJson(response, 200, { projects: (await sharedCatalog.projects()).projects });
299
254
  return;
300
255
  }
301
256
 
302
257
  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', {}));
258
+ sendJson(response, 200, await sharedServer.usage());
305
259
  return;
306
260
  }
307
261
 
308
- if (request.method === 'GET' && url.pathname === '/api/usage-events' && sharedServer) {
262
+ if (request.method === 'GET' && url.pathname === '/api/usage-events') {
309
263
  const execution = sharedServer;
310
264
  response.startEvents();
311
265
  const emit = value => { if (!response.destroyed) response.event('usage', value); };
@@ -317,196 +271,106 @@ async function handleRequest(request, response) {
317
271
  return;
318
272
  }
319
273
 
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
274
  const createThreadMatch = /^\/api\/projects\/([^/]+)\/threads$/.exec(url.pathname);
333
275
  if (request.method === 'POST' && createThreadMatch) {
334
276
  await requireExecutionWritable();
335
277
  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;
278
+ const body = await readJsonBody(request);
279
+ if (body.transport === 'app-server' || (body.hostId && body.hostId !== 'local')) {
280
+ throw new RequestError(410, '此连接方式已停用,请检查 Codex 内核状态。', { code: 'unsupported_control' });
281
+ }
282
+ const projects = (await sharedCatalog.projects()).projects;
283
+ const project = projects.find(item => item.projectId === projectId);
284
+ if (projectId !== '__unassigned__' && !project) throw new RequestError(404, '没有找到对应项目。');
285
+ const execution = sharedServer;
286
+ await execution.connect();
287
+ const rpc = (method, params) => execution.request(method, params);
288
+ let target = appServerEvents.projectlessThreadTarget(projectRoot);
289
+ if (project) {
290
+ const nativeProjects = await appServerEvents.readProjects(rpc);
291
+ const nativeId = nativeProjects.find(item => item.id === project.projectId)?.id;
292
+ if (!nativeId) throw new RequestError(404, 'Codex 内核中没有找到对应项目,请刷新项目列表。');
293
+ target = { projectId:nativeId, cwd:project.path };
294
+ }
295
+ const { input } = appServerTurnArguments('', body);
296
+ const created = await appServerEvents.createThread(rpc, target, input, body);
297
+ const prompt = input.filter(part => part.type === 'text').map(part => part.text).join('\n');
298
+ void generateAndSetThreadTitle({
299
+ request: rpc,
300
+ subscribe: listener => {
301
+ execution.on('notification', listener);
302
+ return () => execution.off('notification', listener);
303
+ },
304
+ threadId: created.threadId,
305
+ prompt,
306
+ cwd: target.cwd || null,
307
+ }).catch(error => console.error(`生成对话标题失败:${error.message}`));
308
+ sendJson(response, 201, { ...created, hostId:'local' });
309
+ return;
395
310
  }
396
311
 
397
- const timelineMatch = /^\/api\/threads\/([^/]+)\/timeline$/.exec(url.pathname);
312
+ const timelineMatch = /^\/api\/threads\/([^/]+)\/timeline$/.exec(url.pathname);
398
313
  if (request.method === 'GET' && timelineMatch) {
399
314
  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
- }
315
+ const timeline = normalizeSharedTimeline(await sharedServer.timeline(threadId, sharedHistoryPageOptions()));
316
+ const knownUpdatedAt = Number(url.searchParams.get('knownUpdatedAt'));
317
+ const knownTurnId = url.searchParams.get('knownTurnId') || '';
318
+ const knownTurnStatus = url.searchParams.get('knownTurnStatus') || '';
319
+ const knownHistoryView = url.searchParams.get('knownHistoryView') || '';
320
+ const latest = timeline.turns.at(-1);
321
+ const knownIndex = knownTurnId ? timeline.turns.findIndex(turn => turn.id === knownTurnId) : -1;
322
+ const sameHistoryView = knownHistoryView === timeline.historyView;
323
+ const unchanged = sameHistoryView
324
+ && knownIndex === timeline.turns.length - 1
325
+ && timeline.thread.status === 'idle' && latest?.status !== 'inProgress'
326
+ && knownTurnStatus && knownTurnStatus !== 'inProgress'
327
+ && (!Number.isFinite(knownUpdatedAt) || knownUpdatedAt <= 0 || timeline.thread.updatedAt === knownUpdatedAt);
328
+ const partial = !unchanged && sameHistoryView && knownIndex >= 0
329
+ ? { ...timeline, turns: timeline.turns.slice(knownIndex), partialFromTurnId: knownTurnId }
330
+ : timeline;
331
+ sendJson(response, 200, unchanged ? { ...timeline, turns: [], notModified: true } : partial);
332
+ return;
333
+ }
436
334
 
437
335
  const approvalMatch = /^\/api\/threads\/([^/]+)\/approvals$/.exec(url.pathname);
438
336
  if (request.method === 'POST' && approvalMatch) {
439
337
  const threadId = decodeURIComponent(approvalMatch[1]);
440
338
  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);
339
+ const body = await readJsonBody(request);
445
340
  sendJson(response, 200, await sharedServer.respondApproval(threadId, body)); return;
446
341
  }
447
342
 
448
343
  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
- }
344
+ if (request.method === 'GET' && eventsMatch) {
345
+ const threadId = decodeURIComponent(eventsMatch[1]);
346
+ const after = Number(url.searchParams.get('afterSeq'));
347
+ const requestedSync = url.searchParams.get('syncId') && url.searchParams.get('syncEpoch') && Number.isSafeInteger(after) && after >= 0
348
+ ? { id: url.searchParams.get('syncId'), epoch: url.searchParams.get('syncEpoch'), after } : undefined;
349
+ const knownUpdatedAt = Number(url.searchParams.get('knownUpdatedAt'));
350
+ const known = url.searchParams.get('knownTurnId') ? {
351
+ updatedAt: Number.isFinite(knownUpdatedAt) ? knownUpdatedAt : 0,
352
+ turnId: url.searchParams.get('knownTurnId'),
353
+ turnStatus: url.searchParams.get('knownTurnStatus') || '',
354
+ } : undefined;
355
+ await streamSharedThread(response, threadId, sharedServer, normalizeSharedTimeline, requestedSync, known);
356
+ return;
357
+ }
493
358
 
494
359
  const contextMatch = /^\/api\/threads\/([^/]+)\/context$/.exec(url.pathname);
495
360
  if (request.method === 'GET' && contextMatch) {
496
361
  const threadId = decodeURIComponent(contextMatch[1]);
497
362
  const current = readContextUsage(threadId);
498
- const native = usesSharedTask({ hostId: url.searchParams.get('hostId') }) ? await sharedServer.readContext(threadId) : {};
363
+ const native = await sharedServer.readContext(threadId);
499
364
  sendJson(response, 200, { ...current, ...(native.model ? { model: native.model, thinking: native.thinking, serviceTier: native.serviceTier,
500
365
  permissionMode: native.permissionMode, permissionModes: native.permissionModes } : {}), ...(native.available ? native : {}) });
501
366
  return;
502
367
  }
503
368
 
504
369
  const sharedAction = /^\/api\/threads\/([^/]+)\/(history|settings|permissions)$/.exec(url.pathname);
505
- if (sharedAction) {
370
+ if (sharedAction) {
506
371
  const id = decodeURIComponent(sharedAction[1]);
507
- if (!usesSharedTask({ hostId: url.searchParams.get('hostId') }) || appServerTasks.owns(id)) throw new RequestError(400, '此连接不支持该操作。');
508
372
  if (request.method === 'GET' && sharedAction[2] === 'history') {
509
- sendJson(response, 200, normalizeSharedTimeline(await sharedServer.history(id, url.searchParams.get('cursor'))));
373
+ sendJson(response, 200, normalizeSharedTimeline(await sharedServer.history(id, url.searchParams.get('cursor'), sharedHistoryPageOptions())));
510
374
  return;
511
375
  }
512
376
  if (request.method === 'POST' && sharedAction[2] === 'settings') {
@@ -520,194 +384,73 @@ async function handleRequest(request, response) {
520
384
  const body = await readJsonBody(request);
521
385
  sendJson(response, 200, await sharedServer.permissions(id, body.mode));
522
386
  return;
523
- }
524
- }
525
-
526
- const archiveMatch = /^\/api\/threads\/([^/]+)\/(archive|unarchive)$/.exec(url.pathname);
387
+ }
388
+ }
389
+
390
+ const sharedOperationGroup = /^\/api\/threads\/([^/]+)\/turns\/([^/]+)\/groups\/([^/]+)\/items$/.exec(url.pathname);
391
+ if (request.method === 'GET' && sharedOperationGroup) {
392
+ const threadId = decodeURIComponent(sharedOperationGroup[1]);
393
+ const turnId = decodeURIComponent(sharedOperationGroup[2]);
394
+ const groupId = decodeURIComponent(sharedOperationGroup[3]);
395
+ const page = await sharedServer.operationGroupItems(threadId, turnId, groupId, url.searchParams.get('offset'));
396
+ sendJson(response, 200, { turnId: page.turnId, groupId: page.groupId,
397
+ entries: page.items.map(normalizeItem).filter(Boolean), nextOffset: page.nextOffset, total: page.total });
398
+ return;
399
+ }
400
+
401
+ const archiveMatch = /^\/api\/threads\/([^/]+)\/(archive|unarchive)$/.exec(url.pathname);
527
402
  if (request.method === 'POST' && archiveMatch) {
528
403
  const threadId = decodeURIComponent(archiveMatch[1]);
529
404
  await requireExecutionWritable(threadId);
530
- if (!usesSharedTask({ hostId: url.searchParams.get('hostId') }) || appServerTasks.owns(threadId)) {
531
- throw new RequestError(400, '当前连接不支持归档对话。请启用完整控制。');
532
- }
533
405
  await sharedServer.request(`thread/${archiveMatch[2]}`, { threadId });
534
406
  sendJson(response, 200, { threadId, archived: archiveMatch[2] === 'archive' });
535
407
  return;
536
408
  }
537
409
 
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
- }
410
+ const queueMatch = /^\/api\/threads\/([^/]+)\/queue$/.exec(url.pathname);
411
+ if (queueMatch) {
412
+ const threadId = decodeURIComponent(queueMatch[1]);
413
+ await sharedServer.connect();
414
+ const rpc = (method, params) => sharedServer.request(method, params);
415
+ if (request.method === 'GET') { sendJson(response, 200, await appServerEvents.readQueue(rpc, threadId)); return; }
581
416
  if (request.method === 'POST') {
582
417
  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
- }
418
+ const args = appServerTurnArguments(threadId, await readJsonBody(request));
419
+ sendJson(response, 202, await appServerEvents.addQueue(rpc, threadId, args.input, crypto.randomUUID())); return;
420
+ }
421
+ }
605
422
 
606
423
  const queueItemMatch = /^\/api\/threads\/([^/]+)\/queue\/([a-f0-9-]+)(?:\/(steer))?$/.exec(url.pathname);
607
424
  if (queueItemMatch) {
608
425
  const threadId = decodeURIComponent(queueItemMatch[1]);
609
426
  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]);
427
+ await sharedServer.connect();
428
+ const action = request.method === 'DELETE' ? 'delete' : queueItemMatch[3] === 'steer' ? 'steer' : null;
429
+ if (!action) throw new RequestError(405, '不支持的队列操作');
646
430
  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
- }
431
+ sendJson(response, 200, await appServerEvents.changeQueue((method, params) => sharedServer.request(method, params), threadId, itemId, action)); return;
432
+ }
662
433
 
663
- const interruptMatch = /^\/api\/threads\/([^/]+)\/interrupt$/.exec(url.pathname);
434
+ const interruptMatch = /^\/api\/threads\/([^/]+)\/interrupt$/.exec(url.pathname);
664
435
  if (request.method === 'POST' && interruptMatch) {
665
436
  const threadId = decodeURIComponent(interruptMatch[1]);
666
437
  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;
438
+ const body = await readJsonBody(request);
439
+ if (!body.turnId) throw new RequestError(400, '缺少活动轮次 ID。');
440
+ await sharedServer.interrupt(threadId, body.turnId);
441
+ sendJson(response, 200, { requested: true });
442
+ return;
684
443
  }
685
444
 
686
445
  const messageMatch = /^\/api\/threads\/([^/]+)\/messages$/.exec(url.pathname);
687
446
  if (request.method === 'POST' && messageMatch) {
688
447
  const threadId = decodeURIComponent(messageMatch[1]);
689
448
  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
- }
449
+ const body = await readJsonBody(request);
450
+ if (body.hostId && body.hostId !== 'local') throw new RequestError(410, '此对话的连接方式已停用。', { code: 'unsupported_control' });
451
+ sendJson(response, 200, { success: true, data: await sharedServer.send(appServerTurnArguments(threadId, body)) });
452
+ return;
453
+ }
711
454
 
712
455
  sendJson(response, 404, { error: '未找到请求的资源。' });
713
456
  } catch (error) {
@@ -731,19 +474,10 @@ const lanSocket = attachLanSocket(server, { token: options.token, dispatch: hand
731
474
  authorizeDevice: (clientId, credential) => authorizeLanDevice(stateRoot, clientId, credential),
732
475
  });
733
476
 
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
477
  let control;
744
- desktopMonitor.start();
745
478
  void managedAppServer.start();
746
479
  executionHealth.start();
480
+ runtimeDiscovery.start();
747
481
  server.on('error', error => {
748
482
  console.error('Bridge 监听失败:', error.message);
749
483
  process.exit(1);
@@ -751,8 +485,14 @@ server.on('error', error => {
751
485
  server.listen(options.port, options.lan ? '0.0.0.0' : '127.0.0.1', async () => {
752
486
  try {
753
487
  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 } });
488
+ control = await openControl({ instanceId: process.env.CODEX_REMOTE_INSTANCE,
489
+ secret: process.env.CODEX_REMOTE_CONTROL_SECRET, shutdown: shutDown, actions: {
490
+ 'refresh-execution': refreshExecution,
491
+ 'restart-managed-app-server': () => {
492
+ const state = managedAppServer.snapshot();
493
+ return state.launcherRestartRequired ? managedAppServer.restart(crypto.randomUUID()) : state;
494
+ },
495
+ } });
756
496
  writeRecord(process.env.CODEX_REMOTE_READY_PATH, { instanceId: process.env.CODEX_REMOTE_INSTANCE,
757
497
  pid: process.pid, controlPort: control.port });
758
498
  }
@@ -772,147 +512,15 @@ async function shutDown() {
772
512
  shuttingDown = true;
773
513
  lanSocket.close();
774
514
  server.close();
775
- desktopMonitor?.stop();
776
515
  executionHealth?.stop();
516
+ runtimeDiscovery.stop();
777
517
  managedAppServer.close();
778
- await appServer.close();
779
518
  await control?.close();
780
519
  process.exit(0);
781
520
  }
782
521
  process.on('SIGTERM', shutDown);
783
522
  process.on('SIGINT', shutDown);
784
523
 
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
524
  function readContextUsage(threadId) {
917
525
  if (!/^[0-9a-f]{8}-[0-9a-f-]{20,55}$/i.test(threadId)) {
918
526
  return { available: false };
@@ -1035,108 +643,6 @@ function findSessionPath(threadId) {
1035
643
  return null;
1036
644
  }
1037
645
 
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
646
  function messagePayload(body) {
1141
647
  const prompt = typeof body.prompt === 'string' ? body.prompt.trim() : '';
1142
648
  if (body.images !== undefined && (!Array.isArray(body.images) || body.images.length > 4)) {
@@ -1173,26 +679,6 @@ function messagePayload(body) {
1173
679
  return payload;
1174
680
  }
1175
681
 
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
682
  function appServerTurnArguments(threadId, body) {
1197
683
  const payload = messagePayload(body);
1198
684
  const input = [];
@@ -1205,332 +691,22 @@ function appServerTurnArguments(threadId, body) {
1205
691
  return value;
1206
692
  }
1207
693
 
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 {
694
+ function normalizeTimeline(data) {
695
+ const source = data && typeof data === 'object' ? data : {};
696
+ const sourceTurns = Array.isArray(source.turns) ? source.turns : source.thread?.turns;
697
+ const turns = Array.isArray(sourceTurns) ? sourceTurns.map(normalizeTurn) : [];
698
+ return {
1473
699
  schemaVersion: 1,
1474
700
  thread: normalizeThread(source.thread),
1475
701
  turns,
1476
702
  };
1477
703
  }
1478
704
 
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
- }
705
+ function normalizeSharedTimeline({ thread, nextCursor, sync }) {
706
+ return { ...normalizeTimeline({ thread: { ...thread, title: thread.name || thread.title || '', hostId: 'local', transport: 'shared-app-server' } }),
707
+ historyView: 'grouped-lazy-skeleton-v11',
708
+ ...(nextCursor !== undefined ? { nextCursor } : {}), ...(sync ? { sync } : {}) };
709
+ }
1534
710
 
1535
711
  function normalizeThread(thread) {
1536
712
  const source = thread && typeof thread === 'object' ? thread : {};
@@ -1539,30 +715,49 @@ function normalizeThread(thread) {
1539
715
  title: source.name || source.title || source.preview || '',
1540
716
  status: statusText(source.status),
1541
717
  updatedAt: Number(source.updatedAt) || 0,
718
+ recencyAt: Number(source.recencyAt ?? source.updatedAt) || 0,
1542
719
  projectId: source.projectId || '',
1543
720
  cwd: source.cwd || '',
1544
721
  hostId: source.hostId || '',
1545
- transport: source.transport || (source.hostId === 'app-server' ? 'app-server' : 'desktop-tools'),
722
+ transport: source.transport || 'shared-app-server',
1546
723
  readOnly: source.readOnly === true,
1547
724
  readOnlyReason: source.readOnlyReason || '',
1548
725
  };
1549
726
  }
1550
727
 
1551
- function normalizeTurn(turn) {
1552
- const source = turn && typeof turn === 'object' ? turn : {};
1553
- return {
1554
- id: source.id || '',
1555
- status: statusText(source.status),
728
+ function normalizeTurn(turn) {
729
+ const source = turn && typeof turn === 'object' ? turn : {};
730
+ const turnId = source.id || '';
731
+ const entries = Array.isArray(source.items) ? source.items.map(normalizeItem).filter(Boolean) : [];
732
+ for (const entry of entries) {
733
+ const images = [...(entry.attachments || []), entry.image, ...Object.values(entry.imageAssets || {})].filter(Boolean);
734
+ for (const image of images) if (image.remotePath && !image.recoveryTurnId) image.recoveryTurnId = turnId;
735
+ }
736
+ return {
737
+ id: turnId,
738
+ status: statusText(source.status),
1556
739
  startedAt: source.startedAt || null,
1557
740
  completedAt: source.completedAt || null,
1558
741
  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;
742
+ error: source.error ?? null,
743
+ entries,
744
+ };
745
+ }
746
+
747
+ function normalizeItem(item) {
748
+ if (!item || typeof item !== 'object') return null;
749
+
750
+ if (item.type === 'lazyOperationGroup') {
751
+ return {
752
+ type: 'activity',
753
+ id: item.id || '',
754
+ kind: 'lazyOperationGroup',
755
+ turnId: item.turnId || '',
756
+ groupId: item.groupId || '',
757
+ title: item.title || '执行过程',
758
+ count: Number(item.count) || 0,
759
+ };
760
+ }
1566
761
 
1567
762
  if (item.type === 'contextCompaction') {
1568
763
  return {
@@ -1616,132 +811,147 @@ function normalizeItem(item) {
1616
811
  };
1617
812
  }
1618
813
 
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
- }
814
+ if (item.type === 'reasoning') {
815
+ const detail = Array.isArray(item.summary)
816
+ ? item.summary.map(summaryText).filter(Boolean).join('\n')
817
+ : summaryText(item.summary);
818
+ return {
819
+ type: 'activity',
820
+ id: item.id || '',
821
+ kind: 'reasoning',
822
+ title: '思考摘要',
823
+ detail,
824
+ };
825
+ }
1646
826
 
827
+ if (item.type === 'commandExecution') {
828
+ return {
829
+ type: 'activity',
830
+ id: item.id || '',
831
+ kind: 'command',
832
+ title: '命令',
833
+ titleOnly: true,
834
+ status: statusText(item.status),
835
+ command: item.command,
836
+ cwd: item.cwd,
837
+ exitCode: item.exitCode,
838
+ durationMs: item.durationMs,
839
+ };
840
+ }
841
+
1647
842
  if (item.type === 'mcpToolCall') {
1648
- const result = normalizeToolResult(item.result, item.error);
1649
843
  return {
1650
844
  type: 'activity',
1651
845
  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,
846
+ kind: 'tool',
847
+ title: '工具',
848
+ titleOnly: true,
849
+ status: statusText(item.status),
850
+ server: item.server,
851
+ tool: item.tool,
852
+ arguments: item.arguments,
853
+ durationMs: item.durationMs,
854
+ error: item.error,
855
+ };
856
+ }
857
+
858
+ if (item.type === 'dynamicToolCall') {
859
+ return {
860
+ type: 'activity',
861
+ id: item.id || '',
862
+ kind: 'tool',
863
+ title: '工具',
864
+ titleOnly: true,
865
+ status: statusText(item.status),
866
+ namespace: item.namespace,
867
+ tool: item.tool,
868
+ arguments: item.arguments,
869
+ durationMs: item.durationMs,
870
+ success: item.success,
871
+ };
872
+ }
873
+
874
+ if (item.type === 'fileChange') {
875
+ return {
876
+ type: 'activity',
877
+ id: item.id || '',
878
+ kind: 'fileChange',
879
+ title: '文件修改',
880
+ titleOnly: true,
881
+ status: statusText(item.status),
882
+ changes: Array.isArray(item.changes) ? item.changes : [],
1663
883
  };
1664
884
  }
1665
885
 
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
- }
886
+ if (item.type === 'webSearch') {
887
+ return {
888
+ type: 'activity',
889
+ id: item.id || '',
890
+ kind: 'webSearch',
891
+ title: '网页搜索',
892
+ titleOnly: true,
893
+ query: item.query || item.action?.query || '',
894
+ ...(item.action && typeof item.action === 'object' ? { action: item.action } : {}),
895
+ };
896
+ }
897
+
898
+ if (item.type === 'imageView') {
899
+ return {
900
+ type: 'activity',
901
+ id: item.id || '',
902
+ kind: 'imageView',
903
+ title: '查看图片',
904
+ titleOnly: true,
905
+ path: item.path || '',
906
+ image: typeof item.path === 'string' && item.path
907
+ ? normalizeAttachment({ type: 'localImage', path: item.path })
908
+ : null,
909
+ };
910
+ }
911
+
912
+ if (item.type === 'imageGeneration') {
913
+ const image = typeof item.savedPath === 'string' && item.savedPath
914
+ ? normalizeAttachment({ type: 'localImage', path: item.savedPath, name: '生成的图片' })
915
+ : null;
916
+ return {
917
+ type: 'activity',
918
+ id: item.id || '',
919
+ kind: 'imageGeneration',
920
+ title: '生成图片',
921
+ titleOnly: true,
922
+ status: statusText(item.status),
923
+ image,
924
+ failure: item.failure || null,
925
+ transparentBackground: item.transparentBackground === true,
926
+ };
927
+ }
928
+
929
+ if (item.type === 'subAgentActivity') {
930
+ return {
931
+ type: 'activity',
932
+ id: item.id || '',
933
+ kind: 'subAgent',
934
+ title: '子任务',
935
+ titleOnly: true,
936
+ status: statusText(item.kind),
937
+ agentPath: item.agentPath || '',
938
+ agentThreadId: item.agentThreadId || '',
939
+ };
940
+ }
941
+
942
+ if (item.type === 'collabAgentToolCall') {
943
+ return {
944
+ type: 'activity',
945
+ id: item.id || '',
946
+ kind: 'collaboration',
947
+ title: '任务协作',
948
+ titleOnly: true,
949
+ status: statusText(item.status),
950
+ tool: item.tool || '',
951
+ prompt: item.prompt || '',
952
+ receiverThreadIds: Array.isArray(item.receiverThreadIds) ? item.receiverThreadIds : [],
953
+ };
954
+ }
1745
955
 
1746
956
  return {
1747
957
  type: 'activity',
@@ -1753,44 +963,15 @@ function normalizeItem(item) {
1753
963
  };
1754
964
  }
1755
965
 
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); }
1764
- };
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;
1774
- }
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;
1780
- }
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);
966
+ function sharedHistoryPageOptions() {
967
+ return {
968
+ maxTurns: SHARED_HISTORY_PAGE_TURNS,
969
+ maxBytes: SHARED_HISTORY_PAGE_BYTES,
970
+ measure: value => Buffer.byteLength(JSON.stringify(normalizeSharedTimeline(value))),
1785
971
  };
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
972
  }
1792
-
1793
- function normalizeAttachments(content) {
973
+
974
+ function normalizeAttachments(content) {
1794
975
  if (!Array.isArray(content)) return [];
1795
976
  const items = content.filter(item => item && !['text', 'input_text'].includes(item.type)).map(item => ({ ...item }));
1796
977
  const text = content.filter(item => ['text', 'input_text'].includes(item?.type)).map(item => item.text || '').join('\n');
@@ -1833,18 +1014,19 @@ function markdownImageAssets(text) {
1833
1014
  return assets;
1834
1015
  }
1835
1016
 
1836
- function markdownFileAssets(text) {
1017
+ function markdownFileAssets(text) {
1837
1018
  const assets = Object.create(null);
1838
1019
  function visit(tokens) {
1839
1020
  for (const token of tokens) {
1840
1021
  if (token.type === 'link_open') {
1841
1022
  const value = token.attrGet('href');
1842
1023
  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);
1024
+ let localPath;
1025
+ try { localPath = decodeURIComponent(value); } catch { localPath = ''; }
1026
+ if (/^\/?[a-z]:[\\/]/i.test(localPath)) {
1027
+ localPath = localPath.replace(/^\/(?=[a-z]:)/i, '');
1028
+ localPath = localPath.replace(/:(\d+)(?::\d+)?$/, '');
1029
+ const asset = rememberLocalFile(localPath);
1848
1030
  if (asset) assets[value] = { remotePath: asset.id, name: asset.name, size: asset.size };
1849
1031
  }
1850
1032
  }
@@ -1914,18 +1096,23 @@ function normalizeAttachment(item) {
1914
1096
  name,
1915
1097
  mimeType: cached?.mimeType || item.mimeType || '',
1916
1098
  available: Boolean(cached),
1917
- remotePath: cached ? cached.id : '',
1099
+ remotePath: cached ? cached.id : '',
1100
+ recoveryToken: cached?.recoveryToken || '',
1918
1101
  url: '',
1919
1102
  thumbnailUrl: '',
1920
1103
  };
1921
1104
  }
1922
1105
 
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);
1106
+ function cacheLocalImage(filePath, name) {
1107
+ try {
1108
+ const resolved = path.resolve(filePath);
1109
+ const stat = fs.statSync(resolved);
1110
+ const mimeType = imageMimeType(resolved);
1111
+ if (!stat.isFile() || !mimeType || stat.size > MAX_ATTACHMENT_BYTES) return null;
1112
+ const cached = rememberAttachment(fs.readFileSync(resolved), mimeType, name,
1113
+ localImageRecoveryToken(resolved, stat));
1114
+ void attachmentThumbnail(cached).catch(() => {});
1115
+ return cached;
1929
1116
  } catch {
1930
1117
  return null;
1931
1118
  }
@@ -1943,16 +1130,65 @@ function cacheDataImage(dataUrl, name) {
1943
1130
  }
1944
1131
  }
1945
1132
 
1946
- function rememberAttachment(buffer, mimeType, name) {
1947
- const id = crypto.createHash('sha256').update(buffer).digest('hex');
1948
- if (!attachmentCache.has(id)) {
1133
+ function rememberAttachment(buffer, mimeType, name, recoveryToken = '') {
1134
+ const id = crypto.createHash('sha256').update(buffer).digest('hex');
1135
+ if (!attachmentCache.has(id)) {
1949
1136
  while (attachmentCache.size >= MAX_CACHED_ATTACHMENTS) {
1950
1137
  attachmentCache.delete(attachmentCache.keys().next().value);
1951
- }
1952
- attachmentCache.set(id, { id, buffer, mimeType, name });
1953
- }
1954
- return attachmentCache.get(id);
1955
- }
1138
+ }
1139
+ attachmentCache.set(id, { id, buffer, mimeType, name, recoveryToken });
1140
+ } else if (recoveryToken && !attachmentCache.get(id).recoveryToken) {
1141
+ attachmentCache.get(id).recoveryToken = recoveryToken;
1142
+ }
1143
+ return attachmentCache.get(id);
1144
+ }
1145
+
1146
+ function localImageRecoveryToken(filePath, stat) {
1147
+ const iv = crypto.randomBytes(12);
1148
+ const cipher = crypto.createCipheriv('aes-256-gcm', imageCapabilityKey, iv);
1149
+ const payload = Buffer.from(JSON.stringify([filePath, stat.size, stat.mtimeMs]));
1150
+ const encrypted = Buffer.concat([cipher.update(payload), cipher.final()]);
1151
+ return Buffer.concat([iv, cipher.getAuthTag(), encrypted]).toString('base64url');
1152
+ }
1153
+
1154
+ function recoverLocalImage(id, token) {
1155
+ if (!token || token.length > 2048) return null;
1156
+ try {
1157
+ const value = Buffer.from(token, 'base64url');
1158
+ if (value.length < 29) return null;
1159
+ const decipher = crypto.createDecipheriv('aes-256-gcm', imageCapabilityKey, value.subarray(0, 12));
1160
+ decipher.setAuthTag(value.subarray(12, 28));
1161
+ const [filePath, size, mtimeMs] = JSON.parse(Buffer.concat([
1162
+ decipher.update(value.subarray(28)), decipher.final(),
1163
+ ]).toString('utf8'));
1164
+ if (typeof filePath !== 'string' || !path.isAbsolute(filePath)
1165
+ || !Number.isSafeInteger(size) || size <= 0 || size > MAX_ATTACHMENT_BYTES
1166
+ || typeof mtimeMs !== 'number' || !Number.isFinite(mtimeMs)) return null;
1167
+ const stat = fs.statSync(filePath);
1168
+ const mimeType = imageMimeType(filePath);
1169
+ if (!stat.isFile() || stat.size !== size || stat.mtimeMs !== mtimeMs || !mimeType) return null;
1170
+ const buffer = fs.readFileSync(filePath);
1171
+ if (crypto.createHash('sha256').update(buffer).digest('hex') !== id) return null;
1172
+ return rememberAttachment(buffer, mimeType, path.basename(filePath), token);
1173
+ } catch {
1174
+ return null;
1175
+ }
1176
+ }
1177
+
1178
+ async function attachmentThumbnail(image) {
1179
+ if (!image.thumbnail) {
1180
+ image.thumbnail = sharp(image.buffer, { limitInputPixels: 40_000_000 }).rotate()
1181
+ .resize({ width: 640, height: 640, fit: 'inside', withoutEnlargement: true })
1182
+ .webp({ quality: 78, effort: 4 })
1183
+ .toBuffer()
1184
+ .then(buffer => ({ buffer, mimeType: 'image/webp' }))
1185
+ .catch(error => {
1186
+ image.thumbnail = null;
1187
+ throw error;
1188
+ });
1189
+ }
1190
+ return image.thumbnail;
1191
+ }
1956
1192
 
1957
1193
  function imageMimeType(filePath) {
1958
1194
  switch (path.extname(filePath).toLowerCase()) {
@@ -1999,27 +1235,23 @@ function extractDelegatedInput(value) {
1999
1235
  .trim();
2000
1236
  }
2001
1237
 
2002
- function delegatedOutputText(output) {
1238
+ function delegatedOutputText(output) {
2003
1239
  if (typeof output === 'string') return output;
2004
1240
  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) {
1241
+ }
1242
+
1243
+ function summaryText(value) {
1244
+ if (typeof value === 'string') return value;
1245
+ if (value && typeof value === 'object') return value.text || value.summary || JSON.stringify(value);
1246
+ return '';
1247
+ }
1248
+
1249
+ function statusText(value) {
2014
1250
  if (typeof value === 'string') return value;
2015
1251
  if (value && typeof value === 'object') return value.type || value.status || 'unknown';
2016
1252
  return value == null ? 'unknown' : String(value);
2017
1253
  }
2018
1254
 
2019
- function delay(milliseconds) {
2020
- return new Promise(resolve => setTimeout(resolve, milliseconds));
2021
- }
2022
-
2023
1255
  function sendJson(response, statusCode, value) {
2024
1256
  if (response.json) { response.json(statusCode, value); return; }
2025
1257
  sendBuffer(response, statusCode, 'application/json; charset=utf-8', Buffer.from(JSON.stringify(value)));
@@ -2044,10 +1276,10 @@ function parseOptions(args) {
2044
1276
  let port = Number.parseInt(process.env.CODEX_REMOTE_BRIDGE_PORT || '45831', 10);
2045
1277
  let lan = process.env.CODEX_REMOTE_BRIDGE_LAN === 'true';
2046
1278
  let token = process.env.CODEX_REMOTE_BRIDGE_TOKEN?.trim() || '';
2047
- let appServer = process.env.CODEX_REMOTE_APP_SERVER === 'true';
1279
+ if (process.env.CODEX_REMOTE_APP_SERVER === 'true') throw new Error('此连接方式已停用。');
2048
1280
  for (let i = 0; i < args.length; i += 1) {
2049
1281
  if (args[i] === '--lan') lan = true;
2050
- if (args[i] === '--app-server') appServer = true;
1282
+ if (args[i] === '--app-server') throw new Error('此连接方式已停用。');
2051
1283
  if (args[i] === '--port' && args[i + 1]) port = Number.parseInt(args[++i], 10);
2052
1284
  if (args[i] === '--token' && args[i + 1]) token = args[++i];
2053
1285
  }
@@ -2055,32 +1287,10 @@ function parseOptions(args) {
2055
1287
  throw new Error('端口必须位于 1024 到 65535 之间。');
2056
1288
  }
2057
1289
  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;
1290
+ return { port, lan, token };
2070
1291
  }
2071
1292
 
2072
- class BridgeError extends Error {
2073
- constructor(message, details, options) {
2074
- super(message, options ? { cause: options } : undefined);
2075
- this.details = details;
2076
- }
2077
- }
2078
-
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
- }
1293
+ function boundedInteger(value, fallback, minimum, maximum) {
1294
+ const parsed = Number.parseInt(value || '', 10);
1295
+ return Number.isFinite(parsed) ? Math.min(maximum, Math.max(minimum, parsed)) : fallback;
2086
1296
  }