@walkhi/code-relax 0.1.0-beta.27 → 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 -82
  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 +471 -199
  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
@@ -7,33 +7,48 @@ import { fileURLToPath } from 'node:url';
7
7
  import vm from 'node:vm';
8
8
  import sharp from 'sharp';
9
9
  import { attachLanSocket } from './lan-socket-server.mjs';
10
- import fileMentions from '../shared/file-mentions.cjs';
11
- import fileCitations from '../shared/file-citations.cjs';
10
+ import fileMentions from '../shared/file-mentions.cjs';
11
+ import fileCitations from '../shared/file-citations.cjs';
12
12
  import { validateInlineImage } from './message-images.mjs';
13
13
  import { ThreadWriterConflictError } from './shared-app-server.mjs';
14
14
  import { ManagedAppServer, managedAppServerUrl } from './managed-app-server.mjs';
15
15
  import { RuntimeDiscovery } from './runtime-discovery.mjs';
16
- import { streamSharedThread } from './shared-thread-stream.mjs';
16
+ import { streamSharedThread, threadListNotificationMethods } from './shared-thread-stream.mjs';
17
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';
18
20
  import { runtimePaths } from './platform/windows/runtime-paths.mjs';
19
21
  import { openControl } from './service-control.mjs';
20
22
  import { writeRecord, readRecord } from './service-lifecycle.mjs';
21
23
  import { ExecutionHealth } from './execution-health.mjs';
22
- import { authorizeLanDevice, onLanAuthorizationsChanged } from './self-relay/lan-authorizations.mjs';
24
+ import { authorizeLanDevice, onLanAuthorizationsChanged } from './self-relay/lan-authorizations.mjs';
23
25
  import { createRelayManager } from './self-relay/lifecycle.mjs';
24
26
  import { generateAndSetThreadTitle } from './thread-title-generation.mjs';
25
27
  import { desktopHost } from './platform/windows/desktop-host.mjs';
26
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';
27
36
  import { requestRecovery, recoveryStatus } from './shared-recovery.mjs';
28
37
  import { requestServiceRestart } from './service-restart.mjs';
29
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';
30
46
 
31
47
  const turnModelHistory = new TurnModelHistory();
48
+ const messageReceipts = new messageDelivery.Store();
32
49
 
33
- const MAX_ATTACHMENT_BYTES = 16 * 1024 * 1024;
34
- const MAX_FILE_DOWNLOAD_BYTES = 16 * 1024 * 1024;
35
- // Each Base64 response fits the 8 MiB relay limit; full chunks have no padding.
36
- const FILE_DOWNLOAD_CHUNK_BYTES = 3 * 1024 * 1024;
50
+ const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024;
51
+ const MAX_FILE_DOWNLOAD_BYTES = 20 * 1024 * 1024;
37
52
  const MAX_CACHED_ATTACHMENTS = 32;
38
53
  const MAX_CACHED_FILES = 64;
39
54
  const MAX_CONTEXT_TAIL_BYTES = 4 * 1024 * 1024;
@@ -41,36 +56,18 @@ const MAX_LIVE_ACTIVITY_TAIL_BYTES = 1024 * 1024;
41
56
  const SHARED_HISTORY_PAGE_BYTES = 500 * 1024;
42
57
  const SHARED_HISTORY_PAGE_TURNS = 8;
43
58
  const MAX_SETTINGS_SCAN_BYTES = 64 * 1024 * 1024;
44
- const ALLOWED_MODELS = new Set([
45
- 'gpt-6-astra',
46
- 'gpt-6-sol',
47
- 'gpt-5.6-sol',
48
- 'gpt-5.6-terra',
49
- 'gpt-5.6-luna',
50
- 'gpt-5.5',
51
- 'gpt-5.3-codex-spark',
52
- ]);
53
59
  const ALLOWED_THINKING = new Set(['low', 'medium', 'high', 'xhigh', 'max', 'ultra']);
54
60
 
55
- class RequestError extends Error {
61
+ class RequestError extends Error {
56
62
  constructor(statusCode, message, details) {
57
63
  super(message);
58
64
  this.statusCode = statusCode;
59
65
  this.details = details;
60
66
  this.code = details?.code;
61
67
  }
62
- }
63
-
64
- function downloadOffset(rawOffset, size) {
65
- const offset = Number(rawOffset);
66
- if (!/^(0|[1-9]\d*)$/.test(rawOffset || '') || !Number.isSafeInteger(offset) ||
67
- offset > size || offset % FILE_DOWNLOAD_CHUNK_BYTES !== 0) {
68
- throw new RequestError(400, '无效的文件下载位置。');
69
- }
70
- return offset;
71
- }
72
-
73
- function readRuntimeVersion() {
68
+ }
69
+
70
+ function readRuntimeVersion() {
74
71
  let directory = path.dirname(fileURLToPath(import.meta.url));
75
72
  while (true) {
76
73
  const manifest = readRecord(path.join(directory, 'package.json'));
@@ -87,11 +84,12 @@ const runtimeVersion = readRuntimeVersion();
87
84
  const webRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'web');
88
85
  const projectRoot = os.homedir();
89
86
  const { codexDataRoot, stateRoot, sessionsRoot, uploadsRoot } = runtimePaths();
87
+ const workstationDownloads = new WorkstationDownloads(stateRoot);
90
88
  const markdownItScript = fs.readFileSync(path.join(webRoot, 'vendor', 'markdown-it.umd.min.js'));
91
89
  const markdownSandbox = { atob };
92
90
  vm.runInNewContext(markdownItScript.toString(), markdownSandbox);
93
- const imageMarkdown = markdownSandbox.markdownit({ html: false });
94
- fileCitations.install(imageMarkdown);
91
+ const imageMarkdown = markdownSandbox.markdownit({ html: false });
92
+ fileCitations.install(imageMarkdown);
95
93
  const attachmentCache = new Map();
96
94
  const fileCache = new Map();
97
95
  const fileCapabilitySecret = crypto.randomBytes(32);
@@ -102,6 +100,11 @@ const sessionSettingsCache = new Map();
102
100
  const liveActivityCache = new Map();
103
101
  let sharedServer = null;
104
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 });
105
108
  const runtimeDiscovery = new RuntimeDiscovery({ stateRoot, host: desktopHost, url: managedAppServerUrl });
