@walkhi/code-relax 0.1.0-beta.30 → 0.1.0-beta.31

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 (185) hide show
  1. package/README.md +95 -89
  2. package/dist/bin/cleanup-accounts.mjs +63 -63
  3. package/dist/bin/codex-remote.mjs +374 -367
  4. package/dist/bin/manage-relay-admin.mjs +20 -20
  5. package/dist/bin/migrate-relay-db.mjs +109 -109
  6. package/dist/bin/publish-android-release.mjs +62 -58
  7. package/dist/bin/self-relay-admin.mjs +8 -8
  8. package/dist/bin/self-relay-demo.mjs +33 -33
  9. package/dist/bin/self-relay-server.mjs +66 -66
  10. package/dist/build-platform.json +1 -1
  11. package/dist/get-lan-networks.ps1 +40 -40
  12. package/dist/get-shared-service-url.ps1 +6 -6
  13. package/dist/shared/app-server-events.cjs +422 -412
  14. package/dist/shared/code-browser.cjs +159 -159
  15. package/dist/shared/file-changes.cjs +31 -31
  16. package/dist/shared/file-citations.cjs +117 -117
  17. package/dist/shared/file-mentions.cjs +92 -92
  18. package/dist/shared/message-delivery.cjs +54 -54
  19. package/dist/shared/p2p-data-channel.cjs +72 -72
  20. package/dist/shared/p2p-diagnostics.cjs +28 -28
  21. package/dist/shared/p2p-file-channel.cjs +74 -74
  22. package/dist/shared/plan-pricing.cjs +32 -32
  23. package/dist/shared/question-attention.cjs +40 -0
  24. package/dist/shared/server-requests.cjs +219 -219
  25. package/dist/shared/timeline-sync.cjs +173 -173
  26. package/dist/skills/code-relax/SKILL.md +22 -22
  27. package/dist/src/archived-threads.mjs +22 -0
  28. package/dist/src/background-process.mjs +18 -18
  29. package/dist/src/bounded-process-log.mjs +63 -63
  30. package/dist/src/celsius-client.mjs +107 -107
  31. package/dist/src/code-browser.mjs +100 -100
  32. package/dist/src/desktop-launcher.mjs +46 -46
  33. package/dist/src/dots-approval-timeline.mjs +99 -99
  34. package/dist/src/dots-avatar.mjs +42 -42
  35. package/dist/src/dots-chat-projection.mjs +50 -50
  36. package/dist/src/dots-chat-server.mjs +461 -461
  37. package/dist/src/dots-message-images.mjs +319 -319
  38. package/dist/src/dots-profile.mjs +77 -77
  39. package/dist/src/dots-room-activity.mjs +94 -94
  40. package/dist/src/dots-room-client.mjs +135 -135
  41. package/dist/src/dots-room-projection.mjs +94 -94
  42. package/dist/src/dots-schedules.mjs +66 -66
  43. package/dist/src/dots-summary-tasks.mjs +32 -32
  44. package/dist/src/dots-task-catalog.mjs +430 -430
  45. package/dist/src/dots-task-files.mjs +52 -52
  46. package/dist/src/execution-health.mjs +26 -26
  47. package/dist/src/file-transfer-range.mjs +22 -22
  48. package/dist/src/file-upload-session.mjs +130 -130
  49. package/dist/src/hosted-history-app-server.mjs +103 -103
  50. package/dist/src/hosted-history-cache.mjs +113 -113
  51. package/dist/src/lan-socket-server.mjs +265 -263
  52. package/dist/src/managed-app-server.mjs +244 -244
  53. package/dist/src/message-images.mjs +9 -9
  54. package/dist/src/message-upload-files.mjs +74 -74
  55. package/dist/src/onboarding.mjs +12 -12
  56. package/dist/src/plan-pricing.mjs +47 -47
  57. package/dist/src/platform/README.md +12 -12
  58. package/dist/src/platform/codex-protocol.mjs +46 -46
  59. package/dist/src/platform/codex-runtime.mjs +3 -3
  60. package/dist/src/platform/credential-store.mjs +3 -3
  61. package/dist/src/platform/desktop-host.mjs +3 -3
  62. package/dist/src/platform/desktop-project-registration.mjs +3 -3
  63. package/dist/src/platform/desktop-project-store.mjs +208 -149
  64. package/dist/src/platform/file-opener.mjs +9 -0
  65. package/dist/src/platform/linux/README.md +3 -3
  66. package/dist/src/platform/linux/codex-runtime.mjs +56 -0
  67. package/dist/src/platform/linux/credential-store.mjs +28 -0
  68. package/dist/src/platform/linux/desktop-host.mjs +83 -0
  69. package/dist/src/platform/linux/desktop-installation.mjs +36 -0
  70. package/dist/src/platform/linux/desktop-project-registration.mjs +11 -0
  71. package/dist/src/platform/linux/network-info.mjs +11 -0
  72. package/dist/src/platform/linux/process.mjs +42 -0
  73. package/dist/src/platform/linux/project-locations.mjs +17 -0
  74. package/dist/src/platform/linux/resident-startup.mjs +52 -0
  75. package/dist/src/platform/linux/retained-runtime.mjs +76 -0
  76. package/dist/src/platform/linux/service-host.mjs +18 -0
  77. package/dist/src/platform/linux/system.mjs +49 -0
  78. package/dist/src/platform/linux/user-environment.mjs +7 -0
  79. package/dist/src/platform/macos/README.md +3 -3
  80. package/dist/src/platform/macos/codex-runtime.mjs +125 -125
  81. package/dist/src/platform/macos/credential-store.mjs +10 -10
  82. package/dist/src/platform/macos/desktop-host.mjs +82 -82
  83. package/dist/src/platform/macos/desktop-project-registration.mjs +11 -10
  84. package/dist/src/platform/macos/network-info.mjs +8 -28
  85. package/dist/src/platform/macos/project-locations.mjs +8 -8
  86. package/dist/src/platform/macos/resident-startup.mjs +84 -84
  87. package/dist/src/platform/macos/retained-runtime.mjs +127 -127
  88. package/dist/src/platform/macos/service-host.mjs +18 -18
  89. package/dist/src/platform/macos/system.mjs +29 -29
  90. package/dist/src/platform/macos/user-environment.mjs +7 -7
  91. package/dist/src/platform/native-package.mjs +58 -46
  92. package/dist/src/platform/network-info.mjs +3 -3
  93. package/dist/src/platform/path-identity.mjs +5 -5
  94. package/dist/src/platform/posix/network-candidates.mjs +23 -0
  95. package/dist/src/platform/posix/runtime-paths.mjs +25 -25
  96. package/dist/src/platform/project-locations.mjs +3 -3
  97. package/dist/src/platform/runtime-paths.mjs +8 -8
  98. package/dist/src/platform/select.mjs +5 -5
  99. package/dist/src/platform/service-host.mjs +3 -3
  100. package/dist/src/platform/user-environment.mjs +3 -3
  101. package/dist/src/platform/windows/IsolatedProcess.cs +79 -79
  102. package/dist/src/platform/windows/activate-desktop.ps1 +48 -48
  103. package/dist/src/platform/windows/background-process.mjs +30 -30
  104. package/dist/src/platform/windows/codex-runtime.mjs +215 -215
  105. package/dist/src/platform/windows/credential-store.mjs +27 -27
  106. package/dist/src/platform/windows/credential-store.ps1 +49 -49
  107. package/dist/src/platform/windows/desktop-helper.mjs +179 -179
  108. package/dist/src/platform/windows/desktop-host.mjs +58 -58
  109. package/dist/src/platform/windows/desktop-info.ps1 +14 -14
  110. package/dist/src/platform/windows/desktop-project-registration.mjs +4 -4
  111. package/dist/src/platform/windows/desktop-runtime.ps1 +10 -10
  112. package/dist/src/platform/windows/desktop-shortcuts.ps1 +21 -21
  113. package/dist/src/platform/windows/dots-profile.mjs +49 -49
  114. package/dist/src/platform/windows/launch-worker.mjs +41 -41
  115. package/dist/src/platform/windows/network-info.mjs +34 -34
  116. package/dist/src/platform/windows/project-locations.mjs +16 -16
  117. package/dist/src/platform/windows/resident-start.js +20 -20
  118. package/dist/src/platform/windows/resident-start.ps1 +7 -7
  119. package/dist/src/platform/windows/resident-startup.ps1 +83 -83
  120. package/dist/src/platform/windows/resolve-state-root.ps1 +14 -14
  121. package/dist/src/platform/windows/runtime-paths.mjs +20 -20
  122. package/dist/src/platform/windows/rustdesk-video-probe.mjs +167 -167
  123. package/dist/src/platform/windows/service-host.mjs +31 -31
  124. package/dist/src/platform/windows/start-hidden-console.ps1 +49 -49
  125. package/dist/src/platform/windows/stop-desktop.ps1 +22 -22
  126. package/dist/src/platform/windows/user-environment.mjs +50 -50
  127. package/dist/src/platform/windows/user-environment.ps1 +41 -41
  128. package/dist/src/project-archive.mjs +30 -0
  129. package/dist/src/project-create.mjs +121 -119
  130. package/dist/src/project-management.mjs +59 -69
  131. package/dist/src/project-remove.mjs +55 -0
  132. package/dist/src/project-update.mjs +56 -0
  133. package/dist/src/runtime-discovery.mjs +47 -47
  134. package/dist/src/self-relay/account-cleanup.mjs +30 -30
  135. package/dist/src/self-relay/account-connections.mjs +52 -52
  136. package/dist/src/self-relay/admin-cloud.mjs +101 -101
  137. package/dist/src/self-relay/admin-page.mjs +80 -80
  138. package/dist/src/self-relay/admin-server.mjs +74 -74
  139. package/dist/src/self-relay/admin-state.mjs +127 -127
  140. package/dist/src/self-relay/admin-style.mjs +14 -14
  141. package/dist/src/self-relay/android-releases.mjs +61 -61
  142. package/dist/src/self-relay/client.mjs +61 -61
  143. package/dist/src/self-relay/connector.mjs +439 -438
  144. package/dist/src/self-relay/debug-allowlist.mjs +33 -33
  145. package/dist/src/self-relay/demo.mjs +37 -37
  146. package/dist/src/self-relay/device-info.mjs +27 -0
  147. package/dist/src/self-relay/device-store.mjs +92 -84
  148. package/dist/src/self-relay/diagnostics.mjs +54 -54
  149. package/dist/src/self-relay/email-accounts.mjs +183 -183
  150. package/dist/src/self-relay/file-wire.mjs +13 -13
  151. package/dist/src/self-relay/lan-authorizations.mjs +71 -42
  152. package/dist/src/self-relay/lifecycle.mjs +264 -250
  153. package/dist/src/self-relay/long-pairing.mjs +40 -40
  154. package/dist/src/self-relay/mysql-state.mjs +118 -105
  155. package/dist/src/self-relay/p2p-probe.mjs +159 -159
  156. package/dist/src/self-relay/privacy-page.mjs +26 -26
  157. package/dist/src/self-relay/public-releases.mjs +34 -0
  158. package/dist/src/self-relay/rtc-file-bridge.mjs +41 -41
  159. package/dist/src/self-relay/server.mjs +1006 -932
  160. package/dist/src/self-relay/usage-daily.mjs +55 -55
  161. package/dist/src/self-relay/usage-meter.mjs +141 -141
  162. package/dist/src/self-relay/windows-route-helper.mjs +75 -75
  163. package/dist/src/self-relay/wire.mjs +36 -36
  164. package/dist/src/server.mjs +1861 -1778
  165. package/dist/src/service-control.mjs +82 -82
  166. package/dist/src/service-doctor.mjs +110 -110
  167. package/dist/src/service-health.mjs +33 -33
  168. package/dist/src/service-install.mjs +91 -91
  169. package/dist/src/service-lifecycle.mjs +138 -138
  170. package/dist/src/service-restart.mjs +81 -81
  171. package/dist/src/shared-app-server.mjs +615 -611
  172. package/dist/src/shared-catalog.mjs +132 -132
  173. package/dist/src/shared-recovery.mjs +146 -146
  174. package/dist/src/shared-thread-stream.mjs +164 -164
  175. package/dist/src/skill-install.mjs +51 -51
  176. package/dist/src/thread-title-generation.mjs +150 -150
  177. package/dist/src/turn-model-history.mjs +47 -47
  178. package/dist/src/usage-reset.mjs +17 -17
  179. package/dist/src/workstation-downloads.mjs +120 -120
  180. package/dist/tools/rtc-route-helper.ps1 +99 -99
  181. package/dist/tools/start-rtc-route-helper.ps1 +15 -15
  182. package/dist/web/vendor/markdown-it.LICENSE.txt +22 -22
  183. package/dist/web/vendor/markdown-it.umd.min.js +13 -13
  184. package/package.json +7 -6
  185. package/tools/postinstall.mjs +127 -127
