@walkhi/code-relax 0.1.0-beta.26 → 0.1.0-beta.28

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 (63) hide show
  1. package/README.md +80 -67
  2. package/dist/bin/codex-remote.mjs +286 -239
  3. package/dist/shared/app-server-events.cjs +102 -16
  4. package/dist/shared/code-browser.cjs +159 -0
  5. package/dist/shared/file-changes.cjs +31 -0
  6. package/dist/shared/file-citations.cjs +117 -0
  7. package/dist/shared/file-mentions.cjs +80 -35
  8. package/dist/shared/message-delivery.cjs +38 -0
  9. package/dist/shared/p2p-file-channel.cjs +74 -0
  10. package/dist/shared/server-requests.cjs +149 -41
  11. package/dist/shared/service-endpoints.cjs +11 -11
  12. package/dist/shared/timeline-sync.cjs +173 -156
  13. package/dist/skills/code-relax/SKILL.md +2 -1
  14. package/dist/src/bounded-process-log.mjs +63 -0
  15. package/dist/src/celsius-client.mjs +107 -0
  16. package/dist/src/code-browser.mjs +101 -0
  17. package/dist/src/desktop-launcher.mjs +30 -30
  18. package/dist/src/dots-approval-timeline.mjs +99 -0
  19. package/dist/src/dots-avatar.mjs +42 -0
  20. package/dist/src/dots-chat-projection.mjs +50 -0
  21. package/dist/src/dots-chat-server.mjs +412 -0
  22. package/dist/src/dots-message-images.mjs +319 -0
  23. package/dist/src/dots-profile.mjs +77 -0
  24. package/dist/src/dots-room-activity.mjs +94 -0
  25. package/dist/src/dots-room-client.mjs +135 -0
  26. package/dist/src/dots-room-projection.mjs +94 -0
  27. package/dist/src/dots-schedules.mjs +66 -0
  28. package/dist/src/dots-summary-tasks.mjs +32 -0
  29. package/dist/src/dots-task-catalog.mjs +411 -0
  30. package/dist/src/file-transfer-range.mjs +22 -0
  31. package/dist/src/file-upload-session.mjs +130 -124
  32. package/dist/src/hosted-history-app-server.mjs +103 -0
  33. package/dist/src/hosted-history-cache.mjs +113 -0
  34. package/dist/src/lan-socket-server.mjs +205 -160
  35. package/dist/src/managed-app-server.mjs +241 -238
  36. package/dist/src/message-upload-files.mjs +74 -0
  37. package/dist/src/platform/windows/activate-desktop.ps1 +48 -0
  38. package/dist/src/platform/windows/codex-runtime.mjs +253 -251
  39. package/dist/src/platform/windows/desktop-host.mjs +38 -32
  40. package/dist/src/platform/windows/desktop-info.ps1 +2 -2
  41. package/dist/src/platform/windows/desktop-project-registration.mjs +153 -0
  42. package/dist/src/platform/windows/dots-profile.mjs +49 -0
  43. package/dist/src/platform/windows/launch-worker.mjs +41 -42
  44. package/dist/src/platform/windows/project-locations.mjs +16 -0
  45. package/dist/src/project-create.mjs +119 -0
  46. package/dist/src/project-management.mjs +69 -0
  47. package/dist/src/self-relay/account-connections.mjs +52 -0
  48. package/dist/src/self-relay/admin-page.mjs +80 -80
  49. package/dist/src/self-relay/admin-style.mjs +14 -9
  50. package/dist/src/self-relay/connector.mjs +22 -4
  51. package/dist/src/self-relay/file-wire.mjs +13 -0
  52. package/dist/src/self-relay/p2p-probe.mjs +152 -123
  53. package/dist/src/self-relay/rtc-file-bridge.mjs +41 -0
  54. package/dist/src/self-relay/server.mjs +894 -849
  55. package/dist/src/server.mjs +463 -148
  56. package/dist/src/service-doctor.mjs +72 -61
  57. package/dist/src/service-lifecycle.mjs +51 -43
  58. package/dist/src/shared-app-server.mjs +166 -107
  59. package/dist/src/shared-catalog.mjs +132 -40
  60. package/dist/src/shared-thread-stream.mjs +103 -25
  61. package/dist/src/usage-reset.mjs +17 -0
  62. package/dist/src/workstation-downloads.mjs +120 -0
  63. package/package.json +1 -1
@@ -8,28 +8,47 @@ import vm from 'node:vm';
8
8
  import sharp from 'sharp';
9
9
  import { attachLanSocket } from './lan-socket-server.mjs';
10
10
  import fileMentions from '../shared/file-mentions.cjs';
11
+ import fileCitations from '../shared/file-citations.cjs';
11
12
  import { validateInlineImage } from './message-images.mjs';
12
13
  import { ThreadWriterConflictError } from './shared-app-server.mjs';
13
14
  import { ManagedAppServer, managedAppServerUrl } from './managed-app-server.mjs';
14
15
  import { RuntimeDiscovery } from './runtime-discovery.mjs';
15
- import { streamSharedThread } from './shared-thread-stream.mjs';
16
+ import { streamSharedThread, threadListNotificationMethods } from './shared-thread-stream.mjs';
16
17
  import appServerEvents from '../shared/app-server-events.cjs';
18
+ import messageDelivery from '../shared/message-delivery.cjs';
19
+ import fileChanges from '../shared/file-changes.cjs';
17
20
  import { runtimePaths } from './platform/windows/runtime-paths.mjs';
18
21
  import { openControl } from './service-control.mjs';
19
22
  import { writeRecord, readRecord } from './service-lifecycle.mjs';
20
23
  import { ExecutionHealth } from './execution-health.mjs';
21
- import { authorizeLanDevice, onLanAuthorizationsChanged } from './self-relay/lan-authorizations.mjs';
24
+ import { authorizeLanDevice, onLanAuthorizationsChanged } from './self-relay/lan-authorizations.mjs';
22
25
  import { createRelayManager } from './self-relay/lifecycle.mjs';
23
26
  import { generateAndSetThreadTitle } from './thread-title-generation.mjs';
24
27
  import { desktopHost } from './platform/windows/desktop-host.mjs';
25
28
  import { SharedCatalog } from './shared-catalog.mjs';
29
+ import { DotsAppServer } from './dots-task-catalog.mjs';
30
+ import { DotsChatServer } from './dots-chat-server.mjs';
31
+ import { HostedHistoryCache } from './hosted-history-cache.mjs';
32
+ import { readDotsSummaryTasks } from './dots-summary-tasks.mjs';
33
+ import { readDotsSchedules } from './dots-schedules.mjs';
34
+ import { DotsMessageImages } from './dots-message-images.mjs';
35
+ import { desktopDotsProfiles } from './platform/windows/dots-profile.mjs';
26
36
  import { requestRecovery, recoveryStatus } from './shared-recovery.mjs';
27
37
  import { requestServiceRestart } from './service-restart.mjs';
28
38
  import { TurnModelHistory } from './turn-model-history.mjs';
39
+ import { browseCode } from './code-browser.mjs';
40
+ import { fileRange, fileRevision, FileRangeError } from './file-transfer-range.mjs';
41
+ import { browseProjectFolders, createProjectFolder, saveProject } from './project-management.mjs';
42
+ import { projectLocations } from './platform/windows/project-locations.mjs';
43
+ import { createWorkstationProject } from './project-create.mjs';
44
+ import { WorkstationDownloads } from './workstation-downloads.mjs';
45
+ import { consumeUsageReset } from './usage-reset.mjs';
29
46
 
30
47
  const turnModelHistory = new TurnModelHistory();
48
+ const messageReceipts = new messageDelivery.Store();
31
49
 
32
- const MAX_ATTACHMENT_BYTES = 16 * 1024 * 1024;
50
+ const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024;
51
+ const MAX_FILE_DOWNLOAD_BYTES = 20 * 1024 * 1024;
33
52
  const MAX_CACHED_ATTACHMENTS = 32;
34
53
  const MAX_CACHED_FILES = 64;
35
54
  const MAX_CONTEXT_TAIL_BYTES = 4 * 1024 * 1024;
@@ -37,15 +56,6 @@ const MAX_LIVE_ACTIVITY_TAIL_BYTES = 1024 * 1024;
37
56
  const SHARED_HISTORY_PAGE_BYTES = 500 * 1024;
38
57
  const SHARED_HISTORY_PAGE_TURNS = 8;
39
58
  const MAX_SETTINGS_SCAN_BYTES = 64 * 1024 * 1024;
40
- const ALLOWED_MODELS = new Set([
41
- 'gpt-6-astra',
42
- 'gpt-6-sol',
43
- 'gpt-5.6-sol',
44
- 'gpt-5.6-terra',
45
- 'gpt-5.6-luna',
46
- 'gpt-5.5',
47
- 'gpt-5.3-codex-spark',
48
- ]);
49
59
  const ALLOWED_THINKING = new Set(['low', 'medium', 'high', 'xhigh', 'max', 'ultra']);