106
109
  const relayManager = createRelayManager(stateRoot);
107
110
  let sharedCatalog = null, executionMode = 'ordinary';
@@ -109,7 +112,7 @@ function selectSharedExecution(enabled) {
109
112
  const next = enabled ? managedAppServer.client : null;
110
113
  if (sharedServer === next) return;
111
114
  sharedServer = next;
112
- sharedCatalog = sharedServer ? new SharedCatalog(sharedServer) : null;
115
+ sharedCatalog = sharedServer ? new SharedCatalog(sharedServer, dotsServer, dotsChat) : null;
113
116
  }
114
117
  const executionHealth = new ExecutionHealth(async () => {
115
118
  await managedAppServer.ensure();
@@ -128,18 +131,23 @@ async function refreshExecution() {
128
131
  await executionHealth.refresh();
129
132
  return { mode: executionMode };
130
133
  }
131
- async function requireExecutionWritable(threadId) {
134
+ async function requireExecutionWritable(threadId, client = sharedServer) {
132
135
  const execution = await executionHealth.refresh();
133
136
  if (!execution.canWrite) {
134
137
  throw new RequestError(503, 'Codex 内核未就绪。请重启工作站服务;若仍失败,运行 relax doctor。',
135
138
  { code: 'full_control_required', execution });
136
139
  }
137
- if (threadId && sharedServer) sharedServer.requireWritable(threadId);
140
+ if (threadId && client) client.requireWritable(threadId);
138
141
  return execution;
139
142
  }
140
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));
141
147
 
148
+ dotsServer.on('profile-error', error => console.error(error.message));
142
149
  async function handleRequest(request, response) {
150
+ let messageSubmissionStarted = false;
143
151
  try {
144
152
  const url = new URL(request.url || '/', `http://${request.headers.host || 'localhost'}`);
145
153
 
@@ -229,9 +237,19 @@ async function handleRequest(request, response) {
229
237
  return;
230
238
  }
231
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
+
232
249
  const imageMatch = /^\/api\/images\/([a-f0-9]{64})$/.exec(url.pathname);
233
250
  if (request.method === 'GET' && imageMatch) {
234
251
  let image = attachmentCache.get(imageMatch[1])
252
+ || dotsImages.restore(imageMatch[1], url.searchParams.get('recovery') || '')
235
253
  || recoverLocalImage(imageMatch[1], url.searchParams.get('recovery') || '');
236
254
  const contextThreadId = url.searchParams.get('threadId') || '';
237
255
  const contextTurnId = url.searchParams.get('turnId') || '';
@@ -243,53 +261,64 @@ async function handleRequest(request, response) {
243
261
  } catch {}
244
262
  }
245
263
  if (!image) throw new RequestError(404, '这张图片已失效,无法从工作站重新读取。');
246
- if (url.searchParams.get('variant') === 'thumbnail') {
247
- const asset = await attachmentThumbnail(image);
248
- sendJson(response, 200, { url: `data:${asset.mimeType};base64,${asset.buffer.toString('base64')}` });
249
- return;
250
- }
251
- const size = image.buffer.length;
252
- if (size > MAX_ATTACHMENT_BYTES) throw new RequestError(413, '图片超过下载上限(16 MiB),无法下载。');
253
- if (!size) throw new RequestError(400, '图片为空,无法下载。');
254
- const offset = downloadOffset(url.searchParams.get('offset'), size);
255
- const data = image.buffer.subarray(offset, Math.min(size, offset + FILE_DOWNLOAD_CHUNK_BYTES));
256
- sendJson(response, 200, { name: image.name || '图片', size, offset, mimeType: image.mimeType,
257
- dataBase64: data.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));
258
278
  return;
259
279
  }
260
280
 
261
281
  const fileMatch = /^\/api\/files\/([a-f0-9]{64})$/.exec(url.pathname);
262
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
+ }
263
291
  const file = fileCache.get(fileMatch[1]);
264
292
  if (!file) throw new RequestError(404, '文件授权已失效,请重新读取对话。');
265
- const offset = downloadOffset(url.searchParams.get('offset'), file.size);
266
- let handle;
267
- try { handle = fs.openSync(file.path, 'r'); }
268
- catch (error) {
269
- if (error.code === 'ENOENT') throw new RequestError(404, '电脑上的文件已不存在。');
270
- throw error;
271
- }
272
- try {
273
- const validate = () => {
274
- const stat = fs.fstatSync(handle);
275
- if (!stat.isFile() || stat.size !== file.size || stat.mtimeMs !== file.mtimeMs) {
276
- fileCache.delete(fileMatch[1]);
277
- throw new RequestError(409, '电脑上的文件已发生变化,请重新读取对话。');
278
- }
279
- if (stat.size > MAX_FILE_DOWNLOAD_BYTES) throw new RequestError(413, '文件超过下载上限(16 MiB),无法下载。');
280
- if (!stat.size) throw new RequestError(400, '文件为空,无法下载。');
281
- };
282
- validate();
283
- const data = Buffer.alloc(Math.min(FILE_DOWNLOAD_CHUNK_BYTES, file.size - offset));
284
- let bytesRead = 0;
285
- while (bytesRead < data.length) {
286
- const count = fs.readSync(handle, data, bytesRead, data.length - bytesRead, offset + bytesRead);
287
- if (!count) throw new RequestError(409, '电脑上的文件读取不完整,请重新读取对话。');
288
- bytesRead += count;
289
- }
290
- validate();
291
- sendJson(response, 200, { name: file.name, size: file.size, offset, dataBase64: data.toString('base64') });
292
- } finally { fs.closeSync(handle); }
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;
300
+ }
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); }
293
322
  return;
