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