@walkhi/code-relax 0.1.0-beta.8 → 0.1.0-beta.9
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.
- package/README.md +10 -11
- package/dist/bin/codex-remote.mjs +11 -12
- package/dist/bin/self-relay-demo.mjs +1 -1
- package/dist/shared/app-server-events.cjs +5 -3
- package/dist/src/desktop-launcher.mjs +7 -10
- package/dist/src/message-images.mjs +0 -18
- package/dist/src/onboarding.mjs +3 -4
- package/dist/src/platform/README.md +1 -1
- package/dist/src/platform/windows/desktop-shortcut-run.ps1 +1 -1
- package/dist/src/platform/windows/desktop-shortcuts.ps1 +1 -1
- package/dist/src/platform/windows/service-host.mjs +4 -7
- package/dist/src/self-relay/admin-page.mjs +54 -0
- package/dist/src/self-relay/admin-state.mjs +25 -12
- package/dist/src/self-relay/demo.mjs +2 -3
- package/dist/src/self-relay/lifecycle.mjs +6 -6
- package/dist/src/self-relay/server.mjs +7 -11
- package/dist/src/server.mjs +147 -1030
- package/dist/src/service-doctor.mjs +3 -3
- package/dist/src/service-lifecycle.mjs +2 -7
- package/dist/src/shared-app-server.mjs +13 -11
- package/dist/src/shared-recovery.mjs +5 -5
- package/dist/src/shared-thread-stream.mjs +1 -1
- package/dist/web/capabilities.js +1 -8
- package/dist/web/chat-transport.js +6 -12
- package/dist/web/chat.css +13 -14
- package/dist/web/chat.js +23 -41
- package/dist/web/community.css +15 -10
- package/dist/web/community.html +4 -5
- package/dist/web/composer-controller.js +0 -4
- package/dist/web/index.html +7 -39
- package/dist/web/resources.json +1 -1
- package/dist/web/task-list-view.js +1 -8
- package/dist/web/timeline-reducer.js +0 -4
- package/package.json +16 -18
- package/dist/src/app-server-client.mjs +0 -468
- package/dist/src/app-server-tasks.mjs +0 -358
- package/dist/src/platform/windows/desktop-monitor.mjs +0 -34
- package/dist/src/platform/windows/desktop-tools.mjs +0 -48
- package/dist/src/thread-catalog.mjs +0 -47
- package/dist/tools/find-desktop-pipe.mjs +0 -10
- package/dist/web/community-view.js +0 -20
package/dist/src/server.mjs
CHANGED
|
@@ -1,20 +1,16 @@
|
|
|
1
1
|
import crypto from 'node:crypto';
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import http from 'node:http';
|
|
4
|
-
import net from 'node:net';
|
|
5
4
|
import path from 'node:path';
|
|
6
5
|
import os from 'node:os';
|
|
7
6
|
import { fileURLToPath } from 'node:url';
|
|
8
7
|
import vm from 'node:vm';
|
|
9
8
|
import sharp from 'sharp';
|
|
10
9
|
import { attachLanSocket } from './lan-socket-server.mjs';
|
|
11
|
-
import { validateInlineImage
|
|
10
|
+
import { validateInlineImage } from './message-images.mjs';
|
|
12
11
|
import { ThreadWriterConflictError } from './shared-app-server.mjs';
|
|
13
12
|
import { ManagedAppServer, managedAppServerUrl } from './managed-app-server.mjs';
|
|
14
13
|
import { streamSharedThread } from './shared-thread-stream.mjs';
|
|
15
|
-
import { AppServerClient } from './app-server-client.mjs';
|
|
16
|
-
import { AppServerTaskStore, AppServerTaskTransport } from './app-server-tasks.mjs';
|
|
17
|
-
import { supplementThreadCatalog } from './thread-catalog.mjs';
|
|
18
14
|
import appServerEvents from '../shared/app-server-events.cjs';
|
|
19
15
|
import { runtimePaths } from './platform/windows/runtime-paths.mjs';
|
|
20
16
|
import { openControl } from './service-control.mjs';
|
|
@@ -23,13 +19,11 @@ import { ExecutionHealth } from './execution-health.mjs';
|
|
|
23
19
|
import { activeDebugPort, serveDebugResource } from './debug-ui.mjs';
|
|
24
20
|
import { authorizeLanDevice } from './self-relay/lan-authorizations.mjs';
|
|
25
21
|
import { generateAndSetThreadTitle } from './thread-title-generation.mjs';
|
|
26
|
-
import { DesktopMonitor } from './platform/windows/desktop-monitor.mjs';
|
|
27
22
|
import { desktopHost } from './platform/windows/desktop-host.mjs';
|
|
28
23
|
import { SharedCatalog } from './shared-catalog.mjs';
|
|
29
24
|
import { requestRecovery, recoveryStatus } from './shared-recovery.mjs';
|
|
30
25
|
import { requestServiceRestart } from './service-restart.mjs';
|
|
31
26
|
|
|
32
|
-
const MAX_FRAME_BYTES = 32 * 1024 * 1024;
|
|
33
27
|
const MAX_ATTACHMENT_BYTES = 16 * 1024 * 1024;
|
|
34
28
|
const MAX_CACHED_ATTACHMENTS = 32;
|
|
35
29
|
const MAX_CACHED_FILES = 64;
|
|
@@ -47,15 +41,6 @@ const ALLOWED_MODELS = new Set([
|
|
|
47
41
|
'gpt-5.3-codex-spark',
|
|
48
42
|
]);
|
|
49
43
|
const ALLOWED_THINKING = new Set(['low', 'medium', 'high', 'xhigh', 'max', 'ultra']);
|
|
50
|
-
const ALLOWED_TOOLS = new Set([
|
|
51
|
-
'create_thread',
|
|
52
|
-
'get_usage_limits',
|
|
53
|
-
'list_projects',
|
|
54
|
-
'list_threads',
|
|
55
|
-
'read_thread',
|
|
56
|
-
'send_message_to_thread',
|
|
57
|
-
'wait_threads',
|
|
58
|
-
]);
|
|
59
44
|
|
|
60
45
|
function readRuntimeVersion() {
|
|
61
46
|
let directory = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -70,8 +55,6 @@ function readRuntimeVersion() {
|
|
|
70
55
|
|
|
71
56
|
const options = parseOptions(process.argv.slice(2));
|
|
72
57
|
const runtimeVersion = readRuntimeVersion();
|
|
73
|
-
const pipePath = process.env.CODEX_APP_TOOLS_PIPE_PATH?.trim();
|
|
74
|
-
let sourceThreadId = (process.env.CODEX_THREAD_ID || process.env.CODEX_SESSION_ID)?.trim();
|
|
75
58
|
|
|
76
59
|
const webRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'web');
|
|
77
60
|
const repositoryRoot = path.resolve(webRoot, '..', '..');
|
|
@@ -81,7 +64,6 @@ const markdownItScript = fs.readFileSync(path.join(webRoot, 'vendor', 'markdown-
|
|
|
81
64
|
const markdownSandbox = { atob };
|
|
82
65
|
vm.runInNewContext(markdownItScript.toString(), markdownSandbox);
|
|
83
66
|
const imageMarkdown = markdownSandbox.markdownit({ html: false });
|
|
84
|
-
let nextRequestId = 0;
|
|
85
67
|
const attachmentCache = new Map();
|
|
86
68
|
const fileCache = new Map();
|
|
87
69
|
const fileCapabilitySecret = crypto.randomBytes(32);
|
|
@@ -93,11 +75,7 @@ const allowedUploadsRoots = [uploadsRoot, legacyUploadsRoot,
|
|
|
93
75
|
const sessionPathCache = new Map();
|
|
94
76
|
const sessionSettingsCache = new Map();
|
|
95
77
|
const liveActivityCache = new Map();
|
|
96
|
-
const messageQueues = new Map();
|
|
97
78
|
let sharedServer = null;
|
|
98
|
-
const desktopMonitor = new DesktopMonitor({
|
|
99
|
-
read: (tool, args, pipe) => callTool(tool, args, 5000, pipe),
|
|
100
|
-
});
|
|
101
79
|
const managedAppServer = new ManagedAppServer({ stateRoot, host: desktopHost });
|
|
102
80
|
let sharedCatalog = null, executionMode = 'ordinary';
|
|
103
81
|
function selectSharedExecution(enabled) {
|
|
@@ -115,16 +93,14 @@ const executionHealth = new ExecutionHealth(async () => {
|
|
|
115
93
|
// same service, so closing Desktop must not disable phone writes.
|
|
116
94
|
selectSharedExecution(managed.ready);
|
|
117
95
|
executionMode = sharedServer ? 'shared' : 'ordinary';
|
|
118
|
-
const basicReady = Boolean(desktop.running && sourceThreadId && desktopMonitor.pipe);
|
|
119
96
|
const managedRestarting = managed.state === 'restarting';
|
|
120
|
-
const ready = !managedRestarting &&
|
|
97
|
+
const ready = !managedRestarting && sharedServer?.ready === true;
|
|
121
98
|
const state = managedRestarting ? 'restarting'
|
|
122
|
-
: ready ?
|
|
99
|
+
: ready ? 'full' : !desktop.running ? 'offline' : 'unavailable';
|
|
123
100
|
return { state, ready, canWrite: ready, mode: executionMode,
|
|
124
101
|
desktop: { state: !desktop.running ? 'stopped' : attached ? 'managed' : 'ordinary', attachedToManagedServer: attached } };
|
|
125
102
|
});
|
|
126
103
|
async function refreshExecution() {
|
|
127
|
-
sourceThreadId = readRecord(path.join(stateRoot, 'bridge-startup-context.json'))?.sourceThreadId || sourceThreadId;
|
|
128
104
|
await executionHealth.refresh();
|
|
129
105
|
return { mode: executionMode };
|
|
130
106
|
}
|
|
@@ -135,25 +111,13 @@ async function requireExecutionWritable(threadId) {
|
|
|
135
111
|
}
|
|
136
112
|
const execution = await executionHealth.refresh();
|
|
137
113
|
if (!execution.canWrite) {
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
: 'Codex Desktop 尚未建立基础控制连接,请稍后重新检测。';
|
|
141
|
-
throw new RequestError(409, message, { code: 'execution_not_writable', execution });
|
|
114
|
+
throw new RequestError(503, 'Codex 内核未就绪。请在电脑上重启 Codex;若仍失败,运行 relax doctor。',
|
|
115
|
+
{ code: 'full_control_required', execution });
|
|
142
116
|
}
|
|
143
117
|
if (threadId && sharedServer) sharedServer.requireWritable(threadId);
|
|
144
118
|
return execution;
|
|
145
119
|
}
|
|
146
|
-
managedAppServer.client.on('subscription-error', error => console.error('
|
|
147
|
-
const queueWorkers = new Map();
|
|
148
|
-
const appServer = new AppServerClient({ cwd: projectRoot });
|
|
149
|
-
const appServerTasks = new AppServerTaskTransport({
|
|
150
|
-
client: appServer,
|
|
151
|
-
store: new AppServerTaskStore(path.join(stateRoot, 'app-server-tasks.json')),
|
|
152
|
-
});
|
|
153
|
-
appServer.on('protocol-error', error => console.error(`app-server 协议错误:${error.message}`));
|
|
154
|
-
appServer.on('server-request', request => {
|
|
155
|
-
console.log(`app-server 等待客户端处理反向请求:${request.method} (${request.id})`);
|
|
156
|
-
});
|
|
120
|
+
managedAppServer.client.on('subscription-error', error => console.error('Codex 内核任务取消订阅失败:', error.message));
|
|
157
121
|
|
|
158
122
|
async function handleRequest(request, response) {
|
|
159
123
|
try {
|
|
@@ -186,7 +150,7 @@ async function handleRequest(request, response) {
|
|
|
186
150
|
}
|
|
187
151
|
if (request.method === 'POST' && url.pathname === '/api/shared-recovery') {
|
|
188
152
|
const body = await readJsonBody(request);
|
|
189
|
-
if (body.confirmed !== true || typeof body.operationId !== 'string') throw new RequestError(400, '请确认重新打开当前 Codex
|
|
153
|
+
if (body.confirmed !== true || typeof body.operationId !== 'string') throw new RequestError(400, '请确认重新打开当前 Codex 并接入 Codex 内核后再继续。');
|
|
190
154
|
sendJson(response, 202, await requestRecovery(stateRoot, body)); return;
|
|
191
155
|
}
|
|
192
156
|
if (request.method === 'POST' && url.pathname === '/api/service/restart') {
|
|
@@ -215,32 +179,23 @@ async function handleRequest(request, response) {
|
|
|
215
179
|
const recovering = ['scheduled', 'running'].includes(recovery?.status);
|
|
216
180
|
sendJson(response, 200, {
|
|
217
181
|
status: 'ok', resident: true, version: runtimeVersion,
|
|
218
|
-
execution: { ...executionHealth.state, ready: executionHealth.state.ready &&
|
|
182
|
+
execution: { ...executionHealth.state, ready: executionHealth.state.ready && sharedServer?.ready === true,
|
|
219
183
|
...(recovering ? { state: 'recovering' } : {}), mode: executionMode },
|
|
220
184
|
managedAppServer: managedAppServer.snapshot(),
|
|
221
185
|
desktop: executionHealth.state.desktop || { state: 'stopped', attachedToManagedServer: false },
|
|
222
186
|
desktopRestartRecommended: executionHealth.state.desktop?.state === 'ordinary' && managedAppServer.snapshot().ready,
|
|
223
187
|
recovery: recovery ? { operationId: recovery.operationId, status: recovery.status, stage: recovery.stage, error: recovery.error } : null,
|
|
224
188
|
debugUiPort: await activeDebugPort(stateRoot, options.port),
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
sharedRecoveryAvailable: true,
|
|
228
|
-
desktopOrderingReady: Boolean(desktopMonitor.snapshot),
|
|
229
|
-
toolCount: 0,
|
|
189
|
+
sharedAppServerConnected: sharedServer?.ready === true,
|
|
190
|
+
sharedRecoveryAvailable: true,
|
|
230
191
|
mode: options.lan ? 'lan' : 'loopback',
|
|
231
192
|
tokenRequired: Boolean(options.token),
|
|
232
193
|
nativeImageTransport: sharedServer ? 'shared-app-server' : null,
|
|
233
|
-
transportMode: sharedServer ? 'lan-shared' : '
|
|
234
|
-
appServer: {
|
|
235
|
-
state: appServer.state,
|
|
236
|
-
cliVersion: appServer.cliVersion,
|
|
237
|
-
taskCount: appServerTasks.store.list().length,
|
|
238
|
-
},
|
|
194
|
+
transportMode: sharedServer?.ready === true ? 'lan-shared' : 'unavailable',
|
|
239
195
|
});
|
|
240
196
|
return;
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
|
|
197
|
+
}
|
|
198
|
+
|
|
244
199
|
const imageMatch = /^\/api\/images\/([a-f0-9]{64})$/.exec(url.pathname);
|
|
245
200
|
if (request.method === 'GET' && imageMatch) {
|
|
246
201
|
let image = attachmentCache.get(imageMatch[1])
|
|
@@ -262,7 +217,7 @@ async function handleRequest(request, response) {
|
|
|
262
217
|
}
|
|
263
218
|
|
|
264
219
|
const fileMatch = /^\/api\/files\/([a-f0-9]{64})$/.exec(url.pathname);
|
|
265
|
-
if (request.method === 'GET' && fileMatch) {
|
|
220
|
+
if (request.method === 'GET' && fileMatch) {
|
|
266
221
|
const file = fileCache.get(fileMatch[1]);
|
|
267
222
|
if (!file) throw new RequestError(404, '文件授权已失效,请重新读取对话。');
|
|
268
223
|
let stat;
|
|
@@ -273,68 +228,47 @@ async function handleRequest(request, response) {
|
|
|
273
228
|
}
|
|
274
229
|
if (stat.size > MAX_ATTACHMENT_BYTES) throw new RequestError(413, '暂只支持下载不超过 16 MiB 的文件。');
|
|
275
230
|
sendJson(response, 200, { name: file.name, size: file.size, dataBase64: fs.readFileSync(file.path).toString('base64') });
|
|
276
|
-
return;
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
if (
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
}
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (url.pathname.startsWith('/api/app-server/') || url.pathname === '/api/wait') {
|
|
235
|
+
throw new RequestError(410, '此连接方式已停用。请检查 Codex 内核状态。', { code: 'unsupported_control' });
|
|
236
|
+
}
|
|
237
|
+
const taskPath = /^\/api\/threads\/([^/]+)/.exec(url.pathname);
|
|
238
|
+
if (taskPath && (url.pathname.endsWith('/takeover') || url.pathname === `/api/threads/${taskPath[1]}`)) {
|
|
239
|
+
throw new RequestError(410, '此连接方式已停用。', { code: 'unsupported_control' });
|
|
240
|
+
}
|
|
241
|
+
if (url.searchParams.has('hostId') && url.searchParams.get('hostId') !== 'local') {
|
|
242
|
+
throw new RequestError(410, '此对话的连接方式已停用,请检查 Codex 内核状态。', { code: 'unsupported_control' });
|
|
243
|
+
}
|
|
244
|
+
if (!executionHealth.state.ready || sharedServer?.ready !== true) {
|
|
245
|
+
throw new RequestError(503, 'Codex 内核未就绪。请在电脑上重启 Codex;若仍失败,运行 relax doctor。',
|
|
246
|
+
{ code: 'full_control_required' });
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (request.method === 'GET' && url.pathname === '/api/models') {
|
|
250
|
+
sendJson(response, 200, await sharedServer.models());
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
284
253
|
|
|
285
254
|
if (request.method === 'GET' && url.pathname === '/api/threads') {
|
|
286
255
|
const limit = boundedInteger(url.searchParams.get('limit'), 20, 1, 200);
|
|
287
256
|
const archived = url.searchParams.get('archived') === 'true';
|
|
288
|
-
|
|
289
|
-
const sharedSnapshot = Boolean(catalog);
|
|
290
|
-
let desktopCatalog;
|
|
291
|
-
if (catalog) desktopCatalog = await catalog.threads(limit, archived);
|
|
292
|
-
else {
|
|
293
|
-
if (archived) throw new RequestError(400, '当前连接不支持归档对话。请启用完整控制。');
|
|
294
|
-
const listing = await callTool('list_threads', { limit: Math.min(limit, 50) });
|
|
295
|
-
if (!listing.success) { sendToolResult(response, listing); return; }
|
|
296
|
-
const projects = await callTool('list_projects', {});
|
|
297
|
-
if (!projects.success) { sendToolResult(response, projects); return; }
|
|
298
|
-
const catalogProjects = projects.data.projects || [];
|
|
299
|
-
desktopCatalog = { ...supplementThreadCatalog(listing.data, catalogProjects, codexDataRoot, limit),
|
|
300
|
-
projects: catalogProjects };
|
|
301
|
-
}
|
|
302
|
-
let appServerThreads = archived ? [] : appServerTasks.store.list().map(record => ({
|
|
303
|
-
id: record.id,
|
|
304
|
-
kind: 'codex',
|
|
305
|
-
hostId: 'app-server',
|
|
306
|
-
title: record.title,
|
|
307
|
-
cwd: record.cwd,
|
|
308
|
-
projectId: record.projectId,
|
|
309
|
-
updatedAt: record.updatedAt,
|
|
310
|
-
recencyAt: record.recencyAt ?? record.updatedAt,
|
|
311
|
-
status: 'unknown',
|
|
312
|
-
transport: 'app-server',
|
|
313
|
-
}));
|
|
314
|
-
if (appServerThreads.length) {
|
|
315
|
-
try { appServerThreads = await appServerTasks.listTasks(); } catch {}
|
|
316
|
-
}
|
|
317
|
-
sendJson(response, 200, mergeThreadCatalog(desktopCatalog, appServerThreads, limit, sharedSnapshot));
|
|
257
|
+
sendJson(response, 200, await sharedCatalog.threads(limit, archived));
|
|
318
258
|
return;
|
|
319
259
|
}
|
|
320
260
|
|
|
321
261
|
if (request.method === 'GET' && url.pathname === '/api/projects') {
|
|
322
|
-
|
|
323
|
-
sendJson(response, 200, { projects: (await sharedCatalog.projects()).projects });
|
|
324
|
-
return;
|
|
325
|
-
}
|
|
326
|
-
const result = await callTool('list_projects', {});
|
|
327
|
-
sendToolResult(response, result);
|
|
262
|
+
sendJson(response, 200, { projects: (await sharedCatalog.projects()).projects });
|
|
328
263
|
return;
|
|
329
264
|
}
|
|
330
265
|
|
|
331
266
|
if (request.method === 'GET' && url.pathname === '/api/usage-limits') {
|
|
332
|
-
|
|
333
|
-
else sendToolResult(response, await callTool('get_usage_limits', {}));
|
|
267
|
+
sendJson(response, 200, await sharedServer.usage());
|
|
334
268
|
return;
|
|
335
269
|
}
|
|
336
270
|
|
|
337
|
-
if (request.method === 'GET' && url.pathname === '/api/usage-events'
|
|
271
|
+
if (request.method === 'GET' && url.pathname === '/api/usage-events') {
|
|
338
272
|
const execution = sharedServer;
|
|
339
273
|
response.startEvents();
|
|
340
274
|
const emit = value => { if (!response.destroyed) response.event('usage', value); };
|
|
@@ -346,171 +280,96 @@ async function handleRequest(request, response) {
|
|
|
346
280
|
return;
|
|
347
281
|
}
|
|
348
282
|
|
|
349
|
-
if (request.method === 'GET' && url.pathname === '/api/app-server/status') {
|
|
350
|
-
sendJson(response, 200, appServerBridgeStatus());
|
|
351
|
-
return;
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
if (request.method === 'POST' && url.pathname === '/api/app-server/start') {
|
|
355
|
-
await requireExecutionWritable();
|
|
356
|
-
await appServer.start();
|
|
357
|
-
sendJson(response, 200, appServerBridgeStatus());
|
|
358
|
-
return;
|
|
359
|
-
}
|
|
360
|
-
|
|
361
283
|
const createThreadMatch = /^\/api\/projects\/([^/]+)\/threads$/.exec(url.pathname);
|
|
362
284
|
if (request.method === 'POST' && createThreadMatch) {
|
|
363
285
|
await requireExecutionWritable();
|
|
364
286
|
const projectId = decodeURIComponent(createThreadMatch[1]);
|
|
365
|
-
const body =
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
const
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
const rpc = (method, params) => execution.request(method, params);
|
|
398
|
-
let target = appServerEvents.projectlessThreadTarget(projectRoot);
|
|
399
|
-
if (project) {
|
|
400
|
-
const nativeProjects = await appServerEvents.readProjects(rpc);
|
|
401
|
-
const nativeId = nativeProjects.find(item => item.id === project.projectId)?.id;
|
|
402
|
-
if (!nativeId) throw new RequestError(404, '完整控制中没有找到对应项目,请刷新项目列表。');
|
|
403
|
-
target = { projectId:nativeId, cwd:project.path };
|
|
404
|
-
}
|
|
405
|
-
const { input } = appServerTurnArguments('', body);
|
|
406
|
-
const created = await appServerEvents.createThread(rpc, target, input, body);
|
|
407
|
-
const prompt = input.filter(part => part.type === 'text').map(part => part.text).join('\n');
|
|
408
|
-
void generateAndSetThreadTitle({
|
|
409
|
-
request: rpc,
|
|
410
|
-
subscribe: listener => {
|
|
411
|
-
execution.on('notification', listener);
|
|
412
|
-
return () => execution.off('notification', listener);
|
|
413
|
-
},
|
|
414
|
-
threadId: created.threadId,
|
|
415
|
-
prompt,
|
|
416
|
-
cwd: target.cwd || null,
|
|
417
|
-
}).catch(error => console.error(`生成对话标题失败:${error.message}`));
|
|
418
|
-
sendJson(response, 201, { ...created, hostId:'local' });
|
|
419
|
-
return;
|
|
420
|
-
}
|
|
421
|
-
const argumentsValue = await createThreadArguments(projectId, body, projects);
|
|
422
|
-
sendToolResult(response, await callTool('create_thread', argumentsValue, 90_000));
|
|
423
|
-
return;
|
|
287
|
+
const body = await readJsonBody(request);
|
|
288
|
+
if (body.transport === 'app-server' || (body.hostId && body.hostId !== 'local')) {
|
|
289
|
+
throw new RequestError(410, '此连接方式已停用,请检查 Codex 内核状态。', { code: 'unsupported_control' });
|
|
290
|
+
}
|
|
291
|
+
const projects = (await sharedCatalog.projects()).projects;
|
|
292
|
+
const project = projects.find(item => item.projectId === projectId);
|
|
293
|
+
if (projectId !== '__unassigned__' && !project) throw new RequestError(404, '没有找到对应项目。');
|
|
294
|
+
const execution = sharedServer;
|
|
295
|
+
await execution.connect();
|
|
296
|
+
const rpc = (method, params) => execution.request(method, params);
|
|
297
|
+
let target = appServerEvents.projectlessThreadTarget(projectRoot);
|
|
298
|
+
if (project) {
|
|
299
|
+
const nativeProjects = await appServerEvents.readProjects(rpc);
|
|
300
|
+
const nativeId = nativeProjects.find(item => item.id === project.projectId)?.id;
|
|
301
|
+
if (!nativeId) throw new RequestError(404, 'Codex 内核中没有找到对应项目,请刷新项目列表。');
|
|
302
|
+
target = { projectId:nativeId, cwd:project.path };
|
|
303
|
+
}
|
|
304
|
+
const { input } = appServerTurnArguments('', body);
|
|
305
|
+
const created = await appServerEvents.createThread(rpc, target, input, body);
|
|
306
|
+
const prompt = input.filter(part => part.type === 'text').map(part => part.text).join('\n');
|
|
307
|
+
void generateAndSetThreadTitle({
|
|
308
|
+
request: rpc,
|
|
309
|
+
subscribe: listener => {
|
|
310
|
+
execution.on('notification', listener);
|
|
311
|
+
return () => execution.off('notification', listener);
|
|
312
|
+
},
|
|
313
|
+
threadId: created.threadId,
|
|
314
|
+
prompt,
|
|
315
|
+
cwd: target.cwd || null,
|
|
316
|
+
}).catch(error => console.error(`生成对话标题失败:${error.message}`));
|
|
317
|
+
sendJson(response, 201, { ...created, hostId:'local' });
|
|
318
|
+
return;
|
|
424
319
|
}
|
|
425
320
|
|
|
426
|
-
const timelineMatch = /^\/api\/threads\/([^/]+)\/timeline$/.exec(url.pathname);
|
|
321
|
+
const timelineMatch = /^\/api\/threads\/([^/]+)\/timeline$/.exec(url.pathname);
|
|
427
322
|
if (request.method === 'GET' && timelineMatch) {
|
|
428
323
|
const threadId = decodeURIComponent(timelineMatch[1]);
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
}
|
|
449
|
-
if (appServerTasks.owns(threadId)) {
|
|
450
|
-
const thread = await appServerTasks.readTask(threadId, {
|
|
451
|
-
includeTurns: true,
|
|
452
|
-
turnLimit: boundedInteger(url.searchParams.get('turnLimit'), 10, 1, 10),
|
|
453
|
-
});
|
|
454
|
-
sendJson(response, 200, normalizeAppServerTimeline(thread));
|
|
455
|
-
return;
|
|
456
|
-
}
|
|
457
|
-
const argumentsValue = readThreadArguments(url, threadId, 10);
|
|
458
|
-
const result = await callTool('read_thread', argumentsValue);
|
|
459
|
-
if (result.success) {
|
|
460
|
-
const timeline = normalizeTimeline(result.data);
|
|
461
|
-
enrichTimelineWithLocalActivity(timeline, threadId);
|
|
462
|
-
sendJson(response, 200, timeline);
|
|
463
|
-
} else {
|
|
464
|
-
sendJson(response, 502, { error: 'Codex Desktop 工具返回失败。', details: result.data });
|
|
465
|
-
}
|
|
466
|
-
return;
|
|
467
|
-
}
|
|
324
|
+
const timeline = normalizeSharedTimeline(await sharedServer.timeline(threadId, sharedHistoryPageOptions()));
|
|
325
|
+
const knownUpdatedAt = Number(url.searchParams.get('knownUpdatedAt'));
|
|
326
|
+
const knownTurnId = url.searchParams.get('knownTurnId') || '';
|
|
327
|
+
const knownTurnStatus = url.searchParams.get('knownTurnStatus') || '';
|
|
328
|
+
const knownHistoryView = url.searchParams.get('knownHistoryView') || '';
|
|
329
|
+
const latest = timeline.turns.at(-1);
|
|
330
|
+
const knownIndex = knownTurnId ? timeline.turns.findIndex(turn => turn.id === knownTurnId) : -1;
|
|
331
|
+
const sameHistoryView = knownHistoryView === timeline.historyView;
|
|
332
|
+
const unchanged = sameHistoryView
|
|
333
|
+
&& knownIndex === timeline.turns.length - 1
|
|
334
|
+
&& timeline.thread.status === 'idle' && latest?.status !== 'inProgress'
|
|
335
|
+
&& knownTurnStatus && knownTurnStatus !== 'inProgress'
|
|
336
|
+
&& (!Number.isFinite(knownUpdatedAt) || knownUpdatedAt <= 0 || timeline.thread.updatedAt === knownUpdatedAt);
|
|
337
|
+
const partial = !unchanged && sameHistoryView && knownIndex >= 0
|
|
338
|
+
? { ...timeline, turns: timeline.turns.slice(knownIndex), partialFromTurnId: knownTurnId }
|
|
339
|
+
: timeline;
|
|
340
|
+
sendJson(response, 200, unchanged ? { ...timeline, turns: [], notModified: true } : partial);
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
468
343
|
|
|
469
344
|
const approvalMatch = /^\/api\/threads\/([^/]+)\/approvals$/.exec(url.pathname);
|
|
470
345
|
if (request.method === 'POST' && approvalMatch) {
|
|
471
346
|
const threadId = decodeURIComponent(approvalMatch[1]);
|
|
472
347
|
await requireExecutionWritable(threadId);
|
|
473
|
-
|
|
474
|
-
sendJson(response, 409, { error: '当前执行模式不支持手机审批,请在工作站处理。' }); return;
|
|
475
|
-
}
|
|
476
|
-
const body = await readJsonBody(request);
|
|
348
|
+
const body = await readJsonBody(request);
|
|
477
349
|
sendJson(response, 200, await sharedServer.respondApproval(threadId, body)); return;
|
|
478
350
|
}
|
|
479
351
|
|
|
480
352
|
const eventsMatch = /^\/api\/threads\/([^/]+)\/events$/.exec(url.pathname);
|
|
481
|
-
if (request.method === 'GET' && eventsMatch) {
|
|
482
|
-
const threadId = decodeURIComponent(eventsMatch[1]);
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
}
|
|
496
|
-
if (appServerTasks.owns(threadId)) {
|
|
497
|
-
await streamAppServerThreadEvents(request, response, threadId);
|
|
498
|
-
return;
|
|
499
|
-
}
|
|
500
|
-
await streamThreadEvents(
|
|
501
|
-
request,
|
|
502
|
-
response,
|
|
503
|
-
threadId,
|
|
504
|
-
url.searchParams.get('hostId')?.trim(),
|
|
505
|
-
);
|
|
506
|
-
return;
|
|
507
|
-
}
|
|
353
|
+
if (request.method === 'GET' && eventsMatch) {
|
|
354
|
+
const threadId = decodeURIComponent(eventsMatch[1]);
|
|
355
|
+
const after = Number(url.searchParams.get('afterSeq'));
|
|
356
|
+
const requestedSync = url.searchParams.get('syncId') && url.searchParams.get('syncEpoch') && Number.isSafeInteger(after) && after >= 0
|
|
357
|
+
? { id: url.searchParams.get('syncId'), epoch: url.searchParams.get('syncEpoch'), after } : undefined;
|
|
358
|
+
const knownUpdatedAt = Number(url.searchParams.get('knownUpdatedAt'));
|
|
359
|
+
const known = url.searchParams.get('knownTurnId') ? {
|
|
360
|
+
updatedAt: Number.isFinite(knownUpdatedAt) ? knownUpdatedAt : 0,
|
|
361
|
+
turnId: url.searchParams.get('knownTurnId'),
|
|
362
|
+
turnStatus: url.searchParams.get('knownTurnStatus') || '',
|
|
363
|
+
} : undefined;
|
|
364
|
+
await streamSharedThread(response, threadId, sharedServer, normalizeSharedTimeline, requestedSync, known);
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
508
367
|
|
|
509
368
|
const contextMatch = /^\/api\/threads\/([^/]+)\/context$/.exec(url.pathname);
|
|
510
369
|
if (request.method === 'GET' && contextMatch) {
|
|
511
370
|
const threadId = decodeURIComponent(contextMatch[1]);
|
|
512
371
|
const current = readContextUsage(threadId);
|
|
513
|
-
const native =
|
|
372
|
+
const native = await sharedServer.readContext(threadId);
|
|
514
373
|
sendJson(response, 200, { ...current, ...(native.model ? { model: native.model, thinking: native.thinking, serviceTier: native.serviceTier,
|
|
515
374
|
permissionMode: native.permissionMode, permissionModes: native.permissionModes } : {}), ...(native.available ? native : {}) });
|
|
516
375
|
return;
|
|
@@ -519,7 +378,6 @@ async function handleRequest(request, response) {
|
|
|
519
378
|
const sharedAction = /^\/api\/threads\/([^/]+)\/(history|settings|permissions)$/.exec(url.pathname);
|
|
520
379
|
if (sharedAction) {
|
|
521
380
|
const id = decodeURIComponent(sharedAction[1]);
|
|
522
|
-
if (!usesSharedTask({ hostId: url.searchParams.get('hostId') }) || appServerTasks.owns(id)) throw new RequestError(400, '此连接不支持该操作。');
|
|
523
381
|
if (request.method === 'GET' && sharedAction[2] === 'history') {
|
|
524
382
|
sendJson(response, 200, normalizeSharedTimeline(await sharedServer.history(id, url.searchParams.get('cursor'), sharedHistoryPageOptions())));
|
|
525
383
|
return;
|
|
@@ -543,9 +401,6 @@ async function handleRequest(request, response) {
|
|
|
543
401
|
const threadId = decodeURIComponent(sharedOperationGroup[1]);
|
|
544
402
|
const turnId = decodeURIComponent(sharedOperationGroup[2]);
|
|
545
403
|
const groupId = decodeURIComponent(sharedOperationGroup[3]);
|
|
546
|
-
if (!usesSharedTask({ hostId: url.searchParams.get('hostId') }) || appServerTasks.owns(threadId)) {
|
|
547
|
-
throw new RequestError(400, '此连接不支持操作组分页。');
|
|
548
|
-
}
|
|
549
404
|
const page = await sharedServer.operationGroupItems(threadId, turnId, groupId, url.searchParams.get('offset'));
|
|
550
405
|
sendJson(response, 200, { turnId: page.turnId, groupId: page.groupId,
|
|
551
406
|
entries: page.items.map(normalizeItem).filter(Boolean), nextOffset: page.nextOffset, total: page.total });
|
|
@@ -556,187 +411,55 @@ async function handleRequest(request, response) {
|
|
|
556
411
|
if (request.method === 'POST' && archiveMatch) {
|
|
557
412
|
const threadId = decodeURIComponent(archiveMatch[1]);
|
|
558
413
|
await requireExecutionWritable(threadId);
|
|
559
|
-
if (!usesSharedTask({ hostId: url.searchParams.get('hostId') }) || appServerTasks.owns(threadId)) {
|
|
560
|
-
throw new RequestError(400, '当前连接不支持归档对话。请启用完整控制。');
|
|
561
|
-
}
|
|
562
414
|
await sharedServer.request(`thread/${archiveMatch[2]}`, { threadId });
|
|
563
415
|
sendJson(response, 200, { threadId, archived: archiveMatch[2] === 'archive' });
|
|
564
416
|
return;
|
|
565
417
|
}
|
|
566
418
|
|
|
567
|
-
const
|
|
568
|
-
if (
|
|
569
|
-
const threadId = decodeURIComponent(
|
|
570
|
-
await
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
}
|
|
574
|
-
await appServerTasks.deleteTask(threadId);
|
|
575
|
-
messageQueues.delete(threadId);
|
|
576
|
-
sendJson(response, 200, { deleted: true, threadId, transport: 'app-server' });
|
|
577
|
-
return;
|
|
578
|
-
}
|
|
579
|
-
if (request.method === 'GET' && readMatch) {
|
|
580
|
-
const threadId = decodeURIComponent(readMatch[1]);
|
|
581
|
-
if (appServerTasks.owns(threadId)) {
|
|
582
|
-
sendJson(response, 200, { thread: await appServerTasks.readTask(threadId, {
|
|
583
|
-
includeTurns: true,
|
|
584
|
-
turnLimit: boundedInteger(url.searchParams.get('turnLimit'), 10, 1, 10),
|
|
585
|
-
}) });
|
|
586
|
-
return;
|
|
587
|
-
}
|
|
588
|
-
const argumentsValue = readThreadArguments(url, threadId, 10, 12_000);
|
|
589
|
-
sendToolResult(response, await callTool('read_thread', argumentsValue));
|
|
590
|
-
return;
|
|
591
|
-
}
|
|
592
|
-
|
|
593
|
-
const queueMatch = /^\/api\/threads\/([^/]+)\/queue$/.exec(url.pathname);
|
|
594
|
-
if (queueMatch) {
|
|
595
|
-
const threadId = decodeURIComponent(queueMatch[1]);
|
|
596
|
-
if (!appServerTasks.owns(threadId) && usesSharedTask({ hostId: url.searchParams.get('hostId') })) {
|
|
597
|
-
await sharedServer.connect();
|
|
598
|
-
const rpc = (method, params) => sharedServer.request(method, params);
|
|
599
|
-
if (request.method === 'GET') { sendJson(response, 200, await appServerEvents.readQueue(rpc, threadId)); return; }
|
|
600
|
-
if (request.method === 'POST') {
|
|
601
|
-
await requireExecutionWritable(threadId);
|
|
602
|
-
const args = appServerTurnArguments(threadId, await readJsonBody(request));
|
|
603
|
-
sendJson(response, 202, await appServerEvents.addQueue(rpc, threadId, args.input, crypto.randomUUID())); return;
|
|
604
|
-
}
|
|
605
|
-
}
|
|
606
|
-
if (request.method === 'GET') {
|
|
607
|
-
sendJson(response, 200, { items: publicQueue(threadId) });
|
|
608
|
-
return;
|
|
609
|
-
}
|
|
419
|
+
const queueMatch = /^\/api\/threads\/([^/]+)\/queue$/.exec(url.pathname);
|
|
420
|
+
if (queueMatch) {
|
|
421
|
+
const threadId = decodeURIComponent(queueMatch[1]);
|
|
422
|
+
await sharedServer.connect();
|
|
423
|
+
const rpc = (method, params) => sharedServer.request(method, params);
|
|
424
|
+
if (request.method === 'GET') { sendJson(response, 200, await appServerEvents.readQueue(rpc, threadId)); return; }
|
|
610
425
|
if (request.method === 'POST') {
|
|
611
426
|
await requireExecutionWritable(threadId);
|
|
612
|
-
const
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
const item = {
|
|
617
|
-
id: crypto.randomUUID(),
|
|
618
|
-
...argumentsValue,
|
|
619
|
-
transport: appServerTasks.owns(threadId) ? 'app-server' : 'desktop-tools',
|
|
620
|
-
displayPrompt: messageDisplayText(body),
|
|
621
|
-
text: typeof body.prompt === 'string' ? body.prompt.trim() : '',
|
|
622
|
-
attachments: (body.images || []).map(id => ({ type:'image', available:true, url:id.startsWith('data:') ? id : `/api/attachments/${id}`, thumbnailUrl:id.startsWith('data:') ? id : `/api/attachments/${id}/thumbnail` })),
|
|
623
|
-
createdAt: new Date().toISOString(),
|
|
624
|
-
error: '',
|
|
625
|
-
};
|
|
626
|
-
const queue = messageQueues.get(threadId) || [];
|
|
627
|
-
queue.push(item);
|
|
628
|
-
messageQueues.set(threadId, queue);
|
|
629
|
-
ensureQueueWorker(threadId);
|
|
630
|
-
sendJson(response, 202, { items: publicQueue(threadId) });
|
|
631
|
-
return;
|
|
632
|
-
}
|
|
633
|
-
}
|
|
427
|
+
const args = appServerTurnArguments(threadId, await readJsonBody(request));
|
|
428
|
+
sendJson(response, 202, await appServerEvents.addQueue(rpc, threadId, args.input, crypto.randomUUID())); return;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
634
431
|
|
|
635
432
|
const queueItemMatch = /^\/api\/threads\/([^/]+)\/queue\/([a-f0-9-]+)(?:\/(steer))?$/.exec(url.pathname);
|
|
636
433
|
if (queueItemMatch) {
|
|
637
434
|
const threadId = decodeURIComponent(queueItemMatch[1]);
|
|
638
435
|
const itemId = queueItemMatch[2];
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
if (!action) throw new RequestError(405, '不支持的队列操作');
|
|
643
|
-
await requireExecutionWritable(threadId);
|
|
644
|
-
sendJson(response, 200, await appServerEvents.changeQueue((method, params) => sharedServer.request(method, params), threadId, itemId, action)); return;
|
|
645
|
-
}
|
|
646
|
-
const queue = messageQueues.get(threadId) || [];
|
|
647
|
-
const index = queue.findIndex(item => item.id === itemId);
|
|
648
|
-
if (index < 0) {
|
|
649
|
-
sendJson(response, 404, { error: '没有找到该排队消息。' });
|
|
650
|
-
return;
|
|
651
|
-
}
|
|
652
|
-
if (request.method === 'DELETE' && !queueItemMatch[3]) {
|
|
653
|
-
await requireExecutionWritable(threadId);
|
|
654
|
-
queue.splice(index, 1);
|
|
655
|
-
sendJson(response, 200, { items: publicQueue(threadId) });
|
|
656
|
-
return;
|
|
657
|
-
}
|
|
658
|
-
if (request.method === 'POST' && queueItemMatch[3] === 'steer') {
|
|
659
|
-
await requireExecutionWritable(threadId);
|
|
660
|
-
const [item] = queue.splice(index, 1);
|
|
661
|
-
const result = await steerQueuedItem(threadId, item);
|
|
662
|
-
if (!result.success) {
|
|
663
|
-
queue.splice(index, 0, item);
|
|
664
|
-
sendJson(response, 502, { error: '插队发送失败。', details: result.data });
|
|
665
|
-
return;
|
|
666
|
-
}
|
|
667
|
-
sendJson(response, 200, { items: publicQueue(threadId) });
|
|
668
|
-
return;
|
|
669
|
-
}
|
|
670
|
-
}
|
|
671
|
-
|
|
672
|
-
const takeoverMatch = /^\/api\/threads\/([^/]+)\/takeover$/.exec(url.pathname);
|
|
673
|
-
if (request.method === 'POST' && takeoverMatch) {
|
|
674
|
-
const threadId = decodeURIComponent(takeoverMatch[1]);
|
|
436
|
+
await sharedServer.connect();
|
|
437
|
+
const action = request.method === 'DELETE' ? 'delete' : queueItemMatch[3] === 'steer' ? 'steer' : null;
|
|
438
|
+
if (!action) throw new RequestError(405, '不支持的队列操作');
|
|
675
439
|
await requireExecutionWritable(threadId);
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
return;
|
|
679
|
-
}
|
|
680
|
-
if (messageQueues.get(threadId)?.length || queueWorkers.has(threadId)) throw new RequestError(409, '请先处理此聊天的排队消息。');
|
|
681
|
-
const result = await callTool('read_thread', { threadId, turnLimit: 1, includeOutputs: false });
|
|
682
|
-
if (!result.success) throw new RequestError(502, '无法确认 Desktop 聊天状态。');
|
|
683
|
-
const timeline = normalizeTimeline(result.data);
|
|
684
|
-
const thread = timeline.thread;
|
|
685
|
-
if (thread.hostId && thread.hostId !== 'local') throw new RequestError(400, '只支持本机聊天。');
|
|
686
|
-
if (!['idle', 'notLoaded'].includes(thread.status) || timeline.turns.some(t => t.status === 'inProgress')) throw new RequestError(409, '只允许接管已空闲的聊天,请等当前轮次结束。');
|
|
687
|
-
if (!findSessionPath(threadId)) throw new RequestError(400, '未找到此聊天的本机历史。');
|
|
688
|
-
sendJson(response, 200, { thread: await appServerTasks.adoptTask({ ...thread, id: threadId }) });
|
|
689
|
-
return;
|
|
690
|
-
}
|
|
440
|
+
sendJson(response, 200, await appServerEvents.changeQueue((method, params) => sharedServer.request(method, params), threadId, itemId, action)); return;
|
|
441
|
+
}
|
|
691
442
|
|
|
692
|
-
const interruptMatch = /^\/api\/threads\/([^/]+)\/interrupt$/.exec(url.pathname);
|
|
443
|
+
const interruptMatch = /^\/api\/threads\/([^/]+)\/interrupt$/.exec(url.pathname);
|
|
693
444
|
if (request.method === 'POST' && interruptMatch) {
|
|
694
445
|
const threadId = decodeURIComponent(interruptMatch[1]);
|
|
695
446
|
await requireExecutionWritable(threadId);
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
return;
|
|
702
|
-
}
|
|
703
|
-
if (!appServerTasks.owns(threadId)) throw new RequestError(409, '请先接管空闲聊天。');
|
|
704
|
-
const body = await readJsonBody(request);
|
|
705
|
-
const thread = await appServerTasks.readTask(threadId, { turnLimit: 1 });
|
|
706
|
-
const active = thread.turns?.at(-1);
|
|
707
|
-
if (!body.turnId || active?.id !== body.turnId || statusText(active.status) !== 'inProgress') throw new RequestError(409, '此轮次已经结束或发生变化。');
|
|
708
|
-
// Stop also clears pending messages so the queue cannot restart work.
|
|
709
|
-
messageQueues.delete(threadId);
|
|
710
|
-
await appServerTasks.interruptTurn(threadId, body.turnId);
|
|
711
|
-
sendJson(response, 200, { requested: true });
|
|
712
|
-
return;
|
|
447
|
+
const body = await readJsonBody(request);
|
|
448
|
+
if (!body.turnId) throw new RequestError(400, '缺少活动轮次 ID。');
|
|
449
|
+
await sharedServer.interrupt(threadId, body.turnId);
|
|
450
|
+
sendJson(response, 200, { requested: true });
|
|
451
|
+
return;
|
|
713
452
|
}
|
|
714
453
|
|
|
715
454
|
const messageMatch = /^\/api\/threads\/([^/]+)\/messages$/.exec(url.pathname);
|
|
716
455
|
if (request.method === 'POST' && messageMatch) {
|
|
717
456
|
const threadId = decodeURIComponent(messageMatch[1]);
|
|
718
457
|
await requireExecutionWritable(threadId);
|
|
719
|
-
const body = await readJsonBody(request);
|
|
720
|
-
if (
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
const current = await appServerTasks.readTask(threadId, { turnLimit: 1 });
|
|
725
|
-
const active = current.turns?.at(-1);
|
|
726
|
-
sendJson(response, 200, statusText(active?.status) === 'inProgress'
|
|
727
|
-
? await appServerTasks.steerTurn(threadId, active.id, args.input)
|
|
728
|
-
: await appServerTasks.startTurn(threadId, args));
|
|
729
|
-
} else {
|
|
730
|
-
sendToolResult(response, await callTool('send_message_to_thread', await messageArguments(threadId, body)));
|
|
731
|
-
}
|
|
732
|
-
return;
|
|
733
|
-
}
|
|
734
|
-
|
|
735
|
-
if (request.method === 'POST' && url.pathname === '/api/wait') {
|
|
736
|
-
const body = await readJsonBody(request);
|
|
737
|
-
sendToolResult(response, await callTool('wait_threads', body, 130_000));
|
|
738
|
-
return;
|
|
739
|
-
}
|
|
458
|
+
const body = await readJsonBody(request);
|
|
459
|
+
if (body.hostId && body.hostId !== 'local') throw new RequestError(410, '此对话的连接方式已停用。', { code: 'unsupported_control' });
|
|
460
|
+
sendJson(response, 200, { success: true, data: await sharedServer.send(appServerTurnArguments(threadId, body)) });
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
740
463
|
|
|
741
464
|
sendJson(response, 404, { error: '未找到请求的资源。' });
|
|
742
465
|
} catch (error) {
|
|
@@ -760,17 +483,7 @@ const lanSocket = attachLanSocket(server, { token: options.token, dispatch: hand
|
|
|
760
483
|
authorizeDevice: (clientId, credential) => authorizeLanDevice(stateRoot, clientId, credential),
|
|
761
484
|
});
|
|
762
485
|
|
|
763
|
-
if (options.appServer) {
|
|
764
|
-
try {
|
|
765
|
-
await appServer.start();
|
|
766
|
-
} catch (error) {
|
|
767
|
-
console.error(`app-server 启动失败:${error instanceof Error ? error.message : String(error)}`);
|
|
768
|
-
process.exit(2);
|
|
769
|
-
}
|
|
770
|
-
}
|
|
771
|
-
|
|
772
486
|
let control;
|
|
773
|
-
desktopMonitor.start();
|
|
774
487
|
void managedAppServer.start();
|
|
775
488
|
executionHealth.start();
|
|
776
489
|
server.on('error', error => {
|
|
@@ -807,147 +520,14 @@ async function shutDown() {
|
|
|
807
520
|
shuttingDown = true;
|
|
808
521
|
lanSocket.close();
|
|
809
522
|
server.close();
|
|
810
|
-
desktopMonitor?.stop();
|
|
811
523
|
executionHealth?.stop();
|
|
812
524
|
managedAppServer.close();
|
|
813
|
-
await appServer.close();
|
|
814
525
|
await control?.close();
|
|
815
526
|
process.exit(0);
|
|
816
527
|
}
|
|
817
528
|
process.on('SIGTERM', shutDown);
|
|
818
529
|
process.on('SIGINT', shutDown);
|
|
819
530
|
|
|
820
|
-
async function callTool(tool, argumentsValue, timeoutMs = 60_000, targetPipe) {
|
|
821
|
-
if (!sourceThreadId) throw new RequestError(503, '基础控制尚未绑定调用上下文;请在 Codex 对话启动一次 Code Relax。电脑管理连接仍可使用。');
|
|
822
|
-
if (!ALLOWED_TOOLS.has(tool)) {
|
|
823
|
-
throw new RequestError(403, `工具 ${tool} 不在 Bridge 允许列表中。`);
|
|
824
|
-
}
|
|
825
|
-
|
|
826
|
-
const invocationId = crypto.randomUUID();
|
|
827
|
-
const rpc = await callDesktop('tools/call', {
|
|
828
|
-
arguments: argumentsValue,
|
|
829
|
-
callId: `codex-remote-${invocationId}`,
|
|
830
|
-
namespace: 'codex_app',
|
|
831
|
-
threadId: sourceThreadId,
|
|
832
|
-
tool,
|
|
833
|
-
turnId: `codex-remote-turn-${invocationId}`,
|
|
834
|
-
}, timeoutMs, targetPipe);
|
|
835
|
-
|
|
836
|
-
const result = rpc.result;
|
|
837
|
-
if (!result || typeof result.success !== 'boolean') {
|
|
838
|
-
throw new BridgeError('Codex Desktop 没有返回有效的工具结果。', rpc);
|
|
839
|
-
}
|
|
840
|
-
|
|
841
|
-
const items = Array.isArray(result.contentItems) ? result.contentItems : [];
|
|
842
|
-
let data = items;
|
|
843
|
-
if (items.length === 1 && items[0]?.type === 'inputText' && typeof items[0].text === 'string') {
|
|
844
|
-
try {
|
|
845
|
-
data = JSON.parse(items[0].text);
|
|
846
|
-
} catch {
|
|
847
|
-
data = items[0].text;
|
|
848
|
-
}
|
|
849
|
-
}
|
|
850
|
-
|
|
851
|
-
return { success: result.success, data };
|
|
852
|
-
}
|
|
853
|
-
|
|
854
|
-
function callDesktop(method, params, timeoutMs, targetPipe = desktopMonitor?.pipe || pipePath) {
|
|
855
|
-
if (!targetPipe) return Promise.reject(new RequestError(503, 'Desktop 工具通道尚未就绪。'));
|
|
856
|
-
return new Promise((resolve, reject) => {
|
|
857
|
-
const socket = net.createConnection(targetPipe);
|
|
858
|
-
const requestId = ++nextRequestId;
|
|
859
|
-
const payload = Buffer.from(JSON.stringify({ jsonrpc: '2.0', id: requestId, method, params }), 'utf8');
|
|
860
|
-
const header = Buffer.allocUnsafe(4);
|
|
861
|
-
header.writeUInt32LE(payload.length, 0);
|
|
862
|
-
let pending = Buffer.alloc(0);
|
|
863
|
-
let expectedLength = null;
|
|
864
|
-
let settled = false;
|
|
865
|
-
|
|
866
|
-
const finish = (callback, value) => {
|
|
867
|
-
if (settled) return;
|
|
868
|
-
settled = true;
|
|
869
|
-
clearTimeout(timer);
|
|
870
|
-
socket.destroy();
|
|
871
|
-
callback(value);
|
|
872
|
-
};
|
|
873
|
-
|
|
874
|
-
const timer = setTimeout(() => {
|
|
875
|
-
finish(reject, new BridgeError('等待 Codex Desktop 响应超时。'));
|
|
876
|
-
}, timeoutMs);
|
|
877
|
-
|
|
878
|
-
socket.once('connect', () => socket.write(Buffer.concat([header, payload])));
|
|
879
|
-
socket.on('data', chunk => {
|
|
880
|
-
pending = Buffer.concat([pending, chunk]);
|
|
881
|
-
if (expectedLength === null && pending.length >= 4) {
|
|
882
|
-
expectedLength = pending.readUInt32LE(0);
|
|
883
|
-
pending = pending.subarray(4);
|
|
884
|
-
if (expectedLength <= 0 || expectedLength > MAX_FRAME_BYTES) {
|
|
885
|
-
finish(reject, new BridgeError(`Codex Desktop 返回了无效帧长度:${expectedLength}。`));
|
|
886
|
-
return;
|
|
887
|
-
}
|
|
888
|
-
}
|
|
889
|
-
|
|
890
|
-
if (expectedLength !== null && pending.length >= expectedLength) {
|
|
891
|
-
try {
|
|
892
|
-
const responseValue = JSON.parse(pending.subarray(0, expectedLength).toString('utf8'));
|
|
893
|
-
if (responseValue.error) {
|
|
894
|
-
finish(reject, new BridgeError('Codex Desktop 工具调用失败。', responseValue.error));
|
|
895
|
-
} else if (responseValue.id !== requestId) {
|
|
896
|
-
finish(reject, new BridgeError('Codex Desktop 返回了不匹配的请求 ID。'));
|
|
897
|
-
} else {
|
|
898
|
-
finish(resolve, responseValue);
|
|
899
|
-
}
|
|
900
|
-
} catch (error) {
|
|
901
|
-
finish(reject, new BridgeError('无法解析 Codex Desktop 响应。', undefined, error));
|
|
902
|
-
}
|
|
903
|
-
}
|
|
904
|
-
});
|
|
905
|
-
socket.once('error', error => finish(reject, new BridgeError(
|
|
906
|
-
'无法连接 Codex Desktop Pipe。桌面版可能已重启,请重新启动 Bridge。',
|
|
907
|
-
undefined,
|
|
908
|
-
error,
|
|
909
|
-
)));
|
|
910
|
-
socket.once('close', () => {
|
|
911
|
-
if (!settled) finish(reject, new BridgeError('Codex Desktop Pipe 在返回结果前关闭。'));
|
|
912
|
-
});
|
|
913
|
-
});
|
|
914
|
-
}
|
|
915
|
-
|
|
916
|
-
function sendToolResult(response, result) {
|
|
917
|
-
if (result.success) {
|
|
918
|
-
sendJson(response, 200, result.data);
|
|
919
|
-
} else {
|
|
920
|
-
sendJson(response, 502, { error: 'Codex Desktop 工具返回失败。', details: result.data });
|
|
921
|
-
}
|
|
922
|
-
}
|
|
923
|
-
|
|
924
|
-
function appServerBridgeStatus() {
|
|
925
|
-
return {
|
|
926
|
-
...appServer.snapshot(),
|
|
927
|
-
platformFamily: appServer.initializeResult?.platformFamily || null,
|
|
928
|
-
platformOs: appServer.initializeResult?.platformOs || null,
|
|
929
|
-
taskCount: appServerTasks.store.list().length,
|
|
930
|
-
ownershipError: appServerTasks.store.loadError?.message || null,
|
|
931
|
-
};
|
|
932
|
-
}
|
|
933
|
-
|
|
934
|
-
function readThreadArguments(url, threadId, defaultTurnLimit, defaultOutputLimit = 4_000) {
|
|
935
|
-
const argumentsValue = {
|
|
936
|
-
threadId,
|
|
937
|
-
turnLimit: boundedInteger(url.searchParams.get('turnLimit'), defaultTurnLimit, 1, 10),
|
|
938
|
-
includeOutputs: booleanValue(url.searchParams.get('includeOutputs'), true),
|
|
939
|
-
maxOutputCharsPerItem: boundedInteger(
|
|
940
|
-
url.searchParams.get('maxOutputCharsPerItem'),
|
|
941
|
-
defaultOutputLimit,
|
|
942
|
-
500,
|
|
943
|
-
100_000,
|
|
944
|
-
),
|
|
945
|
-
};
|
|
946
|
-
const hostId = url.searchParams.get('hostId')?.trim();
|
|
947
|
-
if (hostId) argumentsValue.hostId = hostId;
|
|
948
|
-
return argumentsValue;
|
|
949
|
-
}
|
|
950
|
-
|
|
951
531
|
function readContextUsage(threadId) {
|
|
952
532
|
if (!/^[0-9a-f]{8}-[0-9a-f-]{20,55}$/i.test(threadId)) {
|
|
953
533
|
return { available: false };
|
|
@@ -1070,108 +650,6 @@ function findSessionPath(threadId) {
|
|
|
1070
650
|
return null;
|
|
1071
651
|
}
|
|
1072
652
|
|
|
1073
|
-
function enrichTimelineWithLocalActivity(timeline, threadId) {
|
|
1074
|
-
const turn = timeline.turns.at(-1);
|
|
1075
|
-
if (!turn || turn.status !== 'inProgress') return;
|
|
1076
|
-
if (turn.entries.some(entry => entry?.type === 'activity' && entry.status === 'inProgress')) return;
|
|
1077
|
-
const pending = readPendingLocalActivity(threadId);
|
|
1078
|
-
if (!pending || (pending.turnId && pending.turnId !== turn.id)) return;
|
|
1079
|
-
turn.entries.push(pending.entry);
|
|
1080
|
-
}
|
|
1081
|
-
|
|
1082
|
-
function readPendingLocalActivity(threadId) {
|
|
1083
|
-
const sessionPath = findSessionPath(threadId);
|
|
1084
|
-
if (!sessionPath) return null;
|
|
1085
|
-
let handle;
|
|
1086
|
-
try {
|
|
1087
|
-
const stat = fs.statSync(sessionPath);
|
|
1088
|
-
const cacheKey = `${stat.size}:${stat.mtimeMs}`;
|
|
1089
|
-
const cached = liveActivityCache.get(sessionPath);
|
|
1090
|
-
if (cached?.key === cacheKey) return cached.value;
|
|
1091
|
-
const readLength = Math.min(stat.size, MAX_LIVE_ACTIVITY_TAIL_BYTES);
|
|
1092
|
-
const buffer = Buffer.allocUnsafe(readLength);
|
|
1093
|
-
handle = fs.openSync(sessionPath, 'r');
|
|
1094
|
-
fs.readSync(handle, buffer, 0, readLength, stat.size - readLength);
|
|
1095
|
-
const lines = buffer.toString('utf8').split(/\r?\n/);
|
|
1096
|
-
if (readLength < stat.size) lines.shift();
|
|
1097
|
-
const pending = new Map();
|
|
1098
|
-
for (const line of lines) {
|
|
1099
|
-
if (!line.includes('"custom_tool_call')) continue;
|
|
1100
|
-
let event;
|
|
1101
|
-
try { event = JSON.parse(line); } catch { continue; }
|
|
1102
|
-
if (event?.type !== 'response_item') continue;
|
|
1103
|
-
const payload = event.payload;
|
|
1104
|
-
const callId = payload?.call_id;
|
|
1105
|
-
if (!callId) continue;
|
|
1106
|
-
if (payload.type === 'custom_tool_call') pending.set(callId, payload);
|
|
1107
|
-
if (payload.type === 'custom_tool_call_output') pending.delete(callId);
|
|
1108
|
-
}
|
|
1109
|
-
const payload = [...pending.values()].at(-1);
|
|
1110
|
-
const value = payload ? pendingActivity(payload) : null;
|
|
1111
|
-
liveActivityCache.set(sessionPath, { key: cacheKey, value });
|
|
1112
|
-
return value;
|
|
1113
|
-
} catch {
|
|
1114
|
-
liveActivityCache.delete(sessionPath);
|
|
1115
|
-
return null;
|
|
1116
|
-
} finally {
|
|
1117
|
-
if (handle !== undefined) fs.closeSync(handle);
|
|
1118
|
-
}
|
|
1119
|
-
}
|
|
1120
|
-
|
|
1121
|
-
function pendingActivity(payload) {
|
|
1122
|
-
const input = typeof payload.input === 'string' ? payload.input : '';
|
|
1123
|
-
const nestedTool = input.match(/tools\.([A-Za-z0-9_]+)\s*\(/)?.[1] || payload.name || 'tool';
|
|
1124
|
-
const id = `live:${payload.call_id}`;
|
|
1125
|
-
const common = { type: 'activity', id, status: 'inProgress', transient: true };
|
|
1126
|
-
const turnId = payload.internal_chat_message_metadata_passthrough?.turn_id || '';
|
|
1127
|
-
if (nestedTool === 'exec_command') {
|
|
1128
|
-
return {
|
|
1129
|
-
turnId,
|
|
1130
|
-
entry: {
|
|
1131
|
-
...common,
|
|
1132
|
-
kind: 'command',
|
|
1133
|
-
title: '命令',
|
|
1134
|
-
command: jsonStringField(input, 'cmd'),
|
|
1135
|
-
cwd: jsonStringField(input, 'workdir'),
|
|
1136
|
-
},
|
|
1137
|
-
};
|
|
1138
|
-
}
|
|
1139
|
-
if (nestedTool === 'apply_patch') {
|
|
1140
|
-
return { turnId, entry: { ...common, kind: 'fileChange', title: '文件修改', changes: [] } };
|
|
1141
|
-
}
|
|
1142
|
-
if (nestedTool === 'web__run') {
|
|
1143
|
-
return {
|
|
1144
|
-
turnId,
|
|
1145
|
-
entry: { ...common, kind: 'webSearch', title: '网页搜索', query: jsonStringField(input, 'q') },
|
|
1146
|
-
};
|
|
1147
|
-
}
|
|
1148
|
-
if (nestedTool === 'view_image') {
|
|
1149
|
-
return {
|
|
1150
|
-
turnId,
|
|
1151
|
-
entry: { ...common, kind: 'imageView', title: '查看图片', path: jsonStringField(input, 'path') },
|
|
1152
|
-
};
|
|
1153
|
-
}
|
|
1154
|
-
const parts = nestedTool.replace(/^mcp__/, '').split('__');
|
|
1155
|
-
return {
|
|
1156
|
-
turnId,
|
|
1157
|
-
entry: {
|
|
1158
|
-
...common,
|
|
1159
|
-
kind: 'tool',
|
|
1160
|
-
title: '工具',
|
|
1161
|
-
server: parts.length > 1 ? parts.shift() : 'functions',
|
|
1162
|
-
tool: parts.join('/') || nestedTool,
|
|
1163
|
-
arguments: {},
|
|
1164
|
-
},
|
|
1165
|
-
};
|
|
1166
|
-
}
|
|
1167
|
-
|
|
1168
|
-
function jsonStringField(source, field) {
|
|
1169
|
-
const pattern = new RegExp(`(?:["']${field}["']|${field})\\s*:\\s*("(?:\\\\.|[^"\\\\])*")`);
|
|
1170
|
-
const match = pattern.exec(source);
|
|
1171
|
-
if (!match) return '';
|
|
1172
|
-
try { return JSON.parse(match[1]); } catch { return ''; }
|
|
1173
|
-
}
|
|
1174
|
-
|
|
1175
653
|
function messagePayload(body) {
|
|
1176
654
|
const prompt = typeof body.prompt === 'string' ? body.prompt.trim() : '';
|
|
1177
655
|
if (body.images !== undefined && (!Array.isArray(body.images) || body.images.length > 4)) {
|
|
@@ -1208,26 +686,6 @@ function messagePayload(body) {
|
|
|
1208
686
|
return payload;
|
|
1209
687
|
}
|
|
1210
688
|
|
|
1211
|
-
async function messageArguments(threadId, body) {
|
|
1212
|
-
const payload = messagePayload(body);
|
|
1213
|
-
payload.images = await Promise.all(payload.images.map(image => image.startsWith('data:') ? persistLegacyImage(image, uploadsRoot) : image));
|
|
1214
|
-
let prompt = payload.prompt;
|
|
1215
|
-
if (payload.images.length) {
|
|
1216
|
-
prompt += (prompt ? '\n\n' : '') + payload.images
|
|
1217
|
-
.map((file, index) => `})`)
|
|
1218
|
-
.join('\n\n');
|
|
1219
|
-
}
|
|
1220
|
-
const argumentsValue = { threadId, prompt };
|
|
1221
|
-
if (payload.hostId) argumentsValue.hostId = payload.hostId;
|
|
1222
|
-
if (payload.model) argumentsValue.model = payload.model;
|
|
1223
|
-
if (payload.thinking) argumentsValue.thinking = payload.thinking;
|
|
1224
|
-
return argumentsValue;
|
|
1225
|
-
}
|
|
1226
|
-
|
|
1227
|
-
function usesSharedTask(body) {
|
|
1228
|
-
return Boolean(sharedServer && (!body.hostId || body.hostId === 'local'));
|
|
1229
|
-
}
|
|
1230
|
-
|
|
1231
689
|
function appServerTurnArguments(threadId, body) {
|
|
1232
690
|
const payload = messagePayload(body);
|
|
1233
691
|
const input = [];
|
|
@@ -1240,334 +698,23 @@ function appServerTurnArguments(threadId, body) {
|
|
|
1240
698
|
return value;
|
|
1241
699
|
}
|
|
1242
700
|
|
|
1243
|
-
function
|
|
1244
|
-
const
|
|
1245
|
-
const
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
function createThreadBody(body) {
|
|
1250
|
-
const payload = messagePayload(body);
|
|
1251
|
-
return { ...body, prompt: payload.prompt, transport: body.transport === 'app-server' ? 'app-server' : 'desktop-tools' };
|
|
1252
|
-
}
|
|
1253
|
-
|
|
1254
|
-
function appServerProjectTarget(projectId, projects) {
|
|
1255
|
-
if (projectId === '__unassigned__') return appServerEvents.projectlessThreadTarget(projectRoot);
|
|
1256
|
-
const project = projects.find(item => item?.projectId === projectId);
|
|
1257
|
-
if (!project) throw new RequestError(404, '没有找到对应项目。');
|
|
1258
|
-
if (project.hostId && project.hostId !== 'local') {
|
|
1259
|
-
throw new RequestError(400, '独立 app-server 当前只支持本机项目。');
|
|
1260
|
-
}
|
|
1261
|
-
if (typeof project.path !== 'string' || !path.isAbsolute(project.path)) {
|
|
1262
|
-
throw new RequestError(400, '项目没有可用的本机路径。');
|
|
1263
|
-
}
|
|
1264
|
-
return { projectId, cwd: project.path };
|
|
1265
|
-
}
|
|
1266
|
-
|
|
1267
|
-
function mergeThreadCatalog(catalog, appThreads, limit, sharedSnapshot = Boolean(sharedServer)) {
|
|
1268
|
-
const owned = new Map(appThreads.map(thread => [thread.id, thread]));
|
|
1269
|
-
const pinnedThreads = (catalog.pinnedThreads || []).map(thread => owned.get(thread.id) || thread);
|
|
1270
|
-
const pinnedIds = new Set(pinnedThreads.map(thread => thread.id));
|
|
1271
|
-
if (sharedSnapshot) {
|
|
1272
|
-
const listed = new Set([...pinnedIds, ...(catalog.threads || []).map(thread => thread.id)]);
|
|
1273
|
-
const threads = (catalog.threads || []).map(thread => owned.get(thread.id) || thread);
|
|
1274
|
-
threads.push(...appThreads.filter(thread => !listed.has(thread.id)));
|
|
1275
|
-
threads.sort((left, right) => threadRecency(right) - threadRecency(left));
|
|
1276
|
-
return { ...catalog, pinnedThreads: [], threads: threads.slice(0, limit) };
|
|
1277
|
-
}
|
|
1278
|
-
const desktopThreads = (catalog.threads || []).filter(thread => !owned.has(thread.id));
|
|
1279
|
-
const merged = [...desktopThreads, ...appThreads.filter(thread => !pinnedIds.has(thread.id))]
|
|
1280
|
-
.sort((left, right) => threadRecency(right) - threadRecency(left))
|
|
1281
|
-
.slice(0, limit);
|
|
1282
|
-
return { ...catalog, pinnedThreads, threads: merged };
|
|
1283
|
-
}
|
|
1284
|
-
|
|
1285
|
-
async function createThreadArguments(projectId, body, projects) {
|
|
1286
|
-
const { threadId, hostId, ...message } = await messageArguments('', body);
|
|
1287
|
-
if (projectId === '__unassigned__') return { ...message, target: { type:'projectless' } };
|
|
1288
|
-
const project = projects.find(item => item?.projectId === projectId);
|
|
1289
|
-
if (!project) throw new RequestError(404, '没有找到对应项目。');
|
|
1290
|
-
return { ...message, target:{ type:'project', projectId, environment:{ type:'local' } } };
|
|
1291
|
-
}
|
|
1292
|
-
|
|
1293
|
-
function publicQueue(threadId) {
|
|
1294
|
-
return (messageQueues.get(threadId) || []).map(item => ({
|
|
1295
|
-
id: item.id,
|
|
1296
|
-
prompt: item.displayPrompt || item.prompt,
|
|
1297
|
-
text: item.text, attachments:item.attachments || [],
|
|
1298
|
-
model: item.model || '',
|
|
1299
|
-
thinking: item.thinking || item.effort || '',
|
|
1300
|
-
createdAt: item.createdAt,
|
|
1301
|
-
error: item.error || '',
|
|
1302
|
-
}));
|
|
1303
|
-
}
|
|
1304
|
-
|
|
1305
|
-
function queueArguments(item) {
|
|
1306
|
-
const value = { threadId: item.threadId, prompt: item.prompt };
|
|
1307
|
-
if (item.hostId) value.hostId = item.hostId;
|
|
1308
|
-
if (item.model) value.model = item.model;
|
|
1309
|
-
if (item.thinking) value.thinking = item.thinking;
|
|
1310
|
-
return value;
|
|
1311
|
-
}
|
|
1312
|
-
|
|
1313
|
-
async function sendQueuedItem(threadId, item) {
|
|
1314
|
-
await requireExecutionWritable(threadId);
|
|
1315
|
-
if (item.transport === 'app-server') {
|
|
1316
|
-
try {
|
|
1317
|
-
return { success: true, data: await appServerTasks.startTurn(threadId, item) };
|
|
1318
|
-
} catch (error) {
|
|
1319
|
-
return { success: false, data: error instanceof Error ? error.message : String(error) };
|
|
1320
|
-
}
|
|
1321
|
-
}
|
|
1322
|
-
return callTool('send_message_to_thread', queueArguments(item));
|
|
1323
|
-
}
|
|
1324
|
-
|
|
1325
|
-
async function steerQueuedItem(threadId, item) {
|
|
1326
|
-
if (item.transport !== 'app-server') return callTool('send_message_to_thread', queueArguments(item));
|
|
1327
|
-
try {
|
|
1328
|
-
const thread = await appServerTasks.readTask(threadId, { includeTurns: true, turnLimit: 1 });
|
|
1329
|
-
const activeTurn = thread.turns?.at(-1);
|
|
1330
|
-
if (!activeTurn || statusText(activeTurn.status) !== 'inProgress') {
|
|
1331
|
-
throw new Error('当前没有可引导的 app-server 活动轮次。');
|
|
1332
|
-
}
|
|
1333
|
-
return {
|
|
1334
|
-
success: true,
|
|
1335
|
-
data: await appServerTasks.steerTurn(threadId, activeTurn.id, item.input),
|
|
1336
|
-
};
|
|
1337
|
-
} catch (error) {
|
|
1338
|
-
return { success: false, data: error instanceof Error ? error.message : String(error) };
|
|
1339
|
-
}
|
|
1340
|
-
}
|
|
1341
|
-
|
|
1342
|
-
function ensureQueueWorker(threadId) {
|
|
1343
|
-
if (queueWorkers.has(threadId)) return;
|
|
1344
|
-
const worker = runQueueWorker(threadId)
|
|
1345
|
-
.catch(error => {
|
|
1346
|
-
const item = messageQueues.get(threadId)?.[0];
|
|
1347
|
-
if (item) item.error = error instanceof Error ? error.message : String(error);
|
|
1348
|
-
})
|
|
1349
|
-
.finally(() => queueWorkers.delete(threadId));
|
|
1350
|
-
queueWorkers.set(threadId, worker);
|
|
1351
|
-
}
|
|
1352
|
-
|
|
1353
|
-
async function runQueueWorker(threadId) {
|
|
1354
|
-
while (true) {
|
|
1355
|
-
const queue = messageQueues.get(threadId) || [];
|
|
1356
|
-
const item = queue[0];
|
|
1357
|
-
if (!item || item.error) return;
|
|
1358
|
-
let timeline;
|
|
1359
|
-
if (item.transport === 'app-server') {
|
|
1360
|
-
const thread = await appServerTasks.readTask(threadId, { includeTurns: true, turnLimit: 1 });
|
|
1361
|
-
timeline = normalizeAppServerTimeline(thread);
|
|
1362
|
-
} else {
|
|
1363
|
-
const readArguments = {
|
|
1364
|
-
threadId,
|
|
1365
|
-
turnLimit: 1,
|
|
1366
|
-
includeOutputs: false,
|
|
1367
|
-
maxOutputCharsPerItem: 500,
|
|
1368
|
-
};
|
|
1369
|
-
if (item.hostId) readArguments.hostId = item.hostId;
|
|
1370
|
-
const threadResult = await callTool('read_thread', readArguments, 15_000);
|
|
1371
|
-
if (!threadResult.success) throw new BridgeError('读取排队任务状态失败。', threadResult.data);
|
|
1372
|
-
timeline = normalizeTimeline(threadResult.data);
|
|
1373
|
-
}
|
|
1374
|
-
const active = timeline.thread.status === 'active' || timeline.turns.at(-1)?.status === 'inProgress';
|
|
1375
|
-
if (active) {
|
|
1376
|
-
await delay(2_000);
|
|
1377
|
-
continue;
|
|
1378
|
-
}
|
|
1379
|
-
if (messageQueues.get(threadId) !== queue || queue[0]?.id !== item.id) continue;
|
|
1380
|
-
const sendResult = await sendQueuedItem(threadId, item);
|
|
1381
|
-
if (!sendResult.success) throw new BridgeError('发送排队消息失败。', sendResult.data);
|
|
1382
|
-
if (queue[0]?.id === item.id) queue.shift();
|
|
1383
|
-
}
|
|
1384
|
-
}
|
|
1385
|
-
|
|
1386
|
-
async function streamAppServerThreadEvents(request, response, threadId) {
|
|
1387
|
-
response.startEvents();
|
|
1388
|
-
|
|
1389
|
-
let closed = false;
|
|
1390
|
-
let pushPending = false;
|
|
1391
|
-
let pushing = false;
|
|
1392
|
-
let previousHash = '';
|
|
1393
|
-
let updateTimer = null;
|
|
1394
|
-
let resolveClosed;
|
|
1395
|
-
const closedPromise = new Promise(resolve => { resolveClosed = resolve; });
|
|
1396
|
-
response.once('close', () => { closed = true; resolveClosed(); });
|
|
1397
|
-
|
|
1398
|
-
const pushUpdate = async () => {
|
|
1399
|
-
if (closed) return;
|
|
1400
|
-
if (pushing) { pushPending = true; return; }
|
|
1401
|
-
pushing = true;
|
|
1402
|
-
try {
|
|
1403
|
-
const thread = await appServerTasks.readTask(threadId, { includeTurns: true, turnLimit: 2 });
|
|
1404
|
-
const timeline = normalizeAppServerTimeline(thread);
|
|
1405
|
-
const update = {
|
|
1406
|
-
thread: timeline.thread,
|
|
1407
|
-
turns: timeline.turns.slice(-2),
|
|
1408
|
-
turn: timeline.turns.at(-1) || null,
|
|
1409
|
-
queue: publicQueue(threadId),
|
|
1410
|
-
};
|
|
1411
|
-
const serialized = JSON.stringify(update);
|
|
1412
|
-
const hash = crypto.createHash('sha256').update(serialized).digest('base64url');
|
|
1413
|
-
if (hash !== previousHash) {
|
|
1414
|
-
sendEvent(response, 'update', update);
|
|
1415
|
-
previousHash = hash;
|
|
1416
|
-
}
|
|
1417
|
-
} catch (error) {
|
|
1418
|
-
sendEvent(response, 'bridge-error', { error: error instanceof Error ? error.message : String(error) });
|
|
1419
|
-
} finally {
|
|
1420
|
-
pushing = false;
|
|
1421
|
-
if (pushPending && !closed) {
|
|
1422
|
-
pushPending = false;
|
|
1423
|
-
void pushUpdate();
|
|
1424
|
-
}
|
|
1425
|
-
}
|
|
1426
|
-
};
|
|
1427
|
-
const scheduleUpdate = event => {
|
|
1428
|
-
if (closed || event.threadId !== threadId || updateTimer) return;
|
|
1429
|
-
updateTimer = setTimeout(() => {
|
|
1430
|
-
updateTimer = null;
|
|
1431
|
-
void pushUpdate();
|
|
1432
|
-
}, 60);
|
|
1433
|
-
};
|
|
1434
|
-
appServerTasks.on('thread-event', scheduleUpdate);
|
|
1435
|
-
await pushUpdate();
|
|
1436
|
-
|
|
1437
|
-
await closedPromise;
|
|
1438
|
-
closed = true;
|
|
1439
|
-
if (updateTimer) clearTimeout(updateTimer);
|
|
1440
|
-
appServerTasks.off('thread-event', scheduleUpdate);
|
|
1441
|
-
if (!response.writableEnded) response.end();
|
|
1442
|
-
}
|
|
1443
|
-
|
|
1444
|
-
async function streamThreadEvents(request, response, threadId, hostId) {
|
|
1445
|
-
response.startEvents();
|
|
1446
|
-
|
|
1447
|
-
let closed = false;
|
|
1448
|
-
let previousHash = '';
|
|
1449
|
-
response.once('close', () => { closed = true; });
|
|
1450
|
-
|
|
1451
|
-
while (!closed) {
|
|
1452
|
-
let intervalMs = 2_500;
|
|
1453
|
-
try {
|
|
1454
|
-
const argumentsValue = {
|
|
1455
|
-
threadId,
|
|
1456
|
-
turnLimit: 2,
|
|
1457
|
-
includeOutputs: true,
|
|
1458
|
-
maxOutputCharsPerItem: 4_000,
|
|
1459
|
-
};
|
|
1460
|
-
if (hostId) argumentsValue.hostId = hostId;
|
|
1461
|
-
const result = await callTool('read_thread', argumentsValue);
|
|
1462
|
-
if (!result.success) {
|
|
1463
|
-
throw new BridgeError('Codex Desktop 工具返回失败。', result.data);
|
|
1464
|
-
}
|
|
1465
|
-
|
|
1466
|
-
const timeline = normalizeTimeline(result.data);
|
|
1467
|
-
enrichTimelineWithLocalActivity(timeline, threadId);
|
|
1468
|
-
const update = {
|
|
1469
|
-
thread: timeline.thread,
|
|
1470
|
-
turns: timeline.turns.slice(-2),
|
|
1471
|
-
turn: timeline.turns.at(-1) || null,
|
|
1472
|
-
queue: publicQueue(threadId),
|
|
1473
|
-
};
|
|
1474
|
-
const serialized = JSON.stringify(update);
|
|
1475
|
-
const hash = crypto.createHash('sha256').update(serialized).digest('base64url');
|
|
1476
|
-
if (hash !== previousHash) {
|
|
1477
|
-
sendEvent(response, 'update', update);
|
|
1478
|
-
previousHash = hash;
|
|
1479
|
-
}
|
|
1480
|
-
if (timeline.thread.status === 'active' || update.turn?.status === 'inProgress') {
|
|
1481
|
-
intervalMs = 1_000;
|
|
1482
|
-
}
|
|
1483
|
-
} catch (error) {
|
|
1484
|
-
sendEvent(response, 'bridge-error', {
|
|
1485
|
-
error: error instanceof Error ? error.message : String(error),
|
|
1486
|
-
});
|
|
1487
|
-
intervalMs = 3_000;
|
|
1488
|
-
}
|
|
1489
|
-
|
|
1490
|
-
await delay(intervalMs);
|
|
1491
|
-
}
|
|
1492
|
-
|
|
1493
|
-
if (!response.writableEnded) response.end();
|
|
1494
|
-
}
|
|
1495
|
-
|
|
1496
|
-
function sendEvent(response, eventName, value) {
|
|
1497
|
-
if (response.destroyed || response.writableEnded) return;
|
|
1498
|
-
response.event(eventName, value);
|
|
1499
|
-
}
|
|
1500
|
-
|
|
1501
|
-
function normalizeTimeline(data, reverseTurns = true) {
|
|
1502
|
-
const source = data && typeof data === 'object' ? data : {};
|
|
1503
|
-
const sourceTurns = Array.isArray(source.turns) ? source.turns : source.thread?.turns;
|
|
1504
|
-
const turns = Array.isArray(sourceTurns) ? sourceTurns.map(normalizeTurn) : [];
|
|
1505
|
-
if (reverseTurns) turns.reverse();
|
|
1506
|
-
if (reverseTurns) restoreEmptyTurnMessages(source.thread?.id, turns);
|
|
1507
|
-
return {
|
|
701
|
+
function normalizeTimeline(data) {
|
|
702
|
+
const source = data && typeof data === 'object' ? data : {};
|
|
703
|
+
const sourceTurns = Array.isArray(source.turns) ? source.turns : source.thread?.turns;
|
|
704
|
+
const turns = Array.isArray(sourceTurns) ? sourceTurns.map(normalizeTurn) : [];
|
|
705
|
+
return {
|
|
1508
706
|
schemaVersion: 1,
|
|
1509
707
|
thread: normalizeThread(source.thread),
|
|
1510
708
|
turns,
|
|
1511
709
|
};
|
|
1512
710
|
}
|
|
1513
711
|
|
|
1514
|
-
function normalizeAppServerTimeline(thread) {
|
|
1515
|
-
const record = appServerTasks.store.get(thread?.id);
|
|
1516
|
-
return normalizeTimeline({
|
|
1517
|
-
thread: {
|
|
1518
|
-
...thread,
|
|
1519
|
-
title: thread?.name || thread?.title || record?.title || '',
|
|
1520
|
-
projectId: record?.projectId || '',
|
|
1521
|
-
hostId: 'app-server',
|
|
1522
|
-
transport: 'app-server',
|
|
1523
|
-
},
|
|
1524
|
-
}, false);
|
|
1525
|
-
}
|
|
1526
|
-
|
|
1527
712
|
function normalizeSharedTimeline({ thread, nextCursor, sync }) {
|
|
1528
|
-
return { ...normalizeTimeline({ thread: { ...thread, title: thread.name || thread.title || '', hostId: 'local', transport: 'shared-app-server' } }
|
|
713
|
+
return { ...normalizeTimeline({ thread: { ...thread, title: thread.name || thread.title || '', hostId: 'local', transport: 'shared-app-server' } }),
|
|
1529
714
|
historyView: 'grouped-lazy-skeleton-v11',
|
|
1530
715
|
...(nextCursor !== undefined ? { nextCursor } : {}), ...(sync ? { sync } : {}) };
|
|
1531
716
|
}
|
|
1532
717
|
|
|
1533
|
-
function restoreEmptyTurnMessages(threadId, turns) {
|
|
1534
|
-
const missing = new Map(turns.filter(turn => turn.entries.length === 0).map(turn => [turn.id, turn]));
|
|
1535
|
-
if (!missing.size || !threadId) return;
|
|
1536
|
-
const sessionPath = findSessionPath(threadId);
|
|
1537
|
-
if (!sessionPath) return;
|
|
1538
|
-
const handle = fs.openSync(sessionPath, 'r');
|
|
1539
|
-
try {
|
|
1540
|
-
const size = fs.fstatSync(handle).size;
|
|
1541
|
-
const length = Math.min(size, MAX_SETTINGS_SCAN_BYTES);
|
|
1542
|
-
const buffer = Buffer.allocUnsafe(length);
|
|
1543
|
-
fs.readSync(handle, buffer, 0, length, size - length);
|
|
1544
|
-
const lines = buffer.toString('utf8').split(/\r?\n/);
|
|
1545
|
-
if (length < size) lines.shift();
|
|
1546
|
-
let turnId;
|
|
1547
|
-
for (const line of lines) {
|
|
1548
|
-
if (!line.trim()) continue;
|
|
1549
|
-
let event;
|
|
1550
|
-
try { event = JSON.parse(line); } catch { continue; }
|
|
1551
|
-
const item = event.payload;
|
|
1552
|
-
if (event.type === 'event_msg' && item?.type === 'task_started') turnId = item.turn_id;
|
|
1553
|
-
if (event.type === 'turn_context') turnId = item?.turn_id;
|
|
1554
|
-
const turn = missing.get(turnId);
|
|
1555
|
-
if (!turn || event.type !== 'response_item') continue;
|
|
1556
|
-
let entry;
|
|
1557
|
-
if (item.type === 'function_call_output' && item.namespace === 'codex_app' && ['create_thread', 'send_message_to_thread'].includes(item.name)) {
|
|
1558
|
-
const text = extractDelegatedInput(item.output);
|
|
1559
|
-
if (text) entry = { type: 'message', id: item.id, role: 'user', source: 'remote', ...splitUploadedImages(text) };
|
|
1560
|
-
} else if (item.type === 'message' && ['user', 'assistant'].includes(item.role) && item.channel !== 'analysis') {
|
|
1561
|
-
const text = (item.content || []).filter(part => ['input_text', 'output_text'].includes(part.type)).map(part => part.text || '').join('\n');
|
|
1562
|
-
if (text) entry = item.role === 'user'
|
|
1563
|
-
? normalizeItem({ type: 'userMessage', id: item.id, content: item.content.map(part => ({ ...part, type: part.type === 'input_text' ? 'text' : part.type })) })
|
|
1564
|
-
: normalizeItem({ type: 'agentMessage', id: item.id, phase: item.phase || item.channel, text });
|
|
1565
|
-
}
|
|
1566
|
-
if (entry) turn.entries.push(entry);
|
|
1567
|
-
}
|
|
1568
|
-
} finally { fs.closeSync(handle); }
|
|
1569
|
-
}
|
|
1570
|
-
|
|
1571
718
|
function normalizeThread(thread) {
|
|
1572
719
|
const source = thread && typeof thread === 'object' ? thread : {};
|
|
1573
720
|
return {
|
|
@@ -1579,7 +726,7 @@ function normalizeThread(thread) {
|
|
|
1579
726
|
projectId: source.projectId || '',
|
|
1580
727
|
cwd: source.cwd || '',
|
|
1581
728
|
hostId: source.hostId || '',
|
|
1582
|
-
transport: source.transport ||
|
|
729
|
+
transport: source.transport || 'shared-app-server',
|
|
1583
730
|
readOnly: source.readOnly === true,
|
|
1584
731
|
readOnlyReason: source.readOnlyReason || '',
|
|
1585
732
|
};
|
|
@@ -1831,10 +978,6 @@ function sharedHistoryPageOptions() {
|
|
|
1831
978
|
};
|
|
1832
979
|
}
|
|
1833
980
|
|
|
1834
|
-
function threadRecency(thread) {
|
|
1835
|
-
return Number(thread?.recencyAt ?? thread?.updatedAt) || 0;
|
|
1836
|
-
}
|
|
1837
|
-
|
|
1838
981
|
function normalizeAttachments(content) {
|
|
1839
982
|
if (!Array.isArray(content)) return [];
|
|
1840
983
|
const items = content.filter(item => item && !['text', 'input_text'].includes(item.type)).map(item => ({ ...item }));
|
|
@@ -2116,10 +1259,6 @@ function statusText(value) {
|
|
|
2116
1259
|
return value == null ? 'unknown' : String(value);
|
|
2117
1260
|
}
|
|
2118
1261
|
|
|
2119
|
-
function delay(milliseconds) {
|
|
2120
|
-
return new Promise(resolve => setTimeout(resolve, milliseconds));
|
|
2121
|
-
}
|
|
2122
|
-
|
|
2123
1262
|
function sendJson(response, statusCode, value) {
|
|
2124
1263
|
if (response.json) { response.json(statusCode, value); return; }
|
|
2125
1264
|
sendBuffer(response, statusCode, 'application/json; charset=utf-8', Buffer.from(JSON.stringify(value)));
|
|
@@ -2144,10 +1283,10 @@ function parseOptions(args) {
|
|
|
2144
1283
|
let port = Number.parseInt(process.env.CODEX_REMOTE_BRIDGE_PORT || '45831', 10);
|
|
2145
1284
|
let lan = process.env.CODEX_REMOTE_BRIDGE_LAN === 'true';
|
|
2146
1285
|
let token = process.env.CODEX_REMOTE_BRIDGE_TOKEN?.trim() || '';
|
|
2147
|
-
|
|
1286
|
+
if (process.env.CODEX_REMOTE_APP_SERVER === 'true') throw new Error('此连接方式已停用。');
|
|
2148
1287
|
for (let i = 0; i < args.length; i += 1) {
|
|
2149
1288
|
if (args[i] === '--lan') lan = true;
|
|
2150
|
-
if (args[i] === '--app-server')
|
|
1289
|
+
if (args[i] === '--app-server') throw new Error('此连接方式已停用。');
|
|
2151
1290
|
if (args[i] === '--port' && args[i + 1]) port = Number.parseInt(args[++i], 10);
|
|
2152
1291
|
if (args[i] === '--token' && args[i + 1]) token = args[++i];
|
|
2153
1292
|
}
|
|
@@ -2155,32 +1294,10 @@ function parseOptions(args) {
|
|
|
2155
1294
|
throw new Error('端口必须位于 1024 到 65535 之间。');
|
|
2156
1295
|
}
|
|
2157
1296
|
if (lan && !token) token = crypto.randomBytes(32).toString('base64url');
|
|
2158
|
-
return { port, lan, token
|
|
2159
|
-
}
|
|
2160
|
-
|
|
2161
|
-
function boundedInteger(value, fallback, minimum, maximum) {
|
|
2162
|
-
const parsed = Number.parseInt(value || '', 10);
|
|
2163
|
-
return Number.isFinite(parsed) ? Math.min(maximum, Math.max(minimum, parsed)) : fallback;
|
|
2164
|
-
}
|
|
2165
|
-
|
|
2166
|
-
function booleanValue(value, fallback) {
|
|
2167
|
-
if (value === 'true') return true;
|
|
2168
|
-
if (value === 'false') return false;
|
|
2169
|
-
return fallback;
|
|
1297
|
+
return { port, lan, token };
|
|
2170
1298
|
}
|
|
2171
1299
|
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
this.details = details;
|
|
2176
|
-
}
|
|
2177
|
-
}
|
|
2178
|
-
|
|
2179
|
-
class RequestError extends Error {
|
|
2180
|
-
constructor(statusCode, message, details) {
|
|
2181
|
-
super(message);
|
|
2182
|
-
this.statusCode = statusCode;
|
|
2183
|
-
this.details = details;
|
|
2184
|
-
this.code = details?.code;
|
|
2185
|
-
}
|
|
1300
|
+
function boundedInteger(value, fallback, minimum, maximum) {
|
|
1301
|
+
const parsed = Number.parseInt(value || '', 10);
|
|
1302
|
+
return Number.isFinite(parsed) ? Math.min(maximum, Math.max(minimum, parsed)) : fallback;
|
|
2186
1303
|
}
|