294
323
  }
295
324
 
@@ -300,23 +329,112 @@ async function handleRequest(request, response) {
300
329
  if (taskPath && (url.pathname.endsWith('/takeover') || url.pathname === `/api/threads/${taskPath[1]}`)) {
301
330
  throw new RequestError(410, '此连接方式已停用。', { code: 'unsupported_control' });
302
331
  }
303
- if (url.searchParams.has('hostId') && url.searchParams.get('hostId') !== 'local') {
332
+ const hostId = url.searchParams.get('hostId') || 'local';
333
+ if (!['local', 'durable'].includes(hostId)) {
304
334
  throw new RequestError(410, '此对话的连接方式已停用,请检查 Codex 内核状态。', { code: 'unsupported_control' });
305
335
  }
306
336
  if (!executionHealth.state.ready || sharedServer?.ready !== true) {
307
337
  throw new RequestError(503, 'Codex 内核未就绪。请重启工作站服务;若仍失败,运行 relax doctor。',
308
338
  { code: 'full_control_required' });
309
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
+ }
310
384
 
311
385
  if (request.method === 'GET' && url.pathname === '/api/models') {
312
- sendJson(response, 200, await sharedServer.models());
386
+ sendJson(response, 200, await execution.models());
313
387
  return;
314
388
  }
315
389
 
316
390
  if (request.method === 'GET' && url.pathname === '/api/threads') {
317
391
  const limit = boundedInteger(url.searchParams.get('limit'), 20, 1, 200);
318
392
  const archived = url.searchParams.get('archived') === 'true';
319
- 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); }
320
438
  return;
321
439
  }
322
440
 
@@ -325,11 +443,45 @@ async function handleRequest(request, response) {
325
443
  return;
326
444
  }
327
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
+
328
471
  if (request.method === 'GET' && url.pathname === '/api/usage-limits') {
329
472
  sendJson(response, 200, await sharedServer.usage());
330
473
  return;
331
474
  }
332
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
+
333
485
  if (request.method === 'GET' && url.pathname === '/api/usage-events') {
334
486
  const execution = sharedServer;
335
487
  response.startEvents();
@@ -342,9 +494,37 @@ async function handleRequest(request, response) {
342
494
  return;
343
495
  }
344
496
 
345
- if (request.method === 'GET' && url.pathname === '/api/turn-events') {
346
- const execution = sharedServer;
347
- 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();
348
528
  const listener = ({ method, params }) => {
349
529
  if (!['turn/started', 'turn/completed'].includes(method)) return;
350
530
  const threadId = params?.threadId, turnId = params?.turn?.id;
@@ -353,24 +533,29 @@ async function handleRequest(request, response) {
353
533
  }
354
534
  };
355
535
  const disconnected = () => response.end();
356
- execution.on('notification', listener);
357
- execution.on('disconnected', disconnected);
358
- response.once('close', () => {
359
- execution.off('notification', listener);
360
- 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
+ }
361
545
  });
362
546
  return;
363
547
  }
364
548
 
365
549
  const activeTurnMatch = /^\/api\/threads\/([^/]+)\/active-turn$/.exec(url.pathname);
366
550
  if (request.method === 'GET' && activeTurnMatch) {
551
+ if (rootChat) { sendJson(response, 200, { turnId: null }); return; }
367
552
  const threadId = decodeURIComponent(activeTurnMatch[1]);
368
- await sharedServer.connect();
369
- 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 });
370
555
  const status = typeof thread.status === 'string' ? thread.status : thread.status?.type;
371
556
  let turnId = null;