50
60
 
51
61
  class RequestError extends Error {
@@ -74,10 +84,12 @@ const runtimeVersion = readRuntimeVersion();
74
84
  const webRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'web');
75
85
  const projectRoot = os.homedir();
76
86
  const { codexDataRoot, stateRoot, sessionsRoot, uploadsRoot } = runtimePaths();
87
+ const workstationDownloads = new WorkstationDownloads(stateRoot);
77
88
  const markdownItScript = fs.readFileSync(path.join(webRoot, 'vendor', 'markdown-it.umd.min.js'));
78
89
  const markdownSandbox = { atob };
79
90
  vm.runInNewContext(markdownItScript.toString(), markdownSandbox);
80
91
  const imageMarkdown = markdownSandbox.markdownit({ html: false });
92
+ fileCitations.install(imageMarkdown);
81
93
  const attachmentCache = new Map();
82
94
  const fileCache = new Map();
83
95
  const fileCapabilitySecret = crypto.randomBytes(32);
@@ -88,6 +100,11 @@ const sessionSettingsCache = new Map();
88
100
  const liveActivityCache = new Map();
89
101
  let sharedServer = null;
90
102
  const managedAppServer = new ManagedAppServer({ stateRoot, host: desktopHost });
103
+ const hostedHistoryCache = new HostedHistoryCache(stateRoot);
104
+ const dotsServer = new DotsAppServer(managedAppServer.client, desktopDotsProfiles(codexDataRoot, stateRoot), hostedHistoryCache);
105
+ const dotsImages = new DotsMessageImages({ localServer: managedAppServer.client, identity: () => dotsServer.profileIdentity,
106
+ cache: attachmentCache, capabilityKey: imageCapabilityKey, maxEntries: MAX_CACHED_ATTACHMENTS, maxBytes: MAX_ATTACHMENT_BYTES });
107
+ const dotsChat = new DotsChatServer(dotsServer, undefined, { uploadsRoot });
91
108
  const runtimeDiscovery = new RuntimeDiscovery({ stateRoot, host: desktopHost, url: managedAppServerUrl });
92
109
  const relayManager = createRelayManager(stateRoot);
93
110
  let sharedCatalog = null, executionMode = 'ordinary';
@@ -95,7 +112,7 @@ function selectSharedExecution(enabled) {
95
112
  const next = enabled ? managedAppServer.client : null;
96
113
  if (sharedServer === next) return;
97
114
  sharedServer = next;
98
- sharedCatalog = sharedServer ? new SharedCatalog(sharedServer) : null;
115
+ sharedCatalog = sharedServer ? new SharedCatalog(sharedServer, dotsServer, dotsChat) : null;
99
116
  }