@@ -1,1778 +1,1861 @@
1
- import crypto from 'node:crypto';
2
- import { RustDeskVideoProbe } from './platform/windows/rustdesk-video-probe.mjs';
3
- import fs from 'node:fs';
4
- import http from 'node:http';
5
- import path from 'node:path';
6
- import os from 'node:os';
7
- import { fileURLToPath } from 'node:url';
8
- import vm from 'node:vm';
9
- import sharp from 'sharp';
10
- import { attachLanSocket } from './lan-socket-server.mjs';
11
- import fileMentions from '../shared/file-mentions.cjs';
12
- import fileCitations from '../shared/file-citations.cjs';
13
- import { validateInlineImage } from './message-images.mjs';
14
- import { ThreadWriterConflictError } from './shared-app-server.mjs';
15
- import { ManagedAppServer, managedAppServerUrl } from './managed-app-server.mjs';
16
- import { RuntimeDiscovery } from './runtime-discovery.mjs';
17
- import { streamSharedThread, threadListNotificationMethods } from './shared-thread-stream.mjs';
18
- import appServerEvents from '../shared/app-server-events.cjs';
19
- import messageDelivery from '../shared/message-delivery.cjs';
20
- import fileChanges from '../shared/file-changes.cjs';
21
- import { runtimePaths } from './platform/runtime-paths.mjs';
22
- import { openControl } from './service-control.mjs';
23
- import { writeRecord, readRecord } from './service-lifecycle.mjs';
24
- import { ExecutionHealth } from './execution-health.mjs';
25
- import { authorizeLanDevice, onLanAuthorizationsChanged } from './self-relay/lan-authorizations.mjs';
26
- import { createRelayManager } from './self-relay/lifecycle.mjs';
27
- import { generateAndSetThreadTitle } from './thread-title-generation.mjs';
28
- import { desktopHost } from './platform/desktop-host.mjs';
29
- import { SharedCatalog } from './shared-catalog.mjs';
30
- import { DotsAppServer } from './dots-task-catalog.mjs';
31
- import { DotsChatServer } from './dots-chat-server.mjs';
32
- import { HostedHistoryCache } from './hosted-history-cache.mjs';
33
- import { readDotsSummaryTasks } from './dots-summary-tasks.mjs';
34
- import { readDotsSchedules } from './dots-schedules.mjs';
35
- import { DotsMessageImages } from './dots-message-images.mjs';
36
- import { desktopDotsProfiles } from './platform/windows/dots-profile.mjs';
37
- import { readPlanPricing } from './plan-pricing.mjs';
38
- import { requestRecovery, recoveryStatus } from './shared-recovery.mjs';
39
- import { requestServiceRestart } from './service-restart.mjs';
40
- import { TurnModelHistory } from './turn-model-history.mjs';
41
- import { browseCode } from './code-browser.mjs';
42
- import { fileRange, fileRevision, FileRangeError } from './file-transfer-range.mjs';
43
- import { browseProjectFolders, createProjectFolder, saveProject } from './project-management.mjs';
44
- import { projectLocations } from './platform/project-locations.mjs';
45
- import { createWorkstationProject } from './project-create.mjs';
46
- import { WorkstationDownloads } from './workstation-downloads.mjs';
47
- import { consumeUsageReset } from './usage-reset.mjs';
48
- import { DesktopHelper } from './platform/windows/desktop-helper.mjs';
49
-
50
- const turnModelHistory = new TurnModelHistory();
51
- const messageReceipts = new messageDelivery.Store();
52
- const desktopHelper = process.platform === 'win32' ? new DesktopHelper() : null;
53
-
54
- const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024;
55
- const MAX_FILE_DOWNLOAD_BYTES = 20 * 1024 * 1024;
56
- const MAX_CACHED_ATTACHMENTS = 32;
57
- const MAX_CACHED_FILES = 64;
58
- const MAX_CONTEXT_TAIL_BYTES = 4 * 1024 * 1024;
59
- const MAX_LIVE_ACTIVITY_TAIL_BYTES = 1024 * 1024;
60
- const SHARED_HISTORY_PAGE_BYTES = 500 * 1024;
61
- const SHARED_HISTORY_PAGE_TURNS = 8;
62
- const MAX_SETTINGS_SCAN_BYTES = 64 * 1024 * 1024;
63
- const ALLOWED_THINKING = new Set(['low', 'medium', 'high', 'xhigh', 'max', 'ultra']);
64
-
65
- class RequestError extends Error {
66
- constructor(statusCode, message, details) {
67
- super(message);
68
- this.statusCode = statusCode;
69
- this.details = details;
70
- this.code = details?.code;
71
- }
72
- }
73
-
74
- function readRuntimeVersion() {
75
- let directory = path.dirname(fileURLToPath(import.meta.url));
76
- while (true) {
77
- const manifest = readRecord(path.join(directory, 'package.json'));
78
- if (manifest?.name === '@walkhi/code-relax') return manifest.version || '未知';
79
- const parent = path.dirname(directory);
80
- if (parent === directory) return '未知';
81
- directory = parent;
82
- }
83
- }
84
-
85
- const options = parseOptions(process.argv.slice(2));
86
- const runtimeVersion = readRuntimeVersion();
87
-
88
- const webRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'web');
89
- const projectRoot = os.homedir();
90
- const { codexDataRoot, stateRoot, sessionsRoot, uploadsRoot } = runtimePaths();
91
- const desktopVideoProbe = process.platform === 'win32' ? new RustDeskVideoProbe({ stateRoot }) : null;
92
- process.once('exit', () => desktopVideoProbe?.stop());
93
- const workstationDownloads = new WorkstationDownloads(stateRoot);
94
- const markdownItScript = fs.readFileSync(path.join(webRoot, 'vendor', 'markdown-it.umd.min.js'));
95
- const markdownSandbox = { atob };
96
- vm.runInNewContext(markdownItScript.toString(), markdownSandbox);
97
- const imageMarkdown = markdownSandbox.markdownit({ html: false });
98
- fileCitations.install(imageMarkdown);
99
- const attachmentCache = new Map();
100
- const fileCache = new Map();
101
- const fileCapabilitySecret = crypto.randomBytes(32);
102
- const imageCapabilityKey = crypto.createHash('sha256')
103
- .update('code-relax-image-capability\0').update(options.token || fileCapabilitySecret).digest();
104
- const sessionPathCache = new Map();
105
- const sessionSettingsCache = new Map();
106
- const liveActivityCache = new Map();
107
- let sharedServer = null;
108
- const managedAppServer = new ManagedAppServer({ stateRoot, host: desktopHost });
109
- const hostedHistoryCache = new HostedHistoryCache(stateRoot);
110
- const dotsServer = new DotsAppServer(managedAppServer.client, desktopDotsProfiles(codexDataRoot, stateRoot), hostedHistoryCache, { uploadsRoot });
111
- const dotsImages = new DotsMessageImages({ localServer: managedAppServer.client, identity: () => dotsServer.profileIdentity,
112
- cache: attachmentCache, capabilityKey: imageCapabilityKey, maxEntries: MAX_CACHED_ATTACHMENTS, maxBytes: MAX_ATTACHMENT_BYTES });
113
- const dotsChat = new DotsChatServer(dotsServer, undefined, { uploadsRoot });
114
- const runtimeDiscovery = new RuntimeDiscovery({ stateRoot, host: desktopHost, url: managedAppServerUrl });
115
- const relayManager = createRelayManager(stateRoot);
116
- let sharedCatalog = null, executionMode = 'ordinary';
117
- function selectSharedExecution(enabled) {
118
- const next = enabled ? managedAppServer.client : null;
119
- if (sharedServer === next) return;
120
- sharedServer = next;
121
- sharedCatalog = sharedServer ? new SharedCatalog(sharedServer, dotsServer, dotsChat) : null;
122
- }
123
- const executionHealth = new ExecutionHealth(async () => {
124
- await managedAppServer.ensure();
125
- const managed = managedAppServer.snapshot();
126
- // The managed app-server owns execution. Desktop is another client of the
127
- // same service, so closing Desktop must not disable phone writes.
128
- selectSharedExecution(managed.ready);
129
- executionMode = sharedServer ? 'shared' : 'ordinary';
130
- const managedRestarting = managed.state === 'restarting';
131
- const ready = !managedRestarting && sharedServer?.ready === true;
132
- const state = managedRestarting ? 'restarting'
133
- : ready ? 'full' : 'unavailable';
134
- return { state, ready, canWrite: ready, mode: executionMode };
135
- });
136
- async function refreshExecution() {
137
- await executionHealth.refresh();
138
- return { mode: executionMode };
139
- }
140
- async function requireExecutionWritable(threadId, client = sharedServer) {
141
- const execution = await executionHealth.refresh();
142
- if (!execution.canWrite) {
143
- throw new RequestError(503, 'Codex 内核未就绪。请重启工作站服务;若仍失败,运行 relax doctor。',
144
- { code: 'full_control_required', execution });
145
- }
146
- if (threadId && client) client.requireWritable(threadId);
147
- return execution;
148
- }
149
- managedAppServer.client.on('subscription-error', error => console.error('Codex 内核任务取消订阅失败:', error.message));
150
- dotsServer.on('subscription-error', error => console.error('Dots 任务取消订阅失败:', error.message));
151
- dotsChat.on('subscription-error', error => console.error('Dots 对话取消订阅失败:', error.message));
152
- dotsChat.on('activity-error', error => console.error('点点状态通道不可用:', error.message));
153
-
154
- dotsServer.on('profile-error', error => console.error(error.message));
155
- async function handleRequest(request, response) {
156
- let messageSubmissionStarted = false;
157
- try {
158
- const url = new URL(request.url || '/', `http://${request.headers.host || 'localhost'}`);
159
-
160
- if (!request.socketRequest) {
161
- sendJson(response, 404, { error: '此端口不提供网页。' });
162
- return;
163
- }
164
-
165
- if (process.platform !== 'win32' && url.pathname.startsWith('/api/desktop/'))
166
- throw new RequestError(501, 'Mac 版不提供远程桌面控制。', { code: 'desktop_control_unsupported' });
167
-
168
- if (url.pathname === '/api/desktop/video-probe') {
169
- if (request.method === 'POST') {
170
- try { sendJson(response, 200, await desktopVideoProbe.begin()); }
171
- catch (error) { throw new RequestError(error.statusCode || 502, error.message, { code: error.code || 'desktop_video_failed' }); }
172
- }
173
- else if (request.method === 'PUT' || request.method === 'DELETE') {
174
- const body = await readJsonBody(request);
175
- if (typeof body.leaseId !== 'string' || !/^[a-f0-9]{32}$/.test(body.leaseId)) throw new RequestError(400, '无效的视频会话');
176
- sendJson(response, 200, request.method === 'PUT' ? desktopVideoProbe.renew(body.leaseId) : desktopVideoProbe.release(body.leaseId));
177
- } else throw new RequestError(405, '不支持的操作');
178
- return;
179
- }
180
- if (request.method === 'GET' && url.pathname === '/api/desktop/frame') {
181
- const started = Date.now();
182
- try {
183
- const preview = url.searchParams.get('preview') === '1';
184
- const frame = await desktopHelper.frame({ preview });
185
- if (!preview) console.info(JSON.stringify({ time: new Date().toISOString(), event: 'desktop-frame', state: frame.state,
186
- kind: frame.kind, width: frame.width, height: frame.height, bytes: frame.size, ms: Date.now() - started }));
187
- if (response.writableEnded) { desktopHelper.release(frame.frameId); return; }
188
- sendJson(response, 200, frame);
189
- } catch (error) {
190
- console.error(JSON.stringify({ time: new Date().toISOString(), event: 'desktop-frame', state: 'failed',
191
- error: error.message.slice(0, 160), ms: Date.now() - started }));
192
- throw error;
193
- }
194
- return;
195
- }
196
- const desktopImage = /^\/api\/desktop\/frames\/([a-f0-9]{32})$/.exec(url.pathname);
197
- if (request.method === 'GET' && desktopImage) {
198
- if (!request.fileTransfer) throw new RequestError(400, '请通过文件通道读取桌面画面。');
199
- const image = desktopHelper.image(desktopImage[1]);
200
- const { offset, length } = fileRange(url.searchParams, image.length, desktopImage[1]);
201
- const mimeType = desktopHelper.active.frame.mimeType;
202
- response.filePart({ name: mimeType === 'image/jpeg' ? 'desktop.jpg' : 'desktop.png', size: image.length, offset, revision: desktopImage[1], mimeType },
203
- image.subarray(offset, offset + length));
204
- return;
205
- }
206
- if (request.method === 'POST' && url.pathname === '/api/desktop/move') {
207
- try { sendJson(response, 200, await desktopHelper.move(await readJsonBody(request))); }
208
- catch (error) {
209
- console.error(JSON.stringify({ time: new Date().toISOString(), event: 'desktop-move', state: 'failed',
210
- code: error.code, error: error.message.slice(0, 160) }));
211
- throw error;
212
- }
213
- return;
214
- }
215
- if (request.method === 'POST' && url.pathname === '/api/desktop/click') {
216
- const started = Date.now();
217
- console.info(JSON.stringify({ time: new Date().toISOString(), event: 'desktop-click', state: 'received' }));
218
- try {
219
- const result = await desktopHelper.click(await readJsonBody(request));
220
- console.info(JSON.stringify({ time: new Date().toISOString(), event: 'desktop-click', state: result.state,
221
- inputCount: result.inputCount, ms: Date.now() - started }));
222
- sendJson(response, 200, result);
223
- } catch (error) {
224
- console.error(JSON.stringify({ time: new Date().toISOString(), event: 'desktop-click', state: 'failed',
225
- code: error.code, error: error.message.slice(0, 160), ms: Date.now() - started }));
226
- throw error;
227
- }
228
- return;
229
- }
230
- if (request.method === 'DELETE' && url.pathname === '/api/desktop/frame') {
231
- const body = await readJsonBody(request);
232
- if (typeof body.sessionId !== 'string' || !/^[a-f0-9]{32}$/.test(body.sessionId)) throw new RequestError(400, '无效的桌面会话');
233
- desktopHelper.release(undefined, body.sessionId);
234
- sendJson(response, 200, { released: true });
235
- return;
236
- }
237
-
238
- // These routes never wait on Desktop tools or a stuck thread. They remain
239
- // available to authenticated paired controllers during an app-server stall.
240
- if (request.method === 'GET' && url.pathname === '/api/shared-recovery/events') {
241
- response.startEvents();
242
- let stopped = false, timer, previous = '';
243
- response.once('close', () => { stopped = true; clearTimeout(timer); });
244
- const publish = async () => {
245
- try {
246
- const state = await recoveryStatus(stateRoot);
247
- const serialized = JSON.stringify(state);
248
- if (!stopped && serialized !== previous) { previous = serialized; response.event('progress', state); }
249
- } catch (error) { if (!stopped) response.event('progress-error', { error: error.message }); }
250
- if (!stopped) timer = setTimeout(publish, 1000);
251
- };
252
- void publish(); return;
253
- }
254
- if (request.method === 'GET' && url.pathname === '/api/shared-recovery') {
255
- sendJson(response, 200, await recoveryStatus(stateRoot)); return;
256
- }
257
- if (request.method === 'POST' && url.pathname === '/api/shared-recovery') {
258
- const body = await readJsonBody(request);
259
- if (body.confirmed !== true || typeof body.operationId !== 'string') throw new RequestError(400, '请确认重新打开当前 Codex 并接入 Codex 内核后再继续。');
260
- sendJson(response, 202, await requestRecovery(stateRoot, body)); return;
261
- }
262
- if (request.method === 'POST' && url.pathname === '/api/service/restart') {
263
- const body = await readJsonBody(request);
264
- if (body.confirmed !== true) throw new RequestError(400, '请确认重启工作站服务后再继续。');
265
- sendJson(response, 202, await requestServiceRestart(stateRoot)); return;
266
- }
267
- if (request.method === 'POST' && url.pathname === '/api/managed-app-server/restart') {
268
- const body = await readJsonBody(request);
269
- if (body.confirmed !== true || typeof body.operationId !== 'string' || !body.operationId.trim()) {
270
- throw new RequestError(400, '请先确认中断风险,再重启 Codex 内核。');
271
- }
272
- try {
273
- sendJson(response, 202, managedAppServer.restart(body.operationId.trim()));
274
- } catch (error) {
275
- if (error?.code === 'managed_app_server_restarting') throw new RequestError(409, error.message);
276
- throw error;
277
- }
278
- return;
279
- }
280
-
281
- if (request.method === 'GET' && url.pathname === '/api/official-remote/status') {
282
- if (!managedAppServer.client.ready) throw new RequestError(503, 'Codex 内核未就绪,无法读取官方工作站状态。');
283
- const result = await managedAppServer.client.request('remoteControl/status/read', {});
284
- sendJson(response, 200, { status: result.status, environmentId: result.environmentId });
285
- return;
286
- }
287
- if (request.method === 'POST' && url.pathname === '/api/official-remote/pairing') {
288
- if (!managedAppServer.client.ready) throw new RequestError(503, 'Codex 内核未就绪,无法生成官方配对码。');
289
- const status = await managedAppServer.client.request('remoteControl/status/read', {});
290
- if (status.status !== 'connected') throw new RequestError(409, '当前 Codex 内核未连接 OpenAI Remote,无法配对。');
291
- const result = await managedAppServer.client.request('remoteControl/pairing/start', { manualCode: true });
292
- if (!result.manualPairingCode || !result.environmentId) throw new RequestError(502, 'Codex 内核未返回有效的官方配对码。');
293
- // One-time code: return only to the authenticated station controller; never persist or log it.
294
- sendJson(response, 200, { manualPairingCode: result.manualPairingCode,
295
- environmentId: result.environmentId, expiresAt: result.expiresAt });
296
- return;
297
- }
298
-
299
- if (request.method === 'GET' && url.pathname === '/api/health') {
300
- // Status readers consume the workstation's cached sample. A phone query
301
- // must not trigger PowerShell, process-identity, or app-server checks.
302
- const executionReady = executionHealth.state.ready && sharedServer?.ready === true;
303
- sendJson(response, 200, {
304
- status: 'ok', resident: true, version: runtimeVersion,
305
- execution: { ...executionHealth.state, ready: executionReady, canWrite: executionReady,
306
- mode: executionMode },
307
- managedAppServer: managedAppServer.snapshot(),
308
- ...runtimeDiscovery.state,
309
- sharedAppServerConnected: sharedServer?.ready === true,
310
- sharedRecoveryAvailable: runtimeDiscovery.state.desktop?.installed === true,
311
- mode: options.lan ? 'lan' : 'loopback',
312
- tokenRequired: Boolean(options.token),
313
- nativeImageTransport: sharedServer ? 'shared-app-server' : null,
314
- transportMode: sharedServer?.ready === true ? 'lan-shared' : 'unavailable',
315
- });
316
- return;
317
- }
318
-
319
- const fileLinkMatch = /^\/api\/files\/([a-f0-9]{64})\/link$/.exec(url.pathname);
320
- if (request.method === 'GET' && fileLinkMatch) {
321
- const attachment = attachmentCache.get(fileLinkMatch[1])
322
- || dotsImages.restore(fileLinkMatch[1], url.searchParams.get('recovery') || '');
323
- if (!attachment?.dotsFile) throw new RequestError(404, '此附件没有网络链接,请重新读取对话。');
324
- sendJson(response, 200, { url: await dotsImages.fileLink(attachment) });
325
- return;
326
- }
327
-
328
- const imageMatch = /^\/api\/images\/([a-f0-9]{64})$/.exec(url.pathname);
329
- if (request.method === 'GET' && imageMatch) {
330
- let image = attachmentCache.get(imageMatch[1])
331
- || dotsImages.restore(imageMatch[1], url.searchParams.get('recovery') || '')
332
- || recoverLocalImage(imageMatch[1], url.searchParams.get('recovery') || '');
333
- const contextThreadId = url.searchParams.get('threadId') || '';
334
- const contextTurnId = url.searchParams.get('turnId') || '';
335
- if (!image && sharedServer && contextThreadId && contextTurnId
336
- && contextThreadId.length <= 128 && contextTurnId.length <= 128) {
337
- try {
338
- normalizeTurn(await sharedServer.turnContext(contextThreadId, contextTurnId));
339
- image = attachmentCache.get(imageMatch[1]);
340
- } catch {}
341
- }
342
- if (!image) throw new RequestError(404, '这张图片已失效,无法从工作站重新读取。');
343
- if (image.dotsFile) await dotsImages.load(image);
344
- if (url.searchParams.get('variant') === 'thumbnail') {
345
- const asset = await attachmentThumbnail(image);
346
- sendJson(response, 200, { url: `data:${asset.mimeType};base64,${asset.buffer.toString('base64')}` });
347
- return;
348
- }
349
- const size = image.buffer.length;
350
- if (size > MAX_ATTACHMENT_BYTES) throw new RequestError(413, '图片超过下载上限(20 MiB),无法下载。');
351
- if (!size) throw new RequestError(400, '图片为空,无法下载。');
352
- if (!request.fileTransfer) throw new RequestError(400, '请通过文件通道读取原图。');
353
- const revision = imageMatch[1];
354
- const { offset, length } = fileRange(url.searchParams, size, revision);
355
- response.filePart({ name: image.name || '图片', size, offset, revision, mimeType: image.mimeType },
356
- image.buffer.subarray(offset, offset + length));
357
- return;
358
- }
359
-
360
- const localFileRequest = url.pathname === '/api/files/local';
361
- const fileMatch = /^\/api\/files\/([a-f0-9]{64})$/.exec(url.pathname);
362
- if (request.method === 'GET' && (fileMatch || localFileRequest)) {
363
- if (!request.fileTransfer) throw new RequestError(400, '请通过文件通道下载附件。');
364
- const hosted = !localFileRequest && (attachmentCache.get(fileMatch[1])
365
- || dotsImages.restore(fileMatch[1], url.searchParams.get('recovery') || ''));
366
- if (hosted?.dotsFile) {
367
- const { bytes, ...metadata } = await dotsImages.readPart(hosted, url.searchParams);
368
- response.filePart(metadata, bytes);
369
- return;
370
- }
371
- const localPath = url.searchParams.get('path') || '';
372
- if (localFileRequest && !/^[a-z]:[\\/]/i.test(localPath)) throw new RequestError(400, '无效的工作站文件路径。');
373
- const file = localFileRequest ? describeLocalFile(localPath) : fileCache.get(fileMatch[1]);
374
- if (!file) throw new RequestError(404, '电脑上的文件不存在或无法读取。');
375
- if (file.externalIdentity) await dotsImages.authorization(file.externalIdentity);
376
- const { offset, length } = fileRange(url.searchParams, file.size, file.revision);
377
- let handle;
378
- try { handle = fs.openSync(file.path, 'r'); }
379
- catch (error) {
380
- if (error.code === 'ENOENT') throw new RequestError(404, '电脑上的文件已不存在。');
381
- throw error;
382
- }
383
- try {
384
- const validate = () => {
385
- const stat = fs.fstatSync(handle);
386
- if (!stat.isFile() || fileRevision(stat) !== file.revision || fileRevision(fs.statSync(file.path)) !== file.revision) {
387
- if (fileMatch) fileCache.delete(fileMatch[1]);
388
- throw new RequestError(409, '电脑上的文件已发生变化,请重新读取对话。');
389
- }
390
- if (stat.size > MAX_FILE_DOWNLOAD_BYTES) throw new RequestError(413, '文件超过下载上限(20 MiB),无法下载。');
391
- if (!stat.size) throw new RequestError(400, '文件为空,无法下载。');
392
- };
393
- validate();
394
- const data = Buffer.alloc(length);
395
- let bytesRead = 0;
396
- while (bytesRead < data.length) {
397
- const count = fs.readSync(handle, data, bytesRead, data.length - bytesRead, offset + bytesRead);
398
- if (!count) throw new RequestError(409, '电脑上的文件读取不完整,请重新读取对话。');
399
- bytesRead += count;
400
- }
401
- validate();
402
- response.filePart({ name: file.name, size: file.size, offset, revision: file.revision }, data);
403
- } finally { fs.closeSync(handle); }
404
- return;
405
- }
406
-
407
- if (url.pathname.startsWith('/api/app-server/') || url.pathname === '/api/wait') {
408
- throw new RequestError(410, '此连接方式已停用。请检查 Codex 内核状态。', { code: 'unsupported_control' });
409
- }
410
- const taskPath = /^\/api\/threads\/([^/]+)/.exec(url.pathname);
411
- if (taskPath && (url.pathname.endsWith('/takeover') || url.pathname === `/api/threads/${taskPath[1]}`)) {
412
- throw new RequestError(410, '此连接方式已停用。', { code: 'unsupported_control' });
413
- }
414
- const hostId = url.searchParams.get('hostId') || 'local';
415
- if (!['local', 'durable'].includes(hostId)) {
416
- throw new RequestError(410, '此对话的连接方式已停用,请检查 Codex 内核状态。', { code: 'unsupported_control' });
417
- }
418
- if (!executionHealth.state.ready || sharedServer?.ready !== true) {
419
- throw new RequestError(503, 'Codex 内核未就绪。请重启工作站服务;若仍失败,运行 relax doctor。',
420
- { code: 'full_control_required' });
421
- }
422
- const rootChat = hostId === 'durable' && taskPath
423
- && await dotsServer.source(decodeURIComponent(taskPath[1])) === 'aeon';
424
- const execution = rootChat ? dotsChat : hostId === 'durable' ? dotsServer : sharedServer;
425
-
426
- const dotsActivityMatch = /^\/api\/threads\/([^/]+)\/dots-activity-events$/.exec(url.pathname);
427
- if (request.method === 'GET' && dotsActivityMatch) {
428
- if (!rootChat) throw new RequestError(405, '只有点点主聊天提供此活动面板。');
429
- response.startEvents();
430
- const disconnected = () => response.end();
431
- dotsChat.on('disconnected', disconnected);
432
- const release = dotsChat.acquirePanelActivity(decodeURIComponent(dotsActivityMatch[1]), value => {
433
- if (!response.destroyed) response.event('dots-activity', { threadId: value.threadId, activity: value.activity });
434
- });
435
- response.once('close', () => { release(); dotsChat.off('disconnected', disconnected); });
436
- return;
437
- }
438
-
439
- const dotsTasksMatch = /^\/api\/threads\/([^/]+)\/dots-tasks$/.exec(url.pathname);
440
- if (request.method === 'GET' && dotsTasksMatch) {
441
- if (!rootChat) throw new RequestError(405, '只有点点主聊天提供此任务面板。');
442
- sendJson(response, 200, await readDotsSummaryTasks(dotsServer, decodeURIComponent(dotsTasksMatch[1])));
443
- return;
444
- }
445
-
446
- const dotsSchedulesMatch = /^\/api\/threads\/([^/]+)\/dots-schedules$/.exec(url.pathname);
447
- if (request.method === 'GET' && dotsSchedulesMatch) {
448
- if (!rootChat) throw new RequestError(405, '只有点点主聊天提供此定时任务列表。');
449
- sendJson(response, 200, await readDotsSchedules(dotsServer, decodeURIComponent(dotsSchedulesMatch[1])));
450
- return;
451
- }
452
-
453
- if (request.method === 'GET' && url.pathname.startsWith('/api/code/')) {
454
- const operation = url.pathname.slice('/api/code/'.length);
455
- if (hostId === 'durable' && operation !== 'change') throw new RequestError(405, 'Dots 委派任务尚未接入代码浏览。');
456
- try {
457
- if ((operation === 'image' || operation === 'binary') && !request.fileTransfer) throw new RequestError(400, '请通过文件通道读取预览文件。');
458
- const result = await browseCode((method, params) => execution.request(method, params), operation, url.searchParams);
459
- if (operation === 'image' || operation === 'binary') {
460
- const { data, ...metadata } = result;
461
- response.filePart(metadata, data);
462
- } else sendJson(response, 200, result);
463
- } catch (error) { throw new RequestError(error.statusCode || 502, error.message); }
464
- return;
465
- }
466
-
467
- if (request.method === 'GET' && url.pathname === '/api/models') {
468
- sendJson(response, 200, await execution.models());
469
- return;
470
- }
471
-
472
- if (request.method === 'GET' && url.pathname === '/api/threads') {
473
- const limit = boundedInteger(url.searchParams.get('limit'), 20, 1, 200);
474
- const archived = url.searchParams.get('archived') === 'true';
475
- const scope = url.searchParams.get('scope') || 'all';
476
- if (!['all', 'local', 'durable'].includes(scope)) { sendJson(response, 400, { error: '无效的会话列表范围' }); return; }
477
- sendJson(response, 200, await sharedCatalog.threads(limit, archived, scope, url.searchParams.get('refresh') === '1'));
478
- return;
479
- }
480
-
481
- if (request.method === 'GET' && url.pathname === '/api/file-local-copy') {
482
- const value = url.searchParams.get('source') || '';
483
- const id = /^([a-f0-9]{64})(?:\?recovery=[A-Za-z0-9_-]{1,4096})?$/.exec(value)?.[1];
484
- if (id && (fileCache.has(id) || attachmentCache.has(id) && !attachmentCache.get(id).dotsFile)) {
485
- sendJson(response, 200, { external: false, localCopy: null }); return;
486
- }
487
- const asset = await externalDownloadAsset(value, url.searchParams.get('recovery'));
488
- const copy = await workstationDownloads.copy(asset.id);
489
- sendJson(response, 200, { external: true, localCopy: copy ? workstationCopyReference(copy, asset) : null,
490
- cachedContent: asset.buffer ? { remotePath: asset.id, name: asset.name, size: asset.buffer.length, mimeType: asset.mimeType } : null });
491
- return;
492
- }
493
-
494
- if (request.method === 'GET' && url.pathname === '/api/file-save-locations') {
495
- await requireExecutionWritable();
496
- const locations = await projectLocations();
497
- const projects = (await sharedCatalog.projects()).projects.filter(project => project.hostId === 'local' && project.roots?.length);
498
- sendJson(response, 200, { projects, downloads: locations.downloads });
499
- return;
500
- }
501
-
502
- if (request.method === 'GET' && url.pathname === '/api/file-save-folders') {
503
- await requireExecutionWritable();
504
- sendJson(response, 200, await workstationDownloads.browse((method, params) => sharedServer.request(method, params),
505
- await projectLocations(), Object.fromEntries(url.searchParams)));
506
- return;
507
- }
508
-
509
- if (request.method === 'POST' && url.pathname === '/api/file-saves') {
510
- await requireExecutionWritable();
511
- const body = await readJsonBody(request);
512
- const asset = await externalDownloadAsset(body.source, body.recovery);
513
- const controller = new AbortController(), cancel = () => controller.abort();
514
- response.once('close', cancel);
515
- try {
516
- const result = await workstationDownloads.save((method, params) => sharedServer.request(method, params),
517
- await projectLocations(), body, asset, (file, query) => dotsImages.readPart(file, query), controller.signal);
518
- sendJson(response, 201, { localCopy: workstationCopyReference(result, asset), warning: result.warning || '' });
519
- } finally { response.off('close', cancel); }
520
- return;
521
- }
522
-
523
- if (request.method === 'GET' && url.pathname === '/api/projects') {
524
- sendJson(response, 200, { projects: (await sharedCatalog.projects()).projects });
525
- return;
526
- }
527
-
528
- if (url.pathname === '/api/project-folders' && ['GET', 'POST'].includes(request.method)) {
529
- await requireExecutionWritable();
530
- try {
531
- const rpc = (method, params) => sharedServer.request(method, params);
532
- const result = request.method === 'GET'
533
- ? await browseProjectFolders(rpc, await projectLocations(), url.searchParams.get('path'))
534
- : await createProjectFolder(rpc, await readJsonBody(request));
535
- sendJson(response, request.method === 'GET' ? 200 : 201, result);
536
- } catch (error) { throw new RequestError(error.statusCode || 502, error.message); }
537
- return;
538
- }
539
-
540
- const updateProjectMatch = /^\/api\/projects\/([^/]+)$/.exec(url.pathname);
541
- if (request.method === 'POST' && (url.pathname === '/api/projects' || updateProjectMatch)) {
542
- await requireExecutionWritable();
543
- const body = await readJsonBody(request);
544
- try {
545
- const result = !updateProjectMatch ? await createWorkstationProject(sharedServer, stateRoot, codexDataRoot, body)
546
- : await saveProject((method, params) => sharedServer.request(method, params),
547
- await projectLocations(), body, updateProjectMatch ? decodeURIComponent(updateProjectMatch[1]) : null);
548
- sendJson(response, updateProjectMatch ? 200 : 201, result);
549
- } catch (error) { throw new RequestError(error.statusCode || 502, error.message, error.details); }
550
- return;
551
- }
552
-
553
- if (request.method === 'GET' && url.pathname === '/api/plan-pricing') {
554
- sendJson(response, 200, await readPlanPricing(sharedServer));
555
- return;
556
- }
557
-
558
- if (request.method === 'GET' && url.pathname === '/api/usage-limits') {
559
- sendJson(response, 200, await sharedServer.usage());
560
- return;
561
- }
562
-
563
- if (request.method === 'POST' && url.pathname === '/api/usage-reset') {
564
- await requireExecutionWritable();
565
- const body = await readJsonBody(request);
566
- try { sendJson(response, 200, await consumeUsageReset(sharedServer, body)); }
567
- catch (error) { throw new RequestError(error.statusCode || 502, error.message,
568
- error.code === 'usage_reset_not_submitted' ? { code: error.code, deliveryUnknown: false } : undefined); }
569
- return;
570
- }
571
-
572
- if (request.method === 'GET' && url.pathname === '/api/usage-events') {
573
- const execution = sharedServer;
574
- response.startEvents();
575
- const emit = value => { if (!response.destroyed) response.event('usage', value); };
576
- const failed = error => { if (!response.destroyed) response.event('usage-error', { error: error.message }); };
577
- const disconnected = () => response.end();
578
- execution.on('usage', emit); execution.on('usage-error', failed); execution.on('disconnected', disconnected);
579
- response.once('close', () => { execution.off('usage', emit); execution.off('usage-error', failed); execution.off('disconnected', disconnected); });
580
- try { emit(await execution.usage()); } catch (error) { failed(error); }
581
- return;
582
- }
583
-
584
- if (request.method === 'GET' && url.pathname === '/api/thread-events') {
585
- await sharedServer.connect();
586
- const clients = [sharedServer, dotsServer, dotsChat];
587
- response.startEvents();
588
- const listener = ({ method, params }) => {
589
- if (!threadListNotificationMethods.has(method) || response.destroyed) return;
590
- response.event('thread-list-changed', { method, threadId: params.threadId || params.thread?.id || '',
591
- ...(params.hostId ? { hostId: params.hostId } : {}),
592
- ...(params.chatRevision !== undefined ? { chatRevision: params.chatRevision } : {}),
593
- ...(params.updatedAt !== undefined ? { updatedAt: params.updatedAt } : {}),
594
- ...(params.chatMessageRevision !== undefined ? { chatMessageRevision: params.chatMessageRevision } : {}),
595
- ...(params.status ? { status: typeof params.status === 'string' ? params.status : params.status.type } : {}) });
596
- };
597
- const disconnected = () => response.end();
598
- for (const client of clients) {
599
- client.on('notification', listener);
600
- client.on('disconnected', disconnected);
601
- }
602
- response.once('close', () => {
603
- for (const client of clients) {
604
- client.off('notification', listener);
605
- client.off('disconnected', disconnected);
606
- }
607
- });
608
- response.event('ready', {});
609
- return;
610
- }
611
-
612
- if (request.method === 'GET' && url.pathname === '/api/turn-events') {
613
- const clients = [sharedServer, dotsServer, dotsChat];
614
- response.startEvents();
615
- const listener = ({ method, params }) => {
616
- if (!['turn/started', 'turn/completed'].includes(method)) return;
617
- const threadId = params?.threadId, turnId = params?.turn?.id;
618
- if (typeof threadId === 'string' && typeof turnId === 'string' && !response.destroyed) {
619
- response.event('turn', { method, threadId, turnId });
620
- }
621
- };
622
- const disconnected = () => response.end();
623
- for (const client of clients) {
624
- client.on('notification', listener);
625
- client.on('disconnected', disconnected);
626
- }
627
- response.once('close', () => {
628
- for (const client of clients) {
629
- client.off('notification', listener);
630
- client.off('disconnected', disconnected);
631
- }
632
- });
633
- return;
634
- }
635
-
636
- const activeTurnMatch = /^\/api\/threads\/([^/]+)\/active-turn$/.exec(url.pathname);
637
- if (request.method === 'GET' && activeTurnMatch) {
638
- if (rootChat) { sendJson(response, 200, { turnId: null }); return; }
639
- const threadId = decodeURIComponent(activeTurnMatch[1]);
640
- await execution.connect();
641
- const { thread } = await execution.request('thread/read', { threadId, includeTurns: false });
642
- const status = typeof thread.status === 'string' ? thread.status : thread.status?.type;
643
- let turnId = null;
644
- if (status === 'active') {
645
- const page = await execution.request('thread/turns/list', {
646
- threadId, limit: 1, sortDirection: 'desc', itemsView: 'summary',
647
- });
648
- const latest = page.data?.[0];
649
- if (latest?.status === 'inProgress') turnId = latest.id;
650
- }
651
- sendJson(response, 200, { turnId });
652
- return;
653
- }
654
-
655
- const createThreadMatch = /^\/api\/projects\/([^/]+)\/threads$/.exec(url.pathname);
656
- if (request.method === 'POST' && createThreadMatch) {
657
- await requireExecutionWritable();
658
- const projectId = decodeURIComponent(createThreadMatch[1]);
659
- const body = await readJsonBody(request);
660
- if (body.transport === 'app-server' || (body.hostId && body.hostId !== 'local')) {
661
- throw new RequestError(410, '此连接方式已停用,请检查 Codex 内核状态。', { code: 'unsupported_control' });
662
- }
663
- const projects = (await sharedCatalog.projects()).projects;
664
- const project = projects.find(item => item.projectId === projectId);
665
- if (projectId !== '__unassigned__' && !project) throw new RequestError(404, '没有找到对应项目。');
666
- const execution = sharedServer;
667
- await execution.connect();
668
- const rpc = (method, params) => execution.request(method, params);
669
- let target = appServerEvents.projectlessThreadTarget(projectRoot);
670
- if (project) {
671
- const nativeProjects = await appServerEvents.readProjects(rpc);
672
- const nativeProject = nativeProjects.find(item => item.id === project.projectId);
673
- if (!nativeProject) throw new RequestError(404, 'Codex 内核中没有找到对应项目,请刷新项目列表。');
674
- target = appServerEvents.projectThreadTarget(nativeProject);
675
- }
676
- const { input } = appServerTurnArguments('', body);
677
- messageSubmissionStarted = true;
678
- const created = await appServerEvents.createThread(rpc, target, input, body);
679
- const prompt = input.filter(part => part.type === 'text').map(part => part.text).join('\n');
680
- void generateAndSetThreadTitle({
681
- request: rpc,
682
- subscribe: listener => {
683
- execution.on('notification', listener);
684
- return () => execution.off('notification', listener);
685
- },
686
- threadId: created.threadId,
687
- prompt,
688
- cwd: target.cwd || null,
689
- }).catch(error => console.error(`生成对话标题失败:${error.message}`));
690
- sendJson(response, 201, { ...created, hostId:'local' });
691
- return;
692
- }
693
-
694
- const timelineMatch = /^\/api\/threads\/([^/]+)\/timeline$/.exec(url.pathname);
695
- if (request.method === 'GET' && timelineMatch) {
696
- const threadId = decodeURIComponent(timelineMatch[1]);
697
- const knownSeq = url.searchParams.get('knownSeq');
698
- const knownSync = { id: url.searchParams.get('knownSyncId'),
699
- epoch: url.searchParams.get('knownEpoch'), seq: knownSeq === null ? undefined : Number(knownSeq),
700
- updatedAt: Number(url.searchParams.get('knownUpdatedAt')) };
701
- const pageOptions = sharedHistoryPageOptions();
702
- pageOptions.reconcileObserved = url.searchParams.get('refresh') === '1';
703
- if (request.socketRequest) {
704
- pageOptions.onWindow = async window => {
705
- if (response.writableEnded) return;
706
- await response.historyPage(await prepareSharedTimeline(window));
707
- };
708
- }
709
- const timeline = await prepareSharedTimeline(await execution.timeline(threadId, pageOptions, knownSync));
710
- if (request.socketRequest) {
711
- response.on('progress', () => execution.keepSnapshotAlive(threadId, timeline.sync));
712
- response.once('close', () => execution.keepSnapshotAlive(threadId, timeline.sync));
713
- }
714
- sendJson(response, 200, timeline);
715
- return;
716
- }
717
-
718
- const approvalMatch = /^\/api\/threads\/([^/]+)\/approvals$/.exec(url.pathname);
719
- if (request.method === 'GET' && approvalMatch && rootChat) {
720
- const threadId = decodeURIComponent(approvalMatch[1]);
721
- sendJson(response, 200, await execution.inspectApproval(threadId, {
722
- id: url.searchParams.get('requestId'), roomMessageId: url.searchParams.get('roomMessageId'),
723
- })); return;
724
- }
725
- if (request.method === 'POST' && approvalMatch) {
726
- const threadId = decodeURIComponent(approvalMatch[1]);
727
- await requireExecutionWritable(threadId, execution);
728
- const body = await readJsonBody(request);
729
- sendJson(response, 200, await execution.respondApproval(threadId, body)); return;
730
- }
731
-
732
- const eventsMatch = /^\/api\/threads\/([^/]+)\/events$/.exec(url.pathname);
733
- if (request.method === 'GET' && eventsMatch) {
734
- const threadId = decodeURIComponent(eventsMatch[1]);
735
- const after = Number(url.searchParams.get('afterSeq'));
736
- const requestedSync = { id: url.searchParams.get('syncId'), epoch: url.searchParams.get('syncEpoch'),
737
- after: Number.isSafeInteger(after) && after >= 0 ? after : undefined,
738
- updatedAt: Number(url.searchParams.get('knownUpdatedAt')) };
739
- await streamSharedThread(response, threadId, execution,
740
- prepareSharedTimeline, requestedSync);
741
- return;
742
- }
743
-
744
- const modelChangesMatch = /^\/api\/threads\/([^/]+)\/model-changes$/.exec(url.pathname);
745
- if (request.method === 'GET' && modelChangesMatch) {
746
- const threadId = decodeURIComponent(modelChangesMatch[1]);
747
- if (!/^[0-9a-f]{8}-[0-9a-f-]{20,55}$/i.test(threadId)) throw new RequestError(400, '对话 ID 无效。');
748
- sendJson(response, 200, hostId === 'durable' ? [] : await turnModelHistory.read(findSessionPath(threadId), (url.searchParams.get('turnIds') || '').split(',').filter(Boolean)));
749
- return;
750
- }
751
- const contextMatch = /^\/api\/threads\/([^/]+)\/context$/.exec(url.pathname);
752
- if (request.method === 'GET' && contextMatch) {
753
- const threadId = decodeURIComponent(contextMatch[1]);
754
- const current = hostId === 'durable' ? { available: false } : readContextUsage(threadId);
755
- const native = await execution.readContext(threadId, url.searchParams.get('refreshSettings') === 'true');
756
- sendJson(response, 200, { ...current, ...(native.model ? { model: native.model, thinking: native.thinking, serviceTier: native.serviceTier,
757
- permissionMode: native.permissionMode, permissionModes: native.permissionModes } : {}), ...(native.available ? native : {}) });
758
- return;
759
- }
760
-
761
- const sharedAction = /^\/api\/threads\/([^/]+)\/(history|settings|permissions)$/.exec(url.pathname);
762
- if (sharedAction) {
763
- const id = decodeURIComponent(sharedAction[1]);
764
- if (request.method === 'GET' && sharedAction[2] === 'history') {
765
- const pageOptions = sharedHistoryPageOptions();
766
- if (url.searchParams.has('itemLimit')) {
767
- const itemLimit = Number(url.searchParams.get('itemLimit'));
768
- if (!Number.isInteger(itemLimit) || itemLimit < 1 || itemLimit > 100) throw new RequestError(400, '历史条目数量无效。');
769
- pageOptions.itemLimit = itemLimit;
770
- }
771
- const history = await execution.history(id, url.searchParams.get('cursor'), pageOptions);
772
- sendJson(response, 200, await prepareSharedTimeline(history, hostId));
773
- return;
774
- }
775
- if (request.method === 'POST' && sharedAction[2] === 'settings') {
776
- if (rootChat) throw new RequestError(405, 'Dots 主对话不提供 Codex 模型设置。');
777
- await requireExecutionWritable(id, execution);
778
- const body = await readJsonBody(request);
779
- sendJson(response, 200, await execution.settings(id, body.model, body.thinking, body.serviceTier));
780
- return;
781
- }
782
- if (request.method === 'POST' && sharedAction[2] === 'permissions') {
783
- if (rootChat) throw new RequestError(405, 'Dots 主对话不提供 Codex 权限设置。');
784
- await requireExecutionWritable(id, execution);
785
- const body = await readJsonBody(request);
786
- sendJson(response, 200, await execution.permissions(id, body.mode));
787
- return;
788
- }
789
- }
790
-
791
- const archiveMatch = /^\/api\/threads\/([^/]+)\/(archive|unarchive)$/.exec(url.pathname);
792
- if (request.method === 'POST' && archiveMatch) {
793
- if (rootChat) throw new RequestError(405, 'Dots 主对话不提供任务归档。');
794
- const threadId = decodeURIComponent(archiveMatch[1]);
795
- await requireExecutionWritable(threadId, execution);
796
- await execution.request(`thread/${archiveMatch[2]}`, { threadId });
797
- sendJson(response, 200, { threadId, archived: archiveMatch[2] === 'archive' });
798
- return;
799
- }
800
-
801
- const queueMatch = /^\/api\/threads\/([^/]+)\/queue$/.exec(url.pathname);
802
- if (queueMatch) {
803
- if (hostId === 'durable') throw new RequestError(405, '当前托管连接不提供排队消息。');
804
- const threadId = decodeURIComponent(queueMatch[1]);
805
- await sharedServer.connect();
806
- const rpc = (method, params) => sharedServer.request(method, params);
807
- if (request.method === 'GET') { sendJson(response, 200, await appServerEvents.readQueue(rpc, threadId)); return; }
808
- if (request.method === 'POST') {
809
- await requireExecutionWritable(threadId);
810
- const body = await readJsonBody(request);
811
- const args = appServerTurnArguments(threadId, body);
812
- sendJson(response, 202, await appServerEvents.addQueue(rpc, threadId, args.input, crypto.randomUUID(), body.containsVoiceInput)); return;
813
- }
814
- }
815
-
816
- const queueItemMatch = /^\/api\/threads\/([^/]+)\/queue\/([a-f0-9-]+)(?:\/(steer))?$/.exec(url.pathname);
817
- if (queueItemMatch) {
818
- if (hostId === 'durable') throw new RequestError(405, '当前托管连接不提供排队消息。');
819
- const threadId = decodeURIComponent(queueItemMatch[1]);
820
- const itemId = queueItemMatch[2];
821
- await sharedServer.connect();
822
- const action = request.method === 'DELETE' ? 'delete' : queueItemMatch[3] === 'steer' ? 'steer' : null;
823
- if (!action) throw new RequestError(405, '不支持的队列操作');
824
- await requireExecutionWritable(threadId);
825
- sendJson(response, 200, await appServerEvents.changeQueue((method, params) => sharedServer.request(method, params), threadId, itemId, action)); return;
826
- }
827
-
828
- const interruptMatch = /^\/api\/threads\/([^/]+)\/interrupt$/.exec(url.pathname);
829
- if (request.method === 'POST' && interruptMatch) {
830
- if (rootChat) throw new RequestError(405, 'Dots 主对话不提供停止 Codex 任务的操作。');
831
- const threadId = decodeURIComponent(interruptMatch[1]);
832
- await requireExecutionWritable(threadId, execution);
833
- const body = await readJsonBody(request);
834
- if (!body.turnId) throw new RequestError(400, '缺少活动轮次 ID。');
835
- await execution.interrupt(threadId, body.turnId);
836
- sendJson(response, 200, { requested: true });
837
- return;
838
- }
839
-
840
- const roomReadMatch = /^\/api\/threads\/([^/]+)\/read$/.exec(url.pathname);
841
- if (request.method === 'POST' && roomReadMatch) {
842
- if (!rootChat) throw new RequestError(405, '此对话不提供消息房间已读操作。');
843
- await dotsChat.markRead(decodeURIComponent(roomReadMatch[1]), await readJsonBody(request));
844
- sendJson(response, 200, { success: true });
845
- return;
846
- }
847
-
848
- const messageStatusMatch = /^\/api\/threads\/([^/]+)\/messages\/([a-f0-9]{32})\/status$/.exec(url.pathname);
849
- if (request.method === 'GET' && messageStatusMatch) {
850
- sendJson(response, 200, messageReceipts.status([hostId, decodeURIComponent(messageStatusMatch[1])], messageStatusMatch[2]));
851
- return;
852
- }
853
- const messageMatch = /^\/api\/threads\/([^/]+)\/messages$/.exec(url.pathname);
854
- if (request.method === 'POST' && messageMatch) {
855
- const threadId = decodeURIComponent(messageMatch[1]);
856
- const body = await readJsonBody(request);
857
- if (body.hostId && body.hostId !== hostId) throw new RequestError(400, '请求中的对话主机不一致。');
858
- const result = await messageReceipts.submit([hostId, threadId], body.clientUserMessageId, async () => {
859
- let args;
860
- try { await requireExecutionWritable(threadId, execution); args = execution === dotsChat ? { ...body, threadId } : appServerTurnArguments(threadId, { ...body, hostId }); }
861
- catch (error) { error.deliveryUnknown = false; throw error; }
862
- messageSubmissionStarted = true;
863
- const submitted = await execution.send(args);
864
- return { success: true, data: submitted.result, usage: submitted.usage };
865
- });
866
- sendJson(response, 200, result);
867
- return;
868
- }
869
-
870
- sendJson(response, 404, { error: '未找到请求的资源。' });
871
- } catch (error) {
872
- if (response.headersSent) {
873
- if (!response.writableEnded) response.end();
874
- return;
875
- }
876
- if (!messageSubmissionStarted && error.deliveryUnknown === undefined && request.method === 'POST'
877
- && /^\/api\/threads\/[^/]+\/messages(?:\?|$)/.test(request.url || '')) error.deliveryUnknown = false;
878
- const status = error instanceof RequestError || error instanceof ThreadWriterConflictError || error instanceof FileRangeError ? (error.statusCode || 409) : 502;
879
- sendJson(response, status, {
880
- error: error instanceof Error ? error.message : String(error),
881
- code: error?.code,
882
- threadId: error?.threadId,
883
- readOnly: error?.readOnly,
884
- details: error?.deliveryUnknown === undefined ? error?.details : { ...error?.details, deliveryUnknown: error.deliveryUnknown },
885
- });
886
- }
887
- }
888
-
889
- const server = http.createServer(handleRequest);
890
- const lanSocket = attachLanSocket(server, { token: options.token, dispatch: handleRequest,
891
- uploadsRoot,
892
- authorizeDevice: (clientId, credential) => authorizeLanDevice(stateRoot, clientId, credential),
893
- onDeviceAuthorizationsChanged: listener => onLanAuthorizationsChanged(stateRoot, listener),
894
- });
895
-
896
- let control;
897
- void managedAppServer.start();
898
- executionHealth.start();
899
- runtimeDiscovery.start();
900
- server.on('error', error => {
901
- console.error('桌面服务监听失败:', error.message);
902
- process.exit(1);
903
- });
904
- server.listen(options.port, options.lan ? '0.0.0.0' : '127.0.0.1', async () => {
905
- try {
906
- if (process.env.CODEX_REMOTE_INSTANCE && process.env.CODEX_REMOTE_CONTROL_SECRET && process.env.CODEX_REMOTE_READY_PATH) {
907
- control = await openControl({ instanceId: process.env.CODEX_REMOTE_INSTANCE,
908
- secret: process.env.CODEX_REMOTE_CONTROL_SECRET, shutdown: shutDown, actions: {
909
- 'refresh-execution': refreshExecution,
910
- 'relay-start': () => relayManager.run('relay-start'),
911
- 'relay-status': () => relayManager.run('relay-status'),
912
- 'relay-stop': () => relayManager.run('relay-stop'),
913
- 'relay-pair': () => relayManager.run('relay-pair'),
914
- 'relay-revoke': () => relayManager.run('relay-revoke'),
915
- 'invite-create': details => relayManager.run('invite-create', details),
916
- 'invite-list': () => relayManager.run('invite-list'),
917
- 'invite-revoke': details => relayManager.run('invite-revoke', details),
918
- 'restart-managed-app-server': () => {
919
- const state = managedAppServer.snapshot();
920
- return state.launcherRestartRequired ? managedAppServer.restart(crypto.randomUUID()) : state;
921
- },
922
- 'restart-managed-app-server-confirmed': () => managedAppServer.restart(crypto.randomUUID()),
923
- } });
924
- writeRecord(process.env.CODEX_REMOTE_READY_PATH, { instanceId: process.env.CODEX_REMOTE_INSTANCE,
925
- pid: process.pid, controlPort: control.port });
926
- }
927
- } catch (error) {
928
- console.error('桌面服务管理通道启动失败:', error.message);
929
- process.exit(1);
930
- }
931
- console.log(`Code Relax 桌面服务已启动:http://127.0.0.1:${options.port}/`);
932
- if (options.lan) {
933
- console.log('LAN 模式使用鉴权 WebSocket,仅适合受信任局域网测试,不要暴露到公网。');
934
- }
935
- });
936
-
937
- let shuttingDown = false;
938
- async function shutDown() {
939
- if (shuttingDown) return;
940
- shuttingDown = true;
941
- desktopHelper?.release();
942
- lanSocket.close();
943
- server.close();
944
- executionHealth?.stop();
945
- runtimeDiscovery.stop();
946
- await relayManager.close();
947
- dotsServer.close();
948
- dotsChat.close();
949
- managedAppServer.close();
950
- await control?.close();
951
- process.exit(0);
952
- }
953
- process.on('SIGTERM', shutDown);
954
- process.on('SIGINT', shutDown);
955
-
956
- function readContextUsage(threadId) {
957
- if (!/^[0-9a-f]{8}-[0-9a-f-]{20,55}$/i.test(threadId)) {
958
- return { available: false };
959
- }
960
- const sessionPath = findSessionPath(threadId);
961
- if (!sessionPath) return { available: false };
962
-
963
- let handle;
964
- try {
965
- const stat = fs.statSync(sessionPath);
966
- const readLength = Math.min(stat.size, MAX_CONTEXT_TAIL_BYTES);
967
- const buffer = Buffer.allocUnsafe(readLength);
968
- handle = fs.openSync(sessionPath, 'r');
969
- fs.readSync(handle, buffer, 0, readLength, stat.size - readLength);
970
- const lines = buffer.toString('utf8').split(/\r?\n/);
971
- let usage = null;
972
- let settings = null;
973
- for (let index = lines.length - 1; index >= 0; index -= 1) {
974
- let event;
975
- if (!usage && lines[index].includes('"token_count"')) {
976
- try { event = JSON.parse(lines[index]); } catch { event = null; }
977
- if (event?.type === 'event_msg' && event?.payload?.type === 'token_count') {
978
- const usedTokens = Number(event.payload.info?.last_token_usage?.total_tokens);
979
- const contextWindow = Number(event.payload.info?.model_context_window);
980
- if (Number.isFinite(usedTokens) && Number.isFinite(contextWindow) && contextWindow > 0) {
981
- usage = {
982
- available: true,
983
- usedTokens,
984
- contextWindow,
985
- remainingTokens: Math.max(0, contextWindow - usedTokens),
986
- usedPercent: Math.min(100, Math.max(0, usedTokens / contextWindow * 100)),
987
- updatedAt: event.timestamp || null,
988
- };
989
- }
990
- }
991
- }
992
- if (!settings && lines[index].includes('"type":"turn_context"')) {
993
- try { event = JSON.parse(lines[index]); } catch { event = null; }
994
- settings = turnSettings(event);
995
- }
996
- if (usage && settings) break;
997
- }
998
- settings ||= sessionSettingsCache.get(sessionPath) || readLatestTurnSettings(sessionPath);
999
- if (settings) sessionSettingsCache.set(sessionPath, settings);
1000
- if (usage) return { ...usage, ...settings };
1001
- if (settings) return { available: false, ...settings };
1002
- } catch {
1003
- sessionPathCache.delete(threadId);
1004
- } finally {
1005
- if (handle !== undefined) fs.closeSync(handle);
1006
- }
1007
- return { available: false };
1008
- }
1009
-
1010
- function readLatestTurnSettings(sessionPath) {
1011
- let handle;
1012
- try {
1013
- const stat = fs.statSync(sessionPath);
1014
- const readLength = Math.min(stat.size, MAX_SETTINGS_SCAN_BYTES);
1015
- const buffer = Buffer.allocUnsafe(readLength);
1016
- handle = fs.openSync(sessionPath, 'r');
1017
- fs.readSync(handle, buffer, 0, readLength, stat.size - readLength);
1018
- const text = buffer.toString('utf8');
1019
- let searchFrom = text.length;
1020
- while (searchFrom > 0) {
1021
- const marker = text.lastIndexOf('"type":"turn_context"', searchFrom);
1022
- if (marker < 0) break;
1023
- const lineStart = text.lastIndexOf('\n', marker) + 1;
1024
- const nextLine = text.indexOf('\n', marker);
1025
- const lineEnd = nextLine < 0 ? text.length : nextLine;
1026
- let event;
1027
- try { event = JSON.parse(text.slice(lineStart, lineEnd)); } catch { event = null; }
1028
- const settings = turnSettings(event);
1029
- if (settings) return settings;
1030
- searchFrom = Math.max(0, lineStart - 1);
1031
- }
1032
- } catch {
1033
- return null;
1034
- } finally {
1035
- if (handle !== undefined) fs.closeSync(handle);
1036
- }
1037
- return null;
1038
- }
1039
-
1040
- function turnSettings(event) {
1041
- if (event?.type !== 'turn_context') return null;
1042
- const model = typeof event.payload?.model === 'string' && /^[a-z0-9.-]{1,80}$/i.test(event.payload.model)
1043
- ? event.payload.model
1044
- : '';
1045
- const thinking = typeof event.payload?.effort === 'string' && /^[a-z0-9_-]{1,32}$/i.test(event.payload.effort)
1046
- ? event.payload.effort
1047
- : '';
1048
- return model || thinking ? { model, thinking } : null;
1049
- }
1050
-
1051
- function findSessionPath(threadId) {
1052
- const cached = sessionPathCache.get(threadId);
1053
- if (cached && fs.existsSync(cached)) return cached;
1054
- if (!fs.existsSync(sessionsRoot)) return null;
1055
-
1056
- const suffix = `-${threadId}.jsonl`.toLowerCase();
1057
- const directories = [sessionsRoot];
1058
- while (directories.length) {
1059
- const directory = directories.pop();
1060
- let entries;
1061
- try {
1062
- entries = fs.readdirSync(directory, { withFileTypes: true });
1063
- } catch {
1064
- continue;
1065
- }
1066
- for (const entry of entries) {
1067
- const fullPath = path.join(directory, entry.name);
1068
- if (entry.isDirectory()) directories.push(fullPath);
1069
- if (entry.isFile() && entry.name.toLowerCase().endsWith(suffix)) {
1070
- sessionPathCache.set(threadId, fullPath);
1071
- return fullPath;
1072
- }
1073
- }
1074
- }
1075
- return null;
1076
- }
1077
-
1078
- function messagePayload(body) {
1079
- const prompt = typeof body.prompt === 'string' ? body.prompt.trim() : '';
1080
- if (body.images !== undefined && (!Array.isArray(body.images) || body.images.length > 4)) {
1081
- throw new RequestError(400, '每条消息最多 4 张图片。');
1082
- }
1083
- const images = (body.images || []).map(id => {
1084
- if (typeof id === 'string' && id.startsWith('data:')) return validateInlineImage(id);
1085
- if (typeof id !== 'string' || !/^[a-f0-9]{64}$/.test(id)) throw new RequestError(400, '无效的图片标识。');
1086
- const webpFile = path.join(uploadsRoot, `${id}.webp`);
1087
- const file = fs.existsSync(webpFile) ? webpFile : path.join(uploadsRoot, `${id}.png`);
1088
- if (!fs.existsSync(file)) throw new RequestError(400, '图片不存在,请重新添加。');
1089
- return file;
1090
- });
1091
- for (const input of fileMentions.originalInputs(body)) {
1092
- const incoming = fs.realpathSync(path.join(uploadsRoot, 'incoming'));
1093
- const imagePath = fs.realpathSync(input.path);
1094
- if (path.dirname(imagePath).toLowerCase() !== incoming.toLowerCase()) throw new RequestError(400, '原图必须来自上传目录。');
1095
- const stat = fs.statSync(imagePath);
1096
- if (!stat.isFile() || stat.size <= 0 || stat.size > MAX_ATTACHMENT_BYTES) throw new RequestError(400, '原图为空或超过 20 MiB。');
1097
- images.push(imagePath);
1098
- }
1099
- if (images.filter(image => image.startsWith('data:')).reduce((size, image) => size + Buffer.byteLength(image.slice(image.indexOf(',') + 1), 'base64'), 0) > 8 * 1024 * 1024) throw new RequestError(413, '本条消息图片合计不能超过 8 MiB。');
1100
- if (!prompt && !images.length) throw new RequestError(400, 'prompt 不能为空。');
1101
- if (prompt.length > 32_000) throw new RequestError(400, 'prompt 不能超过 32000 个字符。');
1102
-
1103
- const payload = { prompt, images };
1104
- if (typeof body.hostId === 'string' && body.hostId.trim()) payload.hostId = body.hostId.trim();
1105
- if (typeof body.model === 'string' && body.model.trim()) {
1106
- payload.model = body.model.trim();
1107
- }
1108
- if (typeof body.thinking === 'string' && body.thinking.trim()) {
1109
- const thinking = body.thinking.trim();
1110
- if (!ALLOWED_THINKING.has(thinking)) throw new RequestError(400, '不支持的推理强度。');
1111
- if ((payload.model === 'gpt-5.5' || payload.model === 'gpt-5.3-codex-spark')
1112
- && (thinking === 'max' || thinking === 'ultra')) {
1113
- throw new RequestError(400, '所选模型不支持该推理强度。');
1114
- }
1115
- payload.thinking = thinking;
1116
- }
1117
- return payload;
1118
- }
1119
-
1120
- function appServerTurnArguments(threadId, body) {
1121
- const payload = messagePayload(body);
1122
- const mentioned = payload.hostId === 'durable' ? fileMentions.parse(payload.prompt) : null;
1123
- if (mentioned?.files.length) payload.prompt = mentioned.text;
1124
- const input = [];
1125
- if (payload.prompt) input.push({ type: 'text', text: payload.prompt });
1126
- for (const image of payload.images) {
1127
- if (image.startsWith('data:')) input.push({ type: 'image', url: image });
1128
- else if (payload.hostId === 'durable') {
1129
- const mimeType = imageMimeType(image);
1130
- if (!mimeType) throw new RequestError(400, '云端图片格式无效。');
1131
- input.push({ type: 'image', url: `data:${mimeType};base64,${fs.readFileSync(image).toString('base64')}` });
1132
- } else input.push({ type: 'localImage', path: image });
1133
- }
1134
- const value = { threadId, input, ...appServerEvents.mobileTurnSource(body.clientUserMessageId, body.containsVoiceInput) };
1135
- if (mentioned?.files.length) value.uploadFiles = mentioned.files;
1136
- if (typeof body.clientUserMessageId === 'string' && /^[a-f0-9]{32}$/.test(body.clientUserMessageId)) value.clientUserMessageId = body.clientUserMessageId;
1137
- if (payload.model) value.model = payload.model;
1138
- if (payload.thinking) value.effort = payload.thinking;
1139
- return value;
1140
- }
1141
-
1142
- function normalizeTimeline(data) {
1143
- const source = data && typeof data === 'object' ? data : {};
1144
- const sourceTurns = Array.isArray(source.turns) ? source.turns : source.thread?.turns;
1145
- const turns = Array.isArray(sourceTurns) ? sourceTurns.map(normalizeTurn) : [];
1146
- return {
1147
- schemaVersion: 1,
1148
- thread: normalizeThread(source.thread),
1149
- turns,
1150
- };
1151
- }
1152
-
1153
- function normalizeSharedTimeline({ thread, nextCursor, sync, notModified, incremental }, hostId = thread?.hostId || 'local') {
1154
- const timeline = normalizeTimeline({ thread: { ...thread, title: thread.name || thread.title || '', hostId,
1155
- transport: 'shared-app-server' } });
1156
- return { ...timeline,
1157
- historyItemCount: (thread.turns || []).reduce((count, turn) => count + (turn.roomMessage ? 1 : turn.items?.length || 0), 0),
1158
- historyView: thread?.threadSource === 'aeon' ? 'chat-items-v1' : 'item-pages-v1',
1159
- ...(notModified ? { notModified: true } : {}),
1160
- ...(incremental ? { incremental: true } : {}),
1161
- ...(nextCursor !== undefined ? { nextCursor } : {}), ...(sync ? { sync } : {}) };
1162
- }
1163
-
1164
- async function prepareSharedTimeline(value, hostId) {
1165
- if (value.thread?.threadSource !== 'aeon') return normalizeSharedTimeline(value, hostId);
1166
- const epoch = dotsChat.epoch, threadId = value.thread.id;
1167
- const current = () => {
1168
- if (epoch !== dotsChat.epoch) throw new Error('点点连接已变化,请重新同步。');
1169
- };
1170
- if (value.notModified) {
1171
- const record = dotsChat.threads.get(threadId);
1172
- if (await dotsImages.prepareTimeline(record?.thread)) {
1173
- current();
1174
- value = dotsChat.snapshot(threadId, record);
1175
- }
1176
- }
1177
- const snapshot = structuredClone(value);
1178
- await dotsImages.prepareTimeline(snapshot.thread);
1179
- current();
1180
- return normalizeSharedTimeline(snapshot, hostId);
1181
- }
1182
-
1183
- function normalizeThread(thread) {
1184
- const source = thread && typeof thread === 'object' ? thread : {};
1185
- return {
1186
- id: source.id || '',
1187
- title: source.name || source.title || source.preview || '',
1188
- status: statusText(source.status),
1189
- updatedAt: Number(source.updatedAt) || 0,
1190
- recencyAt: Number(source.recencyAt ?? source.updatedAt) || 0,
1191
- projectId: source.projectId || '',
1192
- cwd: source.cwd || '',
1193
- hostId: source.hostId || '',
1194
- threadSource: source.threadSource || '',
1195
- ...(['aeon', 'aeon_child'].includes(source.threadSource) ? { chatRevision: source.chatRevision || '' } : {}),
1196
- ...(source.threadSource === 'aeon' ? { avatar: source.avatar || '', color: source.color || '',
1197
- chatMessageRevision: source.chatMessageRevision, messagingRoomId: source.messagingRoomId, lastMessageAt: source.lastMessageAt } : {}),
1198
- transport: source.transport || 'shared-app-server',
1199
- readOnly: source.readOnly === true,
1200
- readOnlyReason: source.readOnlyReason || '',
1201
- };
1202
- }
1203
-
1204
- function normalizeTurn(turn) {
1205
- const source = turn && typeof turn === 'object' ? turn : {};
1206
- const turnId = source.id || '';
1207
- const entries = Array.isArray(source.items) ? source.items.map(normalizeItem).filter(Boolean) : [];
1208
- for (const entry of entries) {
1209
- const images = [...(entry.attachments || []), entry.image, ...Object.values(entry.imageAssets || {})].filter(Boolean);
1210
- for (const image of images) if (image.remotePath && !image.recoveryTurnId) image.recoveryTurnId = turnId;
1211
- }
1212
- return {
1213
- id: turnId,
1214
- status: statusText(source.status),
1215
- startedAt: source.startedAt || null,
1216
- completedAt: source.completedAt || null,
1217
- durationMs: source.durationMs ?? null,
1218
- error: source.error ?? null,
1219
- runtimeNotice: source.runtimeNotice ?? null,
1220
- ...(source.itemPage ? { itemPage: source.itemPage } : {}),
1221
- ...(source.roomMessage ? { roomMessage: true } : {}),
1222
- ...(source.deleted ? { deleted: true } : {}),
1223
- ...(source.historyOnly ? { historyOnly: true } : {}),
1224
- ...(source.cacheStub ? { cacheStub: true } : {}),
1225
- entries,
1226
- };
1227
- }
1228
-
1229
- function normalizeItem(item) {
1230
- if (!item || typeof item !== 'object') return null;
1231
-
1232
- if (item.type === 'approval') {
1233
- return { ...item, type: 'activity', kind: 'approval', title: item.params?.message || '需要你的批准' };
1234
- }
1235
-
1236
- if (item.type === 'contextCompaction') {
1237
- return {
1238
- type: 'activity',
1239
- id: item.id || '',
1240
- kind: 'contextCompaction',
1241
- title: '已自动压缩上下文',
1242
- };
1243
- }
1244
-
1245
- const delegated = appServerEvents.toolDelegation(item);
1246
- if (delegated) {
1247
- return {
1248
- type: 'message', id: item.id || '', role: 'user', source: 'remote',
1249
- delegation: delegationSource(delegated), ...splitUploadedImages(delegated.input),
1250
- };
1251
- }
1252
-
1253
- if (item.type === 'userMessage') {
1254
- const { text, files, delegation } = extractUserText(item.content);
1255
- const attachments = [...normalizeAttachments(item.content), ...files.map(file => file.fileId ? normalizeAttachment(file) : ({
1256
- ...file,
1257
- remotePath: file.path,
1258
- previewRemotePath: file.previewPath || '',
1259
- }))];
1260
- if (!text && !attachments.length) return null;
1261
- return {
1262
- clientId: item.clientId,
1263
- type: 'message',
1264
- id: item.id || '',
1265
- role: 'user',
1266
- text,
1267
- attachments,
1268
- ...(delegation ? { delegation: delegationSource(delegation) } : {}),
1269
- };
1270
- }
1271
-
1272
- if (item.type === 'agentMessage') {
1273
- return {
1274
- type: 'message',
1275
- id: item.id || '',
1276
- role: 'assistant',
1277
- phase: item.phase || '',
1278
- delivery: item.delivery,
1279
- questions: item.questions,
1280
- text: item.text || '',
1281
- attachments: normalizeAttachments(item.attachments),
1282
- imageAssets: markdownImageAssets(item.text || ''),
1283
- fileAssets: markdownFileAssets(item.text || ''),
1284
- };
1285
- }
1286
-
1287
- if (item.type === 'reasoning') {
1288
- const detail = Array.isArray(item.summary)
1289
- ? item.summary.map(summaryText).filter(Boolean).join('\n')
1290
- : summaryText(item.summary);
1291
- return {
1292
- type: 'activity',
1293
- id: item.id || '',
1294
- kind: 'reasoning',
1295
- title: '思考摘要',
1296
- detail,
1297
- };
1298
- }
1299
-
1300
- if (item.type === 'commandExecution') {
1301
- return {
1302
- type: 'activity',
1303
- id: item.id || '',
1304
- kind: 'command',
1305
- title: '命令',
1306
- titleOnly: true,
1307
- status: statusText(item.status),
1308
- command: item.command,
1309
- cwd: item.cwd,
1310
- exitCode: item.exitCode,
1311
- durationMs: item.durationMs,
1312
- };
1313
- }
1314
-
1315
- if (item.type === 'mcpToolCall') {
1316
- return {
1317
- type: 'activity',
1318
- id: item.id || '',
1319
- kind: 'tool',
1320
- title: '工具',
1321
- titleOnly: true,
1322
- status: statusText(item.status),
1323
- server: item.server,
1324
- tool: item.tool,
1325
- arguments: item.arguments,
1326
- durationMs: item.durationMs,
1327
- error: item.error,
1328
- };
1329
- }
1330
-
1331
- if (item.type === 'dynamicToolCall') {
1332
- return {
1333
- type: 'activity',
1334
- id: item.id || '',
1335
- kind: 'tool',
1336
- title: '工具',
1337
- titleOnly: true,
1338
- status: statusText(item.status),
1339
- namespace: item.namespace,
1340
- tool: item.tool,
1341
- arguments: item.arguments,
1342
- durationMs: item.durationMs,
1343
- success: item.success,
1344
- };
1345
- }
1346
-
1347
- if (item.type === 'fileChange') {
1348
- return {
1349
- type: 'activity',
1350
- id: item.id || '',
1351
- kind: 'fileChange',
1352
- title: '文件修改',
1353
- titleOnly: true,
1354
- status: statusText(item.status),
1355
- changes: fileChanges.summarize(item.changes),
1356
- };
1357
- }
1358
-
1359
- if (item.type === 'webSearch') {
1360
- return {
1361
- type: 'activity',
1362
- id: item.id || '',
1363
- kind: 'webSearch',
1364
- title: '网页搜索',
1365
- titleOnly: true,
1366
- query: item.query || item.action?.query || '',
1367
- ...(item.action && typeof item.action === 'object' ? { action: item.action } : {}),
1368
- };
1369
- }
1370
-
1371
- if (item.type === 'imageView') {
1372
- const image = typeof item.path === 'string' && item.path
1373
- ? normalizeAttachment({ type: 'localImage', path: item.path })
1374
- : null;
1375
- return {
1376
- type: 'activity',
1377
- id: item.id || '',
1378
- kind: 'imageView',
1379
- title: '查看图片',
1380
- titleOnly: true,
1381
- path: item.path || '',
1382
- image,
1383
- };
1384
- }
1385
-
1386
- if (item.type === 'imageGeneration') {
1387
- const result = typeof item.result === 'string' ? item.result.trim() : '';
1388
- const image = typeof item.savedPath === 'string' && item.savedPath
1389
- ? normalizeAttachment({ type: 'localImage', path: item.savedPath, name: '生成的图片' })
1390
- : result ? normalizeAttachment({ type: 'image', name: '生成的图片',
1391
- url: result.startsWith('data:image/') ? result : `data:image/png;base64,${result}` }) : null;
1392
- return {
1393
- type: 'activity',
1394
- id: item.id || '',
1395
- kind: 'imageGeneration',
1396
- title: '生成图片',
1397
- titleOnly: true,
1398
- status: statusText(item.status),
1399
- image,
1400
- failure: item.failure || null,
1401
- transparentBackground: item.transparentBackground === true,
1402
- };
1403
- }
1404
-
1405
- if (item.type === 'subAgentActivity') {
1406
- return {
1407
- type: 'activity',
1408
- id: item.id || '',
1409
- kind: 'subAgent',
1410
- title: '子任务',
1411
- titleOnly: true,
1412
- status: statusText(item.kind),
1413
- agentPath: item.agentPath || '',
1414
- agentThreadId: item.agentThreadId || '',
1415
- };
1416
- }
1417
-
1418
- if (item.type === 'collabAgentToolCall') {
1419
- return {
1420
- type: 'activity',
1421
- id: item.id || '',
1422
- kind: 'collaboration',
1423
- title: '任务协作',
1424
- titleOnly: true,
1425
- status: statusText(item.status),
1426
- tool: item.tool || '',
1427
- prompt: item.prompt || '',
1428
- senderThreadId: item.senderThreadId || '',
1429
- receiverThreadIds: Array.isArray(item.receiverThreadIds) ? item.receiverThreadIds : [],
1430
- model: item.model || '',
1431
- reasoningEffort: item.reasoningEffort || '',
1432
- agentsStates: item.agentsStates && typeof item.agentsStates === 'object' ? item.agentsStates : {},
1433
- };
1434
- }
1435
-
1436
- return {
1437
- type: 'activity',
1438
- id: item.id || '',
1439
- kind: item.type || 'event',
1440
- title: item.type || '事件',
1441
- status: statusText(item.status),
1442
- detail: JSON.stringify(item, null, 2),
1443
- };
1444
- }
1445
-
1446
- function sharedHistoryPageOptions() {
1447
- return {
1448
- maxTurns: SHARED_HISTORY_PAGE_TURNS,
1449
- maxBytes: SHARED_HISTORY_PAGE_BYTES,
1450
- measure: value => Buffer.byteLength(JSON.stringify(normalizeSharedTimeline(value))),
1451
- };
1452
- }
1453
-
1454
- function normalizeAttachments(content) {
1455
- if (!Array.isArray(content)) return [];
1456
- const items = content.filter(item => item && !['text', 'input_text'].includes(item.type)).map(item => ({ ...item }));
1457
- const text = content.filter(item => ['text', 'input_text'].includes(item?.type)).map(item => item.text || '').join('\n');
1458
- const paths = [...new Set([...text.matchAll(/<image\b[^>]*\bpath="([^"]+)"[^>]*>/g)].map(match => match[1]))];
1459
- const images = items.filter(item => ['image', 'input_image', 'localImage'].includes(item.type));
1460
- for (const [index, imagePath] of paths.entries()) {
1461
- if (images.some(item => item.path === imagePath)) continue;
1462
- if (images[index] && !images[index].path) images[index].path = imagePath;
1463
- else items.push({ type: 'localImage', path: imagePath });
1464
- }
1465
- const seen = new Set();
1466
- return items.map(item => normalizeAttachment(item)).filter(item => {
1467
- if (!item.url) return true;
1468
- if (seen.has(item.url)) return false;
1469
- seen.add(item.url); return true;
1470
- });
1471
- }
1472
-
1473
- function markdownImageAssets(text) {
1474
- const assets = Object.create(null);
1475
- function visit(tokens) {
1476
- for (const token of tokens) {
1477
- const value = token.type === 'image' ? token.attrGet('src')
1478
- : token.type === 'link_open' && !token.meta?.fileCitation ? token.attrGet('href') : null;
1479
- if (value && !Object.hasOwn(assets, value)) {
1480
- const localPath = fileCitations.localReference(value)?.path;
1481
- // Only paths actually referenced by Desktop messages become accessible.
1482
- // Do not expose a client-supplied filesystem path endpoint or UNC shares.
1483
- if (localPath && /^[a-z]:[\\/]/i.test(localPath)) {
1484
- const asset = normalizeAttachment({ type: 'localImage', path: localPath });
1485
- if (asset.available) assets[value] = asset;
1486
- }
1487
- }
1488
- if (token.children && token.type !== 'image') visit(token.children);
1489
- }
1490
- }
1491
- visit(imageMarkdown.parse(text, { allowFileCitations: true }));
1492
- return assets;
1493
- }
1494
-
1495
- function markdownFileAssets(text) {
1496
- const assets = Object.create(null);
1497
- function visit(tokens) {
1498
- for (const token of tokens) {
1499
- if (token.type === 'link_open') {
1500
- const value = token.attrGet('href');
1501
- if (value && !Object.hasOwn(assets, value)) {
1502
- const localPath = fileCitations.localReference(value)?.path;
1503
- if (localPath && /^[a-z]:[\\/]/i.test(localPath)) {
1504
- assets[value] = { remotePath: localPath, name: path.basename(localPath) };
1505
- // Link rendering must not depend on file existence or transient backend state.
1506
- }
1507
- }
1508
- }
1509
- if (token.children && token.type !== 'image') visit(token.children);
1510
- }
1511
- }
1512
- visit(imageMarkdown.parse(text, { allowFileCitations: true }));
1513
- return assets;
1514
- }
1515
-
1516
- function describeLocalFile(filePath) {
1517
- const resolved = path.resolve(filePath);
1518
- let stat;
1519
- try { stat = fs.statSync(resolved); }
1520
- catch (error) {
1521
- if (error.code === 'ENOENT') throw new RequestError(404, '电脑上的文件已不存在。');
1522
- if (error.code === 'EACCES' || error.code === 'EPERM') throw new RequestError(403, '电脑系统无法读取此文件:' + error.code);
1523
- throw error;
1524
- }
1525
- if (!stat.isFile()) throw new RequestError(400, '此路径不是文件。');
1526
- return { path: resolved, name: path.basename(resolved), size: stat.size, revision: fileRevision(stat) };
1527
- }
1528
-
1529
- function splitUploadedImages(text) {
1530
- const tokens = imageMarkdown.parse(text, {});
1531
- const lines = text.split('\n');
1532
- const removed = new Set();
1533
- const attachments = [];
1534
- for (let index = 0; index < tokens.length - 1; index++) {
1535
- const block = tokens[index];
1536
- const inline = tokens[index + 1];
1537
- if (block.type !== 'paragraph_open' || !block.map || inline.type !== 'inline') continue;
1538
- const children = (inline.children || []).filter(token => token.type !== 'softbreak');
1539
- if (!children.length || children.some(token => token.type !== 'image')) continue;
1540
- const paths = children.map(token => {
1541
- try { return path.resolve(decodeURIComponent(token.attrGet('src') || '')); } catch { return ''; }
1542
- });
1543
- if (paths.some(file => path.dirname(file).toLowerCase() !== uploadsRoot.toLowerCase()
1544
- || !/^[a-f0-9]{64}\.(png|webp)$/.test(path.basename(file)))) continue;
1545
- for (const file of paths) attachments.push(normalizeAttachment({ type: 'localImage', path: file, name: '移动端图片' }));
1546
- for (let line = block.map[0]; line < block.map[1]; line++) removed.add(line);
1547
- }
1548
- const visibleText = lines.filter((line, index) => !removed.has(index)).join('\n').trim();
1549
- return { text: visibleText, attachments, imageAssets: markdownImageAssets(visibleText) };
1550
- }
1551
-
1552
- async function externalDownloadAsset(value, recovery = '') {
1553
- const match = /^([a-f0-9]{64})(?:\?recovery=([A-Za-z0-9_-]{1,4096}))?$/.exec(value || '');
1554
- if (!match) throw new RequestError(400, '无效的外部文件标识。');
1555
- const asset = attachmentCache.get(match[1]) || dotsImages.restore(match[1], match[2] || recovery || '');
1556
- if (!asset?.dotsFile) throw new RequestError(404, '外部文件授权已失效,请重新读取对话。');
1557
- await dotsImages.authorization(asset.identity);
1558
- return asset;
1559
- }
1560
-
1561
- function workstationCopyReference(copy, asset) {
1562
- const id = crypto.createHmac('sha256', fileCapabilitySecret).update(JSON.stringify([asset.id, copy.path, copy.revision])).digest('hex');
1563
- fileCache.set(id, { ...copy, id, externalIdentity: asset.identity });
1564
- while (fileCache.size > MAX_CACHED_FILES) fileCache.delete(fileCache.keys().next().value);
1565
- return { remotePath: id, path: copy.path, name: copy.name, size: copy.size, mimeType: copy.mimeType };
1566
- }
1567
-
1568
- function normalizeAttachment(item, identity) {
1569
- const name = item.name || item.fileName || (typeof item.path === 'string' ? path.basename(item.path) : '');
1570
- let cached = null;
1571
- const isImage = ['image', 'input_image', 'localImage'].includes(item.type);
1572
- if (isImage || item.type === 'file') cached = dotsImages.remember(item, identity);
1573
- if (isImage && typeof item.path === 'string') {
1574
- cached = cacheLocalImage(item.path, name) || cached;
1575
- }
1576
- if (!cached) {
1577
- const dataUrl = typeof item.url === 'string' && item.url.startsWith('data:image/')
1578
- ? item.url
1579
- : (typeof item.image_url === 'string' && item.image_url.startsWith('data:image/') ? item.image_url : '');
1580
- if (dataUrl) cached = cacheDataImage(dataUrl, name);
1581
- }
1582
- return {
1583
- type: isImage ? 'image' : item.type || 'attachment',
1584
- name: cached?.name || name,
1585
- mimeType: cached?.mimeType || item.mimeType || '',
1586
- ...(cached?.size ? { size: cached.size } : {}),
1587
- ...(cached?.dotsFile ? { networkFile: true } : {}),
1588
- available: Boolean(cached),
1589
- remotePath: cached ? (isImage ? cached.id : `${cached.id}?recovery=${cached.recoveryToken}`) : '',
1590
- recoveryToken: cached?.recoveryToken || '',
1591
- url: '',
1592
- thumbnailUrl: '',
1593
- };
1594
- }
1595
-
1596
- function cacheLocalImage(filePath, name) {
1597
- try {
1598
- const resolved = path.resolve(filePath);
1599
- const stat = fs.statSync(resolved);
1600
- const mimeType = imageMimeType(resolved);
1601
- if (!stat.isFile() || !mimeType || stat.size > MAX_ATTACHMENT_BYTES) return null;
1602
- const cached = rememberAttachment(fs.readFileSync(resolved), mimeType, name,
1603
- localImageRecoveryToken(resolved, stat));
1604
- void attachmentThumbnail(cached).catch(() => {});
1605
- return cached;
1606
- } catch {
1607
- return null;
1608
- }
1609
- }
1610
-
1611
- function cacheDataImage(dataUrl, name) {
1612
- const match = /^data:(image\/(?:png|jpeg|gif|webp|bmp));base64,([a-z0-9+/=\r\n]+)$/i.exec(dataUrl);
1613
- if (!match) return null;
1614
- try {
1615
- const buffer = Buffer.from(match[2], 'base64');
1616
- if (!buffer.length || buffer.length > MAX_ATTACHMENT_BYTES) return null;
1617
- return rememberAttachment(buffer, match[1].toLowerCase(), name);
1618
- } catch {
1619
- return null;
1620
- }
1621
- }
1622
-
1623
- function rememberAttachment(buffer, mimeType, name, recoveryToken = '') {
1624
- const id = crypto.createHash('sha256').update(buffer).digest('hex');
1625
- if (!attachmentCache.has(id)) {
1626
- while (attachmentCache.size >= MAX_CACHED_ATTACHMENTS) {
1627
- attachmentCache.delete(attachmentCache.keys().next().value);
1628
- }
1629
- attachmentCache.set(id, { id, buffer, mimeType, name, recoveryToken });
1630
- } else if (recoveryToken && !attachmentCache.get(id).recoveryToken) {
1631
- attachmentCache.get(id).recoveryToken = recoveryToken;
1632
- }
1633
- return attachmentCache.get(id);
1634
- }
1635
-
1636
- function localImageRecoveryToken(filePath, stat) {
1637
- const iv = crypto.randomBytes(12);
1638
- const cipher = crypto.createCipheriv('aes-256-gcm', imageCapabilityKey, iv);
1639
- const payload = Buffer.from(JSON.stringify([filePath, stat.size, stat.mtimeMs]));
1640
- const encrypted = Buffer.concat([cipher.update(payload), cipher.final()]);
1641
- return Buffer.concat([iv, cipher.getAuthTag(), encrypted]).toString('base64url');
1642
- }
1643
-
1644
- function recoverLocalImage(id, token) {
1645
- if (!token || token.length > 2048) return null;
1646
- try {
1647
- const value = Buffer.from(token, 'base64url');
1648
- if (value.length < 29) return null;
1649
- const decipher = crypto.createDecipheriv('aes-256-gcm', imageCapabilityKey, value.subarray(0, 12));
1650
- decipher.setAuthTag(value.subarray(12, 28));
1651
- const [filePath, size, mtimeMs] = JSON.parse(Buffer.concat([
1652
- decipher.update(value.subarray(28)), decipher.final(),
1653
- ]).toString('utf8'));
1654
- if (typeof filePath !== 'string' || !path.isAbsolute(filePath)
1655
- || !Number.isSafeInteger(size) || size <= 0 || size > MAX_ATTACHMENT_BYTES
1656
- || typeof mtimeMs !== 'number' || !Number.isFinite(mtimeMs)) return null;
1657
- const stat = fs.statSync(filePath);
1658
- const mimeType = imageMimeType(filePath);
1659
- if (!stat.isFile() || stat.size !== size || stat.mtimeMs !== mtimeMs || !mimeType) return null;
1660
- const buffer = fs.readFileSync(filePath);
1661
- if (crypto.createHash('sha256').update(buffer).digest('hex') !== id) return null;
1662
- return rememberAttachment(buffer, mimeType, path.basename(filePath), token);
1663
- } catch {
1664
- return null;
1665
- }
1666
- }
1667
-
1668
- async function attachmentThumbnail(image) {
1669
- if (!image.thumbnail) {
1670
- image.thumbnail = sharp(image.buffer, { limitInputPixels: 40_000_000 }).rotate()
1671
- .resize({ width: 640, height: 640, fit: 'inside', withoutEnlargement: true })
1672
- .webp({ quality: 78, effort: 4 })
1673
- .toBuffer()
1674
- .then(buffer => ({ buffer, mimeType: 'image/webp' }))
1675
- .catch(error => {
1676
- image.thumbnail = null;
1677
- throw error;
1678
- });
1679
- }
1680
- return image.thumbnail;
1681
- }
1682
-
1683
- function imageMimeType(filePath) {
1684
- switch (path.extname(filePath).toLowerCase()) {
1685
- case '.png': return 'image/png';
1686
- case '.jpg':
1687
- case '.jpeg': return 'image/jpeg';
1688
- case '.gif': return 'image/gif';
1689
- case '.webp': return 'image/webp';
1690
- case '.bmp': return 'image/bmp';
1691
- default: return '';
1692
- }
1693
- }
1694
-
1695
- function extractUserText(content) {
1696
- if (!Array.isArray(content)) return { text: '(包含图片或界面批注)', files: [] };
1697
- let text = content
1698
- .filter(item => ['text', 'input_text'].includes(item?.type) && typeof item.text === 'string' && !appServerEvents.isMobileInputNotice(item))
1699
- .map(item => item.text)
1700
- // Codex injects project instructions as user-role context, not chat input.
1701
- .filter(value => !/^\s*# AGENTS\.md instructions(?: for [^\r\n]+)?\r?\n\s*<INSTRUCTIONS>[\s\S]*<\/INSTRUCTIONS>\s*$/.test(value))
1702
- .filter(value => !/^\s*<(environment_context|recommended_plugins)>[\s\S]*<\/\1>\s*$/.test(value))
1703
- .filter(value => !value.startsWith('The next image is untrusted page evidence'))
1704
- .join('\n\n')
1705
- .trim();
1706
- const delegation = appServerEvents.parseCodexDelegation(text);
1707
- if (delegation) text = delegation.input;
1708
- const mentioned = fileMentions.parse(text);
1709
- const imagePaths = new Set([
1710
- ...content.filter(item => ['image', 'input_image', 'localImage'].includes(item?.type) && item.path).map(item => item.path),
1711
- ...[...mentioned.text.matchAll(/<image\b[^>]*\bpath="([^"]+)"[^>]*>/g)].map(match => match[1]),
1712
- ].map(value => path.resolve(value).toLowerCase()));
1713
- text = mentioned.text;
1714
- text = text.replace(/<image\b[^>]*\bpath="[^"]+"[^>]*>/g, '').replace(/<\/image>/g, '').trim();
1715
- return { text, files: mentioned.files.filter(file => file.fileId || !imagePaths.has(path.resolve(file.path).toLowerCase())), delegation };
1716
- }
1717
-
1718
- function delegationSource(delegation) {
1719
- const dots = dotsServer.threadSources.get(delegation.sourceThreadId) === 'aeon';
1720
- return { sourceThreadId: delegation.sourceThreadId, sourceKind: dots ? 'dots' : 'codex',
1721
- ...(dots ? { sourceName: dotsServer.profileName(delegation.sourceThreadId),
1722
- sourceAvatar: dotsServer.profileAvatar(delegation.sourceThreadId) } : {}) };
1723
- }
1724
-
1725
- function summaryText(value) {
1726
- if (typeof value === 'string') return value;
1727
- if (value && typeof value === 'object') return value.text || value.summary || JSON.stringify(value);
1728
- return '';
1729
- }
1730
-
1731
- function statusText(value) {
1732
- if (typeof value === 'string') return value;
1733
- if (value && typeof value === 'object') return value.type || value.status || 'unknown';
1734
- return value == null ? 'unknown' : String(value);
1735
- }
1736
-
1737
- function sendJson(response, statusCode, value) {
1738
- if (response.json) { response.json(statusCode, value); return; }
1739
- sendBuffer(response, statusCode, 'application/json; charset=utf-8', Buffer.from(JSON.stringify(value)));
1740
- }
1741
-
1742
- function sendBuffer(response, statusCode, contentType, value) {
1743
- response.writeHead(statusCode, {
1744
- 'Content-Type': contentType,
1745
- 'Content-Length': value.length,
1746
- 'Cache-Control': 'no-store',
1747
- 'X-Content-Type-Options': 'nosniff',
1748
- 'Content-Security-Policy': "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src 'self'; img-src 'self' data: blob:; media-src blob:",
1749
- });
1750
- response.end(value);
1751
- }
1752
-
1753
- async function readJsonBody(request) {
1754
- return request.body;
1755
- }
1756
-
1757
- function parseOptions(args) {
1758
- let port = Number.parseInt(process.env.CODEX_REMOTE_BRIDGE_PORT || '45831', 10);
1759
- let lan = process.env.CODEX_REMOTE_BRIDGE_LAN === 'true';
1760
- let token = process.env.CODEX_REMOTE_BRIDGE_TOKEN?.trim() || '';
1761
- if (process.env.CODEX_REMOTE_APP_SERVER === 'true') throw new Error('此连接方式已停用。');
1762
- for (let i = 0; i < args.length; i += 1) {
1763
- if (args[i] === '--lan') lan = true;
1764
- if (args[i] === '--app-server') throw new Error('此连接方式已停用。');
1765
- if (args[i] === '--port' && args[i + 1]) port = Number.parseInt(args[++i], 10);
1766
- if (args[i] === '--token' && args[i + 1]) token = args[++i];
1767
- }
1768
- if (!Number.isInteger(port) || port < 1024 || port > 65535) {
1769
- throw new Error('端口必须位于 1024 到 65535 之间。');
1770
- }
1771
- if (lan && !token) token = crypto.randomBytes(32).toString('base64url');
1772
- return { port, lan, token };
1773
- }
1774
-
1775
- function boundedInteger(value, fallback, minimum, maximum) {
1776
- const parsed = Number.parseInt(value || '', 10);
1777
- return Number.isFinite(parsed) ? Math.min(maximum, Math.max(minimum, parsed)) : fallback;
1778
- }
1
+ import crypto from 'node:crypto';
2
+ import { RustDeskVideoProbe } from './platform/windows/rustdesk-video-probe.mjs';
3
+ import fs from 'node:fs';
4
+ import http from 'node:http';
5
+ import path from 'node:path';
6
+ import os from 'node:os';
7
+ import { fileURLToPath } from 'node:url';
8
+ import vm from 'node:vm';
9
+ import sharp from 'sharp';
10
+ import { attachLanSocket } from './lan-socket-server.mjs';
11
+ import fileMentions from '../shared/file-mentions.cjs';
12
+ import fileCitations from '../shared/file-citations.cjs';
13
+ import { validateInlineImage } from './message-images.mjs';
14
+ import { ThreadWriterConflictError } from './shared-app-server.mjs';
15
+ import { ManagedAppServer, managedAppServerUrl } from './managed-app-server.mjs';
16
+ import { RuntimeDiscovery } from './runtime-discovery.mjs';
17
+ import { streamSharedThread, threadListNotificationMethods } from './shared-thread-stream.mjs';
18
+ import appServerEvents from '../shared/app-server-events.cjs';
19
+ import questionAttention from '../shared/question-attention.cjs';
20
+ import messageDelivery from '../shared/message-delivery.cjs';
21
+ import fileChanges from '../shared/file-changes.cjs';
22
+ import { runtimePaths } from './platform/runtime-paths.mjs';
23
+ import { openControl } from './service-control.mjs';
24
+ import { writeRecord, readRecord } from './service-lifecycle.mjs';
25
+ import { ExecutionHealth } from './execution-health.mjs';
26
+ import { authorizeLanDevice, onLanAuthorizationsChanged, recordLanConnection } from './self-relay/lan-authorizations.mjs';
27
+ import { createRelayManager } from './self-relay/lifecycle.mjs';
28
+ import { generateAndSetThreadTitle } from './thread-title-generation.mjs';
29
+ import { desktopHost } from './platform/desktop-host.mjs';
30
+ import { SharedCatalog } from './shared-catalog.mjs';
31
+ import { DotsAppServer } from './dots-task-catalog.mjs';
32
+ import { DotsChatServer } from './dots-chat-server.mjs';
33
+ import { HostedHistoryCache } from './hosted-history-cache.mjs';
34
+ import { readDotsSummaryTasks } from './dots-summary-tasks.mjs';
35
+ import { readDotsSchedules } from './dots-schedules.mjs';
36
+ import { DotsMessageImages } from './dots-message-images.mjs';
37
+ import { desktopDotsProfiles } from './platform/windows/dots-profile.mjs';
38
+ import { readPlanPricing } from './plan-pricing.mjs';
39
+ import { requestRecovery, recoveryStatus } from './shared-recovery.mjs';
40
+ import { requestServiceRestart } from './service-restart.mjs';
41
+ import { TurnModelHistory } from './turn-model-history.mjs';
42
+ import { browseCode } from './code-browser.mjs';
43
+ import { fileRange, fileRevision, FileRangeError } from './file-transfer-range.mjs';
44
+ import { browseProjectFolders, createProjectFolder } from './project-management.mjs';
45
+ import { projectLocations } from './platform/project-locations.mjs';
46
+ import { createWorkstationProject } from './project-create.mjs';
47
+ import { removeWorkstationProject } from './project-remove.mjs';
48
+ import { projectArchiveCandidates } from './project-archive.mjs';
49
+ import { archivedThreadsPage } from './archived-threads.mjs';
50
+ import { updateWorkstationProject } from './project-update.mjs';
51
+ import { WorkstationDownloads } from './workstation-downloads.mjs';
52
+ import { consumeUsageReset } from './usage-reset.mjs';
53
+ import { DesktopHelper } from './platform/windows/desktop-helper.mjs';
54
+
55
+ const turnModelHistory = new TurnModelHistory();
56
+ const messageReceipts = new messageDelivery.Store();
57
+ const desktopHelper = process.platform === 'win32' ? new DesktopHelper() : null;
58
+
59
+ const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024;
60
+ const MAX_FILE_DOWNLOAD_BYTES = 20 * 1024 * 1024;
61
+ const MAX_CACHED_ATTACHMENTS = 32;
62
+ const MAX_CACHED_FILES = 64;
63
+ const MAX_CONTEXT_TAIL_BYTES = 4 * 1024 * 1024;
64
+ const MAX_LIVE_ACTIVITY_TAIL_BYTES = 1024 * 1024;
65
+ const SHARED_HISTORY_PAGE_BYTES = 500 * 1024;
66
+ const SHARED_HISTORY_PAGE_TURNS = 8;
67
+ const MAX_SETTINGS_SCAN_BYTES = 64 * 1024 * 1024;
68
+ const ALLOWED_THINKING = new Set(['low', 'medium', 'high', 'xhigh', 'max', 'ultra']);
69
+
70
+ class RequestError extends Error {
71
+ constructor(statusCode, message, details) {
72
+ super(message);
73
+ this.statusCode = statusCode;
74
+ this.details = details;
75
+ this.code = details?.code;
76
+ }
77
+ }
78
+
79
+ function readRuntimeVersion() {
80
+ let directory = path.dirname(fileURLToPath(import.meta.url));
81
+ while (true) {
82
+ const manifest = readRecord(path.join(directory, 'package.json'));
83
+ if (manifest?.name === '@walkhi/code-relax') return manifest.version || '未知';
84
+ const parent = path.dirname(directory);
85
+ if (parent === directory) return '未知';
86
+ directory = parent;
87
+ }
88
+ }
89
+
90
+ const options = parseOptions(process.argv.slice(2));
91
+ const runtimeVersion = readRuntimeVersion();
92
+
93
+ const webRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'web');
94
+ const projectRoot = os.homedir();
95
+ const { codexDataRoot, stateRoot, sessionsRoot, uploadsRoot } = runtimePaths();
96
+ const desktopVideoProbe = process.platform === 'win32' ? new RustDeskVideoProbe({ stateRoot }) : null;
97
+ process.once('exit', () => desktopVideoProbe?.stop());
98
+ const workstationDownloads = new WorkstationDownloads(stateRoot);
99
+ const markdownItScript = fs.readFileSync(path.join(webRoot, 'vendor', 'markdown-it.umd.min.js'));
100
+ const markdownSandbox = { atob };
101
+ vm.runInNewContext(markdownItScript.toString(), markdownSandbox);
102
+ const imageMarkdown = markdownSandbox.markdownit({ html: false });
103
+ fileCitations.install(imageMarkdown);
104
+ const attachmentCache = new Map();
105
+ const fileCache = new Map();
106
+ const fileCapabilitySecret = crypto.randomBytes(32);
107
+ const imageCapabilityKey = crypto.createHash('sha256')
108
+ .update('code-relax-image-capability\0').update(options.token || fileCapabilitySecret).digest();
109
+ const sessionPathCache = new Map();
110
+ const sessionSettingsCache = new Map();
111
+ const liveActivityCache = new Map();
112
+ let sharedServer = null;
113
+ const managedAppServer = new ManagedAppServer({ stateRoot, host: desktopHost });
114
+ const hostedHistoryCache = new HostedHistoryCache(stateRoot);
115
+ const dotsServer = new DotsAppServer(managedAppServer.client, desktopDotsProfiles(codexDataRoot, stateRoot), hostedHistoryCache, { uploadsRoot });
116
+ const dotsImages = new DotsMessageImages({ localServer: managedAppServer.client, identity: () => dotsServer.profileIdentity,
117
+ cache: attachmentCache, capabilityKey: imageCapabilityKey, maxEntries: MAX_CACHED_ATTACHMENTS, maxBytes: MAX_ATTACHMENT_BYTES });
118
+ const dotsChat = new DotsChatServer(dotsServer, undefined, { uploadsRoot });
119
+ const runtimeDiscovery = new RuntimeDiscovery({ stateRoot, host: desktopHost, url: managedAppServerUrl });
120
+ const relayManager = createRelayManager(stateRoot);
121
+ let sharedCatalog = null, executionMode = 'ordinary';
122
+ function selectSharedExecution(enabled) {
123
+ const next = enabled ? managedAppServer.client : null;
124
+ if (sharedServer === next) return;
125
+ sharedServer = next;
126
+ sharedCatalog = sharedServer ? new SharedCatalog(sharedServer, dotsServer, dotsChat) : null;
127
+ }
128
+ const executionHealth = new ExecutionHealth(async () => {
129
+ await managedAppServer.ensure();
130
+ const managed = managedAppServer.snapshot();
131
+ // The managed app-server owns execution. Desktop is another client of the
132
+ // same service, so closing Desktop must not disable phone writes.
133
+ selectSharedExecution(managed.ready);
134
+ executionMode = sharedServer ? 'shared' : 'ordinary';
135
+ const managedRestarting = managed.state === 'restarting';
136
+ const ready = !managedRestarting && sharedServer?.ready === true;
137
+ const state = managedRestarting ? 'restarting'
138
+ : ready ? 'full' : 'unavailable';
139
+ return { state, ready, canWrite: ready, mode: executionMode };
140
+ });
141
+ async function refreshExecution() {
142
+ await executionHealth.refresh();
143
+ return { mode: executionMode };
144
+ }
145
+ async function requireExecutionWritable(threadId, client = sharedServer) {
146
+ const execution = await executionHealth.refresh();
147
+ if (!execution.canWrite) {
148
+ throw new RequestError(503, 'Codex 内核未就绪。请重启工作站服务;若仍失败,运行 relax doctor。',
149
+ { code: 'full_control_required', execution });
150
+ }
151
+ if (threadId && client) client.requireWritable(threadId);
152
+ return execution;
153
+ }
154
+ managedAppServer.client.on('subscription-error', error => console.error('Codex 内核任务取消订阅失败:', error.message));
155
+ dotsServer.on('subscription-error', error => console.error('Dots 任务取消订阅失败:', error.message));
156
+ dotsChat.on('subscription-error', error => console.error('Dots 对话取消订阅失败:', error.message));
157
+ dotsChat.on('activity-error', error => console.error('点点状态通道不可用:', error.message));
158
+
159
+ dotsServer.on('profile-error', error => console.error(error.message));
160
+ async function handleRequest(request, response) {
161
+ let messageSubmissionStarted = false;
162
+ try {
163
+ const url = new URL(request.url || '/', `http://${request.headers.host || 'localhost'}`);
164
+
165
+ if (!request.socketRequest) {
166
+ sendJson(response, 404, { error: '此端口不提供网页。' });
167
+ return;
168
+ }
169
+
170
+ if (process.platform !== 'win32' && url.pathname.startsWith('/api/desktop/'))
171
+ throw new RequestError(501, 'Mac 版不提供远程桌面控制。', { code: 'desktop_control_unsupported' });
172
+
173
+ if (url.pathname === '/api/desktop/video-probe') {
174
+ if (request.method === 'POST') {
175
+ try { sendJson(response, 200, await desktopVideoProbe.begin()); }
176
+ catch (error) { throw new RequestError(error.statusCode || 502, error.message, { code: error.code || 'desktop_video_failed' }); }
177
+ }
178
+ else if (request.method === 'PUT' || request.method === 'DELETE') {
179
+ const body = await readJsonBody(request);
180
+ if (typeof body.leaseId !== 'string' || !/^[a-f0-9]{32}$/.test(body.leaseId)) throw new RequestError(400, '无效的视频会话');
181
+ sendJson(response, 200, request.method === 'PUT' ? desktopVideoProbe.renew(body.leaseId) : desktopVideoProbe.release(body.leaseId));
182
+ } else throw new RequestError(405, '不支持的操作');
183
+ return;
184
+ }
185
+ if (request.method === 'GET' && url.pathname === '/api/desktop/frame') {
186
+ const started = Date.now();
187
+ try {
188
+ const preview = url.searchParams.get('preview') === '1';
189
+ const frame = await desktopHelper.frame({ preview });
190
+ if (!preview) console.info(JSON.stringify({ time: new Date().toISOString(), event: 'desktop-frame', state: frame.state,
191
+ kind: frame.kind, width: frame.width, height: frame.height, bytes: frame.size, ms: Date.now() - started }));
192
+ if (response.writableEnded) { desktopHelper.release(frame.frameId); return; }
193
+ sendJson(response, 200, frame);
194
+ } catch (error) {
195
+ console.error(JSON.stringify({ time: new Date().toISOString(), event: 'desktop-frame', state: 'failed',
196
+ error: error.message.slice(0, 160), ms: Date.now() - started }));
197
+ throw error;
198
+ }
199
+ return;
200
+ }
201
+ const desktopImage = /^\/api\/desktop\/frames\/([a-f0-9]{32})$/.exec(url.pathname);
202
+ if (request.method === 'GET' && desktopImage) {
203
+ if (!request.fileTransfer) throw new RequestError(400, '请通过文件通道读取桌面画面。');
204
+ const image = desktopHelper.image(desktopImage[1]);
205
+ const { offset, length } = fileRange(url.searchParams, image.length, desktopImage[1]);
206
+ const mimeType = desktopHelper.active.frame.mimeType;
207
+ response.filePart({ name: mimeType === 'image/jpeg' ? 'desktop.jpg' : 'desktop.png', size: image.length, offset, revision: desktopImage[1], mimeType },
208
+ image.subarray(offset, offset + length));
209
+ return;
210
+ }
211
+ if (request.method === 'POST' && url.pathname === '/api/desktop/move') {
212
+ try { sendJson(response, 200, await desktopHelper.move(await readJsonBody(request))); }
213
+ catch (error) {
214
+ console.error(JSON.stringify({ time: new Date().toISOString(), event: 'desktop-move', state: 'failed',
215
+ code: error.code, error: error.message.slice(0, 160) }));
216
+ throw error;
217
+ }
218
+ return;
219
+ }
220
+ if (request.method === 'POST' && url.pathname === '/api/desktop/click') {
221
+ const started = Date.now();
222
+ console.info(JSON.stringify({ time: new Date().toISOString(), event: 'desktop-click', state: 'received' }));
223
+ try {
224
+ const result = await desktopHelper.click(await readJsonBody(request));
225
+ console.info(JSON.stringify({ time: new Date().toISOString(), event: 'desktop-click', state: result.state,
226
+ inputCount: result.inputCount, ms: Date.now() - started }));
227
+ sendJson(response, 200, result);
228
+ } catch (error) {
229
+ console.error(JSON.stringify({ time: new Date().toISOString(), event: 'desktop-click', state: 'failed',
230
+ code: error.code, error: error.message.slice(0, 160), ms: Date.now() - started }));
231
+ throw error;
232
+ }
233
+ return;
234
+ }
235
+ if (request.method === 'DELETE' && url.pathname === '/api/desktop/frame') {
236
+ const body = await readJsonBody(request);
237
+ if (typeof body.sessionId !== 'string' || !/^[a-f0-9]{32}$/.test(body.sessionId)) throw new RequestError(400, '无效的桌面会话');
238
+ desktopHelper.release(undefined, body.sessionId);
239
+ sendJson(response, 200, { released: true });
240
+ return;
241
+ }
242
+
243
+ // These routes never wait on Desktop tools or a stuck thread. They remain
244
+ // available to authenticated paired controllers during an app-server stall.
245
+ if (request.method === 'GET' && url.pathname === '/api/shared-recovery/events') {
246
+ response.startEvents();
247
+ let stopped = false, timer, previous = '';
248
+ response.once('close', () => { stopped = true; clearTimeout(timer); });
249
+ const publish = async () => {
250
+ try {
251
+ const state = await recoveryStatus(stateRoot);
252
+ const serialized = JSON.stringify(state);
253
+ if (!stopped && serialized !== previous) { previous = serialized; response.event('progress', state); }
254
+ } catch (error) { if (!stopped) response.event('progress-error', { error: error.message }); }
255
+ if (!stopped) timer = setTimeout(publish, 1000);
256
+ };
257
+ void publish(); return;
258
+ }
259
+ if (request.method === 'GET' && url.pathname === '/api/shared-recovery') {
260
+ sendJson(response, 200, await recoveryStatus(stateRoot)); return;
261
+ }
262
+ if (request.method === 'POST' && url.pathname === '/api/shared-recovery') {
263
+ const body = await readJsonBody(request);
264
+ if (body.confirmed !== true || typeof body.operationId !== 'string') throw new RequestError(400, '请确认重新打开当前 Codex 并接入 Codex 内核后再继续。');
265
+ sendJson(response, 202, await requestRecovery(stateRoot, body)); return;
266
+ }
267
+ if (request.method === 'POST' && url.pathname === '/api/service/restart') {
268
+ const body = await readJsonBody(request);
269
+ if (body.confirmed !== true) throw new RequestError(400, '请确认重启工作站服务后再继续。');
270
+ sendJson(response, 202, await requestServiceRestart(stateRoot)); return;
271
+ }
272
+ if (request.method === 'POST' && url.pathname === '/api/managed-app-server/restart') {
273
+ const body = await readJsonBody(request);
274
+ if (body.confirmed !== true || typeof body.operationId !== 'string' || !body.operationId.trim()) {
275
+ throw new RequestError(400, '请先确认中断风险,再重启 Codex 内核。');
276
+ }
277
+ try {
278
+ sendJson(response, 202, managedAppServer.restart(body.operationId.trim()));
279
+ } catch (error) {
280
+ if (error?.code === 'managed_app_server_restarting') throw new RequestError(409, error.message);
281
+ throw error;
282
+ }
283
+ return;
284
+ }
285
+
286
+ if (request.method === 'GET' && url.pathname === '/api/official-remote/status') {
287
+ if (!managedAppServer.client.ready) throw new RequestError(503, 'Codex 内核未就绪,无法读取官方工作站状态。');
288
+ const result = await managedAppServer.client.request('remoteControl/status/read', {});
289
+ sendJson(response, 200, { status: result.status, environmentId: result.environmentId });
290
+ return;
291
+ }
292
+ if (request.method === 'POST' && url.pathname === '/api/official-remote/pairing') {
293
+ if (!managedAppServer.client.ready) throw new RequestError(503, 'Codex 内核未就绪,无法生成官方配对码。');
294
+ const status = await managedAppServer.client.request('remoteControl/status/read', {});
295
+ if (status.status !== 'connected') throw new RequestError(409, '当前 Codex 内核未连接 OpenAI Remote,无法配对。');
296
+ const result = await managedAppServer.client.request('remoteControl/pairing/start', { manualCode: true });
297
+ if (!result.manualPairingCode || !result.environmentId) throw new RequestError(502, 'Codex 内核未返回有效的官方配对码。');
298
+ // One-time code: return only to the authenticated station controller; never persist or log it.
299
+ sendJson(response, 200, { manualPairingCode: result.manualPairingCode,
300
+ environmentId: result.environmentId, expiresAt: result.expiresAt });
301
+ return;
302
+ }
303
+
304
+ if (request.method === 'GET' && url.pathname === '/api/health') {
305
+ // Status readers consume the workstation's cached sample. A phone query
306
+ // must not trigger PowerShell, process-identity, or app-server checks.
307
+ const executionReady = executionHealth.state.ready && sharedServer?.ready === true;
308
+ sendJson(response, 200, {
309
+ status: 'ok', resident: true, version: runtimeVersion,
310
+ execution: { ...executionHealth.state, ready: executionReady, canWrite: executionReady,
311
+ mode: executionMode },
312
+ managedAppServer: managedAppServer.snapshot(),
313
+ ...runtimeDiscovery.state,
314
+ sharedAppServerConnected: sharedServer?.ready === true,
315
+ sharedRecoveryAvailable: runtimeDiscovery.state.desktop?.installed === true,
316
+ mode: options.lan ? 'lan' : 'loopback',
317
+ tokenRequired: Boolean(options.token),
318
+ nativeImageTransport: sharedServer ? 'shared-app-server' : null,
319
+ transportMode: sharedServer?.ready === true ? 'lan-shared' : 'unavailable',
320
+ });
321
+ return;
322
+ }
323
+
324
+ const fileLinkMatch = /^\/api\/files\/([a-f0-9]{64})\/link$/.exec(url.pathname);
325
+ if (request.method === 'GET' && fileLinkMatch) {
326
+ const attachment = attachmentCache.get(fileLinkMatch[1])
327
+ || dotsImages.restore(fileLinkMatch[1], url.searchParams.get('recovery') || '');
328
+ if (!attachment?.dotsFile) throw new RequestError(404, '此附件没有网络链接,请重新读取对话。');
329
+ sendJson(response, 200, { url: await dotsImages.fileLink(attachment) });
330
+ return;
331
+ }
332
+
333
+ const imageMatch = /^\/api\/images\/([a-f0-9]{64})$/.exec(url.pathname);
334
+ if (request.method === 'GET' && imageMatch) {
335
+ let image = attachmentCache.get(imageMatch[1])
336
+ || dotsImages.restore(imageMatch[1], url.searchParams.get('recovery') || '')
337
+ || recoverLocalImage(imageMatch[1], url.searchParams.get('recovery') || '');
338
+ const contextThreadId = url.searchParams.get('threadId') || '';
339
+ const contextTurnId = url.searchParams.get('turnId') || '';
340
+ if (!image && sharedServer && contextThreadId && contextTurnId
341
+ && contextThreadId.length <= 128 && contextTurnId.length <= 128) {
342
+ try {
343
+ normalizeTurn(await sharedServer.turnContext(contextThreadId, contextTurnId));
344
+ image = attachmentCache.get(imageMatch[1]);
345
+ } catch {}
346
+ }
347
+ if (!image) throw new RequestError(404, '这张图片已失效,无法从工作站重新读取。');
348
+ if (image.dotsFile) await dotsImages.load(image);
349
+ if (url.searchParams.get('variant') === 'thumbnail') {
350
+ const asset = await attachmentThumbnail(image);
351
+ sendJson(response, 200, { url: `data:${asset.mimeType};base64,${asset.buffer.toString('base64')}` });
352
+ return;
353
+ }
354
+ const size = image.buffer.length;
355
+ if (size > MAX_ATTACHMENT_BYTES) throw new RequestError(413, '图片超过下载上限(20 MiB),无法下载。');
356
+ if (!size) throw new RequestError(400, '图片为空,无法下载。');
357
+ if (!request.fileTransfer) throw new RequestError(400, '请通过文件通道读取原图。');
358
+ const revision = imageMatch[1];
359
+ const { offset, length } = fileRange(url.searchParams, size, revision);
360
+ response.filePart({ name: image.name || '图片', size, offset, revision, mimeType: image.mimeType },
361
+ image.buffer.subarray(offset, offset + length));
362
+ return;
363
+ }
364
+
365
+ const localFileRequest = url.pathname === '/api/files/local';
366
+ const fileMatch = /^\/api\/files\/([a-f0-9]{64})$/.exec(url.pathname);
367
+ if (request.method === 'GET' && (fileMatch || localFileRequest)) {
368
+ if (!request.fileTransfer) throw new RequestError(400, '请通过文件通道下载附件。');
369
+ const hosted = !localFileRequest && (attachmentCache.get(fileMatch[1])
370
+ || dotsImages.restore(fileMatch[1], url.searchParams.get('recovery') || ''));
371
+ if (hosted?.dotsFile) {
372
+ const { bytes, ...metadata } = await dotsImages.readPart(hosted, url.searchParams);
373
+ response.filePart(metadata, bytes);
374
+ return;
375
+ }
376
+ const localPath = url.searchParams.get('path') || '';
377
+ if (localFileRequest && !/^[a-z]:[\\/]/i.test(localPath)) throw new RequestError(400, '无效的工作站文件路径。');
378
+ const file = localFileRequest ? describeLocalFile(localPath) : fileCache.get(fileMatch[1]);
379
+ if (!file) throw new RequestError(404, '电脑上的文件不存在或无法读取。');
380
+ if (file.externalIdentity) await dotsImages.authorization(file.externalIdentity);
381
+ const { offset, length } = fileRange(url.searchParams, file.size, file.revision);
382
+ let handle;
383
+ try { handle = fs.openSync(file.path, 'r'); }
384
+ catch (error) {
385
+ if (error.code === 'ENOENT') throw new RequestError(404, '电脑上的文件已不存在。');
386
+ throw error;
387
+ }
388
+ try {
389
+ const validate = () => {
390
+ const stat = fs.fstatSync(handle);
391
+ if (!stat.isFile() || fileRevision(stat) !== file.revision || fileRevision(fs.statSync(file.path)) !== file.revision) {
392
+ if (fileMatch) fileCache.delete(fileMatch[1]);
393
+ throw new RequestError(409, '电脑上的文件已发生变化,请重新读取对话。');
394
+ }
395
+ if (stat.size > MAX_FILE_DOWNLOAD_BYTES) throw new RequestError(413, '文件超过下载上限(20 MiB),无法下载。');
396
+ if (!stat.size) throw new RequestError(400, '文件为空,无法下载。');
397
+ };
398
+ validate();
399
+ const data = Buffer.alloc(length);
400
+ let bytesRead = 0;
401
+ while (bytesRead < data.length) {
402
+ const count = fs.readSync(handle, data, bytesRead, data.length - bytesRead, offset + bytesRead);
403
+ if (!count) throw new RequestError(409, '电脑上的文件读取不完整,请重新读取对话。');
404
+ bytesRead += count;
405
+ }
406
+ validate();
407
+ response.filePart({ name: file.name, size: file.size, offset, revision: file.revision }, data);
408
+ } finally { fs.closeSync(handle); }
409
+ return;
410
+ }
411
+
412
+ if (url.pathname.startsWith('/api/app-server/') || url.pathname === '/api/wait') {
413
+ throw new RequestError(410, '此连接方式已停用。请检查 Codex 内核状态。', { code: 'unsupported_control' });
414
+ }
415
+ const taskPath = /^\/api\/threads\/([^/]+)/.exec(url.pathname);
416
+ if (taskPath && (url.pathname.endsWith('/takeover') || url.pathname === `/api/threads/${taskPath[1]}`)) {
417
+ throw new RequestError(410, '此连接方式已停用。', { code: 'unsupported_control' });
418
+ }
419
+ const hostId = url.searchParams.get('hostId') || 'local';
420
+ if (!['local', 'durable'].includes(hostId)) {
421
+ throw new RequestError(410, '此对话的连接方式已停用,请检查 Codex 内核状态。', { code: 'unsupported_control' });
422
+ }
423
+ if (!executionHealth.state.ready || sharedServer?.ready !== true) {
424
+ throw new RequestError(503, 'Codex 内核未就绪。请重启工作站服务;若仍失败,运行 relax doctor。',
425
+ { code: 'full_control_required' });
426
+ }
427
+ const rootChat = hostId === 'durable' && taskPath
428
+ && await dotsServer.source(decodeURIComponent(taskPath[1])) === 'aeon';
429
+ const execution = rootChat ? dotsChat : hostId === 'durable' ? dotsServer : sharedServer;
430
+
431
+ const dotsActivityMatch = /^\/api\/threads\/([^/]+)\/dots-activity-events$/.exec(url.pathname);
432
+ if (request.method === 'GET' && dotsActivityMatch) {
433
+ if (!rootChat) throw new RequestError(405, '只有点点主聊天提供此活动面板。');
434
+ response.startEvents();
435
+ const disconnected = () => response.end();
436
+ dotsChat.on('disconnected', disconnected);
437
+ const release = dotsChat.acquirePanelActivity(decodeURIComponent(dotsActivityMatch[1]), value => {
438
+ if (!response.destroyed) response.event('dots-activity', { threadId: value.threadId, activity: value.activity });
439
+ });
440
+ response.once('close', () => { release(); dotsChat.off('disconnected', disconnected); });
441
+ return;
442
+ }
443
+
444
+ const dotsTasksMatch = /^\/api\/threads\/([^/]+)\/dots-tasks$/.exec(url.pathname);
445
+ if (request.method === 'GET' && dotsTasksMatch) {
446
+ if (!rootChat) throw new RequestError(405, '只有点点主聊天提供此任务面板。');
447
+ sendJson(response, 200, await readDotsSummaryTasks(dotsServer, decodeURIComponent(dotsTasksMatch[1])));
448
+ return;
449
+ }
450
+
451
+ const dotsSchedulesMatch = /^\/api\/threads\/([^/]+)\/dots-schedules$/.exec(url.pathname);
452
+ if (request.method === 'GET' && dotsSchedulesMatch) {
453
+ if (!rootChat) throw new RequestError(405, '只有点点主聊天提供此定时任务列表。');
454
+ sendJson(response, 200, await readDotsSchedules(dotsServer, decodeURIComponent(dotsSchedulesMatch[1])));
455
+ return;
456
+ }
457
+
458
+ if (request.method === 'GET' && url.pathname.startsWith('/api/code/')) {
459
+ const operation = url.pathname.slice('/api/code/'.length);
460
+ if (hostId === 'durable' && operation !== 'change') throw new RequestError(405, 'Dots 委派任务尚未接入代码浏览。');
461
+ try {
462
+ if ((operation === 'image' || operation === 'binary') && !request.fileTransfer) throw new RequestError(400, '请通过文件通道读取预览文件。');
463
+ const result = await browseCode((method, params) => execution.request(method, params), operation, url.searchParams);
464
+ if (operation === 'image' || operation === 'binary') {
465
+ const { data, ...metadata } = result;
466
+ response.filePart(metadata, data);
467
+ } else sendJson(response, 200, result);
468
+ } catch (error) { throw new RequestError(error.statusCode || 502, error.message); }
469
+ return;
470
+ }
471
+
472
+ if (request.method === 'GET' && url.pathname === '/api/models') {
473
+ sendJson(response, 200, await execution.models());
474
+ return;
475
+ }
476
+
477
+ if (request.method === 'GET' && url.pathname === '/api/archived-threads') {
478
+ sendJson(response, 200, await archivedThreadsPage(execution, sharedServer, hostId, url.searchParams.get('cursor')));
479
+ return;
480
+ }
481
+
482
+ if (request.method === 'GET' && url.pathname === '/api/threads') {
483
+ const limit = boundedInteger(url.searchParams.get('limit'), 20, 1, 200);
484
+ const archived = url.searchParams.get('archived') === 'true';
485
+ const scope = url.searchParams.get('scope') || 'all';
486
+ if (!['all', 'local', 'durable'].includes(scope)) { sendJson(response, 400, { error: '无效的会话列表范围' }); return; }
487
+ sendJson(response, 200, await sharedCatalog.threads(limit, archived, scope, url.searchParams.get('refresh') === '1'));
488
+ return;
489
+ }
490
+
491
+ if (request.method === 'GET' && url.pathname === '/api/file-local-copy') {
492
+ const value = url.searchParams.get('source') || '';
493
+ const id = /^([a-f0-9]{64})(?:\?recovery=[A-Za-z0-9_-]{1,4096})?$/.exec(value)?.[1];
494
+ if (id && (fileCache.has(id) || attachmentCache.has(id) && !attachmentCache.get(id).dotsFile)) {
495
+ sendJson(response, 200, { external: false, localCopy: null }); return;
496
+ }
497
+ const asset = await externalDownloadAsset(value, url.searchParams.get('recovery'));
498
+ const copy = await workstationDownloads.copy(asset.id);
499
+ sendJson(response, 200, { external: true, localCopy: copy ? workstationCopyReference(copy, asset) : null,
500
+ cachedContent: asset.buffer ? { remotePath: asset.id, name: asset.name, size: asset.buffer.length, mimeType: asset.mimeType } : null });
501
+ return;
502
+ }
503
+
504
+ if (request.method === 'GET' && url.pathname === '/api/file-save-locations') {
505
+ await requireExecutionWritable();
506
+ const locations = await projectLocations();
507
+ const projects = (await sharedCatalog.projects()).projects.filter(project => project.hostId === 'local' && project.roots?.length);
508
+ sendJson(response, 200, { projects, downloads: locations.downloads });
509
+ return;
510
+ }
511
+
512
+ if (request.method === 'GET' && url.pathname === '/api/file-save-folders') {
513
+ await requireExecutionWritable();
514
+ sendJson(response, 200, await workstationDownloads.browse((method, params) => sharedServer.request(method, params),
515
+ await projectLocations(), Object.fromEntries(url.searchParams)));
516
+ return;
517
+ }
518
+
519
+ if (request.method === 'POST' && url.pathname === '/api/file-saves') {
520
+ await requireExecutionWritable();
521
+ const body = await readJsonBody(request);
522
+ const asset = await externalDownloadAsset(body.source, body.recovery);
523
+ const controller = new AbortController(), cancel = () => controller.abort();
524
+ response.once('close', cancel);
525
+ try {
526
+ const result = await workstationDownloads.save((method, params) => sharedServer.request(method, params),
527
+ await projectLocations(), body, asset, (file, query) => dotsImages.readPart(file, query), controller.signal);
528
+ sendJson(response, 201, { localCopy: workstationCopyReference(result, asset), warning: result.warning || '' });
529
+ } finally { response.off('close', cancel); }
530
+ return;
531
+ }
532
+
533
+ if (request.method === 'GET' && url.pathname === '/api/projects') {
534
+ sendJson(response, 200, { projects: (await sharedCatalog.projects()).projects });
535
+ return;
536
+ }
537
+
538
+ if (url.pathname === '/api/project-folders' && ['GET', 'POST'].includes(request.method)) {
539
+ await requireExecutionWritable();
540
+ try {
541
+ const rpc = (method, params) => sharedServer.request(method, params);
542
+ const result = request.method === 'GET'
543
+ ? await browseProjectFolders(rpc, !url.searchParams.get('path') || url.searchParams.get('locations') === '1' ? await projectLocations() : null, url.searchParams.get('path'))
544
+ : await createProjectFolder(rpc, await readJsonBody(request));
545
+ sendJson(response, request.method === 'GET' ? 200 : 201, result);
546
+ } catch (error) { throw new RequestError(error.statusCode || 502, error.message); }
547
+ return;
548
+ }
549
+
550
+ const projectActionMatch = /^\/api\/projects\/([^/]+)\/(archive-candidates|remove|move)$/.exec(url.pathname);
551
+ if (projectActionMatch) {
552
+ await requireExecutionWritable();
553
+ const projectId = decodeURIComponent(projectActionMatch[1]);
554
+ if (request.method === 'POST' && projectActionMatch[2] === 'move') {
555
+ const { beforeProjectId } = await readJsonBody(request);
556
+ if (beforeProjectId !== null && (typeof beforeProjectId !== 'string' || !beforeProjectId.trim()) || beforeProjectId === projectId) {
557
+ throw new RequestError(400, '排序目标无效。');
558
+ }
559
+ await sharedServer.request('project/move', { projectId, beforeProjectId });
560
+ sendJson(response, 200, await sharedCatalog.projects().then(({ projects }) => ({ projects })));
561
+ return;
562
+ }
563
+ if (request.method === 'GET' && projectActionMatch[2] === 'archive-candidates') {
564
+ sendJson(response, 200, await projectArchiveCandidates(sharedServer, dotsServer, projectId));
565
+ return;
566
+ }
567
+ if (request.method === 'POST' && projectActionMatch[2] === 'remove') {
568
+ const result = await removeWorkstationProject(sharedServer, stateRoot, codexDataRoot, projectId, await readJsonBody(request));
569
+ sendJson(response, 200, result);
570
+ return;
571
+ }
572
+ throw new RequestError(405, '不支持的项目操作。');
573
+ }
574
+
575
+ const updateProjectMatch = /^\/api\/projects\/([^/]+)$/.exec(url.pathname);
576
+ if (request.method === 'POST' && (url.pathname === '/api/projects' || updateProjectMatch)) {
577
+ await requireExecutionWritable();
578
+ const body = await readJsonBody(request);
579
+ try {
580
+ const result = !updateProjectMatch ? await createWorkstationProject(sharedServer, stateRoot, codexDataRoot, body)
581
+ : await updateWorkstationProject(sharedServer, stateRoot, codexDataRoot, decodeURIComponent(updateProjectMatch[1]), body);
582
+ sendJson(response, updateProjectMatch ? 200 : 201, result);
583
+ } catch (error) { throw new RequestError(error.statusCode || 502, error.message, error.details); }
584
+ return;
585
+ }
586
+
587
+ if (request.method === 'GET' && url.pathname === '/api/plan-pricing') {
588
+ sendJson(response, 200, await readPlanPricing(sharedServer));
589
+ return;
590
+ }
591
+
592
+ if (request.method === 'GET' && url.pathname === '/api/usage-limits') {
593
+ sendJson(response, 200, await sharedServer.usage());
594
+ return;
595
+ }
596
+
597
+ if (request.method === 'POST' && url.pathname === '/api/usage-reset') {
598
+ await requireExecutionWritable();
599
+ const body = await readJsonBody(request);
600
+ try { sendJson(response, 200, await consumeUsageReset(sharedServer, body)); }
601
+ catch (error) { throw new RequestError(error.statusCode || 502, error.message,
602
+ error.code === 'usage_reset_not_submitted' ? { code: error.code, deliveryUnknown: false } : undefined); }
603
+ return;
604
+ }
605
+
606
+ if (request.method === 'GET' && url.pathname === '/api/usage-events') {
607
+ const execution = sharedServer;
608
+ response.startEvents();
609
+ const emit = value => { if (!response.destroyed) response.event('usage', value); };
610
+ const failed = error => { if (!response.destroyed) response.event('usage-error', { error: error.message }); };
611
+ const disconnected = () => response.end();
612
+ execution.on('usage', emit); execution.on('usage-error', failed); execution.on('disconnected', disconnected);
613
+ response.once('close', () => { execution.off('usage', emit); execution.off('usage-error', failed); execution.off('disconnected', disconnected); });
614
+ try { emit(await execution.usage()); } catch (error) { failed(error); }
615
+ return;
616
+ }
617
+
618
+ if (request.method === 'GET' && url.pathname === '/api/thread-events') {
619
+ await sharedServer.connect();
620
+ const clients = [sharedServer, dotsServer, dotsChat];
621
+ response.startEvents();
622
+ const listener = ({ method, params }) => {
623
+ const questionChange = questionAttention.fromNotification(method, params);
624
+ if ((!threadListNotificationMethods.has(method) && !questionChange) || response.destroyed) return;
625
+ response.event('thread-list-changed', { method, threadId: params.threadId || params.thread?.id || '',
626
+ ...(questionChange ? { questionChange } : {}),
627
+ ...(params.hostId ? { hostId: params.hostId } : {}),
628
+ ...(params.chatRevision !== undefined ? { chatRevision: params.chatRevision } : {}),
629
+ ...(params.updatedAt !== undefined ? { updatedAt: params.updatedAt } : {}),
630
+ ...(params.chatMessageRevision !== undefined ? { chatMessageRevision: params.chatMessageRevision } : {}),
631
+ ...(params.status ? { status: typeof params.status === 'string' ? params.status : params.status.type } : {}) });
632
+ };
633
+ const disconnected = () => response.end();
634
+ for (const client of clients) {
635
+ client.on('notification', listener);
636
+ client.on('disconnected', disconnected);
637
+ }
638
+ response.once('close', () => {
639
+ for (const client of clients) {
640
+ client.off('notification', listener);
641
+ client.off('disconnected', disconnected);
642
+ }
643
+ });
644
+ response.event('ready', {});
645
+ return;
646
+ }
647
+
648
+ if (request.method === 'GET' && url.pathname === '/api/turn-events') {
649
+ const clients = [sharedServer, dotsServer, dotsChat];
650
+ response.startEvents();
651
+ const listener = ({ method, params }) => {
652
+ if (!['turn/started', 'turn/completed'].includes(method)) return;
653
+ const threadId = params?.threadId, turnId = params?.turn?.id;
654
+ if (typeof threadId === 'string' && typeof turnId === 'string' && !response.destroyed) {
655
+ response.event('turn', { method, threadId, turnId });
656
+ }
657
+ };
658
+ const disconnected = () => response.end();
659
+ for (const client of clients) {
660
+ client.on('notification', listener);
661
+ client.on('disconnected', disconnected);
662
+ }
663
+ response.once('close', () => {
664
+ for (const client of clients) {
665
+ client.off('notification', listener);
666
+ client.off('disconnected', disconnected);
667
+ }
668
+ });
669
+ return;
670
+ }
671
+
672
+ const activeTurnMatch = /^\/api\/threads\/([^/]+)\/active-turn$/.exec(url.pathname);
673
+ if (request.method === 'GET' && activeTurnMatch) {
674
+ if (rootChat) { sendJson(response, 200, { turnId: null }); return; }
675
+ const threadId = decodeURIComponent(activeTurnMatch[1]);
676
+ await execution.connect();
677
+ const { thread } = await execution.request('thread/read', { threadId, includeTurns: false });
678
+ const status = typeof thread.status === 'string' ? thread.status : thread.status?.type;
679
+ let turnId = null;
680
+ if (status === 'active') {
681
+ const page = await execution.request('thread/turns/list', {
682
+ threadId, limit: 1, sortDirection: 'desc', itemsView: 'summary',
683
+ });
684
+ const latest = page.data?.[0];
685
+ if (latest?.status === 'inProgress') turnId = latest.id;
686
+ }
687
+ sendJson(response, 200, { turnId });
688
+ return;
689
+ }
690
+
691
+ const createStatusMatch = /^\/api\/projects\/([^/]+)\/threads\/([a-f0-9]{32})\/status$/.exec(url.pathname);
692
+ if (request.method === 'GET' && createStatusMatch) {
693
+ sendJson(response, 200, messageReceipts.status(['project', decodeURIComponent(createStatusMatch[1])], createStatusMatch[2])); return;
694
+ }
695
+ const createThreadMatch = /^\/api\/projects\/([^/]+)\/threads$/.exec(url.pathname);
696
+ if (request.method === 'POST' && createThreadMatch) {
697
+ const projectId = decodeURIComponent(createThreadMatch[1]);
698
+ const body = await readJsonBody(request);
699
+ const result = await messageReceipts.submit(['project', projectId], body.clientUserMessageId, async () => {
700
+ try {
701
+ await requireExecutionWritable();
702
+ if (body.transport === 'app-server' || (body.hostId && body.hostId !== 'local')) {
703
+ throw new RequestError(410, '此连接方式已停用,请检查 Codex 内核状态。', { code: 'unsupported_control' });
704
+ }
705
+ const projects = (await sharedCatalog.projects()).projects;
706
+ const project = projects.find(item => item.projectId === projectId);
707
+ if (projectId !== '__unassigned__' && !project) throw new RequestError(404, '没有找到对应项目。');
708
+ const execution = sharedServer;
709
+ await execution.connect();
710
+ const rpc = (method, params) => execution.request(method, params);
711
+ let target = appServerEvents.projectlessThreadTarget(projectRoot);
712
+ if (project) {
713
+ const nativeProjects = await appServerEvents.readProjects(rpc);
714
+ const nativeProject = nativeProjects.find(item => item.id === project.projectId);
715
+ if (!nativeProject) throw new RequestError(404, 'Codex 内核中没有找到对应项目,请刷新项目列表。');
716
+ target = appServerEvents.projectThreadTarget(nativeProject);
717
+ }
718
+ const { input } = appServerTurnArguments('', body);
719
+ messageSubmissionStarted = true;
720
+ const created = await appServerEvents.createThread(rpc, target, input, body);
721
+ const prompt = input.filter(part => part.type === 'text').map(part => part.text).join('\n');
722
+ void generateAndSetThreadTitle({
723
+ request: rpc,
724
+ subscribe: listener => {
725
+ execution.on('notification', listener);
726
+ return () => execution.off('notification', listener);
727
+ },
728
+ threadId: created.threadId,
729
+ prompt,
730
+ cwd: target.cwd || null,
731
+ }).catch(error => console.error(`生成对话标题失败:${error.message}`));
732
+ return { ...created, hostId:'local' };
733
+ } catch (error) {
734
+ error.deliveryUnknown = messageSubmissionStarted;
735
+ if (error.deliveryResult) error.deliveryResult = error.deliveryResult.then(created => ({ ...created, hostId: 'local' }));
736
+ throw error;
737
+ }
738
+ });
739
+ sendJson(response, 201, result);
740
+ return;
741
+ }
742
+
743
+ const timelineMatch = /^\/api\/threads\/([^/]+)\/timeline$/.exec(url.pathname);
744
+ if (request.method === 'GET' && timelineMatch) {
745
+ const threadId = decodeURIComponent(timelineMatch[1]);
746
+ const knownSeq = url.searchParams.get('knownSeq');
747
+ const knownSync = { id: url.searchParams.get('knownSyncId'),
748
+ epoch: url.searchParams.get('knownEpoch'), seq: knownSeq === null ? undefined : Number(knownSeq),
749
+ updatedAt: Number(url.searchParams.get('knownUpdatedAt')) };
750
+ const pageOptions = sharedHistoryPageOptions();
751
+ pageOptions.reconcileObserved = url.searchParams.get('refresh') === '1';
752
+ if (request.socketRequest) {
753
+ pageOptions.onWindow = async window => {
754
+ if (response.writableEnded) return;
755
+ await response.historyPage(await prepareSharedTimeline(window));
756
+ };
757
+ }
758
+ const timeline = await prepareSharedTimeline(await execution.timeline(threadId, pageOptions, knownSync));
759
+ if (request.socketRequest) {
760
+ response.on('progress', () => execution.keepSnapshotAlive(threadId, timeline.sync));
761
+ response.once('close', () => execution.keepSnapshotAlive(threadId, timeline.sync));
762
+ }
763
+ sendJson(response, 200, timeline);
764
+ return;
765
+ }
766
+
767
+ const approvalMatch = /^\/api\/threads\/([^/]+)\/approvals$/.exec(url.pathname);
768
+ if (request.method === 'GET' && approvalMatch && rootChat) {
769
+ const threadId = decodeURIComponent(approvalMatch[1]);
770
+ sendJson(response, 200, await execution.inspectApproval(threadId, {
771
+ id: url.searchParams.get('requestId'), roomMessageId: url.searchParams.get('roomMessageId'),
772
+ })); return;
773
+ }
774
+ if (request.method === 'POST' && approvalMatch) {
775
+ const threadId = decodeURIComponent(approvalMatch[1]);
776
+ await requireExecutionWritable(threadId, execution);
777
+ const body = await readJsonBody(request);
778
+ sendJson(response, 200, await execution.respondApproval(threadId, body)); return;
779
+ }
780
+
781
+ const eventsMatch = /^\/api\/threads\/([^/]+)\/events$/.exec(url.pathname);
782
+ if (request.method === 'GET' && eventsMatch) {
783
+ const threadId = decodeURIComponent(eventsMatch[1]);
784
+ const after = Number(url.searchParams.get('afterSeq'));
785
+ const requestedSync = { id: url.searchParams.get('syncId'), epoch: url.searchParams.get('syncEpoch'),
786
+ after: Number.isSafeInteger(after) && after >= 0 ? after : undefined,
787
+ updatedAt: Number(url.searchParams.get('knownUpdatedAt')) };
788
+ await streamSharedThread(response, threadId, execution,
789
+ prepareSharedTimeline, requestedSync);
790
+ return;
791
+ }
792
+
793
+ const modelChangesMatch = /^\/api\/threads\/([^/]+)\/model-changes$/.exec(url.pathname);
794
+ if (request.method === 'GET' && modelChangesMatch) {
795
+ const threadId = decodeURIComponent(modelChangesMatch[1]);
796
+ if (!/^[0-9a-f]{8}-[0-9a-f-]{20,55}$/i.test(threadId)) throw new RequestError(400, '对话 ID 无效。');
797
+ sendJson(response, 200, hostId === 'durable' ? [] : await turnModelHistory.read(findSessionPath(threadId), (url.searchParams.get('turnIds') || '').split(',').filter(Boolean)));
798
+ return;
799
+ }
800
+ const contextMatch = /^\/api\/threads\/([^/]+)\/context$/.exec(url.pathname);
801
+ if (request.method === 'GET' && contextMatch) {
802
+ const threadId = decodeURIComponent(contextMatch[1]);
803
+ const current = hostId === 'durable' ? { available: false } : readContextUsage(threadId);
804
+ const native = await execution.readContext(threadId, url.searchParams.get('refreshSettings') === 'true');
805
+ sendJson(response, 200, { ...current, ...(native.model ? { model: native.model, thinking: native.thinking, serviceTier: native.serviceTier,
806
+ permissionMode: native.permissionMode, permissionModes: native.permissionModes } : {}), ...(native.available ? native : {}) });
807
+ return;
808
+ }
809
+
810
+ const sharedAction = /^\/api\/threads\/([^/]+)\/(history|settings|permissions)$/.exec(url.pathname);
811
+ if (sharedAction) {
812
+ const id = decodeURIComponent(sharedAction[1]);
813
+ if (request.method === 'GET' && sharedAction[2] === 'history') {
814
+ const pageOptions = sharedHistoryPageOptions();
815
+ if (url.searchParams.has('itemLimit')) {
816
+ const itemLimit = Number(url.searchParams.get('itemLimit'));
817
+ if (!Number.isInteger(itemLimit) || itemLimit < 1 || itemLimit > 100) throw new RequestError(400, '历史条目数量无效。');
818
+ pageOptions.itemLimit = itemLimit;
819
+ }
820
+ const history = await execution.history(id, url.searchParams.get('cursor'), pageOptions);
821
+ sendJson(response, 200, await prepareSharedTimeline(history, hostId));
822
+ return;
823
+ }
824
+ if (request.method === 'POST' && sharedAction[2] === 'settings') {
825
+ if (rootChat) throw new RequestError(405, 'Dots 主对话不提供 Codex 模型设置。');
826
+ await requireExecutionWritable(id, execution);
827
+ const body = await readJsonBody(request);
828
+ sendJson(response, 200, await execution.settings(id, body.model, body.thinking, body.serviceTier));
829
+ return;
830
+ }
831
+ if (request.method === 'POST' && sharedAction[2] === 'permissions') {
832
+ if (rootChat) throw new RequestError(405, 'Dots 主对话不提供 Codex 权限设置。');
833
+ await requireExecutionWritable(id, execution);
834
+ const body = await readJsonBody(request);
835
+ sendJson(response, 200, await execution.permissions(id, body.mode));
836
+ return;
837
+ }
838
+ }
839
+
840
+ const renameMatch = /^\/api\/threads\/([^/]+)\/name$/.exec(url.pathname);
841
+ if (request.method === 'POST' && renameMatch) {
842
+ if (rootChat) throw new RequestError(405, '点点主聊天名称由点点资料管理。');
843
+ const threadId = decodeURIComponent(renameMatch[1]);
844
+ await requireExecutionWritable(threadId, execution);
845
+ const body = await readJsonBody(request);
846
+ const name = typeof body.name === 'string' ? body.name.trim() : '';
847
+ if (!name || /[\r\n]/.test(name)) throw new RequestError(400, '请输入单行对话名称。');
848
+ await execution.request('thread/name/set', { threadId, name });
849
+ sendJson(response, 200, { threadId, name });
850
+ return;
851
+ }
852
+
853
+ const archiveMatch = /^\/api\/threads\/([^/]+)\/(archive|unarchive)$/.exec(url.pathname);
854
+ if (request.method === 'POST' && archiveMatch) {
855
+ if (rootChat) throw new RequestError(405, 'Dots 主对话不提供任务归档。');
856
+ const threadId = decodeURIComponent(archiveMatch[1]);
857
+ await requireExecutionWritable(threadId, execution);
858
+ if (archiveMatch[2] === 'archive') {
859
+ const { thread } = await execution.request('thread/read', { threadId, includeTurns: false });
860
+ const status = typeof thread.status === 'string' ? thread.status : thread.status?.type;
861
+ if (['active', 'inProgress'].includes(status)) throw new RequestError(409, '对话正在运行,请先停止任务。');
862
+ }
863
+ await execution.request(`thread/${archiveMatch[2]}`, { threadId });
864
+ sendJson(response, 200, { threadId, archived: archiveMatch[2] === 'archive' });
865
+ return;
866
+ }
867
+
868
+ const queueMatch = /^\/api\/threads\/([^/]+)\/queue$/.exec(url.pathname);
869
+ if (queueMatch) {
870
+ if (hostId === 'durable') throw new RequestError(405, '当前托管连接不提供排队消息。');
871
+ const threadId = decodeURIComponent(queueMatch[1]);
872
+ await sharedServer.connect();
873
+ const rpc = (method, params) => sharedServer.request(method, params);
874
+ if (request.method === 'GET') { sendJson(response, 200, await appServerEvents.readQueue(rpc, threadId)); return; }
875
+ if (request.method === 'POST') {
876
+ const body = await readJsonBody(request);
877
+ const result = await messageReceipts.submit([hostId, threadId], body.clientUserMessageId, async () => {
878
+ let args;
879
+ try { await requireExecutionWritable(threadId); args = appServerTurnArguments(threadId, body); }
880
+ catch (error) { error.deliveryUnknown = false; throw error; }
881
+ messageSubmissionStarted = true;
882
+ try { return await appServerEvents.addQueue(rpc, threadId, args.input, body.clientUserMessageId, body.containsVoiceInput); }
883
+ catch (error) { if (messageDelivery.rejectedBeforeSubmission(error)) error.deliveryUnknown = false; throw error; }
884
+ });
885
+ sendJson(response, 202, result); return;
886
+ }
887
+ }
888
+
889
+ const queueItemMatch = /^\/api\/threads\/([^/]+)\/queue\/([a-f0-9-]+)(?:\/(steer))?$/.exec(url.pathname);
890
+ if (queueItemMatch) {
891
+ if (hostId === 'durable') throw new RequestError(405, '当前托管连接不提供排队消息。');
892
+ const threadId = decodeURIComponent(queueItemMatch[1]);
893
+ const itemId = queueItemMatch[2];
894
+ await sharedServer.connect();
895
+ const action = request.method === 'DELETE' ? 'delete' : queueItemMatch[3] === 'steer' ? 'steer' : null;
896
+ if (!action) throw new RequestError(405, '不支持的队列操作');
897
+ await requireExecutionWritable(threadId);
898
+ sendJson(response, 200, await appServerEvents.changeQueue((method, params) => sharedServer.request(method, params), threadId, itemId, action)); return;
899
+ }
900
+
901
+ const interruptMatch = /^\/api\/threads\/([^/]+)\/interrupt$/.exec(url.pathname);
902
+ if (request.method === 'POST' && interruptMatch) {
903
+ if (rootChat) throw new RequestError(405, 'Dots 主对话不提供停止 Codex 任务的操作。');
904
+ const threadId = decodeURIComponent(interruptMatch[1]);
905
+ await requireExecutionWritable(threadId, execution);
906
+ const body = await readJsonBody(request);
907
+ if (!body.turnId) throw new RequestError(400, '缺少活动轮次 ID。');
908
+ await execution.interrupt(threadId, body.turnId);
909
+ sendJson(response, 200, { requested: true });
910
+ return;
911
+ }
912
+
913
+ const roomReadMatch = /^\/api\/threads\/([^/]+)\/read$/.exec(url.pathname);
914
+ if (request.method === 'POST' && roomReadMatch) {
915
+ if (!rootChat) throw new RequestError(405, '此对话不提供消息房间已读操作。');
916
+ await dotsChat.markRead(decodeURIComponent(roomReadMatch[1]), await readJsonBody(request));
917
+ sendJson(response, 200, { success: true });
918
+ return;
919
+ }
920
+
921
+ const messageStatusMatch = /^\/api\/threads\/([^/]+)\/messages\/([a-f0-9]{32})\/status$/.exec(url.pathname);
922
+ if (request.method === 'GET' && messageStatusMatch) {
923
+ sendJson(response, 200, messageReceipts.status([hostId, decodeURIComponent(messageStatusMatch[1])], messageStatusMatch[2]));
924
+ return;
925
+ }
926
+ const messageMatch = /^\/api\/threads\/([^/]+)\/messages$/.exec(url.pathname);
927
+ if (request.method === 'POST' && messageMatch) {
928
+ const threadId = decodeURIComponent(messageMatch[1]);
929
+ const body = await readJsonBody(request);
930
+ if (body.hostId && body.hostId !== hostId) throw new RequestError(400, '请求中的对话主机不一致。');
931
+ const result = await messageReceipts.submit([hostId, threadId], body.clientUserMessageId, async () => {
932
+ let args;
933
+ try { await requireExecutionWritable(threadId, execution); args = execution === dotsChat ? { ...body, threadId } : appServerTurnArguments(threadId, { ...body, hostId }); }
934
+ catch (error) { error.deliveryUnknown = false; throw error; }
935
+ messageSubmissionStarted = true;
936
+ const responseFor = submitted => ({ success: true, data: submitted.result, usage: submitted.usage });
937
+ try { return responseFor(await execution.send(args)); }
938
+ catch (error) {
939
+ if (error.deliveryResult) error.deliveryResult = error.deliveryResult.then(responseFor);
940
+ throw error;
941
+ }
942
+ });
943
+ sendJson(response, 200, result);
944
+ return;
945
+ }
946
+
947
+ sendJson(response, 404, { error: '未找到请求的资源。' });
948
+ } catch (error) {
949
+ if (response.headersSent) {
950
+ if (!response.writableEnded) response.end();
951
+ return;
952
+ }
953
+ if (!messageSubmissionStarted && error.deliveryUnknown === undefined && request.method === 'POST'
954
+ && /^\/api\/threads\/[^/]+\/(?:messages|queue)(?:\?|$)/.test(request.url || '')) error.deliveryUnknown = false;
955
+ const status = error instanceof RequestError || error instanceof ThreadWriterConflictError || error instanceof FileRangeError ? (error.statusCode || 409) : 502;
956
+ sendJson(response, status, {
957
+ error: error instanceof Error ? error.message : String(error),
958
+ code: error?.code,
959
+ threadId: error?.threadId,
960
+ readOnly: error?.readOnly,
961
+ details: error?.deliveryUnknown === undefined ? error?.details : { ...error?.details, deliveryUnknown: error.deliveryUnknown },
962
+ });
963
+ }
964
+ }
965
+
966
+ const server = http.createServer(handleRequest);
967
+ const lanSocket = attachLanSocket(server, { token: options.token, dispatch: handleRequest,
968
+ uploadsRoot,
969
+ authorizeDevice: (clientId, credential) => authorizeLanDevice(stateRoot, clientId, credential),
970
+ onDeviceAuthorizationsChanged: listener => onLanAuthorizationsChanged(stateRoot, listener),
971
+ onDeviceConnected: (clientId, info) => {
972
+ try { recordLanConnection(stateRoot, clientId, info); }
973
+ catch (error) { console.error('记录局域网设备连接失败:', error.message); }
974
+ },
975
+ });
976
+
977
+ let control;
978
+ void managedAppServer.start();
979
+ executionHealth.start();
980
+ runtimeDiscovery.start();
981
+ server.on('error', error => {
982
+ console.error('桌面服务监听失败:', error.message);
983
+ process.exit(1);
984
+ });
985
+ server.listen(options.port, options.lan ? '0.0.0.0' : '127.0.0.1', async () => {
986
+ try {
987
+ if (process.env.CODEX_REMOTE_INSTANCE && process.env.CODEX_REMOTE_CONTROL_SECRET && process.env.CODEX_REMOTE_READY_PATH) {
988
+ control = await openControl({ instanceId: process.env.CODEX_REMOTE_INSTANCE,
989
+ secret: process.env.CODEX_REMOTE_CONTROL_SECRET, shutdown: shutDown, actions: {
990
+ 'refresh-execution': refreshExecution,
991
+ 'relay-start': () => relayManager.run('relay-start'),
992
+ 'relay-status': () => relayManager.run('relay-status'),
993
+ 'relay-stop': () => relayManager.run('relay-stop'),
994
+ 'relay-pair': () => relayManager.run('relay-pair'),
995
+ 'relay-revoke': () => relayManager.run('relay-revoke'),
996
+ 'invite-create': details => relayManager.run('invite-create', details),
997
+ 'invite-list': () => relayManager.run('invite-list'),
998
+ 'invite-revoke': details => relayManager.run('invite-revoke', details),
999
+ 'devices-list': () => relayManager.run('devices-list'),
1000
+ 'device-revoke': details => relayManager.run('device-revoke', details),
1001
+ 'restart-managed-app-server': () => {
1002
+ const state = managedAppServer.snapshot();
1003
+ return state.launcherRestartRequired ? managedAppServer.restart(crypto.randomUUID()) : state;
1004
+ },
1005
+ 'restart-managed-app-server-confirmed': () => managedAppServer.restart(crypto.randomUUID()),
1006
+ } });
1007
+ writeRecord(process.env.CODEX_REMOTE_READY_PATH, { instanceId: process.env.CODEX_REMOTE_INSTANCE,
1008
+ pid: process.pid, controlPort: control.port });
1009
+ }
1010
+ } catch (error) {
1011
+ console.error('桌面服务管理通道启动失败:', error.message);
1012
+ process.exit(1);
1013
+ }
1014
+ console.log(`Code Relax 桌面服务已启动:http://127.0.0.1:${options.port}/`);
1015
+ if (options.lan) {
1016
+ console.log('LAN 模式使用鉴权 WebSocket,仅适合受信任局域网测试,不要暴露到公网。');
1017
+ }
1018
+ });
1019
+
1020
+ let shuttingDown = false;
1021
+ async function shutDown() {
1022
+ if (shuttingDown) return;
1023
+ shuttingDown = true;
1024
+ desktopHelper?.release();
1025
+ lanSocket.close();
1026
+ server.close();
1027
+ executionHealth?.stop();
1028
+ runtimeDiscovery.stop();
1029
+ await relayManager.close();
1030
+ dotsServer.close();
1031
+ dotsChat.close();
1032
+ managedAppServer.close();
1033
+ await control?.close();
1034
+ process.exit(0);
1035
+ }
1036
+ process.on('SIGTERM', shutDown);
1037
+ process.on('SIGINT', shutDown);
1038
+
1039
+ function readContextUsage(threadId) {
1040
+ if (!/^[0-9a-f]{8}-[0-9a-f-]{20,55}$/i.test(threadId)) {
1041
+ return { available: false };
1042
+ }
1043
+ const sessionPath = findSessionPath(threadId);
1044
+ if (!sessionPath) return { available: false };
1045
+
1046
+ let handle;
1047
+ try {
1048
+ const stat = fs.statSync(sessionPath);
1049
+ const readLength = Math.min(stat.size, MAX_CONTEXT_TAIL_BYTES);
1050
+ const buffer = Buffer.allocUnsafe(readLength);
1051
+ handle = fs.openSync(sessionPath, 'r');
1052
+ fs.readSync(handle, buffer, 0, readLength, stat.size - readLength);
1053
+ const lines = buffer.toString('utf8').split(/\r?\n/);
1054
+ let usage = null;
1055
+ let settings = null;
1056
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
1057
+ let event;
1058
+ if (!usage && lines[index].includes('"token_count"')) {
1059
+ try { event = JSON.parse(lines[index]); } catch { event = null; }
1060
+ if (event?.type === 'event_msg' && event?.payload?.type === 'token_count') {
1061
+ const usedTokens = Number(event.payload.info?.last_token_usage?.total_tokens);
1062
+ const contextWindow = Number(event.payload.info?.model_context_window);
1063
+ if (Number.isFinite(usedTokens) && Number.isFinite(contextWindow) && contextWindow > 0) {
1064
+ usage = {
1065
+ available: true,
1066
+ usedTokens,
1067
+ contextWindow,
1068
+ remainingTokens: Math.max(0, contextWindow - usedTokens),
1069
+ usedPercent: Math.min(100, Math.max(0, usedTokens / contextWindow * 100)),
1070
+ updatedAt: event.timestamp || null,
1071
+ };
1072
+ }
1073
+ }
1074
+ }
1075
+ if (!settings && lines[index].includes('"type":"turn_context"')) {
1076
+ try { event = JSON.parse(lines[index]); } catch { event = null; }
1077
+ settings = turnSettings(event);
1078
+ }
1079
+ if (usage && settings) break;
1080
+ }
1081
+ settings ||= sessionSettingsCache.get(sessionPath) || readLatestTurnSettings(sessionPath);
1082
+ if (settings) sessionSettingsCache.set(sessionPath, settings);
1083
+ if (usage) return { ...usage, ...settings };
1084
+ if (settings) return { available: false, ...settings };
1085
+ } catch {
1086
+ sessionPathCache.delete(threadId);
1087
+ } finally {
1088
+ if (handle !== undefined) fs.closeSync(handle);
1089
+ }
1090
+ return { available: false };
1091
+ }
1092
+
1093
+ function readLatestTurnSettings(sessionPath) {
1094
+ let handle;
1095
+ try {
1096
+ const stat = fs.statSync(sessionPath);
1097
+ const readLength = Math.min(stat.size, MAX_SETTINGS_SCAN_BYTES);
1098
+ const buffer = Buffer.allocUnsafe(readLength);
1099
+ handle = fs.openSync(sessionPath, 'r');
1100
+ fs.readSync(handle, buffer, 0, readLength, stat.size - readLength);
1101
+ const text = buffer.toString('utf8');
1102
+ let searchFrom = text.length;
1103
+ while (searchFrom > 0) {
1104
+ const marker = text.lastIndexOf('"type":"turn_context"', searchFrom);
1105
+ if (marker < 0) break;
1106
+ const lineStart = text.lastIndexOf('\n', marker) + 1;
1107
+ const nextLine = text.indexOf('\n', marker);
1108
+ const lineEnd = nextLine < 0 ? text.length : nextLine;
1109
+ let event;
1110
+ try { event = JSON.parse(text.slice(lineStart, lineEnd)); } catch { event = null; }
1111
+ const settings = turnSettings(event);
1112
+ if (settings) return settings;
1113
+ searchFrom = Math.max(0, lineStart - 1);
1114
+ }
1115
+ } catch {
1116
+ return null;
1117
+ } finally {
1118
+ if (handle !== undefined) fs.closeSync(handle);
1119
+ }
1120
+ return null;
1121
+ }
1122
+
1123
+ function turnSettings(event) {
1124
+ if (event?.type !== 'turn_context') return null;
1125
+ const model = typeof event.payload?.model === 'string' && /^[a-z0-9.-]{1,80}$/i.test(event.payload.model)
1126
+ ? event.payload.model
1127
+ : '';
1128
+ const thinking = typeof event.payload?.effort === 'string' && /^[a-z0-9_-]{1,32}$/i.test(event.payload.effort)
1129
+ ? event.payload.effort
1130
+ : '';
1131
+ return model || thinking ? { model, thinking } : null;
1132
+ }
1133
+
1134
+ function findSessionPath(threadId) {
1135
+ const cached = sessionPathCache.get(threadId);
1136
+ if (cached && fs.existsSync(cached)) return cached;
1137
+ if (!fs.existsSync(sessionsRoot)) return null;
1138
+
1139
+ const suffix = `-${threadId}.jsonl`.toLowerCase();
1140
+ const directories = [sessionsRoot];
1141
+ while (directories.length) {
1142
+ const directory = directories.pop();
1143
+ let entries;
1144
+ try {
1145
+ entries = fs.readdirSync(directory, { withFileTypes: true });
1146
+ } catch {
1147
+ continue;
1148
+ }
1149
+ for (const entry of entries) {
1150
+ const fullPath = path.join(directory, entry.name);
1151
+ if (entry.isDirectory()) directories.push(fullPath);
1152
+ if (entry.isFile() && entry.name.toLowerCase().endsWith(suffix)) {
1153
+ sessionPathCache.set(threadId, fullPath);
1154
+ return fullPath;
1155
+ }
1156
+ }
1157
+ }
1158
+ return null;
1159
+ }
1160
+
1161
+ function messagePayload(body) {
1162
+ const prompt = typeof body.prompt === 'string' ? body.prompt.trim() : '';
1163
+ if (body.images !== undefined && (!Array.isArray(body.images) || body.images.length > 4)) {
1164
+ throw new RequestError(400, '每条消息最多 4 张图片。');
1165
+ }
1166
+ const images = (body.images || []).map(id => {
1167
+ if (typeof id === 'string' && id.startsWith('data:')) return validateInlineImage(id);
1168
+ if (typeof id !== 'string' || !/^[a-f0-9]{64}$/.test(id)) throw new RequestError(400, '无效的图片标识。');
1169
+ const webpFile = path.join(uploadsRoot, `${id}.webp`);
1170
+ const file = fs.existsSync(webpFile) ? webpFile : path.join(uploadsRoot, `${id}.png`);
1171
+ if (!fs.existsSync(file)) throw new RequestError(400, '图片不存在,请重新添加。');
1172
+ return file;
1173
+ });
1174
+ for (const input of fileMentions.originalInputs(body)) {
1175
+ const incoming = fs.realpathSync(path.join(uploadsRoot, 'incoming'));
1176
+ const imagePath = fs.realpathSync(input.path);
1177
+ if (path.dirname(imagePath).toLowerCase() !== incoming.toLowerCase()) throw new RequestError(400, '原图必须来自上传目录。');
1178
+ const stat = fs.statSync(imagePath);
1179
+ if (!stat.isFile() || stat.size <= 0 || stat.size > MAX_ATTACHMENT_BYTES) throw new RequestError(400, '原图为空或超过 20 MiB。');
1180
+ images.push(imagePath);
1181
+ }
1182
+ if (images.filter(image => image.startsWith('data:')).reduce((size, image) => size + Buffer.byteLength(image.slice(image.indexOf(',') + 1), 'base64'), 0) > 8 * 1024 * 1024) throw new RequestError(413, '本条消息图片合计不能超过 8 MiB。');
1183
+ if (!prompt && !images.length) throw new RequestError(400, 'prompt 不能为空。');
1184
+ if (prompt.length > 32_000) throw new RequestError(400, 'prompt 不能超过 32000 个字符。');
1185
+
1186
+ const payload = { prompt, images };
1187
+ if (typeof body.hostId === 'string' && body.hostId.trim()) payload.hostId = body.hostId.trim();
1188
+ if (typeof body.model === 'string' && body.model.trim()) {
1189
+ payload.model = body.model.trim();
1190
+ }
1191
+ if (typeof body.thinking === 'string' && body.thinking.trim()) {
1192
+ const thinking = body.thinking.trim();
1193
+ if (!ALLOWED_THINKING.has(thinking)) throw new RequestError(400, '不支持的推理强度。');
1194
+ if ((payload.model === 'gpt-5.5' || payload.model === 'gpt-5.3-codex-spark')
1195
+ && (thinking === 'max' || thinking === 'ultra')) {
1196
+ throw new RequestError(400, '所选模型不支持该推理强度。');
1197
+ }
1198
+ payload.thinking = thinking;
1199
+ }
1200
+ return payload;
1201
+ }
1202
+
1203
+ function appServerTurnArguments(threadId, body) {
1204
+ const payload = messagePayload(body);
1205
+ const mentioned = payload.hostId === 'durable' ? fileMentions.parse(payload.prompt) : null;
1206
+ if (mentioned?.files.length) payload.prompt = mentioned.text;
1207
+ const input = [];
1208
+ if (payload.prompt) input.push({ type: 'text', text: payload.prompt });
1209
+ for (const image of payload.images) {
1210
+ if (image.startsWith('data:')) input.push({ type: 'image', url: image });
1211
+ else if (payload.hostId === 'durable') {
1212
+ const mimeType = imageMimeType(image);
1213
+ if (!mimeType) throw new RequestError(400, '云端图片格式无效。');
1214
+ input.push({ type: 'image', url: `data:${mimeType};base64,${fs.readFileSync(image).toString('base64')}` });
1215
+ } else input.push({ type: 'localImage', path: image });
1216
+ }
1217
+ const value = { threadId, input, ...appServerEvents.mobileTurnSource(body.clientUserMessageId, body.containsVoiceInput) };
1218
+ if (mentioned?.files.length) value.uploadFiles = mentioned.files;
1219
+ if (typeof body.clientUserMessageId === 'string' && /^[a-f0-9]{32}$/.test(body.clientUserMessageId)) value.clientUserMessageId = body.clientUserMessageId;
1220
+ if (payload.model) value.model = payload.model;
1221
+ if (payload.thinking) value.effort = payload.thinking;
1222
+ return value;
1223
+ }
1224
+
1225
+ function normalizeTimeline(data) {
1226
+ const source = data && typeof data === 'object' ? data : {};
1227
+ const sourceTurns = Array.isArray(source.turns) ? source.turns : source.thread?.turns;
1228
+ const turns = Array.isArray(sourceTurns) ? sourceTurns.map(normalizeTurn) : [];
1229
+ return {
1230
+ schemaVersion: 1,
1231
+ thread: normalizeThread(source.thread),
1232
+ turns,
1233
+ };
1234
+ }
1235
+
1236
+ function normalizeSharedTimeline({ thread, nextCursor, sync, notModified, incremental }, hostId = thread?.hostId || 'local') {
1237
+ const timeline = normalizeTimeline({ thread: { ...thread, title: thread.name || thread.title || '', hostId,
1238
+ transport: 'shared-app-server' } });
1239
+ return { ...timeline,
1240
+ historyItemCount: (thread.turns || []).reduce((count, turn) => count + (turn.roomMessage ? 1 : turn.items?.length || 0), 0),
1241
+ historyView: thread?.threadSource === 'aeon' ? 'chat-items-v1' : 'item-pages-v1',
1242
+ ...(notModified ? { notModified: true } : {}),
1243
+ ...(incremental ? { incremental: true } : {}),
1244
+ ...(nextCursor !== undefined ? { nextCursor } : {}), ...(sync ? { sync } : {}) };
1245
+ }
1246
+
1247
+ async function prepareSharedTimeline(value, hostId) {
1248
+ if (value.thread?.threadSource !== 'aeon') return normalizeSharedTimeline(value, hostId);
1249
+ const epoch = dotsChat.epoch, threadId = value.thread.id;
1250
+ const current = () => {
1251
+ if (epoch !== dotsChat.epoch) throw new Error('点点连接已变化,请重新同步。');
1252
+ };
1253
+ if (value.notModified) {
1254
+ const record = dotsChat.threads.get(threadId);
1255
+ if (await dotsImages.prepareTimeline(record?.thread)) {
1256
+ current();
1257
+ value = dotsChat.snapshot(threadId, record);
1258
+ }
1259
+ }
1260
+ const snapshot = structuredClone(value);
1261
+ await dotsImages.prepareTimeline(snapshot.thread);
1262
+ current();
1263
+ return normalizeSharedTimeline(snapshot, hostId);
1264
+ }
1265
+
1266
+ function normalizeThread(thread) {
1267
+ const source = thread && typeof thread === 'object' ? thread : {};
1268
+ return {
1269
+ id: source.id || '',
1270
+ title: source.name || source.title || source.preview || '',
1271
+ status: statusText(source.status),
1272
+ updatedAt: Number(source.updatedAt) || 0,
1273
+ recencyAt: Number(source.recencyAt ?? source.updatedAt) || 0,
1274
+ projectId: source.projectId || '',
1275
+ cwd: source.cwd || '',
1276
+ hostId: source.hostId || '',
1277
+ threadSource: source.threadSource || '',
1278
+ ...(['aeon', 'aeon_child'].includes(source.threadSource) ? { chatRevision: source.chatRevision || '' } : {}),
1279
+ ...(source.threadSource === 'aeon' ? { avatar: source.avatar || '', color: source.color || '',
1280
+ chatMessageRevision: source.chatMessageRevision, messagingRoomId: source.messagingRoomId, lastMessageAt: source.lastMessageAt } : {}),
1281
+ transport: source.transport || 'shared-app-server',
1282
+ readOnly: source.readOnly === true,
1283
+ readOnlyReason: source.readOnlyReason || '',
1284
+ };
1285
+ }
1286
+
1287
+ function normalizeTurn(turn) {
1288
+ const source = turn && typeof turn === 'object' ? turn : {};
1289
+ const turnId = source.id || '';
1290
+ const entries = Array.isArray(source.items) ? source.items.map(normalizeItem).filter(Boolean) : [];
1291
+ for (const entry of entries) {
1292
+ const images = [...(entry.attachments || []), entry.image, ...Object.values(entry.imageAssets || {})].filter(Boolean);
1293
+ for (const image of images) if (image.remotePath && !image.recoveryTurnId) image.recoveryTurnId = turnId;
1294
+ }
1295
+ return {
1296
+ id: turnId,
1297
+ status: statusText(source.status),
1298
+ startedAt: source.startedAt || null,
1299
+ completedAt: source.completedAt || null,
1300
+ durationMs: source.durationMs ?? null,
1301
+ error: source.error ?? null,
1302
+ runtimeNotice: source.runtimeNotice ?? null,
1303
+ ...(source.itemPage ? { itemPage: source.itemPage } : {}),
1304
+ ...(source.roomMessage ? { roomMessage: true } : {}),
1305
+ ...(source.deleted ? { deleted: true } : {}),
1306
+ ...(source.historyOnly ? { historyOnly: true } : {}),
1307
+ ...(source.cacheStub ? { cacheStub: true } : {}),
1308
+ entries,
1309
+ };
1310
+ }
1311
+
1312
+ function normalizeItem(item) {
1313
+ if (!item || typeof item !== 'object') return null;
1314
+
1315
+ if (item.type === 'approval') {
1316
+ return { ...item, type: 'activity', kind: 'approval', title: item.params?.message || '需要你的批准' };
1317
+ }
1318
+
1319
+ if (item.type === 'contextCompaction') {
1320
+ return {
1321
+ type: 'activity',
1322
+ id: item.id || '',
1323
+ kind: 'contextCompaction',
1324
+ title: '已自动压缩上下文',
1325
+ };
1326
+ }
1327
+
1328
+ const delegated = appServerEvents.toolDelegation(item);
1329
+ if (delegated) {
1330
+ return {
1331
+ type: 'message', id: item.id || '', role: 'user', source: 'remote',
1332
+ delegation: delegationSource(delegated), ...splitUploadedImages(delegated.input),
1333
+ };
1334
+ }
1335
+
1336
+ if (item.type === 'userMessage') {
1337
+ const { text, files, delegation } = extractUserText(item.content);
1338
+ const attachments = [...normalizeAttachments(item.content), ...files.map(file => file.fileId ? normalizeAttachment(file) : ({
1339
+ ...file,
1340
+ remotePath: file.path,
1341
+ previewRemotePath: file.previewPath || '',
1342
+ }))];
1343
+ if (!text && !attachments.length) return null;
1344
+ return {
1345
+ clientId: item.clientId,
1346
+ type: 'message',
1347
+ id: item.id || '',
1348
+ role: 'user',
1349
+ text,
1350
+ attachments,
1351
+ ...(delegation ? { delegation: delegationSource(delegation) } : {}),
1352
+ };
1353
+ }
1354
+
1355
+ if (item.type === 'agentMessage') {
1356
+ return {
1357
+ type: 'message',
1358
+ id: item.id || '',
1359
+ role: 'assistant',
1360
+ phase: item.phase || '',
1361
+ delivery: item.delivery,
1362
+ questions: item.questions,
1363
+ text: item.text || '',
1364
+ attachments: normalizeAttachments(item.attachments),
1365
+ imageAssets: markdownImageAssets(item.text || ''),
1366
+ fileAssets: markdownFileAssets(item.text || ''),
1367
+ };
1368
+ }
1369
+
1370
+ if (item.type === 'reasoning') {
1371
+ const detail = Array.isArray(item.summary)
1372
+ ? item.summary.map(summaryText).filter(Boolean).join('\n')
1373
+ : summaryText(item.summary);
1374
+ return {
1375
+ type: 'activity',
1376
+ id: item.id || '',
1377
+ kind: 'reasoning',
1378
+ title: '思考摘要',
1379
+ detail,
1380
+ };
1381
+ }
1382
+
1383
+ if (item.type === 'commandExecution') {
1384
+ return {
1385
+ type: 'activity',
1386
+ id: item.id || '',
1387
+ kind: 'command',
1388
+ title: '命令',
1389
+ titleOnly: true,
1390
+ status: statusText(item.status),
1391
+ command: item.command,
1392
+ cwd: item.cwd,
1393
+ exitCode: item.exitCode,
1394
+ durationMs: item.durationMs,
1395
+ };
1396
+ }
1397
+
1398
+ if (item.type === 'mcpToolCall') {
1399
+ return {
1400
+ type: 'activity',
1401
+ id: item.id || '',
1402
+ kind: 'tool',
1403
+ title: '工具',
1404
+ titleOnly: true,
1405
+ status: statusText(item.status),
1406
+ server: item.server,
1407
+ tool: item.tool,
1408
+ arguments: item.arguments,
1409
+ durationMs: item.durationMs,
1410
+ error: item.error,
1411
+ };
1412
+ }
1413
+
1414
+ if (item.type === 'dynamicToolCall') {
1415
+ return {
1416
+ type: 'activity',
1417
+ id: item.id || '',
1418
+ kind: 'tool',
1419
+ title: '工具',
1420
+ titleOnly: true,
1421
+ status: statusText(item.status),
1422
+ namespace: item.namespace,
1423
+ tool: item.tool,
1424
+ arguments: item.arguments,
1425
+ durationMs: item.durationMs,
1426
+ success: item.success,
1427
+ };
1428
+ }
1429
+
1430
+ if (item.type === 'fileChange') {
1431
+ return {
1432
+ type: 'activity',
1433
+ id: item.id || '',
1434
+ kind: 'fileChange',
1435
+ title: '文件修改',
1436
+ titleOnly: true,
1437
+ status: statusText(item.status),
1438
+ changes: fileChanges.summarize(item.changes),
1439
+ };
1440
+ }
1441
+
1442
+ if (item.type === 'webSearch') {
1443
+ return {
1444
+ type: 'activity',
1445
+ id: item.id || '',
1446
+ kind: 'webSearch',
1447
+ title: '网页搜索',
1448
+ titleOnly: true,
1449
+ query: item.query || item.action?.query || '',
1450
+ ...(item.action && typeof item.action === 'object' ? { action: item.action } : {}),
1451
+ };
1452
+ }
1453
+
1454
+ if (item.type === 'imageView') {
1455
+ const image = typeof item.path === 'string' && item.path
1456
+ ? normalizeAttachment({ type: 'localImage', path: item.path })
1457
+ : null;
1458
+ return {
1459
+ type: 'activity',
1460
+ id: item.id || '',
1461
+ kind: 'imageView',
1462
+ title: '查看图片',
1463
+ titleOnly: true,
1464
+ path: item.path || '',
1465
+ image,
1466
+ };
1467
+ }
1468
+
1469
+ if (item.type === 'imageGeneration') {
1470
+ const result = typeof item.result === 'string' ? item.result.trim() : '';
1471
+ const image = typeof item.savedPath === 'string' && item.savedPath
1472
+ ? normalizeAttachment({ type: 'localImage', path: item.savedPath, name: '生成的图片' })
1473
+ : result ? normalizeAttachment({ type: 'image', name: '生成的图片',
1474
+ url: result.startsWith('data:image/') ? result : `data:image/png;base64,${result}` }) : null;
1475
+ return {
1476
+ type: 'activity',
1477
+ id: item.id || '',
1478
+ kind: 'imageGeneration',
1479
+ title: '生成图片',
1480
+ titleOnly: true,
1481
+ status: statusText(item.status),
1482
+ image,
1483
+ failure: item.failure || null,
1484
+ transparentBackground: item.transparentBackground === true,
1485
+ };
1486
+ }
1487
+
1488
+ if (item.type === 'subAgentActivity') {
1489
+ return {
1490
+ type: 'activity',
1491
+ id: item.id || '',
1492
+ kind: 'subAgent',
1493
+ title: '子任务',
1494
+ titleOnly: true,
1495
+ status: statusText(item.kind),
1496
+ agentPath: item.agentPath || '',
1497
+ agentThreadId: item.agentThreadId || '',
1498
+ };
1499
+ }
1500
+
1501
+ if (item.type === 'collabAgentToolCall') {
1502
+ return {
1503
+ type: 'activity',
1504
+ id: item.id || '',
1505
+ kind: 'collaboration',
1506
+ title: '任务协作',
1507
+ titleOnly: true,
1508
+ status: statusText(item.status),
1509
+ tool: item.tool || '',
1510
+ prompt: item.prompt || '',
1511
+ senderThreadId: item.senderThreadId || '',
1512
+ receiverThreadIds: Array.isArray(item.receiverThreadIds) ? item.receiverThreadIds : [],
1513
+ model: item.model || '',
1514
+ reasoningEffort: item.reasoningEffort || '',
1515
+ agentsStates: item.agentsStates && typeof item.agentsStates === 'object' ? item.agentsStates : {},
1516
+ };
1517
+ }
1518
+
1519
+ return {
1520
+ type: 'activity',
1521
+ id: item.id || '',
1522
+ kind: item.type || 'event',
1523
+ title: item.type || '事件',
1524
+ status: statusText(item.status),
1525
+ detail: JSON.stringify(item, null, 2),
1526
+ };
1527
+ }
1528
+
1529
+ function sharedHistoryPageOptions() {
1530
+ return {
1531
+ maxTurns: SHARED_HISTORY_PAGE_TURNS,
1532
+ maxBytes: SHARED_HISTORY_PAGE_BYTES,
1533
+ measure: value => Buffer.byteLength(JSON.stringify(normalizeSharedTimeline(value))),
1534
+ };
1535
+ }
1536
+
1537
+ function normalizeAttachments(content) {
1538
+ if (!Array.isArray(content)) return [];
1539
+ const items = content.filter(item => item && !['text', 'input_text'].includes(item.type)).map(item => ({ ...item }));
1540
+ const text = content.filter(item => ['text', 'input_text'].includes(item?.type)).map(item => item.text || '').join('\n');
1541
+ const paths = [...new Set([...text.matchAll(/<image\b[^>]*\bpath="([^"]+)"[^>]*>/g)].map(match => match[1]))];
1542
+ const images = items.filter(item => ['image', 'input_image', 'localImage'].includes(item.type));
1543
+ for (const [index, imagePath] of paths.entries()) {
1544
+ if (images.some(item => item.path === imagePath)) continue;
1545
+ if (images[index] && !images[index].path) images[index].path = imagePath;
1546
+ else items.push({ type: 'localImage', path: imagePath });
1547
+ }
1548
+ const seen = new Set();
1549
+ return items.map(item => normalizeAttachment(item)).filter(item => {
1550
+ if (!item.url) return true;
1551
+ if (seen.has(item.url)) return false;
1552
+ seen.add(item.url); return true;
1553
+ });
1554
+ }
1555
+
1556
+ function markdownImageAssets(text) {
1557
+ const assets = Object.create(null);
1558
+ function visit(tokens) {
1559
+ for (const token of tokens) {
1560
+ const value = token.type === 'image' ? token.attrGet('src')
1561
+ : token.type === 'link_open' && !token.meta?.fileCitation ? token.attrGet('href') : null;
1562
+ if (value && !Object.hasOwn(assets, value)) {
1563
+ const localPath = fileCitations.localReference(value)?.path;
1564
+ // Only paths actually referenced by Desktop messages become accessible.
1565
+ // Do not expose a client-supplied filesystem path endpoint or UNC shares.
1566
+ if (localPath && /^[a-z]:[\\/]/i.test(localPath)) {
1567
+ const asset = normalizeAttachment({ type: 'localImage', path: localPath });
1568
+ if (asset.available) assets[value] = asset;
1569
+ }
1570
+ }
1571
+ if (token.children && token.type !== 'image') visit(token.children);
1572
+ }
1573
+ }
1574
+ visit(imageMarkdown.parse(text, { allowFileCitations: true }));
1575
+ return assets;
1576
+ }
1577
+
1578
+ function markdownFileAssets(text) {
1579
+ const assets = Object.create(null);
1580
+ function visit(tokens) {
1581
+ for (const token of tokens) {
1582
+ if (token.type === 'link_open') {
1583
+ const value = token.attrGet('href');
1584
+ if (value && !Object.hasOwn(assets, value)) {
1585
+ const localPath = fileCitations.localReference(value)?.path;
1586
+ if (localPath && /^[a-z]:[\\/]/i.test(localPath)) {
1587
+ assets[value] = { remotePath: localPath, name: path.basename(localPath) };
1588
+ // Link rendering must not depend on file existence or transient backend state.
1589
+ }
1590
+ }
1591
+ }
1592
+ if (token.children && token.type !== 'image') visit(token.children);
1593
+ }
1594
+ }
1595
+ visit(imageMarkdown.parse(text, { allowFileCitations: true }));
1596
+ return assets;
1597
+ }
1598
+
1599
+ function describeLocalFile(filePath) {
1600
+ const resolved = path.resolve(filePath);
1601
+ let stat;
1602
+ try { stat = fs.statSync(resolved); }
1603
+ catch (error) {
1604
+ if (error.code === 'ENOENT') throw new RequestError(404, '电脑上的文件已不存在。');
1605
+ if (error.code === 'EACCES' || error.code === 'EPERM') throw new RequestError(403, '电脑系统无法读取此文件:' + error.code);
1606
+ throw error;
1607
+ }
1608
+ if (!stat.isFile()) throw new RequestError(400, '此路径不是文件。');
1609
+ return { path: resolved, name: path.basename(resolved), size: stat.size, revision: fileRevision(stat) };
1610
+ }
1611
+
1612
+ function splitUploadedImages(text) {
1613
+ const tokens = imageMarkdown.parse(text, {});
1614
+ const lines = text.split('\n');
1615
+ const removed = new Set();
1616
+ const attachments = [];
1617
+ for (let index = 0; index < tokens.length - 1; index++) {
1618
+ const block = tokens[index];
1619
+ const inline = tokens[index + 1];
1620
+ if (block.type !== 'paragraph_open' || !block.map || inline.type !== 'inline') continue;
1621
+ const children = (inline.children || []).filter(token => token.type !== 'softbreak');
1622
+ if (!children.length || children.some(token => token.type !== 'image')) continue;
1623
+ const paths = children.map(token => {
1624
+ try { return path.resolve(decodeURIComponent(token.attrGet('src') || '')); } catch { return ''; }
1625
+ });
1626
+ if (paths.some(file => path.dirname(file).toLowerCase() !== uploadsRoot.toLowerCase()
1627
+ || !/^[a-f0-9]{64}\.(png|webp)$/.test(path.basename(file)))) continue;
1628
+ for (const file of paths) attachments.push(normalizeAttachment({ type: 'localImage', path: file, name: '移动端图片' }));
1629
+ for (let line = block.map[0]; line < block.map[1]; line++) removed.add(line);
1630
+ }
1631
+ const visibleText = lines.filter((line, index) => !removed.has(index)).join('\n').trim();
1632
+ return { text: visibleText, attachments, imageAssets: markdownImageAssets(visibleText) };
1633
+ }
1634
+
1635
+ async function externalDownloadAsset(value, recovery = '') {
1636
+ const match = /^([a-f0-9]{64})(?:\?recovery=([A-Za-z0-9_-]{1,4096}))?$/.exec(value || '');
1637
+ if (!match) throw new RequestError(400, '无效的外部文件标识。');
1638
+ const asset = attachmentCache.get(match[1]) || dotsImages.restore(match[1], match[2] || recovery || '');
1639
+ if (!asset?.dotsFile) throw new RequestError(404, '外部文件授权已失效,请重新读取对话。');
1640
+ await dotsImages.authorization(asset.identity);
1641
+ return asset;
1642
+ }
1643
+
1644
+ function workstationCopyReference(copy, asset) {
1645
+ const id = crypto.createHmac('sha256', fileCapabilitySecret).update(JSON.stringify([asset.id, copy.path, copy.revision])).digest('hex');
1646
+ fileCache.set(id, { ...copy, id, externalIdentity: asset.identity });
1647
+ while (fileCache.size > MAX_CACHED_FILES) fileCache.delete(fileCache.keys().next().value);
1648
+ return { remotePath: id, path: copy.path, name: copy.name, size: copy.size, mimeType: copy.mimeType };
1649
+ }
1650
+
1651
+ function normalizeAttachment(item, identity) {
1652
+ const name = item.name || item.fileName || (typeof item.path === 'string' ? path.basename(item.path) : '');
1653
+ let cached = null;
1654
+ const isImage = ['image', 'input_image', 'localImage'].includes(item.type);
1655
+ if (isImage || item.type === 'file') cached = dotsImages.remember(item, identity);
1656
+ if (isImage && typeof item.path === 'string') {
1657
+ cached = cacheLocalImage(item.path, name) || cached;
1658
+ }
1659
+ if (!cached) {
1660
+ const dataUrl = typeof item.url === 'string' && item.url.startsWith('data:image/')
1661
+ ? item.url
1662
+ : (typeof item.image_url === 'string' && item.image_url.startsWith('data:image/') ? item.image_url : '');
1663
+ if (dataUrl) cached = cacheDataImage(dataUrl, name);
1664
+ }
1665
+ return {
1666
+ type: isImage ? 'image' : item.type || 'attachment',
1667
+ name: cached?.name || name,
1668
+ mimeType: cached?.mimeType || item.mimeType || '',
1669
+ ...(cached?.size ? { size: cached.size } : {}),
1670
+ ...(cached?.dotsFile ? { networkFile: true } : {}),
1671
+ available: Boolean(cached),
1672
+ remotePath: cached ? (isImage ? cached.id : `${cached.id}?recovery=${cached.recoveryToken}`) : '',
1673
+ recoveryToken: cached?.recoveryToken || '',
1674
+ url: '',
1675
+ thumbnailUrl: '',
1676
+ };
1677
+ }
1678
+
1679
+ function cacheLocalImage(filePath, name) {
1680
+ try {
1681
+ const resolved = path.resolve(filePath);
1682
+ const stat = fs.statSync(resolved);
1683
+ const mimeType = imageMimeType(resolved);
1684
+ if (!stat.isFile() || !mimeType || stat.size > MAX_ATTACHMENT_BYTES) return null;
1685
+ const cached = rememberAttachment(fs.readFileSync(resolved), mimeType, name,
1686
+ localImageRecoveryToken(resolved, stat));
1687
+ void attachmentThumbnail(cached).catch(() => {});
1688
+ return cached;
1689
+ } catch {
1690
+ return null;
1691
+ }
1692
+ }
1693
+
1694
+ function cacheDataImage(dataUrl, name) {
1695
+ const match = /^data:(image\/(?:png|jpeg|gif|webp|bmp));base64,([a-z0-9+/=\r\n]+)$/i.exec(dataUrl);
1696
+ if (!match) return null;
1697
+ try {
1698
+ const buffer = Buffer.from(match[2], 'base64');
1699
+ if (!buffer.length || buffer.length > MAX_ATTACHMENT_BYTES) return null;
1700
+ return rememberAttachment(buffer, match[1].toLowerCase(), name);
1701
+ } catch {
1702
+ return null;
1703
+ }
1704
+ }
1705
+
1706
+ function rememberAttachment(buffer, mimeType, name, recoveryToken = '') {
1707
+ const id = crypto.createHash('sha256').update(buffer).digest('hex');
1708
+ if (!attachmentCache.has(id)) {
1709
+ while (attachmentCache.size >= MAX_CACHED_ATTACHMENTS) {
1710
+ attachmentCache.delete(attachmentCache.keys().next().value);
1711
+ }
1712
+ attachmentCache.set(id, { id, buffer, mimeType, name, recoveryToken });
1713
+ } else if (recoveryToken && !attachmentCache.get(id).recoveryToken) {
1714
+ attachmentCache.get(id).recoveryToken = recoveryToken;
1715
+ }
1716
+ return attachmentCache.get(id);
1717
+ }
1718
+
1719
+ function localImageRecoveryToken(filePath, stat) {
1720
+ const iv = crypto.randomBytes(12);
1721
+ const cipher = crypto.createCipheriv('aes-256-gcm', imageCapabilityKey, iv);
1722
+ const payload = Buffer.from(JSON.stringify([filePath, stat.size, stat.mtimeMs]));
1723
+ const encrypted = Buffer.concat([cipher.update(payload), cipher.final()]);
1724
+ return Buffer.concat([iv, cipher.getAuthTag(), encrypted]).toString('base64url');
1725
+ }
1726
+
1727
+ function recoverLocalImage(id, token) {
1728
+ if (!token || token.length > 2048) return null;
1729
+ try {
1730
+ const value = Buffer.from(token, 'base64url');
1731
+ if (value.length < 29) return null;
1732
+ const decipher = crypto.createDecipheriv('aes-256-gcm', imageCapabilityKey, value.subarray(0, 12));
1733
+ decipher.setAuthTag(value.subarray(12, 28));
1734
+ const [filePath, size, mtimeMs] = JSON.parse(Buffer.concat([
1735
+ decipher.update(value.subarray(28)), decipher.final(),
1736
+ ]).toString('utf8'));
1737
+ if (typeof filePath !== 'string' || !path.isAbsolute(filePath)
1738
+ || !Number.isSafeInteger(size) || size <= 0 || size > MAX_ATTACHMENT_BYTES
1739
+ || typeof mtimeMs !== 'number' || !Number.isFinite(mtimeMs)) return null;
1740
+ const stat = fs.statSync(filePath);
1741
+ const mimeType = imageMimeType(filePath);
1742
+ if (!stat.isFile() || stat.size !== size || stat.mtimeMs !== mtimeMs || !mimeType) return null;
1743
+ const buffer = fs.readFileSync(filePath);
1744
+ if (crypto.createHash('sha256').update(buffer).digest('hex') !== id) return null;
1745
+ return rememberAttachment(buffer, mimeType, path.basename(filePath), token);
1746
+ } catch {
1747
+ return null;
1748
+ }
1749
+ }
1750
+
1751
+ async function attachmentThumbnail(image) {
1752
+ if (!image.thumbnail) {
1753
+ image.thumbnail = sharp(image.buffer, { limitInputPixels: 40_000_000 }).rotate()
1754
+ .resize({ width: 640, height: 640, fit: 'inside', withoutEnlargement: true })
1755
+ .webp({ quality: 78, effort: 4 })
1756
+ .toBuffer()
1757
+ .then(buffer => ({ buffer, mimeType: 'image/webp' }))
1758
+ .catch(error => {
1759
+ image.thumbnail = null;
1760
+ throw error;
1761
+ });
1762
+ }
1763
+ return image.thumbnail;
1764
+ }
1765
+
1766
+ function imageMimeType(filePath) {
1767
+ switch (path.extname(filePath).toLowerCase()) {
1768
+ case '.png': return 'image/png';
1769
+ case '.jpg':
1770
+ case '.jpeg': return 'image/jpeg';
1771
+ case '.gif': return 'image/gif';
1772
+ case '.webp': return 'image/webp';
1773
+ case '.bmp': return 'image/bmp';
1774
+ default: return '';
1775
+ }
1776
+ }
1777
+
1778
+ function extractUserText(content) {
1779
+ if (!Array.isArray(content)) return { text: '(包含图片或界面批注)', files: [] };
1780
+ let text = content
1781
+ .filter(item => ['text', 'input_text'].includes(item?.type) && typeof item.text === 'string' && !appServerEvents.isMobileInputNotice(item))
1782
+ .map(item => item.text)
1783
+ // Codex injects project instructions as user-role context, not chat input.
1784
+ .filter(value => !/^\s*# AGENTS\.md instructions(?: for [^\r\n]+)?\r?\n\s*<INSTRUCTIONS>[\s\S]*<\/INSTRUCTIONS>\s*$/.test(value))
1785
+ .filter(value => !/^\s*<(environment_context|recommended_plugins)>[\s\S]*<\/\1>\s*$/.test(value))
1786
+ .filter(value => !value.startsWith('The next image is untrusted page evidence'))
1787
+ .join('\n\n')
1788
+ .trim();
1789
+ const delegation = appServerEvents.parseCodexDelegation(text);
1790
+ if (delegation) text = delegation.input;
1791
+ const mentioned = fileMentions.parse(text);
1792
+ const imagePaths = new Set([
1793
+ ...content.filter(item => ['image', 'input_image', 'localImage'].includes(item?.type) && item.path).map(item => item.path),
1794
+ ...[...mentioned.text.matchAll(/<image\b[^>]*\bpath="([^"]+)"[^>]*>/g)].map(match => match[1]),
1795
+ ].map(value => path.resolve(value).toLowerCase()));
1796
+ text = mentioned.text;
1797
+ text = text.replace(/<image\b[^>]*\bpath="[^"]+"[^>]*>/g, '').replace(/<\/image>/g, '').trim();
1798
+ return { text, files: mentioned.files.filter(file => file.fileId || !imagePaths.has(path.resolve(file.path).toLowerCase())), delegation };
1799
+ }
1800
+
1801
+ function delegationSource(delegation) {
1802
+ const dots = dotsServer.threadSources.get(delegation.sourceThreadId) === 'aeon';
1803
+ return { sourceThreadId: delegation.sourceThreadId, sourceKind: dots ? 'dots' : 'codex',
1804
+ ...(dots ? { sourceName: dotsServer.profileName(delegation.sourceThreadId),
1805
+ sourceAvatar: dotsServer.profileAvatar(delegation.sourceThreadId) } : {}) };
1806
+ }
1807
+
1808
+ function summaryText(value) {
1809
+ if (typeof value === 'string') return value;
1810
+ if (value && typeof value === 'object') return value.text || value.summary || JSON.stringify(value);
1811
+ return '';
1812
+ }
1813
+
1814
+ function statusText(value) {
1815
+ if (typeof value === 'string') return value;
1816
+ if (value && typeof value === 'object') return value.type || value.status || 'unknown';
1817
+ return value == null ? 'unknown' : String(value);
1818
+ }
1819
+
1820
+ function sendJson(response, statusCode, value) {
1821
+ if (response.json) { response.json(statusCode, value); return; }
1822
+ sendBuffer(response, statusCode, 'application/json; charset=utf-8', Buffer.from(JSON.stringify(value)));
1823
+ }
1824
+
1825
+ function sendBuffer(response, statusCode, contentType, value) {
1826
+ response.writeHead(statusCode, {
1827
+ 'Content-Type': contentType,
1828
+ 'Content-Length': value.length,
1829
+ 'Cache-Control': 'no-store',
1830
+ 'X-Content-Type-Options': 'nosniff',
1831
+ 'Content-Security-Policy': "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src 'self'; img-src 'self' data: blob:; media-src blob:",
1832
+ });
1833
+ response.end(value);
1834
+ }
1835
+
1836
+ async function readJsonBody(request) {
1837
+ return request.body;
1838
+ }
1839
+
1840
+ function parseOptions(args) {
1841
+ let port = Number.parseInt(process.env.CODEX_REMOTE_BRIDGE_PORT || '45831', 10);
1842
+ let lan = process.env.CODEX_REMOTE_BRIDGE_LAN === 'true';
1843
+ let token = process.env.CODEX_REMOTE_BRIDGE_TOKEN?.trim() || '';
1844
+ if (process.env.CODEX_REMOTE_APP_SERVER === 'true') throw new Error('此连接方式已停用。');
1845
+ for (let i = 0; i < args.length; i += 1) {
1846
+ if (args[i] === '--lan') lan = true;
1847
+ if (args[i] === '--app-server') throw new Error('此连接方式已停用。');
1848
+ if (args[i] === '--port' && args[i + 1]) port = Number.parseInt(args[++i], 10);
1849
+ if (args[i] === '--token' && args[i + 1]) token = args[++i];
1850
+ }
1851
+ if (!Number.isInteger(port) || port < 1024 || port > 65535) {
1852
+ throw new Error('端口必须位于 1024 到 65535 之间。');
1853
+ }
1854
+ if (lan && !token) token = crypto.randomBytes(32).toString('base64url');
1855
+ return { port, lan, token };
1856
+ }
1857
+
1858
+ function boundedInteger(value, fallback, minimum, maximum) {
1859
+ const parsed = Number.parseInt(value || '', 10);
1860
+ return Number.isFinite(parsed) ? Math.min(maximum, Math.max(minimum, parsed)) : fallback;
1861
+ }