372
557
  if (status === 'active') {
373
- const page = await sharedServer.request('thread/turns/list', {
558
+ const page = await execution.request('thread/turns/list', {
374
559
  threadId, limit: 1, sortDirection: 'desc', itemsView: 'summary',
375
560
  });
376
561
  const latest = page.data?.[0];
@@ -397,11 +582,12 @@ async function handleRequest(request, response) {
397
582
  let target = appServerEvents.projectlessThreadTarget(projectRoot);
398
583
  if (project) {
399
584
  const nativeProjects = await appServerEvents.readProjects(rpc);
400
- const nativeId = nativeProjects.find(item => item.id === project.projectId)?.id;
401
- if (!nativeId) throw new RequestError(404, 'Codex 内核中没有找到对应项目,请刷新项目列表。');
402
- 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);
403
588
  }
404
589
  const { input } = appServerTurnArguments('', body);
590
+ messageSubmissionStarted = true;
405
591
  const created = await appServerEvents.createThread(rpc, target, input, body);
406
592
  const prompt = input.filter(part => part.type === 'text').map(part => part.text).join('\n');
407
593
  void generateAndSetThreadTitle({
@@ -419,13 +605,24 @@ async function handleRequest(request, response) {
419
605
  }
420
606
 
421
607
  const timelineMatch = /^\/api\/threads\/([^/]+)\/timeline$/.exec(url.pathname);
422
- if (request.method === 'GET' && timelineMatch) {
423
- const threadId = decodeURIComponent(timelineMatch[1]);
424
- const execution = sharedServer;
425
- const timeline = normalizeSharedTimeline(await execution.timeline(threadId, sharedHistoryPageOptions()));
426
- if (request.socketRequest) {
427
- response.on('progress', () => execution.keepSnapshotAlive(threadId, timeline.sync));
428
- 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));
429
626
  }
430
627
  sendJson(response, 200, timeline);
431
628
  return;
@@ -434,19 +631,20 @@ async function handleRequest(request, response) {
434
631
  const approvalMatch = /^\/api\/threads\/([^/]+)\/approvals$/.exec(url.pathname);
435
632
  if (request.method === 'POST' && approvalMatch) {
436
633
  const threadId = decodeURIComponent(approvalMatch[1]);
437
- await requireExecutionWritable(threadId);
634
+ await requireExecutionWritable(threadId, execution);
438
635
  const body = await readJsonBody(request);
439
- sendJson(response, 200, await sharedServer.respondApproval(threadId, body)); return;
636
+ sendJson(response, 200, await execution.respondApproval(threadId, body)); return;
440
637
  }
441
638
 
442
639
  const eventsMatch = /^\/api\/threads\/([^/]+)\/events$/.exec(url.pathname);
443
640
  if (request.method === 'GET' && eventsMatch) {
444
641
  const threadId = decodeURIComponent(eventsMatch[1]);
445
642
  const after = Number(url.searchParams.get('afterSeq'));
446
- const requestedSync = url.searchParams.get('syncId') && url.searchParams.get('syncEpoch') && Number.isSafeInteger(after) && after >= 0
447
- ? { id: url.searchParams.get('syncId'), epoch: url.searchParams.get('syncEpoch'), after } : undefined;
448
- await streamSharedThread(response, threadId, sharedServer,
449
- 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);
450
648
  return;
451
649
  }
452
650
 
@@ -454,14 +652,14 @@ async function handleRequest(request, response) {
454
652
  if (request.method === 'GET' && modelChangesMatch) {
455
653
  const threadId = decodeURIComponent(modelChangesMatch[1]);
456
654
  if (!/^[0-9a-f]{8}-[0-9a-f-]{20,55}$/i.test(threadId)) throw new RequestError(400, '对话 ID 无效。');
457
- 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)));
458
656
  return;
459
657
  }
460
658
  const contextMatch = /^\/api\/threads\/([^/]+)\/context$/.exec(url.pathname);
461
659
  if (request.method === 'GET' && contextMatch) {
462
660
  const threadId = decodeURIComponent(contextMatch[1]);
463
- const current = readContextUsage(threadId);
464
- 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');
465
663
  sendJson(response, 200, { ...current, ...(native.model ? { model: native.model, thinking: native.thinking, serviceTier: native.serviceTier,
466
664
  permissionMode: native.permissionMode, permissionModes: native.permissionModes } : {}), ...(native.available ? native : {}) });
467
665
  return;
@@ -471,34 +669,45 @@ async function handleRequest(request, response) {
471
669
  if (sharedAction) {
472
670
  const id = decodeURIComponent(sharedAction[1]);
473
671
  if (request.method === 'GET' && sharedAction[2] === 'history') {
474
- 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));
475
680
  return;
476
681
  }
477
682
  if (request.method === 'POST' && sharedAction[2] === 'settings') {
478
- await requireExecutionWritable(id);
683
+ if (rootChat) throw new RequestError(405, 'Dots 主对话不提供 Codex 模型设置。');
684
+ await requireExecutionWritable(id, execution);
479
685
  const body = await readJsonBody(request);
480
- 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));
481
687
  return;
482
688
  }
483
689
  if (request.method === 'POST' && sharedAction[2] === 'permissions') {
484
- await requireExecutionWritable(id);
690
+ if (rootChat) throw new RequestError(405, 'Dots 主对话不提供 Codex 权限设置。');
691
+ await requireExecutionWritable(id, execution);
485
692
  const body = await readJsonBody(request);
486
- sendJson(response, 200, await sharedServer.permissions(id, body.mode));
693
+ sendJson(response, 200, await execution.permissions(id, body.mode));
487
694
  return;
488
695
  }
489
696
  }
490
697
 
491
698
  const archiveMatch = /^\/api\/threads\/([^/]+)\/(archive|unarchive)$/.exec(url.pathname);
492
699
  if (request.method === 'POST' && archiveMatch) {
700
+ if (rootChat) throw new RequestError(405, 'Dots 主对话不提供任务归档。');
493
701
  const threadId = decodeURIComponent(archiveMatch[1]);
494
- await requireExecutionWritable(threadId);
495
- await sharedServer.request(`thread/${archiveMatch[2]}`, { threadId });
702
+ await requireExecutionWritable(threadId, execution);
703
+ await execution.request(`thread/${archiveMatch[2]}`, { threadId });
496
704
  sendJson(response, 200, { threadId, archived: archiveMatch[2] === 'archive' });
497
705
  return;
498
706
  }
499
707
 
500
708
  const queueMatch = /^\/api\/threads\/([^/]+)\/queue$/.exec(url.pathname);
501
709
  if (queueMatch) {
710
+ if (hostId === 'durable') throw new RequestError(405, '当前托管连接不提供排队消息。');
502
711
  const threadId = decodeURIComponent(queueMatch[1]);
503
712
  await sharedServer.connect();
504
713
  const rpc = (method, params) => sharedServer.request(method, params);
@@ -513,6 +722,7 @@ async function handleRequest(request, response) {
513
722
 
514
723
  const queueItemMatch = /^\/api\/threads\/([^/]+)\/queue\/([a-f0-9-]+)(?:\/(steer))?$/.exec(url.pathname);
515
724
  if (queueItemMatch) {
725
+ if (hostId === 'durable') throw new RequestError(405, '当前托管连接不提供排队消息。');
516
726
  const threadId = decodeURIComponent(queueItemMatch[1]);
517
727
  const itemId = queueItemMatch[2];
518
728
  await sharedServer.connect();
@@ -524,23 +734,43 @@ async function handleRequest(request, response) {
524
734
 
525
735
  const interruptMatch = /^\/api\/threads\/([^/]+)\/interrupt$/.exec(url.pathname);
526
736
  if (request.method === 'POST' && interruptMatch) {
737
+ if (rootChat) throw new RequestError(405, 'Dots 主对话不提供停止 Codex 任务的操作。');
527
738
  const threadId = decodeURIComponent(interruptMatch[1]);
528
- await requireExecutionWritable(threadId);
739
+ await requireExecutionWritable(threadId, execution);
529
740
  const body = await readJsonBody(request);
530
741
  if (!body.turnId) throw new RequestError(400, '缺少活动轮次 ID。');
531
- await sharedServer.interrupt(threadId, body.turnId);
742
+ await execution.interrupt(threadId, body.turnId);
532
743
  sendJson(response, 200, { requested: true });
533
744
  return;
534
745
  }
535
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
+ }
536
760
  const messageMatch = /^\/api\/threads\/([^/]+)\/messages$/.exec(url.pathname);
537
761
  if (request.method === 'POST' && messageMatch) {
538
762
  const threadId = decodeURIComponent(messageMatch[1]);
539
- await requireExecutionWritable(threadId);
540
763
  const body = await readJsonBody(request);
541
- if (body.hostId && body.hostId !== 'local') throw new RequestError(410, '此对话的连接方式已停用。', { code: 'unsupported_control' });
542
- const submitted = await sharedServer.send(appServerTurnArguments(threadId, body));
543
- 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);
544
774
  return;
545
775
  }
546
776
 
@@ -550,13 +780,15 @@ async function handleRequest(request, response) {
550
780
  if (!response.writableEnded) response.end();
551
781
  return;
552
782
  }
553
- 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;
554
786
  sendJson(response, status, {
555
787
  error: error instanceof Error ? error.message : String(error),
556
788
  code: error?.code,
557
789
  threadId: error?.threadId,
558
790
  readOnly: error?.readOnly,
559
- details: error?.details,
791
+ details: error?.deliveryUnknown === undefined ? error?.details : { ...error?.details, deliveryUnknown: error.deliveryUnknown },
560
792
  });
561
793
  }
562
794
  }
@@ -564,8 +796,8 @@ async function handleRequest(request, response) {
564
796
  const server = http.createServer(handleRequest);
565
797
  const lanSocket = attachLanSocket(server, { token: options.token, dispatch: handleRequest,
566
798
  uploadsRoot,
567
- authorizeDevice: (clientId, credential) => authorizeLanDevice(stateRoot, clientId, credential),
568
- onDeviceAuthorizationsChanged: listener => onLanAuthorizationsChanged(stateRoot, listener),
799
+ authorizeDevice: (clientId, credential) => authorizeLanDevice(stateRoot, clientId, credential),
800
+ onDeviceAuthorizationsChanged: listener => onLanAuthorizationsChanged(stateRoot, listener),
569
801
  });
570
802
 
571
803
  let control;
@@ -591,6 +823,7 @@ server.listen(options.port, options.lan ? '0.0.0.0' : '127.0.0.1', async () => {
591
823
  const state = managedAppServer.snapshot();
592
824
  return state.launcherRestartRequired ? managedAppServer.restart(crypto.randomUUID()) : state;
593
825
  },
826
+ 'restart-managed-app-server-confirmed': () => managedAppServer.restart(crypto.randomUUID()),
594
827
  } });