100
117
  const executionHealth = new ExecutionHealth(async () => {
101
118
  await managedAppServer.ensure();
@@ -114,18 +131,23 @@ async function refreshExecution() {
114
131
  await executionHealth.refresh();
115
132
  return { mode: executionMode };
116
133
  }
117
- async function requireExecutionWritable(threadId) {
134
+ async function requireExecutionWritable(threadId, client = sharedServer) {
118
135
  const execution = await executionHealth.refresh();
119
136
  if (!execution.canWrite) {
120
137
  throw new RequestError(503, 'Codex 内核未就绪。请重启工作站服务;若仍失败,运行 relax doctor。',
121
138
  { code: 'full_control_required', execution });
122
139
  }
123
- if (threadId && sharedServer) sharedServer.requireWritable(threadId);
140
+ if (threadId && client) client.requireWritable(threadId);
124
141
  return execution;
125
142
  }
126
143
  managedAppServer.client.on('subscription-error', error => console.error('Codex 内核任务取消订阅失败:', error.message));
144
+ dotsServer.on('subscription-error', error => console.error('Dots 任务取消订阅失败:', error.message));
145
+ dotsChat.on('subscription-error', error => console.error('Dots 对话取消订阅失败:', error.message));
146
+ dotsChat.on('activity-error', error => console.error('点点状态通道不可用:', error.message));
127
147
 
148
+ dotsServer.on('profile-error', error => console.error(error.message));
128
149
  async function handleRequest(request, response) {
150
+ let messageSubmissionStarted = false;
129
151
  try {
130
152
  const url = new URL(request.url || '/', `http://${request.headers.host || 'localhost'}`);
131
153
 
@@ -215,9 +237,19 @@ async function handleRequest(request, response) {
215
237
  return;
216
238
  }
217
239
 
240
+ const fileLinkMatch = /^\/api\/files\/([a-f0-9]{64})\/link$/.exec(url.pathname);
241
+ if (request.method === 'GET' && fileLinkMatch) {
242
+ const attachment = attachmentCache.get(fileLinkMatch[1])
243
+ || dotsImages.restore(fileLinkMatch[1], url.searchParams.get('recovery') || '');
244
+ if (!attachment?.dotsFile) throw new RequestError(404, '此附件没有网络链接,请重新读取对话。');
245
+ sendJson(response, 200, { url: await dotsImages.fileLink(attachment) });
246
+ return;
247
+ }
248
+
218
249
  const imageMatch = /^\/api\/images\/([a-f0-9]{64})$/.exec(url.pathname);
219
250
  if (request.method === 'GET' && imageMatch) {
220
251
  let image = attachmentCache.get(imageMatch[1])
252
+ || dotsImages.restore(imageMatch[1], url.searchParams.get('recovery') || '')
221
253
  || recoverLocalImage(imageMatch[1], url.searchParams.get('recovery') || '');
222
254
  const contextThreadId = url.searchParams.get('threadId') || '';
223
255
  const contextTurnId = url.searchParams.get('turnId') || '';
@@ -229,24 +261,64 @@ async function handleRequest(request, response) {
229
261
  } catch {}
230
262
  }
231
263
  if (!image) throw new RequestError(404, '这张图片已失效,无法从工作站重新读取。');
232
- const thumbnail = url.searchParams.get('variant') === 'thumbnail';
233
- const asset = thumbnail ? await attachmentThumbnail(image) : { buffer: image.buffer, mimeType: image.mimeType };
234
- sendJson(response, 200, { url: `data:${asset.mimeType};base64,${asset.buffer.toString('base64')}` });
264
+ if (image.dotsFile) await dotsImages.load(image);
265
+ if (url.searchParams.get('variant') === 'thumbnail') {
266
+ const asset = await attachmentThumbnail(image);
267
+ sendJson(response, 200, { url: `data:${asset.mimeType};base64,${asset.buffer.toString('base64')}` });
268
+ return;
269
+ }
270
+ const size = image.buffer.length;
271
+ if (size > MAX_ATTACHMENT_BYTES) throw new RequestError(413, '图片超过下载上限(20 MiB),无法下载。');
272
+ if (!size) throw new RequestError(400, '图片为空,无法下载。');
273
+ if (!request.fileTransfer) throw new RequestError(400, '请通过文件通道读取原图。');
274
+ const revision = imageMatch[1];
275
+ const { offset, length } = fileRange(url.searchParams, size, revision);
276
+ response.filePart({ name: image.name || '图片', size, offset, revision, mimeType: image.mimeType },
277
+ image.buffer.subarray(offset, offset + length));
235
278
  return;
236
279
  }
237
280
 
238
281
  const fileMatch = /^\/api\/files\/([a-f0-9]{64})$/.exec(url.pathname);
239
282
  if (request.method === 'GET' && fileMatch) {
283
+ if (!request.fileTransfer) throw new RequestError(400, '请通过文件通道下载附件。');
284
+ const hosted = attachmentCache.get(fileMatch[1])
285
+ || dotsImages.restore(fileMatch[1], url.searchParams.get('recovery') || '');
286
+ if (hosted?.dotsFile) {
287
+ const { bytes, ...metadata } = await dotsImages.readPart(hosted, url.searchParams);
288
+ response.filePart(metadata, bytes);
289
+ return;
290
+ }
240
291
  const file = fileCache.get(fileMatch[1]);
241
292
  if (!file) throw new RequestError(404, '文件授权已失效,请重新读取对话。');
242
- let stat;
243
- try { stat = fs.statSync(file.path); } catch { throw new RequestError(404, '电脑上的文件已不存在。'); }
244
- if (!stat.isFile() || stat.size !== file.size || stat.mtimeMs !== file.mtimeMs) {
245
- fileCache.delete(fileMatch[1]);
246
- throw new RequestError(409, '电脑上的文件已发生变化,请重新读取对话。');
293
+ if (file.externalIdentity) await dotsImages.authorization(file.externalIdentity);
294
+ const { offset, length } = fileRange(url.searchParams, file.size, file.revision);
295
+ let handle;
296
+ try { handle = fs.openSync(file.path, 'r'); }
297
+ catch (error) {
298
+ if (error.code === 'ENOENT') throw new RequestError(404, '电脑上的文件已不存在。');
299
+ throw error;
247
300
  }
248
- if (stat.size > MAX_ATTACHMENT_BYTES) throw new RequestError(413, '暂只支持下载不超过 16 MiB 的文件。');
249
- sendJson(response, 200, { name: file.name, size: file.size, dataBase64: fs.readFileSync(file.path).toString('base64') });
301
+ try {
302
+ const validate = () => {
303
+ const stat = fs.fstatSync(handle);
304
+ if (!stat.isFile() || fileRevision(stat) !== file.revision || fileRevision(fs.statSync(file.path)) !== file.revision) {
305
+ fileCache.delete(fileMatch[1]);
306
+ throw new RequestError(409, '电脑上的文件已发生变化,请重新读取对话。');
307
+ }
308
+ if (stat.size > MAX_FILE_DOWNLOAD_BYTES) throw new RequestError(413, '文件超过下载上限(20 MiB),无法下载。');
309
+ if (!stat.size) throw new RequestError(400, '文件为空,无法下载。');
310
+ };
311
+ validate();
312
+ const data = Buffer.alloc(length);
313
+ let bytesRead = 0;
314
+ while (bytesRead < data.length) {
315
+ const count = fs.readSync(handle, data, bytesRead, data.length - bytesRead, offset + bytesRead);
316
+ if (!count) throw new RequestError(409, '电脑上的文件读取不完整,请重新读取对话。');
317
+ bytesRead += count;
318
+ }
319
+ validate();
320
+ response.filePart({ name: file.name, size: file.size, offset, revision: file.revision }, data);
321
+ } finally { fs.closeSync(handle); }
250
322
  return;
251
323
  }
252
324
 
@@ -257,23 +329,112 @@ async function handleRequest(request, response) {
257
329
  if (taskPath && (url.pathname.endsWith('/takeover') || url.pathname === `/api/threads/${taskPath[1]}`)) {
258
330
  throw new RequestError(410, '此连接方式已停用。', { code: 'unsupported_control' });
259
331
  }
260
- if (url.searchParams.has('hostId') && url.searchParams.get('hostId') !== 'local') {
332
+ const hostId = url.searchParams.get('hostId') || 'local';
333
+ if (!['local', 'durable'].includes(hostId)) {
261
334
  throw new RequestError(410, '此对话的连接方式已停用,请检查 Codex 内核状态。', { code: 'unsupported_control' });
262
335
  }
263
336
  if (!executionHealth.state.ready || sharedServer?.ready !== true) {
264
337
  throw new RequestError(503, 'Codex 内核未就绪。请重启工作站服务;若仍失败,运行 relax doctor。',
265
338
  { code: 'full_control_required' });
266
339
  }
340
+ const rootChat = hostId === 'durable' && taskPath
341
+ && await dotsServer.source(decodeURIComponent(taskPath[1])) === 'aeon';
342
+ const execution = rootChat ? dotsChat : hostId === 'durable' ? dotsServer : sharedServer;
343
+
344
+ const dotsActivityMatch = /^\/api\/threads\/([^/]+)\/dots-activity-events$/.exec(url.pathname);
345
+ if (request.method === 'GET' && dotsActivityMatch) {
346
+ if (!rootChat) throw new RequestError(405, '只有点点主聊天提供此活动面板。');
347
+ response.startEvents();
348
+ const disconnected = () => response.end();
349
+ dotsChat.on('disconnected', disconnected);
350
+ const release = dotsChat.acquirePanelActivity(decodeURIComponent(dotsActivityMatch[1]), value => {
351
+ if (!response.destroyed) response.event('dots-activity', { threadId: value.threadId, activity: value.activity });
352
+ });
353
+ response.once('close', () => { release(); dotsChat.off('disconnected', disconnected); });
354
+ return;
355
+ }
356
+
357
+ const dotsTasksMatch = /^\/api\/threads\/([^/]+)\/dots-tasks$/.exec(url.pathname);
358
+ if (request.method === 'GET' && dotsTasksMatch) {
359
+ if (!rootChat) throw new RequestError(405, '只有点点主聊天提供此任务面板。');
360
+ sendJson(response, 200, await readDotsSummaryTasks(dotsServer, decodeURIComponent(dotsTasksMatch[1])));
361
+ return;
362
+ }
363
+
364
+ const dotsSchedulesMatch = /^\/api\/threads\/([^/]+)\/dots-schedules$/.exec(url.pathname);
365
+ if (request.method === 'GET' && dotsSchedulesMatch) {
366
+ if (!rootChat) throw new RequestError(405, '只有点点主聊天提供此定时任务列表。');
367
+ sendJson(response, 200, await readDotsSchedules(dotsServer, decodeURIComponent(dotsSchedulesMatch[1])));
368
+ return;
369
+ }
370
+
371
+ if (request.method === 'GET' && url.pathname.startsWith('/api/code/')) {
372
+ const operation = url.pathname.slice('/api/code/'.length);
373
+ if (hostId === 'durable' && operation !== 'change') throw new RequestError(405, 'Dots 委派任务尚未接入代码浏览。');
374
+ try {
375
+ if ((operation === 'image' || operation === 'binary') && !request.fileTransfer) throw new RequestError(400, '请通过文件通道读取预览文件。');
376
+ const result = await browseCode((method, params) => execution.request(method, params), operation, url.searchParams);
377
+ if (operation === 'image' || operation === 'binary') {
378
+ const { data, ...metadata } = result;
379
+ response.filePart(metadata, data);
380
+ } else sendJson(response, 200, result);
381
+ } catch (error) { throw new RequestError(error.statusCode || 502, error.message); }
382
+ return;
383
+ }
267
384
 
268
385
  if (request.method === 'GET' && url.pathname === '/api/models') {
269
- sendJson(response, 200, await sharedServer.models());
386
+ sendJson(response, 200, await execution.models());
270
387
  return;
271
388
  }
272
389
 
273
390
  if (request.method === 'GET' && url.pathname === '/api/threads') {
274
391
  const limit = boundedInteger(url.searchParams.get('limit'), 20, 1, 200);
275
392
  const archived = url.searchParams.get('archived') === 'true';
276
- sendJson(response, 200, await sharedCatalog.threads(limit, archived));
393
+ const scope = url.searchParams.get('scope') || 'all';
394
+ if (!['all', 'local', 'durable'].includes(scope)) { sendJson(response, 400, { error: '无效的会话列表范围' }); return; }
395
+ sendJson(response, 200, await sharedCatalog.threads(limit, archived, scope, url.searchParams.get('refresh') === '1'));
396
+ return;
397
+ }
398
+
399
+ if (request.method === 'GET' && url.pathname === '/api/file-local-copy') {
400
+ const value = url.searchParams.get('source') || '';
401
+ const id = /^([a-f0-9]{64})(?:\?recovery=[A-Za-z0-9_-]{1,4096})?$/.exec(value)?.[1];
402
+ if (id && (fileCache.has(id) || attachmentCache.has(id) && !attachmentCache.get(id).dotsFile)) {
403
+ sendJson(response, 200, { external: false, localCopy: null }); return;
404
+ }
405
+ const asset = await externalDownloadAsset(value, url.searchParams.get('recovery'));
406
+ const copy = await workstationDownloads.copy(asset.id);
407
+ sendJson(response, 200, { external: true, localCopy: copy ? workstationCopyReference(copy, asset) : null,
408
+ cachedContent: asset.buffer ? { remotePath: asset.id, name: asset.name, size: asset.buffer.length, mimeType: asset.mimeType } : null });
409
+ return;
410
+ }
411
+
412
+ if (request.method === 'GET' && url.pathname === '/api/file-save-locations') {
413
+ await requireExecutionWritable();
414
+ const locations = await projectLocations();
415
+ const projects = (await sharedCatalog.projects()).projects.filter(project => project.hostId === 'local' && project.roots?.length);
416
+ sendJson(response, 200, { projects, downloads: locations.downloads });
417
+ return;
418
+ }
419
+
420
+ if (request.method === 'GET' && url.pathname === '/api/file-save-folders') {
421
+ await requireExecutionWritable();
422
+ sendJson(response, 200, await workstationDownloads.browse((method, params) => sharedServer.request(method, params),
423
+ await projectLocations(), Object.fromEntries(url.searchParams)));
424
+ return;
425
+ }
426
+
427
+ if (request.method === 'POST' && url.pathname === '/api/file-saves') {
428
+ await requireExecutionWritable();
429
+ const body = await readJsonBody(request);
430
+ const asset = await externalDownloadAsset(body.source, body.recovery);
431
+ const controller = new AbortController(), cancel = () => controller.abort();
432
+ response.once('close', cancel);
433
+ try {
434
+ const result = await workstationDownloads.save((method, params) => sharedServer.request(method, params),
435
+ await projectLocations(), body, asset, (file, query) => dotsImages.readPart(file, query), controller.signal);
436
+ sendJson(response, 201, { localCopy: workstationCopyReference(result, asset), warning: result.warning || '' });
437
+ } finally { response.off('close', cancel); }
277
438
  return;
278
439
  }
279
440
 
@@ -282,11 +443,45 @@ async function handleRequest(request, response) {
282
443
  return;
283
444
  }
284
445
 
446
+ if (url.pathname === '/api/project-folders' && ['GET', 'POST'].includes(request.method)) {
447
+ await requireExecutionWritable();
448
+ try {
449
+ const rpc = (method, params) => sharedServer.request(method, params);
450
+ const result = request.method === 'GET'
451
+ ? await browseProjectFolders(rpc, await projectLocations(), url.searchParams.get('path'))
452
+ : await createProjectFolder(rpc, await readJsonBody(request));
453
+ sendJson(response, request.method === 'GET' ? 200 : 201, result);
454
+ } catch (error) { throw new RequestError(error.statusCode || 502, error.message); }
455
+ return;
456
+ }
457
+
458
+ const updateProjectMatch = /^\/api\/projects\/([^/]+)$/.exec(url.pathname);
459
+ if (request.method === 'POST' && (url.pathname === '/api/projects' || updateProjectMatch)) {
460
+ await requireExecutionWritable();
461
+ const body = await readJsonBody(request);
462
+ try {
463
+ const result = !updateProjectMatch ? await createWorkstationProject(sharedServer, stateRoot, codexDataRoot, body)
464
+ : await saveProject((method, params) => sharedServer.request(method, params),
465
+ await projectLocations(), body, updateProjectMatch ? decodeURIComponent(updateProjectMatch[1]) : null);
466
+ sendJson(response, updateProjectMatch ? 200 : 201, result);
467
+ } catch (error) { throw new RequestError(error.statusCode || 502, error.message, error.details); }
468
+ return;
469
+ }
470
+
285
471
  if (request.method === 'GET' && url.pathname === '/api/usage-limits') {
286
472
  sendJson(response, 200, await sharedServer.usage());
287
473
  return;
288
474
  }
289
475
 
476
+ if (request.method === 'POST' && url.pathname === '/api/usage-reset') {
477
+ await requireExecutionWritable();
478
+ const body = await readJsonBody(request);
479
+ try { sendJson(response, 200, await consumeUsageReset(sharedServer, body)); }
480
+ catch (error) { throw new RequestError(error.statusCode || 502, error.message,
481
+ error.code === 'usage_reset_not_submitted' ? { code: error.code, deliveryUnknown: false } : undefined); }
482
+ return;
483
+ }
484
+
290
485
  if (request.method === 'GET' && url.pathname === '/api/usage-events') {
291
486
  const execution = sharedServer;
292
487
  response.startEvents();
@@ -299,9 +494,37 @@ async function handleRequest(request, response) {
299
494
  return;
300
495
  }
301
496
 
302
- if (request.method === 'GET' && url.pathname === '/api/turn-events') {
303
- const execution = sharedServer;
304
- response.startEvents();
497
+ if (request.method === 'GET' && url.pathname === '/api/thread-events') {
498
+ await sharedServer.connect();
499
+ const clients = [sharedServer, dotsServer, dotsChat];
500
+ response.startEvents();
501
+ const listener = ({ method, params }) => {
502
+ if (!threadListNotificationMethods.has(method) || response.destroyed) return;
503
+ response.event('thread-list-changed', { method, threadId: params.threadId || params.thread?.id || '',
504
+ ...(params.hostId ? { hostId: params.hostId } : {}),
505
+ ...(params.chatRevision !== undefined ? { chatRevision: params.chatRevision } : {}),
506
+ ...(params.updatedAt !== undefined ? { updatedAt: params.updatedAt } : {}),
507
+ ...(params.chatMessageRevision !== undefined ? { chatMessageRevision: params.chatMessageRevision } : {}),
508
+ ...(params.status ? { status: typeof params.status === 'string' ? params.status : params.status.type } : {}) });
509
+ };
510
+ const disconnected = () => response.end();
511
+ for (const client of clients) {
512
+ client.on('notification', listener);
513
+ client.on('disconnected', disconnected);
514
+ }
515
+ response.once('close', () => {
516
+ for (const client of clients) {
517
+ client.off('notification', listener);
518
+ client.off('disconnected', disconnected);
519
+ }
520
+ });
521
+ response.event('ready', {});
522
+ return;
523
+ }
524
+
525
+ if (request.method === 'GET' && url.pathname === '/api/turn-events') {
526
+ const clients = [sharedServer, dotsServer, dotsChat];
527
+ response.startEvents();
305
528
  const listener = ({ method, params }) => {
306
529
  if (!['turn/started', 'turn/completed'].includes(method)) return;
307
530
  const threadId = params?.threadId, turnId = params?.turn?.id;
@@ -310,24 +533,29 @@ async function handleRequest(request, response) {
310
533
  }
311
534
  };
312
535
  const disconnected = () => response.end();
313
- execution.on('notification', listener);
314
- execution.on('disconnected', disconnected);
315
- response.once('close', () => {
316
- execution.off('notification', listener);
317
- execution.off('disconnected', disconnected);
536
+ for (const client of clients) {
537
+ client.on('notification', listener);
538
+ client.on('disconnected', disconnected);
539
+ }
540
+ response.once('close', () => {
541
+ for (const client of clients) {
542
+ client.off('notification', listener);
543
+ client.off('disconnected', disconnected);
544
+ }
318
545
  });
319
546
  return;
320
547
  }
321
548
 
322
549
  const activeTurnMatch = /^\/api\/threads\/([^/]+)\/active-turn$/.exec(url.pathname);
323
550
  if (request.method === 'GET' && activeTurnMatch) {
551
+ if (rootChat) { sendJson(response, 200, { turnId: null }); return; }
324
552
  const threadId = decodeURIComponent(activeTurnMatch[1]);
325
- await sharedServer.connect();
326
- const { thread } = await sharedServer.request('thread/read', { threadId, includeTurns: false });
553
+ await execution.connect();
554
+ const { thread } = await execution.request('thread/read', { threadId, includeTurns: false });
327
555
  const status = typeof thread.status === 'string' ? thread.status : thread.status?.type;
328
556
  let turnId = null;
329
557
  if (status === 'active') {
330
- const page = await sharedServer.request('thread/turns/list', {
558
+ const page = await execution.request('thread/turns/list', {
331
559
  threadId, limit: 1, sortDirection: 'desc', itemsView: 'summary',
332
560
  });
333
561
  const latest = page.data?.[0];
@@ -354,11 +582,12 @@ async function handleRequest(request, response) {
354
582
  let target = appServerEvents.projectlessThreadTarget(projectRoot);
355
583
  if (project) {
356
584
  const nativeProjects = await appServerEvents.readProjects(rpc);
357
- const nativeId = nativeProjects.find(item => item.id === project.projectId)?.id;
358
- if (!nativeId) throw new RequestError(404, 'Codex 内核中没有找到对应项目,请刷新项目列表。');
359
- target = { projectId:nativeId, cwd:project.path };
585
+ const nativeProject = nativeProjects.find(item => item.id === project.projectId);
586
+ if (!nativeProject) throw new RequestError(404, 'Codex 内核中没有找到对应项目,请刷新项目列表。');
587
+ target = appServerEvents.projectThreadTarget(nativeProject);
360
588
  }
361
589
  const { input } = appServerTurnArguments('', body);
590
+ messageSubmissionStarted = true;
362
591
  const created = await appServerEvents.createThread(rpc, target, input, body);
363
592
  const prompt = input.filter(part => part.type === 'text').map(part => part.text).join('\n');
364
593
  void generateAndSetThreadTitle({
@@ -376,13 +605,24 @@ async function handleRequest(request, response) {
376
605
  }
377
606
 
378
607
  const timelineMatch = /^\/api\/threads\/([^/]+)\/timeline$/.exec(url.pathname);
379
- if (request.method === 'GET' && timelineMatch) {
380
- const threadId = decodeURIComponent(timelineMatch[1]);
381
- const execution = sharedServer;
382
- const timeline = normalizeSharedTimeline(await execution.timeline(threadId, sharedHistoryPageOptions()));
383
- if (request.socketRequest) {
384
- response.on('progress', () => execution.keepSnapshotAlive(threadId, timeline.sync));
385
- response.once('close', () => execution.keepSnapshotAlive(threadId, timeline.sync));
608
+ if (request.method === 'GET' && timelineMatch) {
609
+ const threadId = decodeURIComponent(timelineMatch[1]);
610
+ const knownSeq = url.searchParams.get('knownSeq');
611
+ const knownSync = { id: url.searchParams.get('knownSyncId'),
612
+ epoch: url.searchParams.get('knownEpoch'), seq: knownSeq === null ? undefined : Number(knownSeq),
613
+ updatedAt: Number(url.searchParams.get('knownUpdatedAt')) };
614
+ const pageOptions = sharedHistoryPageOptions();
615
+ pageOptions.reconcileObserved = url.searchParams.get('refresh') === '1';
616
+ if (request.socketRequest) {
617
+ pageOptions.onWindow = async window => {
618
+ if (response.writableEnded) return;
619
+ await response.historyPage(await prepareSharedTimeline(window));
620
+ };
621
+ }
622
+ const timeline = await prepareSharedTimeline(await execution.timeline(threadId, pageOptions, knownSync));
623
+ if (request.socketRequest) {
624
+ response.on('progress', () => execution.keepSnapshotAlive(threadId, timeline.sync));
625
+ response.once('close', () => execution.keepSnapshotAlive(threadId, timeline.sync));
386
626
  }
387
627
  sendJson(response, 200, timeline);
388
628
  return;
@@ -391,19 +631,20 @@ async function handleRequest(request, response) {
391
631
  const approvalMatch = /^\/api\/threads\/([^/]+)\/approvals$/.exec(url.pathname);
392
632
  if (request.method === 'POST' && approvalMatch) {
393
633
  const threadId = decodeURIComponent(approvalMatch[1]);
394
- await requireExecutionWritable(threadId);
634
+ await requireExecutionWritable(threadId, execution);
395
635
  const body = await readJsonBody(request);
396
- sendJson(response, 200, await sharedServer.respondApproval(threadId, body)); return;
636
+ sendJson(response, 200, await execution.respondApproval(threadId, body)); return;
397
637
  }
398
638
 
399
639
  const eventsMatch = /^\/api\/threads\/([^/]+)\/events$/.exec(url.pathname);
400
640
  if (request.method === 'GET' && eventsMatch) {
401
641
  const threadId = decodeURIComponent(eventsMatch[1]);
402
642
  const after = Number(url.searchParams.get('afterSeq'));
403
- const requestedSync = url.searchParams.get('syncId') && url.searchParams.get('syncEpoch') && Number.isSafeInteger(after) && after >= 0
404
- ? { id: url.searchParams.get('syncId'), epoch: url.searchParams.get('syncEpoch'), after } : undefined;
405
- await streamSharedThread(response, threadId, sharedServer,
406
- normalizeSharedTimeline, requestedSync);
643
+ const requestedSync = { id: url.searchParams.get('syncId'), epoch: url.searchParams.get('syncEpoch'),
644
+ after: Number.isSafeInteger(after) && after >= 0 ? after : undefined,
645
+ updatedAt: Number(url.searchParams.get('knownUpdatedAt')) };
646
+ await streamSharedThread(response, threadId, execution,
647
+ prepareSharedTimeline, requestedSync);
407
648
  return;
408
649
  }
409
650
 
@@ -411,14 +652,14 @@ async function handleRequest(request, response) {
411
652
  if (request.method === 'GET' && modelChangesMatch) {
412
653
  const threadId = decodeURIComponent(modelChangesMatch[1]);
413
654
  if (!/^[0-9a-f]{8}-[0-9a-f-]{20,55}$/i.test(threadId)) throw new RequestError(400, '对话 ID 无效。');
414
- sendJson(response, 200, await turnModelHistory.read(findSessionPath(threadId), (url.searchParams.get('turnIds') || '').split(',').filter(Boolean)));
655
+ sendJson(response, 200, hostId === 'durable' ? [] : await turnModelHistory.read(findSessionPath(threadId), (url.searchParams.get('turnIds') || '').split(',').filter(Boolean)));
415
656
  return;
416
657
  }
417
658
  const contextMatch = /^\/api\/threads\/([^/]+)\/context$/.exec(url.pathname);
418
659
  if (request.method === 'GET' && contextMatch) {
419
660
  const threadId = decodeURIComponent(contextMatch[1]);
420
- const current = readContextUsage(threadId);
421
- const native = await sharedServer.readContext(threadId);
661
+ const current = hostId === 'durable' ? { available: false } : readContextUsage(threadId);
662
+ const native = await execution.readContext(threadId, url.searchParams.get('refreshSettings') === 'true');
422
663
  sendJson(response, 200, { ...current, ...(native.model ? { model: native.model, thinking: native.thinking, serviceTier: native.serviceTier,
423
664
  permissionMode: native.permissionMode, permissionModes: native.permissionModes } : {}), ...(native.available ? native : {}) });
424
665
  return;
@@ -428,34 +669,45 @@ async function handleRequest(request, response) {
428
669
  if (sharedAction) {
429
670
  const id = decodeURIComponent(sharedAction[1]);
430
671
  if (request.method === 'GET' && sharedAction[2] === 'history') {
431
- sendJson(response, 200, normalizeSharedTimeline(await sharedServer.history(id, url.searchParams.get('cursor'), sharedHistoryPageOptions())));
672
+ const pageOptions = sharedHistoryPageOptions();
673
+ if (url.searchParams.has('itemLimit')) {
674
+ const itemLimit = Number(url.searchParams.get('itemLimit'));
675
+ if (!Number.isInteger(itemLimit) || itemLimit < 1 || itemLimit > 100) throw new RequestError(400, '历史条目数量无效。');
676
+ pageOptions.itemLimit = itemLimit;
677
+ }
678
+ const history = await execution.history(id, url.searchParams.get('cursor'), pageOptions);
679
+ sendJson(response, 200, await prepareSharedTimeline(history, hostId));
432
680
  return;
433
681
  }
434
682
  if (request.method === 'POST' && sharedAction[2] === 'settings') {
435
- await requireExecutionWritable(id);
683
+ if (rootChat) throw new RequestError(405, 'Dots 主对话不提供 Codex 模型设置。');
684
+ await requireExecutionWritable(id, execution);
436
685
  const body = await readJsonBody(request);
437
- sendJson(response, 200, await sharedServer.settings(id, body.model, body.thinking, body.serviceTier));
686
+ sendJson(response, 200, await execution.settings(id, body.model, body.thinking, body.serviceTier));
438
687
  return;
439
688
  }
440
689
  if (request.method === 'POST' && sharedAction[2] === 'permissions') {
441
- await requireExecutionWritable(id);
690
+ if (rootChat) throw new RequestError(405, 'Dots 主对话不提供 Codex 权限设置。');
691
+ await requireExecutionWritable(id, execution);
442
692
  const body = await readJsonBody(request);
443
- sendJson(response, 200, await sharedServer.permissions(id, body.mode));
693
+ sendJson(response, 200, await execution.permissions(id, body.mode));
444
694
  return;
445
695
  }
446
696
  }
447
697
 
448
698
  const archiveMatch = /^\/api\/threads\/([^/]+)\/(archive|unarchive)$/.exec(url.pathname);
449
699
  if (request.method === 'POST' && archiveMatch) {
700
+ if (rootChat) throw new RequestError(405, 'Dots 主对话不提供任务归档。');
450
701
  const threadId = decodeURIComponent(archiveMatch[1]);
451
- await requireExecutionWritable(threadId);
452
- await sharedServer.request(`thread/${archiveMatch[2]}`, { threadId });
702
+ await requireExecutionWritable(threadId, execution);
703
+ await execution.request(`thread/${archiveMatch[2]}`, { threadId });
453
704
  sendJson(response, 200, { threadId, archived: archiveMatch[2] === 'archive' });
454
705
  return;
455
706
  }
456
707
 
457
708
  const queueMatch = /^\/api\/threads\/([^/]+)\/queue$/.exec(url.pathname);
458
709
  if (queueMatch) {
710
+ if (hostId === 'durable') throw new RequestError(405, '当前托管连接不提供排队消息。');
459
711
  const threadId = decodeURIComponent(queueMatch[1]);
460
712
  await sharedServer.connect();
461
713
  const rpc = (method, params) => sharedServer.request(method, params);
@@ -470,6 +722,7 @@ async function handleRequest(request, response) {
470
722
 
471
723
  const queueItemMatch = /^\/api\/threads\/([^/]+)\/queue\/([a-f0-9-]+)(?:\/(steer))?$/.exec(url.pathname);
472
724
  if (queueItemMatch) {
725
+ if (hostId === 'durable') throw new RequestError(405, '当前托管连接不提供排队消息。');
473
726
  const threadId = decodeURIComponent(queueItemMatch[1]);
474
727
  const itemId = queueItemMatch[2];
475
728
  await sharedServer.connect();
@@ -481,23 +734,43 @@ async function handleRequest(request, response) {
481
734
 
482
735
  const interruptMatch = /^\/api\/threads\/([^/]+)\/interrupt$/.exec(url.pathname);
483
736
  if (request.method === 'POST' && interruptMatch) {
737
+ if (rootChat) throw new RequestError(405, 'Dots 主对话不提供停止 Codex 任务的操作。');
484
738
  const threadId = decodeURIComponent(interruptMatch[1]);
485
- await requireExecutionWritable(threadId);
739
+ await requireExecutionWritable(threadId, execution);
486
740
  const body = await readJsonBody(request);
487
741
  if (!body.turnId) throw new RequestError(400, '缺少活动轮次 ID。');
488
- await sharedServer.interrupt(threadId, body.turnId);
742
+ await execution.interrupt(threadId, body.turnId);
489
743
  sendJson(response, 200, { requested: true });
490
744
  return;
491
745
  }
492
746
 
747
+ const roomReadMatch = /^\/api\/threads\/([^/]+)\/read$/.exec(url.pathname);
748
+ if (request.method === 'POST' && roomReadMatch) {
749
+ if (!rootChat) throw new RequestError(405, '此对话不提供消息房间已读操作。');
750
+ await dotsChat.markRead(decodeURIComponent(roomReadMatch[1]), await readJsonBody(request));
751
+ sendJson(response, 200, { success: true });
752
+ return;
753
+ }
754
+
755
+ const messageStatusMatch = /^\/api\/threads\/([^/]+)\/messages\/([a-f0-9]{32})\/status$/.exec(url.pathname);
756
+ if (request.method === 'GET' && messageStatusMatch) {
757
+ sendJson(response, 200, messageReceipts.status([hostId, decodeURIComponent(messageStatusMatch[1])], messageStatusMatch[2]));
758
+ return;
759
+ }
493
760
  const messageMatch = /^\/api\/threads\/([^/]+)\/messages$/.exec(url.pathname);
494
761
  if (request.method === 'POST' && messageMatch) {
495
762
  const threadId = decodeURIComponent(messageMatch[1]);
496
- await requireExecutionWritable(threadId);
497
763
  const body = await readJsonBody(request);
498
- if (body.hostId && body.hostId !== 'local') throw new RequestError(410, '此对话的连接方式已停用。', { code: 'unsupported_control' });
499
- const submitted = await sharedServer.send(appServerTurnArguments(threadId, body));
500
- sendJson(response, 200, { success: true, data: submitted.result, usage: submitted.usage });
764
+ if (body.hostId && body.hostId !== hostId) throw new RequestError(400, '请求中的对话主机不一致。');
765
+ const result = await messageReceipts.submit([hostId, threadId], body.clientUserMessageId, async () => {
766
+ let args;
767
+ try { await requireExecutionWritable(threadId, execution); args = execution === dotsChat ? { ...body, threadId } : appServerTurnArguments(threadId, body); }
768
+ catch (error) { error.deliveryUnknown = false; throw error; }
769
+ messageSubmissionStarted = true;
770
+ const submitted = await execution.send(args);
771
+ return { success: true, data: submitted.result, usage: submitted.usage };
772
+ });
773
+ sendJson(response, 200, result);
501
774
  return;
502
775
  }
503
776
 
@@ -507,13 +780,15 @@ async function handleRequest(request, response) {
507
780
  if (!response.writableEnded) response.end();
508
781
  return;
509
782
  }
510
- const status = error instanceof RequestError || error instanceof ThreadWriterConflictError ? (error.statusCode || 409) : 502;
783
+ if (!messageSubmissionStarted && error.deliveryUnknown === undefined && request.method === 'POST'
784
+ && /^\/api\/threads\/[^/]+\/messages(?:\?|$)/.test(request.url || '')) error.deliveryUnknown = false;
785
+ const status = error instanceof RequestError || error instanceof ThreadWriterConflictError || error instanceof FileRangeError ? (error.statusCode || 409) : 502;
511
786
  sendJson(response, status, {
512
787
  error: error instanceof Error ? error.message : String(error),
513
788
  code: error?.code,
514
789
  threadId: error?.threadId,
515
790
  readOnly: error?.readOnly,
516
- details: error?.details,
791
+ details: error?.deliveryUnknown === undefined ? error?.details : { ...error?.details, deliveryUnknown: error.deliveryUnknown },
517
792
  });
518
793
  }
519
794
  }
@@ -521,8 +796,8 @@ async function handleRequest(request, response) {
521
796
  const server = http.createServer(handleRequest);
522
797
  const lanSocket = attachLanSocket(server, { token: options.token, dispatch: handleRequest,
523
798
  uploadsRoot,
524
- authorizeDevice: (clientId, credential) => authorizeLanDevice(stateRoot, clientId, credential),
525
- onDeviceAuthorizationsChanged: listener => onLanAuthorizationsChanged(stateRoot, listener),
799
+ authorizeDevice: (clientId, credential) => authorizeLanDevice(stateRoot, clientId, credential),
800
+ onDeviceAuthorizationsChanged: listener => onLanAuthorizationsChanged(stateRoot, listener),
526
801
  });
527
802
 
528
803
  let control;
@@ -548,6 +823,7 @@ server.listen(options.port, options.lan ? '0.0.0.0' : '127.0.0.1', async () => {
548
823
  const state = managedAppServer.snapshot();
549
824
  return state.launcherRestartRequired ? managedAppServer.restart(crypto.randomUUID()) : state;
550
825
  },
826
+ 'restart-managed-app-server-confirmed': () => managedAppServer.restart(crypto.randomUUID()),
551
827
  } });
552
828
  writeRecord(process.env.CODEX_REMOTE_READY_PATH, { instanceId: process.env.CODEX_REMOTE_INSTANCE,
553
829
  pid: process.pid, controlPort: control.port });
@@ -571,6 +847,8 @@ async function shutDown() {
571
847
  executionHealth?.stop();
572
848
  runtimeDiscovery.stop();
573
849
  await relayManager.close();
850
+ dotsServer.close();
851
+ dotsChat.close();
574
852
  managedAppServer.close();
575
853
  await control?.close();
576
854
  process.exit(0);
@@ -713,6 +991,14 @@ function messagePayload(body) {
713
991
  if (!fs.existsSync(file)) throw new RequestError(400, '图片不存在,请重新添加。');
714
992
  return file;
715
993
  });
994
+ for (const input of fileMentions.originalInputs(body)) {
995
+ const incoming = fs.realpathSync(path.join(uploadsRoot, 'incoming'));
996
+ const imagePath = fs.realpathSync(input.path);
997
+ if (path.dirname(imagePath).toLowerCase() !== incoming.toLowerCase()) throw new RequestError(400, '原图必须来自上传目录。');
998
+ const stat = fs.statSync(imagePath);
999
+ if (!stat.isFile() || stat.size <= 0 || stat.size > MAX_ATTACHMENT_BYTES) throw new RequestError(400, '原图为空或超过 20 MiB。');
1000
+ images.push(imagePath);
1001
+ }
716
1002
  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。');
717
1003
  if (!prompt && !images.length) throw new RequestError(400, 'prompt 不能为空。');
718
1004
  if (prompt.length > 32_000) throw new RequestError(400, 'prompt 不能超过 32000 个字符。');
@@ -720,9 +1006,7 @@ function messagePayload(body) {
720
1006
  const payload = { prompt, images };
721
1007
  if (typeof body.hostId === 'string' && body.hostId.trim()) payload.hostId = body.hostId.trim();
722
1008
  if (typeof body.model === 'string' && body.model.trim()) {
723
- const model = body.model.trim();
724
- if (!ALLOWED_MODELS.has(model)) throw new RequestError(400, '不支持的模型。');
725
- payload.model = model;
1009
+ payload.model = body.model.trim();
726
1010
  }
727
1011
  if (typeof body.thinking === 'string' && body.thinking.trim()) {
728
1012
  const thinking = body.thinking.trim();
@@ -759,13 +1043,36 @@ function normalizeTimeline(data) {
759
1043
  };
760
1044
  }
761
1045
 
762
- function normalizeSharedTimeline({ thread, nextCursor, sync }) {
763
- const timeline = normalizeTimeline({ thread: { ...thread, title: thread.name || thread.title || '', hostId: 'local', transport: 'shared-app-server' } });
1046
+ function normalizeSharedTimeline({ thread, nextCursor, sync, notModified, incremental }, hostId = thread?.hostId || 'local') {
1047
+ const timeline = normalizeTimeline({ thread: { ...thread, title: thread.name || thread.title || '', hostId,
1048
+ transport: 'shared-app-server' } });
764
1049
  return { ...timeline,
765
- historyView: 'item-pages-v1',
1050
+ historyItemCount: (thread.turns || []).reduce((count, turn) => count + (turn.roomMessage ? 1 : turn.items?.length || 0), 0),
1051
+ historyView: thread?.threadSource === 'aeon' ? 'chat-items-v1' : 'item-pages-v1',
1052
+ ...(notModified ? { notModified: true } : {}),
1053
+ ...(incremental ? { incremental: true } : {}),
766
1054
  ...(nextCursor !== undefined ? { nextCursor } : {}), ...(sync ? { sync } : {}) };
767
1055
  }
768
1056
 
1057
+ async function prepareSharedTimeline(value, hostId) {
1058
+ if (value.thread?.threadSource !== 'aeon') return normalizeSharedTimeline(value, hostId);
1059
+ const epoch = dotsChat.epoch, threadId = value.thread.id;
1060
+ const current = () => {
1061
+ if (epoch !== dotsChat.epoch) throw new Error('点点连接已变化,请重新同步。');
1062
+ };
1063
+ if (value.notModified) {
1064
+ const record = dotsChat.threads.get(threadId);
1065
+ if (await dotsImages.prepareTimeline(record?.thread)) {
1066
+ current();
1067
+ value = dotsChat.snapshot(threadId, record);
1068
+ }
1069
+ }
1070
+ const snapshot = structuredClone(value);
1071
+ await dotsImages.prepareTimeline(snapshot.thread);
1072
+ current();
1073
+ return normalizeSharedTimeline(snapshot, hostId);
1074
+ }
1075
+
769
1076
  function normalizeThread(thread) {
770
1077
  const source = thread && typeof thread === 'object' ? thread : {};
771
1078
  return {
@@ -777,6 +1084,10 @@ function normalizeThread(thread) {
777
1084
  projectId: source.projectId || '',
778
1085
  cwd: source.cwd || '',
779
1086
  hostId: source.hostId || '',
1087
+ threadSource: source.threadSource || '',
1088
+ ...(['aeon', 'aeon_child'].includes(source.threadSource) ? { chatRevision: source.chatRevision || '' } : {}),
1089
+ ...(source.threadSource === 'aeon' ? { avatar: source.avatar || '', color: source.color || '',
1090
+ chatMessageRevision: source.chatMessageRevision, messagingRoomId: source.messagingRoomId, lastMessageAt: source.lastMessageAt } : {}),
780
1091
  transport: source.transport || 'shared-app-server',
781
1092
  readOnly: source.readOnly === true,
782
1093
  readOnlyReason: source.readOnlyReason || '',
@@ -798,7 +1109,12 @@ function normalizeTurn(turn) {
798
1109
  completedAt: source.completedAt || null,
799
1110
  durationMs: source.durationMs ?? null,
800
1111
  error: source.error ?? null,
1112
+ runtimeNotice: source.runtimeNotice ?? null,
801
1113
  ...(source.itemPage ? { itemPage: source.itemPage } : {}),
1114
+ ...(source.roomMessage ? { roomMessage: true } : {}),
1115
+ ...(source.deleted ? { deleted: true } : {}),
1116
+ ...(source.historyOnly ? { historyOnly: true } : {}),
1117
+ ...(source.cacheStub ? { cacheStub: true } : {}),
802
1118
  entries,
803
1119
  };
804
1120
  }
@@ -806,6 +1122,10 @@ function normalizeTurn(turn) {
806
1122
  function normalizeItem(item) {
807
1123
  if (!item || typeof item !== 'object') return null;
808
1124
 
1125
+ if (item.type === 'approval') {
1126
+ return { ...item, type: 'activity', kind: 'approval', title: item.params?.message || '需要你的批准' };
1127
+ }
1128
+
809
1129
  if (item.type === 'contextCompaction') {
810
1130
  return {
811
1131
  type: 'activity',
@@ -815,24 +1135,21 @@ function normalizeItem(item) {
815
1135
  };
816
1136
  }
817
1137
 
818
- if (item.type === 'functionCallOutput'
819
- && item.namespace === 'codex_app'
820
- && ['create_thread', 'send_message_to_thread'].includes(item.name)) {
821
- const text = extractDelegatedInput(delegatedOutputText(item.output));
822
- if (text) {
823
- return {
824
- type: 'message',
825
- id: item.id || '',
826
- role: 'user',
827
- source: 'remote',
828
- ...splitUploadedImages(text),
829
- };
830
- }
1138
+ const delegated = appServerEvents.toolDelegation(item);
1139
+ if (delegated) {
1140
+ return {
1141
+ type: 'message', id: item.id || '', role: 'user', source: 'remote',
1142
+ delegation: delegationSource(delegated), ...splitUploadedImages(delegated.input),
1143
+ };
831
1144
  }
832
1145
 
833
1146
  if (item.type === 'userMessage') {
834
- const { text, files } = extractUserText(item.content);
835
- const attachments = [...normalizeAttachments(item.content), ...files];
1147
+ const { text, files, delegation } = extractUserText(item.content);
1148
+ const attachments = [...normalizeAttachments(item.content), ...files.map(file => ({
1149
+ ...file,
1150
+ remotePath: rememberLocalFile(file.path)?.id || '',
1151
+ previewRemotePath: file.previewPath ? rememberLocalFile(file.previewPath)?.id || '' : '',
1152
+ }))];
836
1153
  if (!text && !attachments.length) return null;
837
1154
  return {
838
1155
  clientId: item.clientId,
@@ -841,6 +1158,7 @@ function normalizeItem(item) {
841
1158
  role: 'user',
842
1159
  text,
843
1160
  attachments,
1161
+ ...(delegation ? { delegation: delegationSource(delegation) } : {}),
844
1162
  };
845
1163
  }
846
1164
 
@@ -853,6 +1171,7 @@ function normalizeItem(item) {
853
1171
  delivery: item.delivery,
854
1172
  questions: item.questions,
855
1173
  text: item.text || '',
1174
+ attachments: normalizeAttachments(item.attachments),
856
1175
  imageAssets: markdownImageAssets(item.text || ''),
857
1176
  fileAssets: markdownFileAssets(item.text || ''),
858
1177
  };
@@ -926,7 +1245,7 @@ function normalizeItem(item) {
926
1245
  title: '文件修改',
927
1246
  titleOnly: true,
928
1247
  status: statusText(item.status),
929
- changes: Array.isArray(item.changes) ? item.changes : [],
1248
+ changes: fileChanges.summarize(item.changes),
930
1249
  };
931
1250
  }
932
1251
 
@@ -946,16 +1265,6 @@ function normalizeItem(item) {
946
1265
  const image = typeof item.path === 'string' && item.path
947
1266
  ? normalizeAttachment({ type: 'localImage', path: item.path })
948
1267
  : null;
949
- if (image?.available) {
950
- return {
951
- type: 'message',
952
- id: item.id || '',
953
- role: 'assistant',
954
- phase: 'commentary',
955
- text: '',
956
- attachments: [image],
957
- };
958
- }
959
1268
  return {
960
1269
  type: 'activity',
961
1270
  id: item.id || '',
@@ -968,9 +1277,11 @@ function normalizeItem(item) {
968
1277
  }
969
1278
 
970
1279
  if (item.type === 'imageGeneration') {
1280
+ const result = typeof item.result === 'string' ? item.result.trim() : '';
971
1281
  const image = typeof item.savedPath === 'string' && item.savedPath
972
1282
  ? normalizeAttachment({ type: 'localImage', path: item.savedPath, name: '生成的图片' })
973
- : null;
1283
+ : result ? normalizeAttachment({ type: 'image', name: '生成的图片',
1284
+ url: result.startsWith('data:image/') ? result : `data:image/png;base64,${result}` }) : null;
974
1285
  return {
975
1286
  type: 'activity',
976
1287
  id: item.id || '',
@@ -1045,7 +1356,7 @@ function normalizeAttachments(content) {
1045
1356
  else items.push({ type: 'localImage', path: imagePath });
1046
1357
  }
1047
1358
  const seen = new Set();
1048
- return items.map(normalizeAttachment).filter(item => {
1359
+ return items.map(item => normalizeAttachment(item)).filter(item => {
1049
1360
  if (!item.url) return true;
1050
1361
  if (seen.has(item.url)) return false;
1051
1362
  seen.add(item.url); return true;
@@ -1057,22 +1368,20 @@ function markdownImageAssets(text) {
1057
1368
  function visit(tokens) {
1058
1369
  for (const token of tokens) {
1059
1370
  const value = token.type === 'image' ? token.attrGet('src')
1060
- : token.type === 'link_open' ? token.attrGet('href') : null;
1371
+ : token.type === 'link_open' && !token.meta?.fileCitation ? token.attrGet('href') : null;
1061
1372
  if (value && !Object.hasOwn(assets, value)) {
1062
- let localPath;
1063
- try { localPath = decodeURIComponent(value); } catch { continue; }
1373
+ const localPath = fileCitations.localReference(value)?.path;
1064
1374
  // Only paths actually referenced by Desktop messages become accessible.
1065
1375
  // Do not expose a client-supplied filesystem path endpoint or UNC shares.
1066
- if (/^\/?[a-z]:[\\/]/i.test(localPath)) {
1067
- localPath = localPath.replace(/^\/(?=[a-z]:)/i, '');
1376
+ if (localPath && /^[a-z]:[\\/]/i.test(localPath)) {
1068
1377
  const asset = normalizeAttachment({ type: 'localImage', path: localPath });
1069
1378
  if (asset.available) assets[value] = asset;
1070
1379
  }
1071
1380
  }
1072
- if (token.children) visit(token.children);
1381
+ if (token.children && token.type !== 'image') visit(token.children);
1073
1382
  }
1074
1383
  }
1075
- visit(imageMarkdown.parse(text, {}));
1384
+ visit(imageMarkdown.parse(text, { allowFileCitations: true }));
1076
1385
  return assets;
1077
1386
  }
1078
1387
 
@@ -1083,20 +1392,17 @@ function markdownFileAssets(text) {
1083
1392
  if (token.type === 'link_open') {
1084
1393
  const value = token.attrGet('href');
1085
1394
  if (value && !Object.hasOwn(assets, value)) {
1086
- let localPath;
1087
- try { localPath = decodeURIComponent(value); } catch { localPath = ''; }
1088
- if (/^\/?[a-z]:[\\/]/i.test(localPath)) {
1089
- localPath = localPath.replace(/^\/(?=[a-z]:)/i, '');
1090
- localPath = localPath.replace(/:(\d+)(?::\d+)?$/, '');
1395
+ const localPath = fileCitations.localReference(value)?.path;
1396
+ if (localPath && /^[a-z]:[\\/]/i.test(localPath)) {
1091
1397
  const asset = rememberLocalFile(localPath);
1092
1398
  if (asset) assets[value] = { remotePath: asset.id, name: asset.name, size: asset.size };
1093
1399
  }
1094
1400
  }
1095
1401
  }
1096
- if (token.children) visit(token.children);
1402
+ if (token.children && token.type !== 'image') visit(token.children);
1097
1403
  }
1098
1404
  }
1099
- visit(imageMarkdown.parse(text, {}));
1405
+ visit(imageMarkdown.parse(text, { allowFileCitations: true }));
1100
1406
  return assets;
1101
1407
  }
1102
1408
 
@@ -1104,12 +1410,12 @@ function rememberLocalFile(filePath) {
1104
1410
  try {
1105
1411
  const resolved = path.resolve(filePath);
1106
1412
  const stat = fs.statSync(resolved);
1107
- if (!stat.isFile() || stat.size > MAX_ATTACHMENT_BYTES) return null;
1413
+ if (!stat.isFile()) return null;
1108
1414
  const id = crypto.createHmac('sha256', fileCapabilitySecret)
1109
1415
  .update(resolved).update('\0').update(String(stat.size)).update('\0').update(String(stat.mtimeMs)).digest('hex');
1110
1416
  if (!fileCache.has(id)) {
1111
1417
  while (fileCache.size >= MAX_CACHED_FILES) fileCache.delete(fileCache.keys().next().value);
1112
- fileCache.set(id, { id, path: resolved, name: path.basename(resolved), size: stat.size, mtimeMs: stat.mtimeMs });
1418
+ fileCache.set(id, { id, path: resolved, name: path.basename(resolved), size: stat.size, revision: fileRevision(stat) });
1113
1419
  }
1114
1420
  return fileCache.get(id);
1115
1421
  } catch {
@@ -1140,12 +1446,29 @@ function splitUploadedImages(text) {
1140
1446
  return { text: visibleText, attachments, imageAssets: markdownImageAssets(visibleText) };
1141
1447
  }
1142
1448
 
1143
- function normalizeAttachment(item) {
1449
+ async function externalDownloadAsset(value, recovery = '') {
1450
+ const match = /^([a-f0-9]{64})(?:\?recovery=([A-Za-z0-9_-]{1,4096}))?$/.exec(value || '');
1451
+ if (!match) throw new RequestError(400, '无效的外部文件标识。');
1452
+ const asset = attachmentCache.get(match[1]) || dotsImages.restore(match[1], match[2] || recovery || '');
1453
+ if (!asset?.dotsFile) throw new RequestError(404, '外部文件授权已失效,请重新读取对话。');
1454
+ await dotsImages.authorization(asset.identity);
1455
+ return asset;
1456
+ }
1457
+
1458
+ function workstationCopyReference(copy, asset) {
1459
+ const id = crypto.createHmac('sha256', fileCapabilitySecret).update(JSON.stringify([asset.id, copy.path, copy.revision])).digest('hex');
1460
+ fileCache.set(id, { ...copy, id, externalIdentity: asset.identity });
1461
+ while (fileCache.size > MAX_CACHED_FILES) fileCache.delete(fileCache.keys().next().value);
1462
+ return { remotePath: id, path: copy.path, name: copy.name, size: copy.size, mimeType: copy.mimeType };
1463
+ }
1464
+
1465
+ function normalizeAttachment(item, identity) {
1144
1466
  const name = item.name || item.fileName || (typeof item.path === 'string' ? path.basename(item.path) : '');
1145
1467
  let cached = null;
1146
1468
  const isImage = ['image', 'input_image', 'localImage'].includes(item.type);
1469
+ if (isImage || item.type === 'file') cached = dotsImages.remember(item, identity);
1147
1470
  if (isImage && typeof item.path === 'string') {
1148
- cached = cacheLocalImage(item.path, name);
1471
+ cached = cacheLocalImage(item.path, name) || cached;
1149
1472
  }
1150
1473
  if (!cached) {
1151
1474
  const dataUrl = typeof item.url === 'string' && item.url.startsWith('data:image/')
@@ -1155,10 +1478,12 @@ function normalizeAttachment(item) {
1155
1478
  }
1156
1479
  return {
1157
1480
  type: isImage ? 'image' : item.type || 'attachment',
1158
- name,
1481
+ name: cached?.name || name,
1159
1482
  mimeType: cached?.mimeType || item.mimeType || '',
1483
+ ...(cached?.size ? { size: cached.size } : {}),
1484
+ ...(cached?.dotsFile ? { networkFile: true } : {}),
1160
1485
  available: Boolean(cached),
1161
- remotePath: cached ? cached.id : '',
1486
+ remotePath: cached ? (isImage ? cached.id : `${cached.id}?recovery=${cached.recoveryToken}`) : '',
1162
1487
  recoveryToken: cached?.recoveryToken || '',
1163
1488
  url: '',
1164
1489
  thumbnailUrl: '',
@@ -1275,33 +1600,23 @@ function extractUserText(content) {
1275
1600
  .filter(value => !value.startsWith('The next image is untrusted page evidence'))
1276
1601
  .join('\n\n')
1277
1602
  .trim();
1603
+ const delegation = appServerEvents.parseCodexDelegation(text);
1604
+ if (delegation) text = delegation.input;
1278
1605
  const mentioned = fileMentions.parse(text);
1279
1606
  const imagePaths = new Set([
1280
1607
  ...content.filter(item => ['image', 'input_image', 'localImage'].includes(item?.type) && item.path).map(item => item.path),
1281
1608
  ...[...mentioned.text.matchAll(/<image\b[^>]*\bpath="([^"]+)"[^>]*>/g)].map(match => match[1]),
1282
1609
  ].map(value => path.resolve(value).toLowerCase()));
1283
1610
  text = mentioned.text;
1284
- text = text.replace(/<in-app-browser-context[\s\S]*?<\/in-app-browser-context>/g, '').trim();
1285
1611
  text = text.replace(/<image\b[^>]*\bpath="[^"]+"[^>]*>/g, '').replace(/<\/image>/g, '').trim();
1286
- return { text, files: mentioned.files.filter(file => !imagePaths.has(path.resolve(file.path).toLowerCase())) };
1287
- }
1288
-
1289
- function extractDelegatedInput(value) {
1290
- if (typeof value !== 'string') return '';
1291
- const match = value.match(/<codex_delegation>[\s\S]*?<input>([\s\S]*?)<\/input>[\s\S]*?<\/codex_delegation>/);
1292
- if (!match) return '';
1293
- return match[1]
1294
- .replace(/&lt;/g, '<')
1295
- .replace(/&gt;/g, '>')
1296
- .replace(/&quot;/g, '"')
1297
- .replace(/&#39;/g, "'")
1298
- .replace(/&amp;/g, '&')
1299
- .trim();
1612
+ return { text, files: mentioned.files.filter(file => !imagePaths.has(path.resolve(file.path).toLowerCase())), delegation };
1300
1613
  }
1301
1614
 
1302
- function delegatedOutputText(output) {
1303
- if (typeof output === 'string') return output;
1304
- return output && typeof output.text === 'string' ? output.text : '';
1615
+ function delegationSource(delegation) {
1616
+ const dots = dotsServer.threadSources.get(delegation.sourceThreadId) === 'aeon';
1617
+ return { sourceThreadId: delegation.sourceThreadId, sourceKind: dots ? 'dots' : 'codex',
1618
+ ...(dots ? { sourceName: dotsServer.profileName(delegation.sourceThreadId),
1619
+ sourceAvatar: dotsServer.profileAvatar(delegation.sourceThreadId) } : {}) };
1305
1620
  }
1306
1621
 
1307
1622
  function summaryText(value) {
@@ -1327,7 +1642,7 @@ function sendBuffer(response, statusCode, contentType, value) {
1327
1642
  'Content-Length': value.length,
1328
1643
  'Cache-Control': 'no-store',
1329
1644
  'X-Content-Type-Options': 'nosniff',
1330
- 'Content-Security-Policy': "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src 'self'; img-src 'self' data:",
1645
+ '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:",
1331
1646
  });
1332
1647
  response.end(value);
1333
1648
  }