@walkhi/code-relax 0.1.0-beta.1 → 0.1.0-beta.11
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 +53 -64
- package/dist/bin/codex-remote.mjs +90 -71
- package/dist/bin/self-relay-admin.mjs +8 -0
- package/dist/bin/self-relay-demo.mjs +1 -1
- package/dist/bin/self-relay-server.mjs +5 -2
- package/dist/shared/app-server-events.cjs +64 -24
- package/dist/shared/file-mentions.cjs +35 -0
- package/dist/shared/p2p-data-channel.cjs +50 -0
- package/dist/skills/code-relax/SKILL.md +1 -1
- package/dist/src/desktop-launcher.mjs +16 -21
- package/dist/src/file-upload-session.mjs +124 -0
- package/dist/src/lan-socket-server.mjs +59 -10
- package/dist/src/managed-app-server.mjs +36 -14
- package/dist/src/message-images.mjs +0 -18
- package/dist/src/onboarding.mjs +6 -8
- package/dist/src/platform/README.md +1 -1
- package/dist/src/platform/windows/IsolatedProcess.cs +7 -3
- package/dist/src/platform/windows/background-process.mjs +7 -2
- package/dist/src/platform/windows/codex-runtime.mjs +117 -0
- package/dist/src/platform/windows/desktop-host.mjs +17 -3
- package/dist/src/platform/windows/desktop-info.ps1 +2 -4
- package/dist/src/platform/windows/desktop-shortcuts.ps1 +7 -14
- package/dist/src/platform/windows/launch-worker.mjs +42 -0
- package/dist/src/platform/windows/resident-startup.ps1 +58 -18
- package/dist/src/platform/windows/service-host.mjs +4 -16
- package/dist/src/platform/windows/start-hidden-console.ps1 +18 -8
- package/dist/src/platform/windows/stop-desktop.ps1 +7 -4
- package/dist/src/runtime-discovery.mjs +43 -0
- package/dist/src/self-relay/admin-cloud.mjs +101 -0
- package/dist/src/self-relay/admin-page.mjs +71 -0
- package/dist/src/self-relay/admin-server.mjs +61 -0
- package/dist/src/self-relay/admin-state.mjs +122 -0
- package/dist/src/self-relay/client.mjs +2 -0
- package/dist/src/self-relay/connector.mjs +107 -20
- package/dist/src/self-relay/demo.mjs +4 -5
- package/dist/src/self-relay/lifecycle.mjs +58 -70
- package/dist/src/self-relay/p2p-probe.mjs +123 -83
- package/dist/src/self-relay/server.mjs +224 -29
- package/dist/src/server.mjs +616 -1299
- package/dist/src/service-doctor.mjs +14 -26
- package/dist/src/service-install.mjs +1 -4
- package/dist/src/service-lifecycle.mjs +12 -12
- package/dist/src/shared-app-server.mjs +206 -46
- package/dist/src/shared-catalog.mjs +5 -25
- package/dist/src/shared-recovery.mjs +11 -9
- package/dist/src/shared-thread-stream.mjs +1 -1
- package/dist/src/skill-install.mjs +0 -13
- package/package.json +16 -18
- package/tools/postinstall.mjs +140 -3
- package/dist/src/app-server-client.mjs +0 -468
- package/dist/src/app-server-tasks.mjs +0 -355
- package/dist/src/debug-ui.mjs +0 -131
- package/dist/src/platform/windows/desktop-monitor.mjs +0 -34
- package/dist/src/platform/windows/desktop-shortcut-run.ps1 +0 -5
- package/dist/src/platform/windows/desktop-tools.mjs +0 -48
- package/dist/src/platform/windows/launch-worker.ps1 +0 -13
- package/dist/src/thread-catalog.mjs +0 -42
- package/dist/tools/find-desktop-pipe.mjs +0 -10
- package/dist/web/approval-ui.js +0 -188
- package/dist/web/capabilities.js +0 -57
- package/dist/web/chat-transport.js +0 -196
- package/dist/web/chat.css +0 -736
- package/dist/web/chat.js +0 -5149
- package/dist/web/client-platform.js +0 -11
- package/dist/web/harmony-platform.js +0 -51
- package/dist/web/highlight-powershell.min.js +0 -39
- package/dist/web/highlight.min.js +0 -1262
- package/dist/web/history-cache.js +0 -146
- package/dist/web/index.html +0 -222
- package/dist/web/lan-session.js +0 -122
- package/dist/web/p2p-diagnostics.js +0 -28
- package/dist/web/p2p-probe.js +0 -83
- package/dist/web/resources.json +0 -1
- package/dist/web/self-relay-session.js +0 -119
- package/dist/web/server-requests.js +0 -67
- package/dist/web/timeline-sync.js +0 -67
- package/dist/web/vendor/README.md +0 -14
- package/dist/web/vendor/highlight-powershell.min.js +0 -39
- package/dist/web/vendor/highlight.LICENSE.txt +0 -29
- package/dist/web/vendor/highlight.min.js +0 -1262
|
@@ -2,11 +2,10 @@ import fs from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { readRecord } from './service-lifecycle.mjs';
|
|
4
4
|
import { inspectSkill } from './skill-install.mjs';
|
|
5
|
-
import { readManagedEnvironment } from './platform/windows/user-environment.mjs';
|
|
6
|
-
import { managedAppServerUrl } from './managed-app-server.mjs';
|
|
7
5
|
|
|
8
6
|
export async function doctor({ paths, host, service, skillTarget }) {
|
|
9
7
|
const checks = [];
|
|
8
|
+
let installedVersion;
|
|
10
9
|
const add = (id, status, message) => checks.push({ id, status, message });
|
|
11
10
|
const check = async (id, task) => {
|
|
12
11
|
try { await task(); } catch (error) { add(id, 'error', error.message); }
|
|
@@ -15,16 +14,12 @@ export async function doctor({ paths, host, service, skillTarget }) {
|
|
|
15
14
|
const record = readRecord(path.join(paths.serviceRoot, 'installation.json'));
|
|
16
15
|
if (!record || !fs.existsSync(path.join(paths.serviceRoot, 'dist/bin/codex-remote.mjs'))) throw Error('尚未完整部署服务,请重新安装 Code Relax。');
|
|
17
16
|
if (!record.nodeExecutable || !fs.existsSync(record.nodeExecutable)) throw Error('记录的 Node 已失效,请用可用的 Node 重新安装 Code Relax。');
|
|
18
|
-
|
|
17
|
+
installedVersion = record.version;
|
|
18
|
+
add('installation', 'ok', `已安装版本:${record.version};Node:${record.nodeExecutable}`);
|
|
19
19
|
});
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
add('shared-service', 'ok', sharedUrl ? '完整控制连接可用。' : '当前使用基础控制。');
|
|
24
|
-
});
|
|
25
|
-
await check('desktop', async () => {
|
|
26
|
-
await host.desktopEnvironment(readRecord(path.join(paths.stateRoot, 'bridge-startup-context.json')), sharedUrl);
|
|
27
|
-
add('desktop', 'ok', 'Desktop 来源上下文和工具管道已定位;实际任务操作尚未验证。');
|
|
20
|
+
await check('bridge-runtime', async () => {
|
|
21
|
+
await host.bridgeEnvironment();
|
|
22
|
+
add('bridge-runtime', 'ok', 'Bridge 启动环境已就绪;实际任务操作尚未验证。');
|
|
28
23
|
});
|
|
29
24
|
await check('network', async () => {
|
|
30
25
|
const address = await host.lanAddress();
|
|
@@ -34,30 +29,23 @@ export async function doctor({ paths, host, service, skillTarget }) {
|
|
|
34
29
|
await check('service', async () => {
|
|
35
30
|
const state = await service.status();
|
|
36
31
|
add('service', state.running ? 'ok' : 'warning', state.running
|
|
37
|
-
? `本机服务正常:${state.mode},端口 ${state.port}` : '服务未就绪;安装后执行 relax start。');
|
|
32
|
+
? `本机服务正常:${state.mode},端口 ${state.port},运行版本:${state.version || '未知'}` : '服务未就绪;安装后执行 relax start。');
|
|
33
|
+
if (state.running && installedVersion && state.version !== installedVersion) add('service-version', 'warning', state.version
|
|
34
|
+
? `已安装版本 ${installedVersion},但当前运行版本为 ${state.version};请重新执行安装或重启工作站服务。`
|
|
35
|
+
: `已安装版本 ${installedVersion},但当前运行 Bridge 未报告版本,可能仍是旧进程;请重新执行安装或重启工作站服务。`);
|
|
38
36
|
if (state.running) add('managed-app-server', state.managedAppServer?.state === 'ready' ? 'ok' : 'error',
|
|
39
37
|
state.managedAppServer?.state === 'ready' ? '受管 app-server 已就绪。'
|
|
40
38
|
: state.managedAppServer?.error || '受管 app-server 尚未就绪。');
|
|
41
39
|
if (state.managedAppServer?.updatePending) add('managed-app-server-update', 'warning',
|
|
42
|
-
'
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
const environment = await readManagedEnvironment();
|
|
46
|
-
if (environment.forceCliUser === '1' || environment.forceCliMachine === '1') {
|
|
47
|
-
add('desktop-environment', 'error', 'CODEX_APP_SERVER_FORCE_CLI=1 会阻止 Desktop 使用受管 app-server。');
|
|
48
|
-
} else if (environment.userValue === managedAppServerUrl) {
|
|
49
|
-
add('desktop-environment', 'ok', `Desktop 后续启动地址:${managedAppServerUrl}`);
|
|
50
|
-
} else {
|
|
51
|
-
add('desktop-environment', 'warning', environment.userValue
|
|
52
|
-
? `用户配置了其他 app-server 地址:${environment.userValue}`
|
|
53
|
-
: '尚未配置 Desktop 后续启动地址;请重新执行 setup。');
|
|
54
|
-
}
|
|
40
|
+
'已选择另一版本 CLI;当前 app-server 继续完成任务,下次内核启动时采用选定版本。');
|
|
41
|
+
if (state.managedAppServer?.launcherRestartRequired) add('managed-app-server-launcher', 'warning',
|
|
42
|
+
'安装文件已经更新,但当前 app-server 仍由旧启动链创建;隐藏控制台等运行时修复尚未生效,请执行 relax setup 或重启 Windows。');
|
|
55
43
|
});
|
|
56
44
|
await check('skill', async () => {
|
|
57
45
|
const state = inspectSkill(skillTarget);
|
|
58
46
|
add('skill', state.managed ? 'ok' : 'warning', state.managed ? '本包管理的 Skill 已安装。'
|
|
59
47
|
: state.installed ? '存在同名自定义 Skill,本包不会覆盖。' : '可执行 relax skill-install 安装 Skill。');
|
|
60
48
|
});
|
|
61
|
-
add('firewall', 'warning', '
|
|
49
|
+
add('firewall', 'warning', '移动端连通性未验证。首次启动请允许记录的 Node 在受信任的专用网络通信;若曾拒绝,可在 Windows 安全中心的防火墙“允许应用通过防火墙”中检查该 Node。不要关闭整个防火墙。');
|
|
62
50
|
return { ready: !checks.some(item => item.status === 'error'), lanAccess: 'unverified', checks };
|
|
63
51
|
}
|
|
@@ -22,8 +22,6 @@ export async function installService(source, target, executable, stateRoot, hook
|
|
|
22
22
|
if (!fs.existsSync(path.join(source, 'dist/bin/codex-remote.mjs'))) throw new Error('缺少发布构建;源码目录请先执行 npm run build。');
|
|
23
23
|
if (!fs.existsSync(path.join(source, 'node_modules'))) throw new Error('缺少包依赖,请重新安装完整 npm 包。');
|
|
24
24
|
const parent = path.dirname(target);
|
|
25
|
-
const legacyUploadsRoots = readRecord(path.join(target, 'installation.json'))?.legacyUploadsRoots || [];
|
|
26
|
-
if (fs.existsSync(path.join(source, 'src/server.mjs'))) legacyUploadsRoots.push(path.join(source, 'uploads'));
|
|
27
25
|
fs.mkdirSync(parent, { recursive: true });
|
|
28
26
|
const stage = path.join(parent, `service-stage-${randomUUID()}`);
|
|
29
27
|
const backup = path.join(parent, `service-backup-${randomUUID()}`);
|
|
@@ -34,8 +32,7 @@ export async function installService(source, target, executable, stateRoot, hook
|
|
|
34
32
|
}
|
|
35
33
|
await exec(executable, [path.join(stage, 'dist/bin/codex-remote.mjs'), '--help'], { windowsHide: true });
|
|
36
34
|
await exec(executable, ['-e', 'const r=require("node:module").createRequire(process.argv[1]); r("sharp"); r("qrcode"); r("ws"); r("node-datachannel");', path.join(stage, 'package.json')], { windowsHide: true });
|
|
37
|
-
writeRecord(path.join(stage, 'installation.json'), { installedAt: new Date().toISOString(), source, nodeExecutable: executable, stateRoot,
|
|
38
|
-
legacyUploadsRoots: [...new Set(legacyUploadsRoots)],
|
|
35
|
+
writeRecord(path.join(stage, 'installation.json'), { installedAt: new Date().toISOString(), source, nodeExecutable: executable, stateRoot,
|
|
39
36
|
version: readRecord(path.join(source, 'package.json')).version });
|
|
40
37
|
const previous = fs.existsSync(target);
|
|
41
38
|
let previousMoved = false, activated = false;
|
|
@@ -23,7 +23,6 @@ export function lifecycle({ stateRoot, host }) {
|
|
|
23
23
|
fs.mkdirSync(stateRoot, { recursive: true });
|
|
24
24
|
const sessionPath = path.join(stateRoot, 'bridge-session.json');
|
|
25
25
|
const configPath = path.join(stateRoot, 'bridge-config.json');
|
|
26
|
-
const contextPath = path.join(stateRoot, 'bridge-startup-context.json');
|
|
27
26
|
const scriptPath = fileURLToPath(new URL('./server.mjs', import.meta.url));
|
|
28
27
|
|
|
29
28
|
async function alive(session) {
|
|
@@ -56,12 +55,12 @@ export function lifecycle({ stateRoot, host }) {
|
|
|
56
55
|
const running = await alive(session);
|
|
57
56
|
const result = running ? await health(session) : null;
|
|
58
57
|
return { running: running && result?.status === 'ok', processRunning: running, pid: session?.pid,
|
|
59
|
-
port: session?.port, mode: result?.transportMode, execution: result?.execution,
|
|
58
|
+
port: session?.port, mode: result?.transportMode, version: result?.version, execution: result?.execution,
|
|
60
59
|
managedAppServer: result?.managedAppServer, desktop: result?.desktop,
|
|
61
60
|
desktopRestartRecommended: result?.desktopRestartRecommended === true,
|
|
62
61
|
resident: result?.resident === true };
|
|
63
62
|
}
|
|
64
|
-
async function pair() {
|
|
63
|
+
async function pair() {
|
|
65
64
|
const session = readRecord(sessionPath);
|
|
66
65
|
const connection = await alive(session) ? await health(session) : null;
|
|
67
66
|
if (connection?.status !== 'ok' || !session.lan) {
|
|
@@ -76,15 +75,17 @@ export function lifecycle({ stateRoot, host }) {
|
|
|
76
75
|
execution: connection.execution, managedAppServer: connection.managedAppServer, desktop: connection.desktop,
|
|
77
76
|
desktopRestartRecommended: connection.desktopRestartRecommended === true, accessUrl: session.accessUrl,
|
|
78
77
|
qrPath: path.join(stateRoot, 'connection-qr.png') };
|
|
79
|
-
}
|
|
78
|
+
}
|
|
79
|
+
async function restartManagedAppServer() {
|
|
80
|
+
const session = readRecord(sessionPath);
|
|
81
|
+
if (!await alive(session)) throw new Error('Bridge 尚未运行,无法迁移受管 app-server。');
|
|
82
|
+
return controlRequest(session, 'POST', 'restart-managed-app-server');
|
|
83
|
+
}
|
|
80
84
|
async function start() {
|
|
81
85
|
let session = readRecord(sessionPath);
|
|
82
86
|
if (await alive(session)) {
|
|
83
87
|
const result = await health(session);
|
|
84
88
|
if (session.scriptPath === scriptPath && session.lan && result?.status === 'ok' && result.resident) {
|
|
85
|
-
const context = readRecord(contextPath) || {};
|
|
86
|
-
const sourceThreadId = process.env.CODEX_THREAD_ID || process.env.CODEX_SESSION_ID || context.sourceThreadId;
|
|
87
|
-
writeRecord(contextPath, { sourceThreadId });
|
|
88
89
|
await controlRequest(session, 'POST', 'refresh-execution');
|
|
89
90
|
return pair();
|
|
90
91
|
}
|
|
@@ -97,9 +98,8 @@ export function lifecycle({ stateRoot, host }) {
|
|
|
97
98
|
throw new Error('bridge-config.json 中端口或 Token 无效;不会自动重置凭据。');
|
|
98
99
|
}
|
|
99
100
|
const address = await host.lanAddress() || '127.0.0.1';
|
|
100
|
-
const { executable, environment } = await host.
|
|
101
|
-
writeRecord(configPath, config);
|
|
102
|
-
writeRecord(contextPath, { sourceThreadId: environment.CODEX_THREAD_ID || environment.CODEX_SESSION_ID });
|
|
101
|
+
const { executable, environment } = await host.bridgeEnvironment();
|
|
102
|
+
writeRecord(configPath, config);
|
|
103
103
|
const instanceId = randomUUID();
|
|
104
104
|
const controlSecret = randomBytes(32).toString('base64url');
|
|
105
105
|
const readyPath = path.join(stateRoot, `bridge-ready-${instanceId}.json`);
|
|
@@ -126,5 +126,5 @@ export function lifecycle({ stateRoot, host }) {
|
|
|
126
126
|
throw error;
|
|
127
127
|
} finally { fs.rmSync(readyPath, { force: true }); }
|
|
128
128
|
}
|
|
129
|
-
return { start, pair, status, stop };
|
|
130
|
-
}
|
|
129
|
+
return { start, pair, status, stop, restartManagedAppServer };
|
|
130
|
+
}
|
|
@@ -6,6 +6,9 @@ import serverRequests from '../shared/server-requests.cjs';
|
|
|
6
6
|
|
|
7
7
|
const syncTokenTtlMs = 30_000;
|
|
8
8
|
const syncBufferMaxBytes = 8 * 1024 * 1024;
|
|
9
|
+
const historyPageMaxTurns = 8;
|
|
10
|
+
const historyPageMaxBytes = 500 * 1024;
|
|
11
|
+
const turnItemPageSize = 50;
|
|
9
12
|
const writerConflictMessages = ['already has an active writer', 'already has a live local writer'];
|
|
10
13
|
|
|
11
14
|
export class ThreadWriterConflictError extends Error {
|
|
@@ -29,8 +32,9 @@ export class SharedAppServer extends EventEmitter {
|
|
|
29
32
|
constructor(url) {
|
|
30
33
|
super();
|
|
31
34
|
const address = new URL(url);
|
|
32
|
-
if (address.protocol !== 'ws:' || address.hostname !== '127.0.0.1') throw new Error('
|
|
33
|
-
this.url = url;
|
|
35
|
+
if (address.protocol !== 'ws:' || address.hostname !== '127.0.0.1') throw new Error('Codex 内核必须使用本机回环 WebSocket。');
|
|
36
|
+
this.url = url;
|
|
37
|
+
this.serverVersion = '';
|
|
34
38
|
this.epoch = randomUUID();
|
|
35
39
|
this.sequence = 0;
|
|
36
40
|
this.pending = new Map();
|
|
@@ -38,9 +42,10 @@ export class SharedAppServer extends EventEmitter {
|
|
|
38
42
|
this.threads = new Map();
|
|
39
43
|
this.contexts = new Map();
|
|
40
44
|
this.settingsNotifications = new Map();
|
|
41
|
-
this.permissionOptions = new events.PermissionOptionsCache();
|
|
42
|
-
this.subscriptions = new Map();
|
|
43
|
-
this.
|
|
45
|
+
this.permissionOptions = new events.PermissionOptionsCache();
|
|
46
|
+
this.subscriptions = new Map();
|
|
47
|
+
this.operationGroups = new Map();
|
|
48
|
+
this.usageCache = new events.UsageCache(value => this.emit('usage', value));
|
|
44
49
|
}
|
|
45
50
|
|
|
46
51
|
async connect() {
|
|
@@ -52,8 +57,9 @@ export class SharedAppServer extends EventEmitter {
|
|
|
52
57
|
|
|
53
58
|
async open() {
|
|
54
59
|
const socket = new WebSocket(this.url, { handshakeTimeout: 5000 });
|
|
55
|
-
this.socket = socket;
|
|
56
|
-
this.ready = false;
|
|
60
|
+
this.socket = socket;
|
|
61
|
+
this.ready = false;
|
|
62
|
+
this.serverVersion = '';
|
|
57
63
|
this.serverRequests.reset(randomUUID());
|
|
58
64
|
socket.on('message', bytes => {
|
|
59
65
|
let message;
|
|
@@ -72,15 +78,17 @@ export class SharedAppServer extends EventEmitter {
|
|
|
72
78
|
}
|
|
73
79
|
else pending.resolve(message.result);
|
|
74
80
|
});
|
|
75
|
-
socket.on('close', () => {
|
|
76
|
-
this.ready = false;
|
|
81
|
+
socket.on('close', () => {
|
|
82
|
+
this.ready = false;
|
|
83
|
+
this.serverVersion = '';
|
|
77
84
|
for (const pending of this.pending.values()) {
|
|
78
85
|
clearTimeout(pending.timer);
|
|
79
|
-
pending.reject(new Error('
|
|
86
|
+
pending.reject(new Error('Codex 内核连接断开;发送结果可能未确认,请检查对话后再操作。'));
|
|
80
87
|
}
|
|
81
|
-
this.pending.clear();
|
|
82
|
-
this.serverRequests.reset(randomUUID());
|
|
83
|
-
this.threads.clear();
|
|
88
|
+
this.pending.clear();
|
|
89
|
+
this.serverRequests.reset(randomUUID());
|
|
90
|
+
this.threads.clear();
|
|
91
|
+
this.operationGroups.clear();
|
|
84
92
|
for (const slot of this.subscriptions.values()) clearTimeout(slot.expiry);
|
|
85
93
|
this.subscriptions.clear();
|
|
86
94
|
this.contexts.clear();
|
|
@@ -92,7 +100,10 @@ export class SharedAppServer extends EventEmitter {
|
|
|
92
100
|
socket.on('error', () => {}); // open/request reports the connection error to the caller.
|
|
93
101
|
await new Promise((resolve, reject) => { socket.once('open', resolve); socket.once('error', reject); });
|
|
94
102
|
try {
|
|
95
|
-
await this.request('initialize', { clientInfo: { name: 'codex_remote_shared', version: '1.0.0' }, capabilities: { experimentalApi: true } });
|
|
103
|
+
const initialized = await this.request('initialize', { clientInfo: { name: 'codex_remote_shared', version: '1.0.0' }, capabilities: { experimentalApi: true } });
|
|
104
|
+
// The first user-agent product reports the running server build, not the
|
|
105
|
+
// selected executable that may only take effect after a future restart.
|
|
106
|
+
this.serverVersion = String(initialized?.userAgent || '').match(/^[^\s/]+\/([^\s]+)/)?.[1] || '';
|
|
96
107
|
socket.send(JSON.stringify({ method: 'initialized' }));
|
|
97
108
|
this.ready = true;
|
|
98
109
|
this.usageCache.read(() => this.request('account/rateLimits/read')).catch(error => this.emit('usage-error', error));
|
|
@@ -123,19 +134,21 @@ export class SharedAppServer extends EventEmitter {
|
|
|
123
134
|
await this.connect();
|
|
124
135
|
this.requireWritable(args.threadId);
|
|
125
136
|
// Reading the live thread checks this service owns it without resume changing settings.
|
|
126
|
-
const { thread } = await this.request('thread/read', { threadId: args.threadId, includeTurns: false });
|
|
127
|
-
const status = typeof thread.status === 'string' ? thread.status : thread.status?.type;
|
|
128
|
-
if (status === 'active') {
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
|
|
137
|
+
const { thread } = await this.request('thread/read', { threadId: args.threadId, includeTurns: false });
|
|
138
|
+
const status = typeof thread.status === 'string' ? thread.status : thread.status?.type;
|
|
139
|
+
if (status === 'active') {
|
|
140
|
+
const page = await this.request('thread/turns/list', {
|
|
141
|
+
threadId: args.threadId, limit: 1, sortDirection: 'desc', itemsView: 'summary',
|
|
142
|
+
});
|
|
143
|
+
const active = page.data?.[0];
|
|
144
|
+
if (active?.status !== 'inProgress') throw new Error('活动轮次已变化,请刷新后再发送。');
|
|
132
145
|
return this.request('turn/steer', { threadId: args.threadId, expectedTurnId: active.id, input: args.input, ...(args.clientUserMessageId ? { clientUserMessageId: args.clientUserMessageId } : {}) });
|
|
133
146
|
}
|
|
134
147
|
return this.request('turn/start', args);
|
|
135
148
|
}
|
|
136
149
|
|
|
137
150
|
respondApproval(threadId, body) {
|
|
138
|
-
if (!this.ready || this.socket?.readyState !== WebSocket.OPEN) throw new Error('
|
|
151
|
+
if (!this.ready || this.socket?.readyState !== WebSocket.OPEN) throw new Error('Codex 内核未就绪');
|
|
139
152
|
this.requireWritable(threadId);
|
|
140
153
|
return this.serverRequests.respond(threadId, body, message => new Promise((resolve, reject) => {
|
|
141
154
|
this.socket.send(JSON.stringify(message), error => error ? reject(error) : resolve());
|
|
@@ -159,14 +172,20 @@ export class SharedAppServer extends EventEmitter {
|
|
|
159
172
|
if (method === 'thread/tokenUsage/updated') {
|
|
160
173
|
this.contexts.set(id, { ...this.contexts.get(id), tokenUsage: params.tokenUsage });
|
|
161
174
|
return;
|
|
162
|
-
}
|
|
163
|
-
const record = this.threads.get(id);
|
|
164
|
-
if (!record) return;
|
|
165
|
-
const
|
|
166
|
-
|
|
167
|
-
|
|
175
|
+
}
|
|
176
|
+
const record = this.threads.get(id);
|
|
177
|
+
if (!record) return;
|
|
178
|
+
const current = params.turn?.id ? record.thread.turns?.find(turn => turn.id === params.turn.id) : null;
|
|
179
|
+
const groupedHistory = current?.items?.some(item => item?.type === 'lazyOperationGroup');
|
|
180
|
+
const appliedParams = groupedHistory && method === 'turn/completed' && Array.isArray(params.turn?.items)
|
|
181
|
+
? { ...params, turn: { ...params.turn,
|
|
182
|
+
items: params.turn.items.filter(item => ['userMessage', 'agentMessage'].includes(item?.type)) } }
|
|
183
|
+
: params;
|
|
184
|
+
const turn = events.applyNotification(record.thread, method, appliedParams);
|
|
185
|
+
if (turn) {
|
|
186
|
+
const sequence = ++record.sequence;
|
|
168
187
|
record.changes.set(turn.id, { sequence, turn });
|
|
169
|
-
record.changeBytes += Buffer.byteLength(JSON.stringify({ method, params }));
|
|
188
|
+
record.changeBytes += Buffer.byteLength(JSON.stringify({ method, params: appliedParams }));
|
|
170
189
|
if (record.changeBytes > syncBufferMaxBytes) {
|
|
171
190
|
record.recoverAfter = sequence;
|
|
172
191
|
record.changes.clear(); record.changeBytes = 0;
|
|
@@ -177,7 +196,7 @@ export class SharedAppServer extends EventEmitter {
|
|
|
177
196
|
if (method === 'turn/completed') record.thread.turns = record.thread.turns.slice(-10);
|
|
178
197
|
}
|
|
179
198
|
|
|
180
|
-
async timeline(threadId) {
|
|
199
|
+
async timeline(threadId, pageOptions = {}) {
|
|
181
200
|
await this.connect();
|
|
182
201
|
const slot = this.slot(threadId);
|
|
183
202
|
const task = slot.tail.then(async () => {
|
|
@@ -191,29 +210,37 @@ export class SharedAppServer extends EventEmitter {
|
|
|
191
210
|
let result;
|
|
192
211
|
try {
|
|
193
212
|
result = await this.request('thread/resume', { threadId, excludeTurns: true,
|
|
194
|
-
initialTurnsPage: { limit:
|
|
213
|
+
initialTurnsPage: { limit: 1, sortDirection: 'desc', itemsView: 'summary' } });
|
|
195
214
|
} catch (error) {
|
|
196
215
|
if (!isThreadWriterConflict(error)) throw error;
|
|
197
|
-
const read = await
|
|
198
|
-
|
|
216
|
+
const [read, page] = await Promise.all([
|
|
217
|
+
this.request('thread/read', { threadId, includeTurns: false }),
|
|
218
|
+
this.request('thread/turns/list', {
|
|
219
|
+
threadId, limit: historyPageMaxTurns, sortDirection: 'desc', itemsView: 'summary',
|
|
220
|
+
}),
|
|
221
|
+
]);
|
|
222
|
+
const compact = await Promise.all([...(page.data || [])].reverse().map(turn => this.compactTurn(threadId, turn)));
|
|
223
|
+
const turns = this.fitTurns({ thread: read.thread }, compact, pageOptions);
|
|
199
224
|
const thread = { ...read.thread, turns, readOnly: true,
|
|
200
225
|
readOnlyReason: '该对话正在另一个 Codex 实例中使用。' };
|
|
201
|
-
record = { thread, nextCursor: null, sequence: 0, recoverAfter: 0,
|
|
226
|
+
record = { thread, nextCursor: page.nextCursor ?? null, sequence: 0, recoverAfter: 0,
|
|
202
227
|
changes: new Map(), changeBytes: 0, tokens: new Map(), readOnly: true };
|
|
203
228
|
this.threads.set(threadId, record);
|
|
204
229
|
result = null;
|
|
205
230
|
}
|
|
206
231
|
if (result) {
|
|
207
|
-
if (!Array.isArray(result.initialTurnsPage?.data)) throw new Error('
|
|
208
|
-
const
|
|
232
|
+
if (!Array.isArray(result.initialTurnsPage?.data)) throw new Error('Codex 内核未返回分页历史。');
|
|
233
|
+
const compact = await Promise.all([...result.initialTurnsPage.data].reverse().map(turn => this.compactTurn(threadId, turn)));
|
|
234
|
+
const thread = { ...result.thread, turns: compact };
|
|
209
235
|
record = { thread, nextCursor: result.initialTurnsPage.nextCursor ?? null, sequence: 0,
|
|
210
236
|
recoverAfter: 0, changes: new Map(), changeBytes: 0, tokens: new Map() };
|
|
211
237
|
this.threads.set(threadId, record);
|
|
212
238
|
this.contexts.set(threadId, { ...this.contexts.get(threadId), model: result.model, thinking: result.reasoningEffort, serviceTier: result.serviceTier ?? null,
|
|
213
239
|
cwd: result.cwd, ...events.permissionState(result) });
|
|
240
|
+
await this.extendHistoryRecord(threadId, record, pageOptions);
|
|
214
241
|
}
|
|
215
|
-
}
|
|
216
|
-
const id = randomUUID(), expires = Date.now() + syncTokenTtlMs;
|
|
242
|
+
}
|
|
243
|
+
const id = randomUUID(), expires = Date.now() + syncTokenTtlMs;
|
|
217
244
|
record.tokens.set(id, { sequence: record.sequence, expires });
|
|
218
245
|
this.scheduleCleanup(threadId, slot);
|
|
219
246
|
return { thread: record.thread, nextCursor: record.nextCursor,
|
|
@@ -250,14 +277,15 @@ export class SharedAppServer extends EventEmitter {
|
|
|
250
277
|
if (record?.tokens.size) { this.scheduleCleanup(threadId, slot); return; }
|
|
251
278
|
slot.tail = slot.tail.then(async () => {
|
|
252
279
|
if (slot.users || this.threads.get(threadId)?.tokens.size) return;
|
|
253
|
-
this.threads.delete(threadId); this.contexts.delete(threadId); this.permissionOptions.delete(threadId);
|
|
280
|
+
this.threads.delete(threadId); this.contexts.delete(threadId); this.permissionOptions.delete(threadId);
|
|
281
|
+
this.operationGroups.delete(threadId);
|
|
254
282
|
this.subscriptions.delete(threadId); clearTimeout(slot.expiry);
|
|
255
283
|
if (this.ready && !record?.readOnly) await this.request('thread/unsubscribe', { threadId });
|
|
256
284
|
this.serverRequests.forget(threadId);
|
|
257
285
|
}).catch(error => this.emit('subscription-error', error));
|
|
258
286
|
}
|
|
259
287
|
|
|
260
|
-
async acquire(threadId, requested) {
|
|
288
|
+
async acquire(threadId, requested) {
|
|
261
289
|
const slot = this.slot(threadId);
|
|
262
290
|
let record = this.threads.get(threadId), token;
|
|
263
291
|
if (record && requested?.epoch === this.epoch && requested.id && Number.isSafeInteger(requested.after)) {
|
|
@@ -266,7 +294,7 @@ export class SharedAppServer extends EventEmitter {
|
|
|
266
294
|
}
|
|
267
295
|
let snapshot = false, catchup;
|
|
268
296
|
if (!record || !token) {
|
|
269
|
-
const fresh = await this.timeline(threadId);
|
|
297
|
+
const fresh = await this.timeline(threadId);
|
|
270
298
|
record = this.threads.get(threadId); token = record.tokens.get(fresh.sync.id);
|
|
271
299
|
requested = { id: fresh.sync.id, epoch: fresh.sync.epoch, after: fresh.sync.seq };
|
|
272
300
|
snapshot = true;
|
|
@@ -291,12 +319,140 @@ export class SharedAppServer extends EventEmitter {
|
|
|
291
319
|
snapshot, catchup, release };
|
|
292
320
|
}
|
|
293
321
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
322
|
+
pageSettings(options = {}) {
|
|
323
|
+
return {
|
|
324
|
+
maxTurns: Math.max(1, Math.min(historyPageMaxTurns, Number(options.maxTurns) || historyPageMaxTurns)),
|
|
325
|
+
maxBytes: Math.max(1, Number(options.maxBytes) || historyPageMaxBytes),
|
|
326
|
+
measure: typeof options.measure === 'function' ? options.measure : value => Buffer.byteLength(JSON.stringify(value)),
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
async compactTurn(threadId, turn) {
|
|
331
|
+
const compact = events.compactTurn(turn);
|
|
332
|
+
if (!turn?.id) return compact;
|
|
333
|
+
const batches = [];
|
|
334
|
+
let cursor;
|
|
335
|
+
do {
|
|
336
|
+
const page = await this.request('thread/items/list', {
|
|
337
|
+
threadId, turnId: turn.id, ...(cursor ? { cursor } : {}),
|
|
338
|
+
limit: 100, sortDirection: 'desc',
|
|
339
|
+
});
|
|
340
|
+
if (!Array.isArray(page.data)) throw new Error('Codex 内核未返回单轮条目索引。');
|
|
341
|
+
batches.push(page.data.map(entry => events.compactItem(entry.item)));
|
|
342
|
+
cursor = page.nextCursor ?? null;
|
|
343
|
+
} while (cursor);
|
|
344
|
+
const items = batches.reverse().flatMap(batch => batch.reverse());
|
|
345
|
+
const groups = new Map(), skeleton = [];
|
|
346
|
+
let operationItems = [];
|
|
347
|
+
const flushOperations = () => {
|
|
348
|
+
if (!operationItems.length) return;
|
|
349
|
+
const visibleItems = operationItems.filter(item => item?.type !== 'reasoning');
|
|
350
|
+
operationItems = [];
|
|
351
|
+
if (!visibleItems.length) return;
|
|
352
|
+
const subAgentItems = visibleItems.filter(isSubAgentItem);
|
|
353
|
+
const detailItems = visibleItems.filter(item => !isSubAgentItem(item));
|
|
354
|
+
if (detailItems.length) {
|
|
355
|
+
const groupId = `g${groups.size + 1}`;
|
|
356
|
+
groups.set(groupId, detailItems);
|
|
357
|
+
skeleton.push({ type: 'lazyOperationGroup', id: `${turn.id}:${groupId}`,
|
|
358
|
+
turnId: turn.id, groupId,
|
|
359
|
+
operationTypes: [...new Set(detailItems.map(item => item.type).filter(Boolean))] });
|
|
360
|
+
}
|
|
361
|
+
skeleton.push(...subAgentItems);
|
|
362
|
+
};
|
|
363
|
+
for (const item of items) {
|
|
364
|
+
if (['userMessage', 'agentMessage', 'imageGeneration'].includes(item?.type)) {
|
|
365
|
+
flushOperations();
|
|
366
|
+
skeleton.push(item);
|
|
367
|
+
} else operationItems.push(item);
|
|
368
|
+
}
|
|
369
|
+
flushOperations();
|
|
370
|
+
let threadGroups = this.operationGroups.get(threadId);
|
|
371
|
+
if (!threadGroups) { threadGroups = new Map(); this.operationGroups.set(threadId, threadGroups); }
|
|
372
|
+
threadGroups.set(turn.id, groups);
|
|
373
|
+
return { ...compact, items: skeleton };
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
async operationGroupItems(threadId, turnId, groupId, offset) {
|
|
377
|
+
await this.connect();
|
|
378
|
+
const items = this.operationGroups.get(threadId)?.get(turnId)?.get(groupId);
|
|
379
|
+
if (!items) throw new Error('操作组已失效,请重新打开对话。');
|
|
380
|
+
const start = Math.max(0, Number(offset) || 0);
|
|
381
|
+
const pageItems = items.slice(start, start + turnItemPageSize);
|
|
382
|
+
const nextOffset = start + pageItems.length < items.length ? start + pageItems.length : null;
|
|
383
|
+
return { turnId, groupId, items: pageItems, nextOffset, total: items.length };
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
async turnContext(threadId, turnId) {
|
|
387
|
+
await this.connect();
|
|
388
|
+
if (!threadId || !turnId) throw new Error('缺少图片所属的对话或轮次。');
|
|
389
|
+
return this.compactTurn(threadId, { id: turnId });
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
fitTurns(base, turns, options = {}) {
|
|
393
|
+
const settings = this.pageSettings(options), accepted = [];
|
|
394
|
+
let firstOversized = false;
|
|
395
|
+
for (let index = turns.length - 1; index >= 0 && accepted.length < settings.maxTurns; index--) {
|
|
396
|
+
const candidate = [turns[index], ...accepted];
|
|
397
|
+
const candidateBytes = settings.measure({ ...base, thread: { ...base.thread, turns: candidate } });
|
|
398
|
+
if (accepted.length >= 2 && candidateBytes > settings.maxBytes) break;
|
|
399
|
+
accepted.unshift(turns[index]);
|
|
400
|
+
if (accepted.length === 1) firstOversized = candidateBytes > settings.maxBytes;
|
|
401
|
+
if (firstOversized || (accepted.length >= 2 && candidateBytes > settings.maxBytes)) break;
|
|
402
|
+
}
|
|
403
|
+
return accepted;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
async extendHistoryRecord(threadId, record, options = {}) {
|
|
407
|
+
const settings = this.pageSettings(options);
|
|
408
|
+
if (!record.nextCursor || !record.thread.turns.length) return;
|
|
409
|
+
const firstOversized = settings.measure({ thread: record.thread, nextCursor: record.nextCursor }) > settings.maxBytes;
|
|
410
|
+
if (firstOversized) return;
|
|
411
|
+
while (record.nextCursor && record.thread.turns.length < settings.maxTurns) {
|
|
412
|
+
const cursor = record.nextCursor;
|
|
413
|
+
const page = await this.request('thread/turns/list', {
|
|
414
|
+
threadId, cursor, limit: 1, sortDirection: 'desc', itemsView: 'summary',
|
|
415
|
+
});
|
|
416
|
+
if (!Array.isArray(page.data) || !page.data.length) {
|
|
417
|
+
record.nextCursor = page.nextCursor ?? null;
|
|
418
|
+
break;
|
|
419
|
+
}
|
|
420
|
+
const candidate = [await this.compactTurn(threadId, page.data[0]), ...record.thread.turns];
|
|
421
|
+
const value = { thread: { ...record.thread, turns: candidate }, nextCursor: page.nextCursor ?? null };
|
|
422
|
+
if (record.thread.turns.length >= 2 && settings.measure(value) > settings.maxBytes) break;
|
|
423
|
+
record.thread.turns = candidate;
|
|
424
|
+
record.nextCursor = page.nextCursor ?? null;
|
|
425
|
+
if (record.thread.turns.length >= 2 && settings.measure(value) > settings.maxBytes) break;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
async history(threadId, cursor, options = {}) {
|
|
430
|
+
await this.connect();
|
|
431
|
+
if (!cursor) throw new Error('缺少历史分页游标。');
|
|
432
|
+
const settings = this.pageSettings(options), turns = [];
|
|
433
|
+
let firstOversized = false;
|
|
434
|
+
let nextCursor = cursor;
|
|
435
|
+
while (nextCursor && turns.length < settings.maxTurns) {
|
|
436
|
+
const pageCursor = nextCursor;
|
|
437
|
+
const page = await this.request('thread/turns/list', {
|
|
438
|
+
threadId, cursor: pageCursor, limit: 1, sortDirection: 'desc', itemsView: 'summary',
|
|
439
|
+
});
|
|
440
|
+
if (!Array.isArray(page.data) || !page.data.length) {
|
|
441
|
+
nextCursor = page.nextCursor ?? null;
|
|
442
|
+
break;
|
|
443
|
+
}
|
|
444
|
+
const compact = await this.compactTurn(threadId, page.data[0]);
|
|
445
|
+
const candidate = [compact, ...turns];
|
|
446
|
+
const value = { thread: { id: threadId, turns: candidate }, nextCursor: page.nextCursor ?? null };
|
|
447
|
+
const candidateBytes = settings.measure(value);
|
|
448
|
+
if (turns.length >= 2 && candidateBytes > settings.maxBytes) break;
|
|
449
|
+
turns.unshift(compact);
|
|
450
|
+
nextCursor = page.nextCursor ?? null;
|
|
451
|
+
if (turns.length === 1) firstOversized = candidateBytes > settings.maxBytes;
|
|
452
|
+
if (firstOversized || (turns.length >= 2 && candidateBytes > settings.maxBytes)) break;
|
|
453
|
+
}
|
|
454
|
+
return { thread: { id: threadId, turns }, nextCursor };
|
|
455
|
+
}
|
|
300
456
|
|
|
301
457
|
async interrupt(threadId, turnId) {
|
|
302
458
|
await this.connect();
|
|
@@ -354,3 +510,7 @@ export class SharedAppServer extends EventEmitter {
|
|
|
354
510
|
|
|
355
511
|
close() { this.socket?.close(); }
|
|
356
512
|
}
|
|
513
|
+
|
|
514
|
+
function isSubAgentItem(item) {
|
|
515
|
+
return item?.type === 'collabAgentToolCall' || item?.type === 'subAgentActivity';
|
|
516
|
+
}
|
|
@@ -1,31 +1,17 @@
|
|
|
1
1
|
import events from '../shared/app-server-events.cjs';
|
|
2
2
|
|
|
3
3
|
export function sharedThreadProjectId(thread, projects) {
|
|
4
|
-
|
|
5
|
-
const local = thread.environments?.find(environment => environment.environmentId === 'local');
|
|
6
|
-
if (local && Array.isArray(local.runtimeWorkspaceRoots)) {
|
|
7
|
-
for (const root of local.runtimeWorkspaceRoots) {
|
|
8
|
-
const projectId = events.threadProjectId({ cwd: root }, projects);
|
|
9
|
-
if (projectId) return projectId;
|
|
10
|
-
}
|
|
11
|
-
return null;
|
|
12
|
-
}
|
|
13
|
-
// Unloaded Desktop history omits environments and often has no persisted project ID.
|
|
14
|
-
return events.threadProjectId({ cwd: thread.cwd }, projects);
|
|
4
|
+
return events.catalogThreadProjectId(thread, projects);
|
|
15
5
|
}
|
|
16
6
|
|
|
17
|
-
//
|
|
7
|
+
// Mobile catalog order comes entirely from app-server authority, independent of Desktop UI state.
|
|
18
8
|
export class SharedCatalog {
|
|
19
|
-
constructor(server
|
|
9
|
+
constructor(server) { this.server = server; }
|
|
20
10
|
|
|
21
11
|
async projects() {
|
|
22
12
|
const native = await this.server.projects();
|
|
23
13
|
const projects = native.map(project => ({ projectId: project.id, name: project.name,
|
|
24
14
|
label: project.name, path: project.roots[0]?.path || '', cwd: project.roots[0]?.path || '', hostId: 'local' }));
|
|
25
|
-
const ordered = this.desktop.snapshot?.projects || [];
|
|
26
|
-
const key = value => value.replace(/^\\\\\?\\/, '').replaceAll('\\', '/').replace(/\/+$/, '').toLowerCase();
|
|
27
|
-
const ranks = new Map(ordered.filter(p => p.hostId === 'local').map((p, i) => [key(p.path || ''), i]));
|
|
28
|
-
projects.sort((a, b) => (ranks.get(key(a.path)) ?? Infinity) - (ranks.get(key(b.path)) ?? Infinity));
|
|
29
15
|
return { projects, native };
|
|
30
16
|
}
|
|
31
17
|
|
|
@@ -40,16 +26,10 @@ export class SharedCatalog {
|
|
|
40
26
|
const summaries = threads.map(thread => ({ id: thread.id, kind: 'codex', hostId: 'local',
|
|
41
27
|
title: thread.name || thread.preview || '', cwd: thread.cwd,
|
|
42
28
|
projectId: sharedThreadProjectId(thread, native), updatedAt: thread.updatedAt,
|
|
29
|
+
recencyAt: thread.recencyAt ?? thread.updatedAt,
|
|
43
30
|
status: typeof thread.status === 'string' ? thread.status : thread.status?.type || 'unknown',
|
|
44
31
|
activeFlags: threadActiveFlags(thread.status) }));
|
|
45
|
-
|
|
46
|
-
const byId = new Map(summaries.map(thread => [thread.id, thread]));
|
|
47
|
-
const pinnedThreads = (listing?.pinnedThreads || []).filter(t => t.hostId === 'local' && byId.has(t.id)).map(t => byId.get(t.id));
|
|
48
|
-
const pinned = new Set(pinnedThreads.map(t => t.id));
|
|
49
|
-
const ranks = new Map((listing?.threads || []).filter(t => t.hostId === 'local').map((t, i) => [t.id, i]));
|
|
50
|
-
const rest = summaries.filter(t => !pinned.has(t.id));
|
|
51
|
-
rest.sort((a, b) => (ranks.get(a.id) ?? Infinity) - (ranks.get(b.id) ?? Infinity));
|
|
52
|
-
return { projects, pinnedThreads, threads: rest };
|
|
32
|
+
return { projects, pinnedThreads: [], threads: summaries };
|
|
53
33
|
}
|
|
54
34
|
}
|
|
55
35
|
|