595
828
  writeRecord(process.env.CODEX_REMOTE_READY_PATH, { instanceId: process.env.CODEX_REMOTE_INSTANCE,
596
829
  pid: process.pid, controlPort: control.port });
@@ -614,6 +847,8 @@ async function shutDown() {
614
847
  executionHealth?.stop();
615
848
  runtimeDiscovery.stop();
616
849
  await relayManager.close();
850
+ dotsServer.close();
851
+ dotsChat.close();
617
852
  managedAppServer.close();
618
853
  await control?.close();
619
854
  process.exit(0);
@@ -756,6 +991,14 @@ function messagePayload(body) {
756
991
  if (!fs.existsSync(file)) throw new RequestError(400, '图片不存在,请重新添加。');
757
992
  return file;
758
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
+ }
759
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。');
760
1003
  if (!prompt && !images.length) throw new RequestError(400, 'prompt 不能为空。');
761
1004
  if (prompt.length > 32_000) throw new RequestError(400, 'prompt 不能超过 32000 个字符。');
@@ -763,9 +1006,7 @@ function messagePayload(body) {
763
1006
  const payload = { prompt, images };
764
1007
  if (typeof body.hostId === 'string' && body.hostId.trim()) payload.hostId = body.hostId.trim();
765
1008
  if (typeof body.model === 'string' && body.model.trim()) {
766
- const model = body.model.trim();
767
- if (!ALLOWED_MODELS.has(model)) throw new RequestError(400, '不支持的模型。');
768
- payload.model = model;
1009
+ payload.model = body.model.trim();
769
1010
  }
770
1011
  if (typeof body.thinking === 'string' && body.thinking.trim()) {
771
1012
  const thinking = body.thinking.trim();
@@ -802,13 +1043,36 @@ function normalizeTimeline(data) {
802
1043
  };
803
1044
  }
804
1045
 
805
- function normalizeSharedTimeline({ thread, nextCursor, sync }) {
806
- 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' } });
807
1049
  return { ...timeline,
808
- 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 } : {}),
809
1054
  ...(nextCursor !== undefined ? { nextCursor } : {}), ...(sync ? { sync } : {}) };
810
1055
  }
