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

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 (88) hide show
  1. package/README.md +44 -54
  2. package/dist/bin/codex-remote.mjs +34 -60
  3. package/dist/bin/migrate-relay-db.mjs +104 -0
  4. package/dist/bin/self-relay-server.mjs +17 -10
  5. package/dist/shared/app-server-events.cjs +15 -3
  6. package/dist/shared/file-mentions.cjs +35 -0
  7. package/dist/shared/server-requests.cjs +86 -58
  8. package/dist/skills/code-relax/SKILL.md +1 -1
  9. package/dist/src/desktop-launcher.mjs +6 -11
  10. package/dist/src/file-upload-session.mjs +124 -0
  11. package/dist/src/lan-socket-server.mjs +51 -8
  12. package/dist/src/managed-app-server.mjs +21 -10
  13. package/dist/src/onboarding.mjs +3 -3
  14. package/dist/src/platform/windows/codex-runtime.mjs +138 -4
  15. package/dist/src/platform/windows/desktop-host.mjs +1 -2
  16. package/dist/src/platform/windows/desktop-runtime.ps1 +10 -0
  17. package/dist/src/platform/windows/desktop-shortcuts.ps1 +6 -13
  18. package/dist/src/platform/windows/resident-start.js +8 -0
  19. package/dist/src/platform/windows/resident-startup.ps1 +6 -3
  20. package/dist/src/platform/windows/service-host.mjs +0 -9
  21. package/dist/src/self-relay/admin-page.mjs +1 -1
  22. package/dist/src/self-relay/admin-state.mjs +8 -6
  23. package/dist/src/self-relay/connector.mjs +84 -12
  24. package/dist/src/self-relay/email-accounts.mjs +125 -0
  25. package/dist/src/self-relay/lifecycle.mjs +52 -100
  26. package/dist/src/self-relay/mysql-state.mjs +104 -0
  27. package/dist/src/self-relay/p2p-probe.mjs +1 -1
  28. package/dist/src/self-relay/server.mjs +155 -38
  29. package/dist/src/server.mjs +736 -610
  30. package/dist/src/service-doctor.mjs +1 -1
  31. package/dist/src/service-install.mjs +1 -4
  32. package/dist/src/shared-app-server.mjs +274 -272
  33. package/dist/src/shared-catalog.mjs +1 -11
  34. package/dist/src/shared-recovery.mjs +5 -5
  35. package/dist/src/skill-install.mjs +0 -13
  36. package/package.json +2 -1
  37. package/dist/src/debug-ui.mjs +0 -131
  38. package/dist/src/platform/windows/desktop-shortcut-run.ps1 +0 -5
  39. package/dist/web/activity-view.js +0 -283
  40. package/dist/web/approval-ui.js +0 -188
  41. package/dist/web/capabilities.js +0 -50
  42. package/dist/web/chat-transport.js +0 -196
  43. package/dist/web/chat.css +0 -778
  44. package/dist/web/chat.js +0 -3815
  45. package/dist/web/client-platform.js +0 -11
  46. package/dist/web/community.css +0 -82
  47. package/dist/web/community.html +0 -36
  48. package/dist/web/composer-controller.js +0 -97
  49. package/dist/web/conversation-controller.js +0 -99
  50. package/dist/web/disclosure-state-controller.js +0 -95
  51. package/dist/web/draft-controller.js +0 -103
  52. package/dist/web/harmony-platform.js +0 -52
  53. package/dist/web/highlight-powershell.min.js +0 -39
  54. package/dist/web/highlight.min.js +0 -1262
  55. package/dist/web/history-cache.js +0 -240
  56. package/dist/web/history-controller.js +0 -167
  57. package/dist/web/index.html +0 -288
  58. package/dist/web/lan-session.js +0 -122
  59. package/dist/web/link-action-controller.js +0 -212
  60. package/dist/web/message-send-controller.js +0 -177
  61. package/dist/web/message-view.js +0 -98
  62. package/dist/web/p2p-data-channel.js +0 -50
  63. package/dist/web/p2p-diagnostics.js +0 -28
  64. package/dist/web/p2p-probe.js +0 -123
  65. package/dist/web/page-resume.js +0 -28
  66. package/dist/web/pending-message-store.js +0 -108
  67. package/dist/web/queue-controller.js +0 -82
  68. package/dist/web/resources.json +0 -1
  69. package/dist/web/self-relay-session.js +0 -129
  70. package/dist/web/server-requests.js +0 -67
  71. package/dist/web/station-connection-controller.js +0 -75
  72. package/dist/web/task-list-view.js +0 -289
  73. package/dist/web/thread-attention-controller.js +0 -124
  74. package/dist/web/thread-context-controller.js +0 -30
  75. package/dist/web/thread-list-controller.js +0 -61
  76. package/dist/web/thread-list-sync.js +0 -86
  77. package/dist/web/thread-title-controller.js +0 -57
  78. package/dist/web/timeline-formatters.js +0 -249
  79. package/dist/web/timeline-reducer.js +0 -77
  80. package/dist/web/timeline-renderer.js +0 -161
  81. package/dist/web/timeline-scroll-controller.js +0 -50
  82. package/dist/web/timeline-sync.js +0 -67
  83. package/dist/web/usage-controller.js +0 -258
  84. package/dist/web/vendor/README.md +0 -14
  85. package/dist/web/vendor/highlight-powershell.min.js +0 -39
  86. package/dist/web/vendor/highlight.LICENSE.txt +0 -29
  87. package/dist/web/vendor/highlight.min.js +0 -1262
  88. package/dist/web/vendor/lucide.LICENSE.txt +0 -17
@@ -4,23 +4,24 @@ import http from 'node:http';
4
4
  import path from 'node:path';
5
5
  import os from 'node:os';
6
6
  import { fileURLToPath } from 'node:url';
7
- import vm from 'node:vm';
8
- import sharp from 'sharp';
7
+ import vm from 'node:vm';
8
+ import sharp from 'sharp';
9
9
  import { attachLanSocket } from './lan-socket-server.mjs';
10
- import { validateInlineImage } from './message-images.mjs';
11
- import { ThreadWriterConflictError } from './shared-app-server.mjs';
12
- import { ManagedAppServer, managedAppServerUrl } from './managed-app-server.mjs';
13
- import { RuntimeDiscovery } from './runtime-discovery.mjs';
10
+ import fileMentions from '../shared/file-mentions.cjs';
11
+ import { validateInlineImage } from './message-images.mjs';
12
+ import { ThreadWriterConflictError } from './shared-app-server.mjs';
13
+ import { ManagedAppServer, managedAppServerUrl } from './managed-app-server.mjs';
14
+ import { RuntimeDiscovery } from './runtime-discovery.mjs';
14
15
  import { streamSharedThread } from './shared-thread-stream.mjs';
15
16
  import appServerEvents from '../shared/app-server-events.cjs';
16
17
  import { runtimePaths } from './platform/windows/runtime-paths.mjs';
17
18
  import { openControl } from './service-control.mjs';
18
19
  import { writeRecord, readRecord } from './service-lifecycle.mjs';
19
20
  import { ExecutionHealth } from './execution-health.mjs';
20
- import { activeDebugPort, serveDebugResource } from './debug-ui.mjs';
21
- import { authorizeLanDevice } from './self-relay/lan-authorizations.mjs';
21
+ import { authorizeLanDevice } from './self-relay/lan-authorizations.mjs';
22
+ import { createRelayManager } from './self-relay/lifecycle.mjs';
22
23
  import { generateAndSetThreadTitle } from './thread-title-generation.mjs';
23
- import { desktopHost } from './platform/windows/desktop-host.mjs';
24
+ import { desktopHost } from './platform/windows/desktop-host.mjs';
24
25
  import { SharedCatalog } from './shared-catalog.mjs';
25
26
  import { requestRecovery, recoveryStatus } from './shared-recovery.mjs';
26
27
  import { requestServiceRestart } from './service-restart.mjs';
@@ -29,12 +30,13 @@ const MAX_ATTACHMENT_BYTES = 16 * 1024 * 1024;
29
30
  const MAX_CACHED_ATTACHMENTS = 32;
30
31
  const MAX_CACHED_FILES = 64;
31
32
  const MAX_CONTEXT_TAIL_BYTES = 4 * 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;
33
+ const MAX_LIVE_ACTIVITY_TAIL_BYTES = 1024 * 1024;
34
+ const SHARED_HISTORY_PAGE_BYTES = 500 * 1024;
35
+ const SHARED_HISTORY_PAGE_TURNS = 8;
35
36
  const MAX_SETTINGS_SCAN_BYTES = 64 * 1024 * 1024;
