@walkhi/code-relax 0.1.0-beta.11 → 0.1.0-beta.13

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