811
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
+
812
1076
  function normalizeThread(thread) {
813
1077
  const source = thread && typeof thread === 'object' ? thread : {};
814
1078
  return {
@@ -820,6 +1084,10 @@ function normalizeThread(thread) {
820
1084
  projectId: source.projectId || '',
821
1085
  cwd: source.cwd || '',
822
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 } : {}),
823
1091
  transport: source.transport || 'shared-app-server',
824
1092
  readOnly: source.readOnly === true,
825
1093
  readOnlyReason: source.readOnlyReason || '',
@@ -841,7 +1109,12 @@ function normalizeTurn(turn) {
841
1109
  completedAt: source.completedAt || null,
842
1110
  durationMs: source.durationMs ?? null,
843
1111
  error: source.error ?? null,
1112
+ runtimeNotice: source.runtimeNotice ?? null,
844
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 } : {}),
845
1118
  entries,
846
1119
  };
847
1120
  }
@@ -849,6 +1122,10 @@ function normalizeTurn(turn) {
849
1122
  function normalizeItem(item) {
850
1123
  if (!item || typeof item !== 'object') return null;
851
1124
 
1125
+ if (item.type === 'approval') {
1126
+ return { ...item, type: 'activity', kind: 'approval', title: item.params?.message || '需要你的批准' };
1127
+ }
1128
+
852
1129
  if (item.type === 'contextCompaction') {
853
1130
  return {
854
1131
  type: 'activity',
@@ -858,24 +1135,21 @@ function normalizeItem(item) {
858
1135
  };
859
1136
  }
860
1137
 
861
- if (item.type === 'functionCallOutput'
862
- && item.namespace === 'codex_app'
863
- && ['create_thread', 'send_message_to_thread'].includes(item.name)) {
864
- const text = extractDelegatedInput(delegatedOutputText(item.output));
865
- if (text) {
866
- return {
867
- type: 'message',
868
- id: item.id || '',
869
- role: 'user',
870
- source: 'remote',
871
- ...splitUploadedImages(text),
872
- };
873
- }
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
+ };
874
1144
  }
875
1145
 
876
1146
  if (item.type === 'userMessage') {
877
- const { text, files } = extractUserText(item.content);
878
- 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
+ }))];
879
1153
  if (!text && !attachments.length) return null;
880
1154
  return {
881
1155
  clientId: item.clientId,
@@ -884,6 +1158,7 @@ function normalizeItem(item) {
884
1158
  role: 'user',
885
1159
  text,
886
1160
  attachments,
1161
+ ...(delegation ? { delegation: delegationSource(delegation) } : {}),
887
1162
  };
888
1163
  }
889
1164
 
@@ -896,6 +1171,7 @@ function normalizeItem(item) {
896
1171
  delivery: item.delivery,
897
1172
  questions: item.questions,
898
1173
  text: item.text || '',
1174
+ attachments: normalizeAttachments(item.attachments),
899
1175
  imageAssets: markdownImageAssets(item.text || ''),
900
1176
  fileAssets: markdownFileAssets(item.text || ''),
901
1177
  };
@@ -969,7 +1245,7 @@ function normalizeItem(item) {
969
1245
  title: '文件修改',
970
1246
  titleOnly: true,
971
1247
  status: statusText(item.status),
972
- changes: Array.isArray(item.changes) ? item.changes : [],
1248
+ changes: fileChanges.summarize(item.changes),
973
1249
  };