36
37
  const ALLOWED_MODELS = new Set([
37
38
  'gpt-6-astra',
39
+ 'gpt-6-sol',
38
40
  'gpt-5.6-sol',
39
41
  'gpt-5.6-terra',
40
42
  'gpt-5.6-luna',
@@ -42,85 +44,90 @@ const ALLOWED_MODELS = new Set([
42
44
  'gpt-5.3-codex-spark',
43
45
  ]);
44
46
  const ALLOWED_THINKING = new Set(['low', 'medium', 'high', 'xhigh', 'max', 'ultra']);
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();
47
+
48
+ class RequestError extends Error {
49
+ constructor(statusCode, message, details) {
50
+ super(message);
51
+ this.statusCode = statusCode;
52
+ this.details = details;
53
+ this.code = details?.code;
54
+ }
55
+ }
56
+
57
+ function readRuntimeVersion() {
58
+ let directory = path.dirname(fileURLToPath(import.meta.url));
59
+ while (true) {
60
+ const manifest = readRecord(path.join(directory, 'package.json'));
61
+ if (manifest?.name === '@walkhi/code-relax') return manifest.version || '未知';
62
+ const parent = path.dirname(directory);
63
+ if (parent === directory) return '未知';
64
+ directory = parent;
65
+ }
66
+ }
67
+
68
+ const options = parseOptions(process.argv.slice(2));
69
+ const runtimeVersion = readRuntimeVersion();
59
70
 
60
71
  const webRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'web');
61
- const repositoryRoot = path.resolve(webRoot, '..', '..');
62
72
  const projectRoot = os.homedir();
63
73
  const { codexDataRoot, stateRoot, sessionsRoot, uploadsRoot } = runtimePaths();
64
74
  const markdownItScript = fs.readFileSync(path.join(webRoot, 'vendor', 'markdown-it.umd.min.js'));
65
75
  const markdownSandbox = { atob };
66
76
  vm.runInNewContext(markdownItScript.toString(), markdownSandbox);
67
77
  const imageMarkdown = markdownSandbox.markdownit({ html: false });
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();
73
- const legacyUploadsRoot = path.resolve(webRoot, '..', 'uploads');
74
- const allowedUploadsRoots = [uploadsRoot, legacyUploadsRoot,
75
- ...JSON.parse(process.env.CODEX_REMOTE_LEGACY_UPLOADS_ROOTS || '[]')];
78
+ const attachmentCache = new Map();
79
+ const fileCache = new Map();
80
+ const fileCapabilitySecret = crypto.randomBytes(32);
81
+ const imageCapabilityKey = crypto.createHash('sha256')
82
+ .update('code-relax-image-capability\0').update(options.token || fileCapabilitySecret).digest();
76
83
  const sessionPathCache = new Map();
77
84
  const sessionSettingsCache = new Map();
78
85
  const liveActivityCache = new Map();
79
- let sharedServer = null;
80
- const managedAppServer = new ManagedAppServer({ stateRoot, host: desktopHost });
86
+ let sharedServer = null;
87
+ const managedAppServer = new ManagedAppServer({ stateRoot, host: desktopHost });
81
88
  const runtimeDiscovery = new RuntimeDiscovery({ stateRoot, host: desktopHost, url: managedAppServerUrl });
82
- let sharedCatalog = null, executionMode = 'ordinary';
83
- function selectSharedExecution(enabled) {
84
- const next = enabled ? managedAppServer.client : null;
85
- if (sharedServer === next) return;
86
- sharedServer = next;
87
- sharedCatalog = sharedServer ? new SharedCatalog(sharedServer) : null;
88
- }
89
- const executionHealth = new ExecutionHealth(async () => {
90
- await managedAppServer.ensure();
91
- const managed = managedAppServer.snapshot();
92
- // The managed app-server owns execution. Desktop is another client of the
93
- // same service, so closing Desktop must not disable phone writes.
94
- selectSharedExecution(managed.ready);
95
- executionMode = sharedServer ? 'shared' : 'ordinary';
96
- const managedRestarting = managed.state === 'restarting';
97
- const ready = !managedRestarting && sharedServer?.ready === true;
98
- const state = managedRestarting ? 'restarting'
99
- : ready ? 'full' : 'unavailable';
100
- return { state, ready, canWrite: ready, mode: executionMode };
101
- });
102
- async function refreshExecution() {
103
- await executionHealth.refresh();
104
- return { mode: executionMode };
105
- }
106
- async function requireExecutionWritable(threadId) {
107
- const execution = await executionHealth.refresh();
108
- if (!execution.canWrite) {
109
- throw new RequestError(503, 'Codex 内核未就绪。请重启工作站服务;若仍失败,运行 relax doctor。',
110
- { code: 'full_control_required', execution });
111
- }
112
- if (threadId && sharedServer) sharedServer.requireWritable(threadId);
113
- return execution;
114
- }
115
- managedAppServer.client.on('subscription-error', error => console.error('Codex 内核任务取消订阅失败:', error.message));
89
+ const relayManager = createRelayManager(stateRoot);
90
+ let sharedCatalog = null, executionMode = 'ordinary';
91
+ function selectSharedExecution(enabled) {
92
+ const next = enabled ? managedAppServer.client : null;
93
+ if (sharedServer === next) return;
94
+ sharedServer = next;
95
+ sharedCatalog = sharedServer ? new SharedCatalog(sharedServer) : null;
96
+ }
97
+ const executionHealth = new ExecutionHealth(async () => {
98
+ await managedAppServer.ensure();
99
+ const managed = managedAppServer.snapshot();
100
+ // The managed app-server owns execution. Desktop is another client of the
101
+ // same service, so closing Desktop must not disable phone writes.
102
+ selectSharedExecution(managed.ready);
103
+ executionMode = sharedServer ? 'shared' : 'ordinary';
104
+ const managedRestarting = managed.state === 'restarting';
105
+ const ready = !managedRestarting && sharedServer?.ready === true;
106
+ const state = managedRestarting ? 'restarting'
107
+ : ready ? 'full' : 'unavailable';
108
+ return { state, ready, canWrite: ready, mode: executionMode };
109
+ });
110
+ async function refreshExecution() {
111
+ await executionHealth.refresh();
112
+ return { mode: executionMode };
113
+ }
114
+ async function requireExecutionWritable(threadId) {
115
+ const execution = await executionHealth.refresh();
116
+ if (!execution.canWrite) {
117
+ throw new RequestError(503, 'Codex 内核未就绪。请重启工作站服务;若仍失败,运行 relax doctor。',
118
+ { code: 'full_control_required', execution });
119
+ }
120
+ if (threadId && sharedServer) sharedServer.requireWritable(threadId);
121
+ return execution;
122
+ }
123
+ managedAppServer.client.on('subscription-error', error => console.error('Codex 内核任务取消订阅失败:', error.message));
116
124
 
117
125
  async function handleRequest(request, response) {
118
126
  try {
119
127
  const url = new URL(request.url || '/', `http://${request.headers.host || 'localhost'}`);
120
128
 
121
129
  if (!request.socketRequest) {
122
- if (serveDebugResource(request, response, stateRoot, options.port)) return;
123
- sendJson(response, 404, { error: 'Bridge 调试页面未开启,或请求的资源不存在。' });
130
+ sendJson(response, 404, { error: '此端口不提供网页。' });
124
131
  return;
125
132
  }
126
133
 
@@ -145,70 +152,87 @@ async function handleRequest(request, response) {
145
152
  }
146
153
  if (request.method === 'POST' && url.pathname === '/api/shared-recovery') {
147
154
  const body = await readJsonBody(request);
148
- if (body.confirmed !== true || typeof body.operationId !== 'string') throw new RequestError(400, '请确认重新打开当前 Codex 并接入 Codex 内核后再继续。');
155
+ if (body.confirmed !== true || typeof body.operationId !== 'string') throw new RequestError(400, '请确认重新打开当前 Codex 并接入 Codex 内核后再继续。');
149
156
  sendJson(response, 202, await requestRecovery(stateRoot, body)); return;
150
157
  }
151
- if (request.method === 'POST' && url.pathname === '/api/service/restart') {
152
- const body = await readJsonBody(request);
153
- if (body.confirmed !== true) throw new RequestError(400, '请确认重启工作站服务后再继续。');
154
- sendJson(response, 202, await requestServiceRestart(stateRoot)); return;
155
- }
156
- if (request.method === 'POST' && url.pathname === '/api/managed-app-server/restart') {
157
- const body = await readJsonBody(request);
158
- if (body.confirmed !== true || typeof body.operationId !== 'string' || !body.operationId.trim()) {
159
- throw new RequestError(400, '请先确认中断风险,再重启 Codex 内核。');
160
- }
161
- try {
162
- sendJson(response, 202, managedAppServer.restart(body.operationId.trim()));
163
- } catch (error) {
164
- if (error?.code === 'managed_app_server_restarting') throw new RequestError(409, error.message);
165
- throw error;
166
- }
167
- return;
168
- }
169
-
170
- if (request.method === 'GET' && url.pathname === '/api/health') {
171
- // Status readers consume the workstation's cached sample. A phone query
172
- // must not trigger PowerShell, process-identity, or app-server checks.
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 },
177
- managedAppServer: managedAppServer.snapshot(),
178
- ...runtimeDiscovery.state,
179
- debugUiPort: await activeDebugPort(stateRoot, options.port),
180
- sharedAppServerConnected: sharedServer?.ready === true,
181
- sharedRecoveryAvailable: runtimeDiscovery.state.desktop?.installed === true,
158
+ if (request.method === 'POST' && url.pathname === '/api/service/restart') {
159
+ const body = await readJsonBody(request);
160
+ if (body.confirmed !== true) throw new RequestError(400, '请确认重启工作站服务后再继续。');
161
+ sendJson(response, 202, await requestServiceRestart(stateRoot)); return;
162
+ }
163
+ if (request.method === 'POST' && url.pathname === '/api/managed-app-server/restart') {
164
+ const body = await readJsonBody(request);
165
+ if (body.confirmed !== true || typeof body.operationId !== 'string' || !body.operationId.trim()) {
166
+ throw new RequestError(400, '请先确认中断风险,再重启 Codex 内核。');
167
+ }
168
+ try {
169
+ sendJson(response, 202, managedAppServer.restart(body.operationId.trim()));
170
+ } catch (error) {
171
+ if (error?.code === 'managed_app_server_restarting') throw new RequestError(409, error.message);
172
+ throw error;
173
+ }
174
+ return;
175
+ }
176
+
177
+ if (request.method === 'GET' && url.pathname === '/api/official-remote/status') {
178
+ if (!managedAppServer.client.ready) throw new RequestError(503, 'Codex 内核未就绪,无法读取官方工作站状态。');
179
+ const result = await managedAppServer.client.request('remoteControl/status/read', {});
180
+ sendJson(response, 200, { status: result.status, environmentId: result.environmentId });
181
+ return;
182
+ }
183
+ if (request.method === 'POST' && url.pathname === '/api/official-remote/pairing') {
184
+ if (!managedAppServer.client.ready) throw new RequestError(503, 'Codex 内核未就绪,无法生成官方配对码。');
185
+ const status = await managedAppServer.client.request('remoteControl/status/read', {});
186
+ if (status.status !== 'connected') throw new RequestError(409, '当前 Codex 内核未连接 OpenAI Remote,无法配对。');
187
+ const result = await managedAppServer.client.request('remoteControl/pairing/start', { manualCode: true });
188
+ if (!result.manualPairingCode || !result.environmentId) throw new RequestError(502, 'Codex 内核未返回有效的官方配对码。');
189
+ // One-time code: return only to the authenticated station controller; never persist or log it.
190
+ sendJson(response, 200, { manualPairingCode: result.manualPairingCode,
191
+ environmentId: result.environmentId, expiresAt: result.expiresAt });
192
+ return;
193
+ }
194
+
195
+ if (request.method === 'GET' && url.pathname === '/api/health') {
196
+ // Status readers consume the workstation's cached sample. A phone query
197
+ // must not trigger PowerShell, process-identity, or app-server checks.
198
+ sendJson(response, 200, {
199
+ status: 'ok', resident: true, version: runtimeVersion,
200
+ execution: { ...executionHealth.state, ready: executionHealth.state.ready && sharedServer?.ready === true,
201
+ mode: executionMode },
202
+ managedAppServer: managedAppServer.snapshot(),
203
+ ...runtimeDiscovery.state,
204
+ sharedAppServerConnected: sharedServer?.ready === true,
205
+ sharedRecoveryAvailable: runtimeDiscovery.state.desktop?.installed === true,
182
206
  mode: options.lan ? 'lan' : 'loopback',
183
207
  tokenRequired: Boolean(options.token),
184
208
  nativeImageTransport: sharedServer ? 'shared-app-server' : null,
185
- transportMode: sharedServer?.ready === true ? 'lan-shared' : 'unavailable',
209
+ transportMode: sharedServer?.ready === true ? 'lan-shared' : 'unavailable',
186
210
  });
187
211
  return;
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')}` });
212
+ }
213
+
214
+ const imageMatch = /^\/api\/images\/([a-f0-9]{64})$/.exec(url.pathname);
215
+ if (request.method === 'GET' && imageMatch) {
216
+ let image = attachmentCache.get(imageMatch[1])
217
+ || recoverLocalImage(imageMatch[1], url.searchParams.get('recovery') || '');
218
+ const contextThreadId = url.searchParams.get('threadId') || '';
219
+ const contextTurnId = url.searchParams.get('turnId') || '';
220
+ if (!image && sharedServer && contextThreadId && contextTurnId
221
+ && contextThreadId.length <= 128 && contextTurnId.length <= 128) {
222
+ try {
223
+ normalizeTurn(await sharedServer.turnContext(contextThreadId, contextTurnId));
224
+ image = attachmentCache.get(imageMatch[1]);
225
+ } catch {}
226
+ }
227
+ if (!image) throw new RequestError(404, '这张图片已失效,无法从工作站重新读取。');
228
+ const thumbnail = url.searchParams.get('variant') === 'thumbnail';
229
+ const asset = thumbnail ? await attachmentThumbnail(image) : { buffer: image.buffer, mimeType: image.mimeType };
230
+ sendJson(response, 200, { url: `data:${asset.mimeType};base64,${asset.buffer.toString('base64')}` });
207
231
  return;
208
232
  }
209
233
 
210
234
  const fileMatch = /^\/api\/files\/([a-f0-9]{64})$/.exec(url.pathname);
211
- if (request.method === 'GET' && fileMatch) {
235
+ if (request.method === 'GET' && fileMatch) {
212
236
  const file = fileCache.get(fileMatch[1]);
213
237
  if (!file) throw new RequestError(404, '文件授权已失效,请重新读取对话。');
214
238
  let stat;
@@ -219,47 +243,47 @@ async function handleRequest(request, response) {
219
243
  }
220
244
  if (stat.size > MAX_ATTACHMENT_BYTES) throw new RequestError(413, '暂只支持下载不超过 16 MiB 的文件。');
221
245
  sendJson(response, 200, { name: file.name, size: file.size, dataBase64: fs.readFileSync(file.path).toString('base64') });
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
- }
246
+ return;
247
+ }
248
+
249
+ if (url.pathname.startsWith('/api/app-server/') || url.pathname === '/api/wait') {
250
+ throw new RequestError(410, '此连接方式已停用。请检查 Codex 内核状态。', { code: 'unsupported_control' });
251
+ }
252
+ const taskPath = /^\/api\/threads\/([^/]+)/.exec(url.pathname);
253
+ if (taskPath && (url.pathname.endsWith('/takeover') || url.pathname === `/api/threads/${taskPath[1]}`)) {
254
+ throw new RequestError(410, '此连接方式已停用。', { code: 'unsupported_control' });
255
+ }
256
+ if (url.searchParams.has('hostId') && url.searchParams.get('hostId') !== 'local') {
257
+ throw new RequestError(410, '此对话的连接方式已停用,请检查 Codex 内核状态。', { code: 'unsupported_control' });
258
+ }
259
+ if (!executionHealth.state.ready || sharedServer?.ready !== true) {
260
+ throw new RequestError(503, 'Codex 内核未就绪。请重启工作站服务;若仍失败,运行 relax doctor。',
261
+ { code: 'full_control_required' });
262
+ }
263
+
264
+ if (request.method === 'GET' && url.pathname === '/api/models') {
265
+ sendJson(response, 200, await sharedServer.models());
266
+ return;
267
+ }
244
268
 
245
269
  if (request.method === 'GET' && url.pathname === '/api/threads') {
246
270
  const limit = boundedInteger(url.searchParams.get('limit'), 20, 1, 200);
247
271
  const archived = url.searchParams.get('archived') === 'true';
248
- sendJson(response, 200, await sharedCatalog.threads(limit, archived));
272
+ sendJson(response, 200, await sharedCatalog.threads(limit, archived));
249
273
  return;
250
274
  }
251
275
 
252
276
  if (request.method === 'GET' && url.pathname === '/api/projects') {
253
- sendJson(response, 200, { projects: (await sharedCatalog.projects()).projects });
277
+ sendJson(response, 200, { projects: (await sharedCatalog.projects()).projects });
254
278
  return;
255
279
  }
256
280
 
257
281
  if (request.method === 'GET' && url.pathname === '/api/usage-limits') {
258
- sendJson(response, 200, await sharedServer.usage());
282
+ sendJson(response, 200, await sharedServer.usage());
259
283
  return;
260
284
  }
261
285
 
262
- if (request.method === 'GET' && url.pathname === '/api/usage-events') {
286
+ if (request.method === 'GET' && url.pathname === '/api/usage-events') {
263
287
  const execution = sharedServer;
264
288
  response.startEvents();
265
289
  const emit = value => { if (!response.destroyed) response.event('usage', value); };
@@ -272,185 +296,186 @@ async function handleRequest(request, response) {
272
296
  }
273
297
 
274
298
  const createThreadMatch = /^\/api\/projects\/([^/]+)\/threads$/.exec(url.pathname);
275
- if (request.method === 'POST' && createThreadMatch) {
276
- await requireExecutionWritable();
299
+ if (request.method === 'POST' && createThreadMatch) {
300
+ await requireExecutionWritable();
277
301
  const projectId = decodeURIComponent(createThreadMatch[1]);
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;
302
+ const body = await readJsonBody(request);
303
+ if (body.transport === 'app-server' || (body.hostId && body.hostId !== 'local')) {
304
+ throw new RequestError(410, '此连接方式已停用,请检查 Codex 内核状态。', { code: 'unsupported_control' });
305
+ }
306
+ const projects = (await sharedCatalog.projects()).projects;
307
+ const project = projects.find(item => item.projectId === projectId);
308
+ if (projectId !== '__unassigned__' && !project) throw new RequestError(404, '没有找到对应项目。');
309
+ const execution = sharedServer;
310
+ await execution.connect();
311
+ const rpc = (method, params) => execution.request(method, params);
312
+ let target = appServerEvents.projectlessThreadTarget(projectRoot);
313
+ if (project) {
314
+ const nativeProjects = await appServerEvents.readProjects(rpc);
315
+ const nativeId = nativeProjects.find(item => item.id === project.projectId)?.id;
316
+ if (!nativeId) throw new RequestError(404, 'Codex 内核中没有找到对应项目,请刷新项目列表。');
317
+ target = { projectId:nativeId, cwd:project.path };
318
+ }
319
+ const { input } = appServerTurnArguments('', body);
320
+ const created = await appServerEvents.createThread(rpc, target, input, body);
321
+ const prompt = input.filter(part => part.type === 'text').map(part => part.text).join('\n');
322
+ void generateAndSetThreadTitle({
323
+ request: rpc,
324
+ subscribe: listener => {
325
+ execution.on('notification', listener);
326
+ return () => execution.off('notification', listener);
327
+ },
328
+ threadId: created.threadId,
329
+ prompt,
330
+ cwd: target.cwd || null,
331
+ }).catch(error => console.error(`生成对话标题失败:${error.message}`));
332
+ sendJson(response, 201, { ...created, hostId:'local' });
333
+ return;
310
334
  }
311
335
 
312
- const timelineMatch = /^\/api\/threads\/([^/]+)\/timeline$/.exec(url.pathname);
313
- if (request.method === 'GET' && timelineMatch) {
314
- const threadId = decodeURIComponent(timelineMatch[1]);
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
- }
336
+ const timelineMatch = /^\/api\/threads\/([^/]+)\/timeline$/.exec(url.pathname);
337
+ if (request.method === 'GET' && timelineMatch) {
338
+ const threadId = decodeURIComponent(timelineMatch[1]);
339
+ const timeline = normalizeSharedTimeline(await sharedServer.timeline(threadId, sharedHistoryPageOptions()));
340
+ const knownUpdatedAt = Number(url.searchParams.get('knownUpdatedAt'));
341
+ const knownTurnId = url.searchParams.get('knownTurnId') || '';
342
+ const knownTurnStatus = url.searchParams.get('knownTurnStatus') || '';
343
+ const knownHistoryView = url.searchParams.get('knownHistoryView') || '';
344
+ const latest = timeline.turns.at(-1);
345
+ const knownIndex = knownTurnId ? timeline.turns.findIndex(turn => turn.id === knownTurnId) : -1;
346
+ const sameHistoryView = knownHistoryView === timeline.historyView;
347
+ const unchanged = sameHistoryView
348
+ && knownIndex === timeline.turns.length - 1
349
+ && timeline.thread.status === 'idle' && latest?.status !== 'inProgress'
350
+ && knownTurnStatus && knownTurnStatus !== 'inProgress'
351
+ && (!Number.isFinite(knownUpdatedAt) || knownUpdatedAt <= 0 || timeline.thread.updatedAt === knownUpdatedAt);
352
+ const partial = !unchanged && sameHistoryView && knownIndex >= 0
353
+ ? { ...timeline, turns: timeline.turns.slice(knownIndex), partialFromTurnId: knownTurnId }
354
+ : timeline;
355
+ sendJson(response, 200, unchanged ? { ...timeline, turns: [], notModified: true } : partial);
356
+ return;
357
+ }
334
358
 
335
359
  const approvalMatch = /^\/api\/threads\/([^/]+)\/approvals$/.exec(url.pathname);
336
- if (request.method === 'POST' && approvalMatch) {
337
- const threadId = decodeURIComponent(approvalMatch[1]);
338
- await requireExecutionWritable(threadId);
339
- const body = await readJsonBody(request);
360
+ if (request.method === 'POST' && approvalMatch) {
361
+ const threadId = decodeURIComponent(approvalMatch[1]);
362
+ await requireExecutionWritable(threadId);
363
+ const body = await readJsonBody(request);
340
364
  sendJson(response, 200, await sharedServer.respondApproval(threadId, body)); return;
341
365
  }
342
366
 
343
367
  const eventsMatch = /^\/api\/threads\/([^/]+)\/events$/.exec(url.pathname);
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
- }
368
+ if (request.method === 'GET' && eventsMatch) {
369
+ const threadId = decodeURIComponent(eventsMatch[1]);
370
+ const after = Number(url.searchParams.get('afterSeq'));
371
+ const requestedSync = url.searchParams.get('syncId') && url.searchParams.get('syncEpoch') && Number.isSafeInteger(after) && after >= 0
372
+ ? { id: url.searchParams.get('syncId'), epoch: url.searchParams.get('syncEpoch'), after } : undefined;
373
+ const knownUpdatedAt = Number(url.searchParams.get('knownUpdatedAt'));
374
+ const known = url.searchParams.get('knownTurnId') ? {
375
+ updatedAt: Number.isFinite(knownUpdatedAt) ? knownUpdatedAt : 0,
376
+ turnId: url.searchParams.get('knownTurnId'),
377
+ turnStatus: url.searchParams.get('knownTurnStatus') || '',
378
+ } : undefined;
379
+ await streamSharedThread(response, threadId, sharedServer,
380
+ normalizeSharedTimeline, requestedSync, known);
381
+ return;
382
+ }
358
383
 
359
384
  const contextMatch = /^\/api\/threads\/([^/]+)\/context$/.exec(url.pathname);
360
385
  if (request.method === 'GET' && contextMatch) {
361
386
  const threadId = decodeURIComponent(contextMatch[1]);
362
387
  const current = readContextUsage(threadId);
363
- const native = await sharedServer.readContext(threadId);
388
+ const native = await sharedServer.readContext(threadId);
364
389
  sendJson(response, 200, { ...current, ...(native.model ? { model: native.model, thinking: native.thinking, serviceTier: native.serviceTier,
365
390
  permissionMode: native.permissionMode, permissionModes: native.permissionModes } : {}), ...(native.available ? native : {}) });
366
391
  return;
367
392
  }
368
393
 
369
394
  const sharedAction = /^\/api\/threads\/([^/]+)\/(history|settings|permissions)$/.exec(url.pathname);
370
- if (sharedAction) {
395
+ if (sharedAction) {
371
396
  const id = decodeURIComponent(sharedAction[1]);
372
397
  if (request.method === 'GET' && sharedAction[2] === 'history') {
373
- sendJson(response, 200, normalizeSharedTimeline(await sharedServer.history(id, url.searchParams.get('cursor'), sharedHistoryPageOptions())));
398
+ sendJson(response, 200, normalizeSharedTimeline(await sharedServer.history(id, url.searchParams.get('cursor'), sharedHistoryPageOptions())));
374
399
  return;
375
400
  }
376
- if (request.method === 'POST' && sharedAction[2] === 'settings') {
377
- await requireExecutionWritable(id);
401
+ if (request.method === 'POST' && sharedAction[2] === 'settings') {
402
+ await requireExecutionWritable(id);
378
403
  const body = await readJsonBody(request);
379
404
  sendJson(response, 200, await sharedServer.settings(id, body.model, body.thinking, body.serviceTier));
380
405
  return;
381
406
  }
382
- if (request.method === 'POST' && sharedAction[2] === 'permissions') {
383
- await requireExecutionWritable(id);
407
+ if (request.method === 'POST' && sharedAction[2] === 'permissions') {
408
+ await requireExecutionWritable(id);
384
409
  const body = await readJsonBody(request);
385
410
  sendJson(response, 200, await sharedServer.permissions(id, body.mode));
386
411
  return;
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);
402
- if (request.method === 'POST' && archiveMatch) {
403
- const threadId = decodeURIComponent(archiveMatch[1]);
404
- await requireExecutionWritable(threadId);
412
+ }
413
+ }
414
+
415
+ const sharedOperationGroup = /^\/api\/threads\/([^/]+)\/turns\/([^/]+)\/groups\/([^/]+)\/items$/.exec(url.pathname);
416
+ if (request.method === 'GET' && sharedOperationGroup) {
417
+ const threadId = decodeURIComponent(sharedOperationGroup[1]);
418
+ const turnId = decodeURIComponent(sharedOperationGroup[2]);
419
+ const groupId = decodeURIComponent(sharedOperationGroup[3]);
420
+ const page = await sharedServer.operationGroupItems(threadId, turnId, groupId, url.searchParams.get('offset'));
421
+ sendJson(response, 200, { turnId: page.turnId, groupId: page.groupId,
422
+ entries: page.items.map(normalizeItem).filter(Boolean), nextOffset: page.nextOffset, total: page.total });
423
+ return;
424
+ }
425
+
426
+ const archiveMatch = /^\/api\/threads\/([^/]+)\/(archive|unarchive)$/.exec(url.pathname);
427
+ if (request.method === 'POST' && archiveMatch) {
428
+ const threadId = decodeURIComponent(archiveMatch[1]);
429
+ await requireExecutionWritable(threadId);
405
430
  await sharedServer.request(`thread/${archiveMatch[2]}`, { threadId });
406
431
  sendJson(response, 200, { threadId, archived: archiveMatch[2] === 'archive' });
407
432
  return;
408
433
  }
409
434
 
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; }
416
- if (request.method === 'POST') {
417
- await requireExecutionWritable(threadId);
418
- const args = appServerTurnArguments(threadId, await readJsonBody(request));
419
- sendJson(response, 202, await appServerEvents.addQueue(rpc, threadId, args.input, crypto.randomUUID())); return;
420
- }
421
- }
435
+ const queueMatch = /^\/api\/threads\/([^/]+)\/queue$/.exec(url.pathname);
436
+ if (queueMatch) {
437
+ const threadId = decodeURIComponent(queueMatch[1]);
438
+ await sharedServer.connect();
439
+ const rpc = (method, params) => sharedServer.request(method, params);
440
+ if (request.method === 'GET') { sendJson(response, 200, await appServerEvents.readQueue(rpc, threadId)); return; }
441
+ if (request.method === 'POST') {
442
+ await requireExecutionWritable(threadId);
443
+ const args = appServerTurnArguments(threadId, await readJsonBody(request));
444
+ sendJson(response, 202, await appServerEvents.addQueue(rpc, threadId, args.input, crypto.randomUUID())); return;
445
+ }
446
+ }
422
447
 
423
448
  const queueItemMatch = /^\/api\/threads\/([^/]+)\/queue\/([a-f0-9-]+)(?:\/(steer))?$/.exec(url.pathname);
424
449
  if (queueItemMatch) {
425
450
  const threadId = decodeURIComponent(queueItemMatch[1]);
426
451
  const itemId = queueItemMatch[2];
427
- await sharedServer.connect();
428
- const action = request.method === 'DELETE' ? 'delete' : queueItemMatch[3] === 'steer' ? 'steer' : null;
429
- if (!action) throw new RequestError(405, '不支持的队列操作');
430
- await requireExecutionWritable(threadId);
431
- sendJson(response, 200, await appServerEvents.changeQueue((method, params) => sharedServer.request(method, params), threadId, itemId, action)); return;
432
- }
452
+ await sharedServer.connect();
453
+ const action = request.method === 'DELETE' ? 'delete' : queueItemMatch[3] === 'steer' ? 'steer' : null;
454
+ if (!action) throw new RequestError(405, '不支持的队列操作');
455
+ await requireExecutionWritable(threadId);
456
+ sendJson(response, 200, await appServerEvents.changeQueue((method, params) => sharedServer.request(method, params), threadId, itemId, action)); return;
457
+ }
433
458
 
434
- const interruptMatch = /^\/api\/threads\/([^/]+)\/interrupt$/.exec(url.pathname);
435
- if (request.method === 'POST' && interruptMatch) {
436
- const threadId = decodeURIComponent(interruptMatch[1]);
437
- await requireExecutionWritable(threadId);
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;
459
+ const interruptMatch = /^\/api\/threads\/([^/]+)\/interrupt$/.exec(url.pathname);
460
+ if (request.method === 'POST' && interruptMatch) {
461
+ const threadId = decodeURIComponent(interruptMatch[1]);
462
+ await requireExecutionWritable(threadId);
463
+ const body = await readJsonBody(request);
464
+ if (!body.turnId) throw new RequestError(400, '缺少活动轮次 ID。');
465
+ await sharedServer.interrupt(threadId, body.turnId);
466
+ sendJson(response, 200, { requested: true });
467
+ return;
443
468
  }
444
469
 
445
470
  const messageMatch = /^\/api\/threads\/([^/]+)\/messages$/.exec(url.pathname);
446
- if (request.method === 'POST' && messageMatch) {
447
- const threadId = decodeURIComponent(messageMatch[1]);
448
- await requireExecutionWritable(threadId);
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
- }
471
+ if (request.method === 'POST' && messageMatch) {
472
+ const threadId = decodeURIComponent(messageMatch[1]);
473
+ await requireExecutionWritable(threadId);
474
+ const body = await readJsonBody(request);
475
+ if (body.hostId && body.hostId !== 'local') throw new RequestError(410, '此对话的连接方式已停用。', { code: 'unsupported_control' });
476
+ sendJson(response, 200, { success: true, data: await sharedServer.send(appServerTurnArguments(threadId, body)) });
477
+ return;
478
+ }
454
479
 
455
480
  sendJson(response, 404, { error: '未找到请求的资源。' });
456
481
  } catch (error) {
@@ -458,26 +483,27 @@ async function handleRequest(request, response) {
458
483
  if (!response.writableEnded) response.end();
459
484
  return;
460
485
  }
461
- const status = error instanceof RequestError || error instanceof ThreadWriterConflictError ? (error.statusCode || 409) : 502;
462
- sendJson(response, status, {
463
- error: error instanceof Error ? error.message : String(error),
464
- code: error?.code,
465
- threadId: error?.threadId,
466
- readOnly: error?.readOnly,
467
- details: error?.details,
486
+ const status = error instanceof RequestError || error instanceof ThreadWriterConflictError ? (error.statusCode || 409) : 502;
487
+ sendJson(response, status, {
488
+ error: error instanceof Error ? error.message : String(error),
489
+ code: error?.code,
490
+ threadId: error?.threadId,
491
+ readOnly: error?.readOnly,
492
+ details: error?.details,
468
493
  });
469
494
  }
470
495
  }
471
496
 
472
497
  const server = http.createServer(handleRequest);
473
498
  const lanSocket = attachLanSocket(server, { token: options.token, dispatch: handleRequest,
499
+ uploadsRoot,
474
500
  authorizeDevice: (clientId, credential) => authorizeLanDevice(stateRoot, clientId, credential),
475
501
  });
476
502
 
477
- let control;
478
- void managedAppServer.start();
479
- executionHealth.start();
480
- runtimeDiscovery.start();
503
+ let control;
504
+ void managedAppServer.start();
505
+ executionHealth.start();
506
+ runtimeDiscovery.start();
481
507
  server.on('error', error => {
482
508
  console.error('Bridge 监听失败:', error.message);
483
509
  process.exit(1);
@@ -485,14 +511,19 @@ server.on('error', error => {
485
511
  server.listen(options.port, options.lan ? '0.0.0.0' : '127.0.0.1', async () => {
486
512
  try {
487
513
  if (process.env.CODEX_REMOTE_INSTANCE && process.env.CODEX_REMOTE_CONTROL_SECRET && process.env.CODEX_REMOTE_READY_PATH) {
488
- control = await openControl({ instanceId: process.env.CODEX_REMOTE_INSTANCE,
489
- secret: process.env.CODEX_REMOTE_CONTROL_SECRET, shutdown: shutDown, actions: {
514
+ control = await openControl({ instanceId: process.env.CODEX_REMOTE_INSTANCE,
515
+ secret: process.env.CODEX_REMOTE_CONTROL_SECRET, shutdown: shutDown, actions: {
490
516
  'refresh-execution': refreshExecution,
491
- 'restart-managed-app-server': () => {
492
- const state = managedAppServer.snapshot();
493
- return state.launcherRestartRequired ? managedAppServer.restart(crypto.randomUUID()) : state;
494
- },
495
- } });
517
+ 'relay-start': () => relayManager.run('relay-start'),
518
+ 'relay-status': () => relayManager.run('relay-status'),
519
+ 'relay-stop': () => relayManager.run('relay-stop'),
520
+ 'relay-pair': () => relayManager.run('relay-pair'),
521
+ 'relay-revoke': () => relayManager.run('relay-revoke'),
522
+ 'restart-managed-app-server': () => {
523
+ const state = managedAppServer.snapshot();
524
+ return state.launcherRestartRequired ? managedAppServer.restart(crypto.randomUUID()) : state;
525
+ },
526
+ } });
496
527
  writeRecord(process.env.CODEX_REMOTE_READY_PATH, { instanceId: process.env.CODEX_REMOTE_INSTANCE,
497
528
  pid: process.pid, controlPort: control.port });
498
529
  }
@@ -512,9 +543,10 @@ async function shutDown() {
512
543
  shuttingDown = true;
513
544
  lanSocket.close();
514
545
  server.close();
515
- executionHealth?.stop();
546
+ executionHealth?.stop();
516
547
  runtimeDiscovery.stop();
517
- managedAppServer.close();
548
+ await relayManager.close();
549
+ managedAppServer.close();
518
550
  await control?.close();
519
551
  process.exit(0);
520
552
  }
@@ -691,73 +723,73 @@ function appServerTurnArguments(threadId, body) {
691
723
  return value;
692
724
  }
693
725
 
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 {
726
+ function normalizeTimeline(data) {
727
+ const source = data && typeof data === 'object' ? data : {};
728
+ const sourceTurns = Array.isArray(source.turns) ? source.turns : source.thread?.turns;
729
+ const turns = Array.isArray(sourceTurns) ? sourceTurns.map(normalizeTurn) : [];
730
+ return {
699
731
  schemaVersion: 1,
700
732
  thread: normalizeThread(source.thread),
701
733
  turns,
702
734
  };
703
735
  }
704
736
 
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
- }
737
+ function normalizeSharedTimeline({ thread, nextCursor, sync }) {
738
+ const timeline = normalizeTimeline({ thread: { ...thread, title: thread.name || thread.title || '', hostId: 'local', transport: 'shared-app-server' } });
739
+ return { ...timeline,
740
+ historyView: 'grouped-lazy-skeleton-v13',
741
+ ...(nextCursor !== undefined ? { nextCursor } : {}), ...(sync ? { sync } : {}) };
742
+ }
710
743
 
711
- function normalizeThread(thread) {
744
+ function normalizeThread(thread) {
712
745
  const source = thread && typeof thread === 'object' ? thread : {};
713
746
  return {
714
- id: source.id || '',
715
- title: source.name || source.title || source.preview || '',
716
- status: statusText(source.status),
717
- updatedAt: Number(source.updatedAt) || 0,
718
- recencyAt: Number(source.recencyAt ?? source.updatedAt) || 0,
719
- projectId: source.projectId || '',
747
+ id: source.id || '',
748
+ title: source.name || source.title || source.preview || '',
749
+ status: statusText(source.status),
750
+ updatedAt: Number(source.updatedAt) || 0,
751
+ recencyAt: Number(source.recencyAt ?? source.updatedAt) || 0,
752
+ projectId: source.projectId || '',
720
753
  cwd: source.cwd || '',
721
754
  hostId: source.hostId || '',
722
- transport: source.transport || 'shared-app-server',
723
- readOnly: source.readOnly === true,
724
- readOnlyReason: source.readOnlyReason || '',
755
+ transport: source.transport || 'shared-app-server',
756
+ readOnly: source.readOnly === true,
757
+ readOnlyReason: source.readOnlyReason || '',
725
758
  };
726
759
  }
727
760
 
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),
761
+ function normalizeTurn(turn) {
762
+ const source = turn && typeof turn === 'object' ? turn : {};
763
+ const turnId = source.id || '';
764
+ const entries = collapseSubAgentEntries(Array.isArray(source.items) ? source.items.map(normalizeItem).filter(Boolean) : []);
765
+ for (const entry of entries) {
766
+ const images = [...(entry.attachments || []), entry.image, ...Object.values(entry.imageAssets || {})].filter(Boolean);
767
+ for (const image of images) if (image.remotePath && !image.recoveryTurnId) image.recoveryTurnId = turnId;
768
+ }
769
+ return {
770
+ id: turnId,
771
+ status: statusText(source.status),
739
772
  startedAt: source.startedAt || null,
740
773
  completedAt: source.completedAt || null,
741
774
  durationMs: source.durationMs ?? 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
- }
775
+ error: source.error ?? null,
776
+ entries,
777
+ };
778
+ }
779
+
780
+ function normalizeItem(item) {
781
+ if (!item || typeof item !== 'object') return null;
782
+
783
+ if (item.type === 'lazyOperationGroup') {
784
+ return {
785
+ type: 'activity',
786
+ id: item.id || '',
787
+ kind: 'lazyOperationGroup',
788
+ turnId: item.turnId || '',
789
+ groupId: item.groupId || '',
790
+ operationTypes: Array.isArray(item.operationTypes) ? item.operationTypes : [],
791
+ };
792
+ }
761
793
 
762
794
  if (item.type === 'contextCompaction') {
763
795
  return {
@@ -784,8 +816,8 @@ function normalizeItem(item) {
784
816
  }
785
817
 
786
818
  if (item.type === 'userMessage') {
787
- const text = extractUserText(item.content);
788
- const attachments = normalizeAttachments(item.content);
819
+ const { text, files } = extractUserText(item.content);
820
+ const attachments = [...normalizeAttachments(item.content), ...files];
789
821
  if (!text && !attachments.length) return null;
790
822
  return {
791
823
  clientId: item.clientId,
@@ -811,167 +843,259 @@ function normalizeItem(item) {
811
843
  };
812
844
  }
813
845
 
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
- }
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
-
842
- if (item.type === 'mcpToolCall') {
843
- return {
844
- type: 'activity',
845
- id: item.id || '',
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 : [],
883
- };
884
- }
885
-
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
-
846
+ if (item.type === 'reasoning') {
847
+ const detail = Array.isArray(item.summary)
848
+ ? item.summary.map(summaryText).filter(Boolean).join('\n')
849
+ : summaryText(item.summary);
850
+ return {
851
+ type: 'activity',
852
+ id: item.id || '',
853
+ kind: 'reasoning',
854
+ title: '思考摘要',
855
+ detail,
856
+ };
857
+ }
858
+
859
+ if (item.type === 'commandExecution') {
860
+ return {
861
+ type: 'activity',
862
+ id: item.id || '',
863
+ kind: 'command',
864
+ title: '命令',
865
+ titleOnly: true,
866
+ status: statusText(item.status),
867
+ command: item.command,
868
+ cwd: item.cwd,
869
+ exitCode: item.exitCode,
870
+ durationMs: item.durationMs,
871
+ };
872
+ }
873
+
874
+ if (item.type === 'mcpToolCall') {
875
+ return {
876
+ type: 'activity',
877
+ id: item.id || '',
878
+ kind: 'tool',
879
+ title: '工具',
880
+ titleOnly: true,
881
+ status: statusText(item.status),
882
+ server: item.server,
883
+ tool: item.tool,
884
+ arguments: item.arguments,
885
+ durationMs: item.durationMs,
886
+ error: item.error,
887
+ };
888
+ }
889
+
890
+ if (item.type === 'dynamicToolCall') {
891
+ return {
892
+ type: 'activity',
893
+ id: item.id || '',
894
+ kind: 'tool',
895
+ title: '工具',
896
+ titleOnly: true,
897
+ status: statusText(item.status),
898
+ namespace: item.namespace,
899
+ tool: item.tool,
900
+ arguments: item.arguments,
901
+ durationMs: item.durationMs,
902
+ success: item.success,
903
+ };
904
+ }
905
+
906
+ if (item.type === 'fileChange') {
907
+ return {
908
+ type: 'activity',
909
+ id: item.id || '',
910
+ kind: 'fileChange',
911
+ title: '文件修改',
912
+ titleOnly: true,
913
+ status: statusText(item.status),
914
+ changes: Array.isArray(item.changes) ? item.changes : [],
915
+ };
916
+ }
917
+
918
+ if (item.type === 'webSearch') {
919
+ return {
920
+ type: 'activity',
921
+ id: item.id || '',
922
+ kind: 'webSearch',
923
+ title: '网页搜索',
924
+ titleOnly: true,
925
+ query: item.query || item.action?.query || '',
926
+ ...(item.action && typeof item.action === 'object' ? { action: item.action } : {}),
927
+ };
928
+ }
929
+
898
930
  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: '生成的图片' })
931
+ const image = typeof item.path === 'string' && item.path
932
+ ? normalizeAttachment({ type: 'localImage', path: item.path })
915
933
  : null;
934
+ if (image?.available) {
935
+ return {
936
+ type: 'message',
937
+ id: item.id || '',
938
+ role: 'assistant',
939
+ phase: 'commentary',
940
+ text: '',
941
+ attachments: [image],
942
+ };
943
+ }
916
944
  return {
917
- type: 'activity',
918
- id: item.id || '',
919
- kind: 'imageGeneration',
920
- title: '生成图片',
921
- titleOnly: true,
922
- status: statusText(item.status),
945
+ type: 'activity',
946
+ id: item.id || '',
947
+ kind: 'imageView',
948
+ title: '查看图片',
949
+ titleOnly: true,
950
+ path: item.path || '',
923
951
  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
- }
955
-
956
- return {
952
+ };
953
+ }
954
+
955
+ if (item.type === 'imageGeneration') {
956
+ const image = typeof item.savedPath === 'string' && item.savedPath
957
+ ? normalizeAttachment({ type: 'localImage', path: item.savedPath, name: '生成的图片' })
958
+ : null;
959
+ return {
960
+ type: 'activity',
961
+ id: item.id || '',
962
+ kind: 'imageGeneration',
963
+ title: '生成图片',
964
+ titleOnly: true,
965
+ status: statusText(item.status),
966
+ image,
967
+ failure: item.failure || null,
968
+ transparentBackground: item.transparentBackground === true,
969
+ };
970
+ }
971
+
972
+ if (item.type === 'subAgentActivity') {
973
+ return {
974
+ type: 'activity',
975
+ id: item.id || '',
976
+ kind: 'subAgent',
977
+ title: '子任务',
978
+ titleOnly: true,
979
+ status: statusText(item.kind),
980
+ agentPath: item.agentPath || '',
981
+ agentThreadId: item.agentThreadId || '',
982
+ };
983
+ }
984
+
985
+ if (item.type === 'collabAgentToolCall') {
986
+ return {
987
+ type: 'activity',
988
+ id: item.id || '',
989
+ kind: 'collaboration',
990
+ title: '任务协作',
991
+ titleOnly: true,
992
+ status: statusText(item.status),
993
+ tool: item.tool || '',
994
+ prompt: item.prompt || '',
995
+ senderThreadId: item.senderThreadId || '',
996
+ receiverThreadIds: Array.isArray(item.receiverThreadIds) ? item.receiverThreadIds : [],
997
+ model: item.model || '',
998
+ reasoningEffort: item.reasoningEffort || '',
999
+ agentsStates: item.agentsStates && typeof item.agentsStates === 'object' ? item.agentsStates : {},
1000
+ };
1001
+ }
1002
+
1003
+ return {
957
1004
  type: 'activity',
958
1005
  id: item.id || '',
959
1006
  kind: item.type || 'event',
960
1007
  title: item.type || '事件',
961
1008
  status: statusText(item.status),
962
1009
  detail: JSON.stringify(item, null, 2),
963
- };
964
- }
965
-
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))),
971
- };
972
- }
973
-
974
- function normalizeAttachments(content) {
1010
+ };
1011
+ }
1012
+
1013
+ function collapseSubAgentEntries(entries) {
1014
+ const agents = new Map();
1015
+ let firstIndex = -1;
1016
+ const retained = [];
1017
+ const ensureAgent = threadId => {
1018
+ if (!threadId) return null;
1019
+ let agent = agents.get(threadId);
1020
+ if (!agent) {
1021
+ agent = { threadId, name: '', status: 'pendingInit', objective: '', model: '', reasoningEffort: '' };
1022
+ agents.set(threadId, agent);
1023
+ }
1024
+ return agent;
1025
+ };
1026
+ const setProtocolStatus = (agent, status) => {
1027
+ if (!agent || !status || status === 'unknown') return;
1028
+ const terminal = ['interrupted', 'completed', 'errored', 'shutdown', 'notFound'];
1029
+ if (terminal.includes(agent.status) && ['pendingInit', 'running'].includes(status)) return;
1030
+ if (status === 'shutdown' && agent.status === 'completed') return;
1031
+ agent.status = status;
1032
+ };
1033
+ for (const entry of entries) {
1034
+ if (entry.kind === 'subAgent') {
1035
+ if (firstIndex < 0) firstIndex = retained.length;
1036
+ const agent = ensureAgent(entry.agentThreadId);
1037
+ if (!agent) continue;
1038
+ agent.name = subAgentDisplayName(entry.agentPath) || agent.name;
1039
+ const activityStatus = {
1040
+ started: 'running',
1041
+ interacted: 'running',
1042
+ interrupted: 'interrupted',
1043
+ completed: 'completed',
1044
+ }[entry.status];
1045
+ agent.activity = entry.status;
1046
+ setProtocolStatus(agent, activityStatus);
1047
+ continue;
1048
+ }
1049
+ if (entry.kind === 'collaboration') {
1050
+ const receiverIds = entry.receiverThreadIds || [];
1051
+ const createsAgent = entry.tool === 'spawnAgent';
1052
+ const updatesKnownAgent = receiverIds.some(threadId => agents.has(threadId));
1053
+ if (!createsAgent && !updatesKnownAgent) {
1054
+ retained.push(entry);
1055
+ continue;
1056
+ }
1057
+ if (firstIndex < 0) firstIndex = retained.length;
1058
+ for (const threadId of receiverIds) {
1059
+ const agent = ensureAgent(threadId);
1060
+ if (!agent) continue;
1061
+ if (entry.prompt) agent.objective = entry.prompt;
1062
+ if (entry.model) agent.model = entry.model;
1063
+ if (entry.reasoningEffort) agent.reasoningEffort = entry.reasoningEffort;
1064
+ setProtocolStatus(agent, statusText(entry.agentsStates?.[threadId]?.status));
1065
+ if (entry.status === 'failed' && !entry.agentsStates?.[threadId]?.status) agent.status = 'errored';
1066
+ if (entry.status === 'interrupted' && !entry.agentsStates?.[threadId]?.status) agent.status = 'interrupted';
1067
+ }
1068
+ continue;
1069
+ }
1070
+ retained.push(entry);
1071
+ }
1072
+ if (!agents.size) return retained;
1073
+ retained.splice(firstIndex < 0 ? retained.length : firstIndex, 0, {
1074
+ type: 'activity',
1075
+ id: `subagents:${[...agents.keys()].join(':')}`,
1076
+ kind: 'subAgentGroup',
1077
+ title: '子 Agent',
1078
+ agents: [...agents.values()],
1079
+ });
1080
+ return retained;
1081
+ }
1082
+
1083
+ function subAgentDisplayName(agentPath) {
1084
+ const segment = String(agentPath || '').split('/').map(value => value.trim()).filter(value => value && value !== 'root').at(-1);
1085
+ if (!segment) return '';
1086
+ const text = segment.replace(/[_-]+/g, ' ').replace(/\s+/g, ' ').trim().toLowerCase();
1087
+ return text ? text[0].toUpperCase() + text.slice(1) : '';
1088
+ }
1089
+
1090
+ function sharedHistoryPageOptions() {
1091
+ return {
1092
+ maxTurns: SHARED_HISTORY_PAGE_TURNS,
1093
+ maxBytes: SHARED_HISTORY_PAGE_BYTES,
1094
+ measure: value => Buffer.byteLength(JSON.stringify(normalizeSharedTimeline(value))),
1095
+ };
1096
+ }
1097
+
1098
+ function normalizeAttachments(content) {
975
1099
  if (!Array.isArray(content)) return [];
976
1100
  const items = content.filter(item => item && !['text', 'input_text'].includes(item.type)).map(item => ({ ...item }));
977
1101
  const text = content.filter(item => ['text', 'input_text'].includes(item?.type)).map(item => item.text || '').join('\n');
@@ -1014,19 +1138,19 @@ function markdownImageAssets(text) {
1014
1138
  return assets;
1015
1139
  }
1016
1140
 
1017
- function markdownFileAssets(text) {
1141
+ function markdownFileAssets(text) {
1018
1142
  const assets = Object.create(null);
1019
1143
  function visit(tokens) {
1020
1144
  for (const token of tokens) {
1021
1145
  if (token.type === 'link_open') {
1022
1146
  const value = token.attrGet('href');
1023
1147
  if (value && !Object.hasOwn(assets, value)) {
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);
1148
+ let localPath;
1149
+ try { localPath = decodeURIComponent(value); } catch { localPath = ''; }
1150
+ if (/^\/?[a-z]:[\\/]/i.test(localPath)) {
1151
+ localPath = localPath.replace(/^\/(?=[a-z]:)/i, '');
1152
+ localPath = localPath.replace(/:(\d+)(?::\d+)?$/, '');
1153
+ const asset = rememberLocalFile(localPath);
1030
1154
  if (asset) assets[value] = { remotePath: asset.id, name: asset.name, size: asset.size };
1031
1155
  }
1032
1156
  }
@@ -1069,9 +1193,9 @@ function splitUploadedImages(text) {
1069
1193
  const paths = children.map(token => {
1070
1194
  try { return path.resolve(decodeURIComponent(token.attrGet('src') || '')); } catch { return ''; }
1071
1195
  });
1072
- if (paths.some(file => !allowedUploadsRoots.some(root => path.dirname(file).toLowerCase() === root.toLowerCase())
1196
+ if (paths.some(file => path.dirname(file).toLowerCase() !== uploadsRoot.toLowerCase()
1073
1197
  || !/^[a-f0-9]{64}\.(png|webp)$/.test(path.basename(file)))) continue;
1074
- for (const file of paths) attachments.push(normalizeAttachment({ type: 'localImage', path: file, name: '手机图片' }));
1198
+ for (const file of paths) attachments.push(normalizeAttachment({ type: 'localImage', path: file, name: '移动端图片' }));
1075
1199
  for (let line = block.map[0]; line < block.map[1]; line++) removed.add(line);
1076
1200
  }
1077
1201
  const visibleText = lines.filter((line, index) => !removed.has(index)).join('\n').trim();
@@ -1096,23 +1220,23 @@ function normalizeAttachment(item) {
1096
1220
  name,
1097
1221
  mimeType: cached?.mimeType || item.mimeType || '',
1098
1222
  available: Boolean(cached),
1099
- remotePath: cached ? cached.id : '',
1100
- recoveryToken: cached?.recoveryToken || '',
1223
+ remotePath: cached ? cached.id : '',
1224
+ recoveryToken: cached?.recoveryToken || '',
1101
1225
  url: '',
1102
1226
  thumbnailUrl: '',
1103
1227
  };
1104
1228
  }
1105
1229
 
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;
1230
+ function cacheLocalImage(filePath, name) {
1231
+ try {
1232
+ const resolved = path.resolve(filePath);
1233
+ const stat = fs.statSync(resolved);
1234
+ const mimeType = imageMimeType(resolved);
1235
+ if (!stat.isFile() || !mimeType || stat.size > MAX_ATTACHMENT_BYTES) return null;
1236
+ const cached = rememberAttachment(fs.readFileSync(resolved), mimeType, name,
1237
+ localImageRecoveryToken(resolved, stat));
1238
+ void attachmentThumbnail(cached).catch(() => {});
1239
+ return cached;
1116
1240
  } catch {
1117
1241
  return null;
1118
1242
  }
@@ -1130,65 +1254,65 @@ function cacheDataImage(dataUrl, name) {
1130
1254
  }
1131
1255
  }
1132
1256
 
1133
- function rememberAttachment(buffer, mimeType, name, recoveryToken = '') {
1134
- const id = crypto.createHash('sha256').update(buffer).digest('hex');
1135
- if (!attachmentCache.has(id)) {
1257
+ function rememberAttachment(buffer, mimeType, name, recoveryToken = '') {
1258
+ const id = crypto.createHash('sha256').update(buffer).digest('hex');
1259
+ if (!attachmentCache.has(id)) {
1136
1260
  while (attachmentCache.size >= MAX_CACHED_ATTACHMENTS) {
1137
1261
  attachmentCache.delete(attachmentCache.keys().next().value);
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
- }
1262
+ }
1263
+ attachmentCache.set(id, { id, buffer, mimeType, name, recoveryToken });
1264
+ } else if (recoveryToken && !attachmentCache.get(id).recoveryToken) {
1265
+ attachmentCache.get(id).recoveryToken = recoveryToken;
1266
+ }
1267
+ return attachmentCache.get(id);
1268
+ }
1269
+
1270
+ function localImageRecoveryToken(filePath, stat) {
1271
+ const iv = crypto.randomBytes(12);
1272
+ const cipher = crypto.createCipheriv('aes-256-gcm', imageCapabilityKey, iv);
1273
+ const payload = Buffer.from(JSON.stringify([filePath, stat.size, stat.mtimeMs]));
1274
+ const encrypted = Buffer.concat([cipher.update(payload), cipher.final()]);
1275
+ return Buffer.concat([iv, cipher.getAuthTag(), encrypted]).toString('base64url');
1276
+ }
1277
+
1278
+ function recoverLocalImage(id, token) {
1279
+ if (!token || token.length > 2048) return null;
1280
+ try {
1281
+ const value = Buffer.from(token, 'base64url');
1282
+ if (value.length < 29) return null;
1283
+ const decipher = crypto.createDecipheriv('aes-256-gcm', imageCapabilityKey, value.subarray(0, 12));
1284
+ decipher.setAuthTag(value.subarray(12, 28));
1285
+ const [filePath, size, mtimeMs] = JSON.parse(Buffer.concat([
1286
+ decipher.update(value.subarray(28)), decipher.final(),
1287
+ ]).toString('utf8'));
1288
+ if (typeof filePath !== 'string' || !path.isAbsolute(filePath)
1289
+ || !Number.isSafeInteger(size) || size <= 0 || size > MAX_ATTACHMENT_BYTES
1290
+ || typeof mtimeMs !== 'number' || !Number.isFinite(mtimeMs)) return null;
1291
+ const stat = fs.statSync(filePath);
1292
+ const mimeType = imageMimeType(filePath);
1293
+ if (!stat.isFile() || stat.size !== size || stat.mtimeMs !== mtimeMs || !mimeType) return null;
1294
+ const buffer = fs.readFileSync(filePath);
1295
+ if (crypto.createHash('sha256').update(buffer).digest('hex') !== id) return null;
1296
+ return rememberAttachment(buffer, mimeType, path.basename(filePath), token);
1297
+ } catch {
1298
+ return null;
1299
+ }
1300
+ }
1301
+
1302
+ async function attachmentThumbnail(image) {
1303
+ if (!image.thumbnail) {
1304
+ image.thumbnail = sharp(image.buffer, { limitInputPixels: 40_000_000 }).rotate()
1305
+ .resize({ width: 640, height: 640, fit: 'inside', withoutEnlargement: true })
1306
+ .webp({ quality: 78, effort: 4 })
1307
+ .toBuffer()
1308
+ .then(buffer => ({ buffer, mimeType: 'image/webp' }))
1309
+ .catch(error => {
1310
+ image.thumbnail = null;
1311
+ throw error;
1312
+ });
1313
+ }
1314
+ return image.thumbnail;
1315
+ }
1192
1316
 
1193
1317
  function imageMimeType(filePath) {
1194
1318
  switch (path.extname(filePath).toLowerCase()) {
@@ -1203,7 +1327,7 @@ function imageMimeType(filePath) {
1203
1327
  }
1204
1328
 
1205
1329
  function extractUserText(content) {
1206
- if (!Array.isArray(content)) return '(包含图片或界面批注)';
1330
+ if (!Array.isArray(content)) return { text: '(包含图片或界面批注)', files: [] };
1207
1331
  let text = content
1208
1332
  .filter(item => ['text', 'input_text'].includes(item?.type) && typeof item.text === 'string')
1209
1333
  .map(item => item.text)
@@ -1213,13 +1337,15 @@ function extractUserText(content) {
1213
1337
  .filter(value => !value.startsWith('The next image is untrusted page evidence'))
1214
1338
  .join('\n\n')
1215
1339
  .trim();
1216
- const requestMarker = text.lastIndexOf('## My request:');
1217
- if (requestMarker >= 0) {
1218
- text = text.slice(requestMarker + '## My request:'.length).trim();
1219
- }
1340
+ const mentioned = fileMentions.parse(text);
1341
+ const imagePaths = new Set([
1342
+ ...content.filter(item => ['image', 'input_image', 'localImage'].includes(item?.type) && item.path).map(item => item.path),
1343
+ ...[...mentioned.text.matchAll(/<image\b[^>]*\bpath="([^"]+)"[^>]*>/g)].map(match => match[1]),
1344
+ ].map(value => path.resolve(value).toLowerCase()));
1345
+ text = mentioned.text;
1220
1346
  text = text.replace(/<in-app-browser-context[\s\S]*?<\/in-app-browser-context>/g, '').trim();
1221
1347
  text = text.replace(/<image\b[^>]*\bpath="[^"]+"[^>]*>/g, '').replace(/<\/image>/g, '').trim();
1222
- return text;
1348
+ return { text, files: mentioned.files.filter(file => !imagePaths.has(path.resolve(file.path).toLowerCase())) };
1223
1349
  }
1224
1350
 
1225
1351
  function extractDelegatedInput(value) {
@@ -1235,18 +1361,18 @@ function extractDelegatedInput(value) {
1235
1361
  .trim();
1236
1362
  }
1237
1363
 
1238
- function delegatedOutputText(output) {
1364
+ function delegatedOutputText(output) {
1239
1365
  if (typeof output === 'string') return output;
1240
1366
  return output && typeof output.text === 'string' ? output.text : '';
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) {
1367
+ }
1368
+
1369
+ function summaryText(value) {
1370
+ if (typeof value === 'string') return value;
1371
+ if (value && typeof value === 'object') return value.text || value.summary || JSON.stringify(value);
1372
+ return '';
1373
+ }
1374
+
1375
+ function statusText(value) {
1250
1376
  if (typeof value === 'string') return value;
1251
1377
  if (value && typeof value === 'object') return value.type || value.status || 'unknown';
1252
1378
  return value == null ? 'unknown' : String(value);
@@ -1276,10 +1402,10 @@ function parseOptions(args) {
1276
1402
  let port = Number.parseInt(process.env.CODEX_REMOTE_BRIDGE_PORT || '45831', 10);
1277
1403
  let lan = process.env.CODEX_REMOTE_BRIDGE_LAN === 'true';
1278
1404
  let token = process.env.CODEX_REMOTE_BRIDGE_TOKEN?.trim() || '';
1279
- if (process.env.CODEX_REMOTE_APP_SERVER === 'true') throw new Error('此连接方式已停用。');
1405
+ if (process.env.CODEX_REMOTE_APP_SERVER === 'true') throw new Error('此连接方式已停用。');
1280
1406
  for (let i = 0; i < args.length; i += 1) {
1281
1407
  if (args[i] === '--lan') lan = true;
1282
- if (args[i] === '--app-server') throw new Error('此连接方式已停用。');
1408
+ if (args[i] === '--app-server') throw new Error('此连接方式已停用。');
1283
1409
  if (args[i] === '--port' && args[i + 1]) port = Number.parseInt(args[++i], 10);
1284
1410
  if (args[i] === '--token' && args[i + 1]) token = args[++i];
1285
1411
  }
@@ -1287,10 +1413,10 @@ function parseOptions(args) {
1287
1413
  throw new Error('端口必须位于 1024 到 65535 之间。');
1288
1414
  }
1289
1415
  if (lan && !token) token = crypto.randomBytes(32).toString('base64url');
1290
- return { port, lan, token };
1416
+ return { port, lan, token };
1291
1417
  }
1292
1418
 
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;
1296
- }
1419
+ function boundedInteger(value, fallback, minimum, maximum) {
1420
+ const parsed = Number.parseInt(value || '', 10);
1421
+ return Number.isFinite(parsed) ? Math.min(maximum, Math.max(minimum, parsed)) : fallback;
1422
+ }