974
1250
  }
975
1251
 
@@ -989,16 +1265,6 @@ function normalizeItem(item) {
989
1265
  const image = typeof item.path === 'string' && item.path
990
1266
  ? normalizeAttachment({ type: 'localImage', path: item.path })
991
1267
  : null;
992
- if (image?.available) {
993
- return {
994
- type: 'message',
995
- id: item.id || '',
996
- role: 'assistant',
997
- phase: 'commentary',
998
- text: '',
999
- attachments: [image],
1000
- };
1001
- }
1002
1268
  return {
1003
1269
  type: 'activity',
1004
1270
  id: item.id || '',
@@ -1011,9 +1277,11 @@ function normalizeItem(item) {
1011
1277
  }
1012
1278
 
1013
1279
  if (item.type === 'imageGeneration') {
1280
+ const result = typeof item.result === 'string' ? item.result.trim() : '';
1014
1281
  const image = typeof item.savedPath === 'string' && item.savedPath
1015
1282
  ? normalizeAttachment({ type: 'localImage', path: item.savedPath, name: '生成的图片' })
1016
- : null;
1283
+ : result ? normalizeAttachment({ type: 'image', name: '生成的图片',
1284
+ url: result.startsWith('data:image/') ? result : `data:image/png;base64,${result}` }) : null;
1017
1285
  return {
1018
1286
  type: 'activity',
1019
1287
  id: item.id || '',
@@ -1088,7 +1356,7 @@ function normalizeAttachments(content) {
1088
1356
  else items.push({ type: 'localImage', path: imagePath });
1089
1357
  }
1090
1358
  const seen = new Set();
1091
- return items.map(normalizeAttachment).filter(item => {
1359
+ return items.map(item => normalizeAttachment(item)).filter(item => {
1092
1360
  if (!item.url) return true;
1093
1361
  if (seen.has(item.url)) return false;
1094
1362
  seen.add(item.url); return true;
@@ -1099,23 +1367,21 @@ function markdownImageAssets(text) {
1099
1367
  const assets = Object.create(null);
1100
1368
  function visit(tokens) {
1101
1369
  for (const token of tokens) {
1102
- const value = token.type === 'image' ? token.attrGet('src')
1103
- : token.type === 'link_open' && !token.meta?.fileCitation ? token.attrGet('href') : null;
1370
+ const value = token.type === 'image' ? token.attrGet('src')
1371
+ : token.type === 'link_open' && !token.meta?.fileCitation ? token.attrGet('href') : null;
1104
1372
  if (value && !Object.hasOwn(assets, value)) {
1105
- let localPath;
1106
- try { localPath = decodeURIComponent(value); } catch { continue; }
1373
+ const localPath = fileCitations.localReference(value)?.path;
1107
1374
  // Only paths actually referenced by Desktop messages become accessible.
1108
1375
  // Do not expose a client-supplied filesystem path endpoint or UNC shares.
1109
- if (/^\/?[a-z]:[\\/]/i.test(localPath)) {
1110
- localPath = localPath.replace(/^\/(?=[a-z]:)/i, '');
1376
+ if (localPath && /^[a-z]:[\\/]/i.test(localPath)) {
1111
1377
  const asset = normalizeAttachment({ type: 'localImage', path: localPath });
1112
1378
  if (asset.available) assets[value] = asset;
1113
1379
  }
1114
1380
  }
1115
- if (token.children && token.type !== 'image') visit(token.children);
1381
+ if (token.children && token.type !== 'image') visit(token.children);
1116
1382
  }
1117
1383
  }
1118
- visit(imageMarkdown.parse(text, { allowFileCitations: true }));
1384
+ visit(imageMarkdown.parse(text, { allowFileCitations: true }));
1119
1385
  return assets;
1120
1386
  }
1121
1387
 
@@ -1126,20 +1392,17 @@ function markdownFileAssets(text) {
1126
1392
  if (token.type === 'link_open') {
1127
1393
  const value = token.attrGet('href');
1128
1394
  if (value && !Object.hasOwn(assets, value)) {
1129
- let localPath;
1130
- try { localPath = decodeURIComponent(value); } catch { localPath = ''; }
1131
- if (/^\/?[a-z]:[\\/]/i.test(localPath)) {
1132
- localPath = localPath.replace(/^\/(?=[a-z]:)/i, '');
1133
- localPath = localPath.replace(/:(\d+)(?::\d+)?$/, '');
1395
+ const localPath = fileCitations.localReference(value)?.path;
1396
+ if (localPath && /^[a-z]:[\\/]/i.test(localPath)) {
1134
1397
  const asset = rememberLocalFile(localPath);
1135
1398
  if (asset) assets[value] = { remotePath: asset.id, name: asset.name, size: asset.size };
1136
1399
  }
1137
1400
  }
1138
1401
  }
1139
- if (token.children && token.type !== 'image') visit(token.children);
1402
+ if (token.children && token.type !== 'image') visit(token.children);
1140
1403
  }
1141
1404
  }
1142
- visit(imageMarkdown.parse(text, { allowFileCitations: true }));
1405
+ visit(imageMarkdown.parse(text, { allowFileCitations: true }));
1143
1406
  return assets;
1144
1407
  }
1145
1408
 
@@ -1147,12 +1410,12 @@ function rememberLocalFile(filePath) {
1147
1410
  try {
1148
1411
  const resolved = path.resolve(filePath);
1149
1412
  const stat = fs.statSync(resolved);
1150
- if (!stat.isFile()) return null;
1413
+ if (!stat.isFile()) return null;
1151
1414
  const id = crypto.createHmac('sha256', fileCapabilitySecret)
1152
1415
  .update(resolved).update('\0').update(String(stat.size)).update('\0').update(String(stat.mtimeMs)).digest('hex');
1153
1416
  if (!fileCache.has(id)) {
1154
1417
  while (fileCache.size >= MAX_CACHED_FILES) fileCache.delete(fileCache.keys().next().value);
1155
- 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) });
1156
1419
  }
1157
1420
  return fileCache.get(id);
1158
1421
  } catch {
@@ -1183,12 +1446,29 @@ function splitUploadedImages(text) {
1183
1446
  return { text: visibleText, attachments, imageAssets: markdownImageAssets(visibleText) };
1184
1447
  }
1185
1448
 
1186
- 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) {
1187
1466
  const name = item.name || item.fileName || (typeof item.path === 'string' ? path.basename(item.path) : '');
1188
1467
  let cached = null;
1189
1468
  const isImage = ['image', 'input_image', 'localImage'].includes(item.type);
1469
+ if (isImage || item.type === 'file') cached = dotsImages.remember(item, identity);
1190
1470
  if (isImage && typeof item.path === 'string') {
1191
- cached = cacheLocalImage(item.path, name);
1471
+ cached = cacheLocalImage(item.path, name) || cached;
1192
1472
  }
1193
1473
  if (!cached) {
1194
1474
  const dataUrl = typeof item.url === 'string' && item.url.startsWith('data:image/')
@@ -1198,10 +1478,12 @@ function normalizeAttachment(item) {
1198
1478
  }
1199
1479
  return {
1200
1480
  type: isImage ? 'image' : item.type || 'attachment',
1201
- name,
1481
+ name: cached?.name || name,
1202
1482
  mimeType: cached?.mimeType || item.mimeType || '',
1483
+ ...(cached?.size ? { size: cached.size } : {}),
1484
+ ...(cached?.dotsFile ? { networkFile: true } : {}),
1203
1485
  available: Boolean(cached),
1204
- remotePath: cached ? cached.id : '',
1486
+ remotePath: cached ? (isImage ? cached.id : `${cached.id}?recovery=${cached.recoveryToken}`) : '',
1205
1487
  recoveryToken: cached?.recoveryToken || '',
1206
1488
  url: '',
1207
1489
  thumbnailUrl: '',
@@ -1318,33 +1600,23 @@ function extractUserText(content) {
1318
1600
  .filter(value => !value.startsWith('The next image is untrusted page evidence'))
1319
1601
  .join('\n\n')
1320
1602
  .trim();
1603
+ const delegation = appServerEvents.parseCodexDelegation(text);
1604
+ if (delegation) text = delegation.input;
1321
1605
  const mentioned = fileMentions.parse(text);
1322
1606
  const imagePaths = new Set([
1323
1607
  ...content.filter(item => ['image', 'input_image', 'localImage'].includes(item?.type) && item.path).map(item => item.path),
1324
1608
  ...[...mentioned.text.matchAll(/<image\b[^>]*\bpath="([^"]+)"[^>]*>/g)].map(match => match[1]),
1325
1609
  ].map(value => path.resolve(value).toLowerCase()));
1326
1610
  text = mentioned.text;
1327
- text = text.replace(/<in-app-browser-context[\s\S]*?<\/in-app-browser-context>/g, '').trim();
1328
1611
  text = text.replace(/<image\b[^>]*\bpath="[^"]+"[^>]*>/g, '').replace(/<\/image>/g, '').trim();
1329
- return { text, files: mentioned.files.filter(file => !imagePaths.has(path.resolve(file.path).toLowerCase())) };
1330
- }
1331
-
1332
- function extractDelegatedInput(value) {
1333
- if (typeof value !== 'string') return '';
1334
- const match = value.match(/<codex_delegation>[\s\S]*?<input>([\s\S]*?)<\/input>[\s\S]*?<\/codex_delegation>/);
1335
- if (!match) return '';
1336
- return match[1]
1337
- .replace(/&lt;/g, '<')
1338
- .replace(/&gt;/g, '>')
1339
- .replace(/&quot;/g, '"')
1340
- .replace(/&#39;/g, "'")
1341
- .replace(/&amp;/g, '&')
1342
- .trim();
1612
+ return { text, files: mentioned.files.filter(file => !imagePaths.has(path.resolve(file.path).toLowerCase())), delegation };
1343
1613
  }
1344
1614
 
1345
- function delegatedOutputText(output) {
1346
- if (typeof output === 'string') return output;
1347
- 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) } : {}) };
1348
1620
  }
1349
1621
 
1350
1622
  function summaryText(value) {
@@ -1370,7 +1642,7 @@ function sendBuffer(response, statusCode, contentType, value) {
1370
1642
  'Content-Length': value.length,
1371
1643
  'Cache-Control': 'no-store',
1372
1644
  'X-Content-Type-Options': 'nosniff',
1373
- '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:",
1374
1646
  });
1375
1647
  response.end(value);
1376
1648
  }