@walkhi/code-relax 0.1.0-beta.1 → 0.1.0-beta.10
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 +14 -15
- package/dist/bin/codex-remote.mjs +81 -26
- 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 +50 -22
- package/dist/shared/p2p-data-channel.cjs +50 -0
- package/dist/skills/code-relax/SKILL.md +1 -1
- package/dist/src/desktop-launcher.mjs +11 -11
- package/dist/src/lan-socket-server.mjs +8 -2
- package/dist/src/managed-app-server.mjs +27 -7
- package/dist/src/message-images.mjs +0 -18
- package/dist/src/onboarding.mjs +3 -5
- 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 +16 -1
- package/dist/src/platform/windows/desktop-info.ps1 +2 -4
- 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/launch-worker.mjs +42 -0
- 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/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 +23 -8
- package/dist/src/self-relay/demo.mjs +4 -5
- package/dist/src/self-relay/lifecycle.mjs +53 -49
- package/dist/src/self-relay/p2p-probe.mjs +123 -83
- package/dist/src/self-relay/server.mjs +157 -17
- package/dist/src/server.mjs +484 -1274
- package/dist/src/service-doctor.mjs +13 -25
- package/dist/src/service-lifecycle.mjs +12 -12
- package/dist/src/shared-app-server.mjs +192 -34
- package/dist/src/shared-catalog.mjs +4 -14
- package/dist/src/shared-recovery.mjs +8 -6
- package/dist/src/shared-thread-stream.mjs +1 -1
- package/dist/web/activity-view.js +283 -0
- package/dist/web/capabilities.js +2 -9
- package/dist/web/chat-transport.js +21 -21
- package/dist/web/chat.css +148 -106
- package/dist/web/chat.js +1454 -2788
- package/dist/web/community.css +82 -0
- package/dist/web/community.html +36 -0
- package/dist/web/composer-controller.js +97 -0
- package/dist/web/conversation-controller.js +99 -0
- package/dist/web/disclosure-state-controller.js +95 -0
- package/dist/web/draft-controller.js +103 -0
- package/dist/web/harmony-platform.js +3 -2
- package/dist/web/history-cache.js +112 -18
- package/dist/web/history-controller.js +167 -0
- package/dist/web/index.html +113 -47
- package/dist/web/link-action-controller.js +212 -0
- package/dist/web/message-send-controller.js +177 -0
- package/dist/web/message-view.js +98 -0
- package/dist/web/p2p-data-channel.js +50 -0
- package/dist/web/p2p-probe.js +67 -27
- package/dist/web/page-resume.js +28 -0
- package/dist/web/pending-message-store.js +108 -0
- package/dist/web/queue-controller.js +82 -0
- package/dist/web/resources.json +1 -1
- package/dist/web/self-relay-session.js +28 -18
- package/dist/web/station-connection-controller.js +75 -0
- package/dist/web/task-list-view.js +289 -0
- package/dist/web/thread-attention-controller.js +124 -0
- package/dist/web/thread-context-controller.js +30 -0
- package/dist/web/thread-list-controller.js +61 -0
- package/dist/web/thread-list-sync.js +86 -0
- package/dist/web/thread-title-controller.js +57 -0
- package/dist/web/timeline-formatters.js +249 -0
- package/dist/web/timeline-reducer.js +77 -0
- package/dist/web/timeline-renderer.js +161 -0
- package/dist/web/timeline-scroll-controller.js +50 -0
- package/dist/web/usage-controller.js +258 -0
- package/dist/web/vendor/lucide.LICENSE.txt +17 -0
- 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/platform/windows/desktop-monitor.mjs +0 -34
- 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
|
@@ -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,24 +29,17 @@ 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);
|
|
@@ -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,7 +32,7 @@ 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('
|
|
35
|
+
if (address.protocol !== 'ws:' || address.hostname !== '127.0.0.1') throw new Error('Codex 内核必须使用本机回环 WebSocket。');
|
|
33
36
|
this.url = url;
|
|
34
37
|
this.epoch = randomUUID();
|
|
35
38
|
this.sequence = 0;
|
|
@@ -38,9 +41,10 @@ export class SharedAppServer extends EventEmitter {
|
|
|
38
41
|
this.threads = new Map();
|
|
39
42
|
this.contexts = new Map();
|
|
40
43
|
this.settingsNotifications = new Map();
|
|
41
|
-
this.permissionOptions = new events.PermissionOptionsCache();
|
|
42
|
-
this.subscriptions = new Map();
|
|
43
|
-
this.
|
|
44
|
+
this.permissionOptions = new events.PermissionOptionsCache();
|
|
45
|
+
this.subscriptions = new Map();
|
|
46
|
+
this.operationGroups = new Map();
|
|
47
|
+
this.usageCache = new events.UsageCache(value => this.emit('usage', value));
|
|
44
48
|
}
|
|
45
49
|
|
|
46
50
|
async connect() {
|
|
@@ -76,11 +80,12 @@ export class SharedAppServer extends EventEmitter {
|
|
|
76
80
|
this.ready = false;
|
|
77
81
|
for (const pending of this.pending.values()) {
|
|
78
82
|
clearTimeout(pending.timer);
|
|
79
|
-
pending.reject(new Error('
|
|
83
|
+
pending.reject(new Error('Codex 内核连接断开;发送结果可能未确认,请检查对话后再操作。'));
|
|
80
84
|
}
|
|
81
|
-
this.pending.clear();
|
|
82
|
-
this.serverRequests.reset(randomUUID());
|
|
83
|
-
this.threads.clear();
|
|
85
|
+
this.pending.clear();
|
|
86
|
+
this.serverRequests.reset(randomUUID());
|
|
87
|
+
this.threads.clear();
|
|
88
|
+
this.operationGroups.clear();
|
|
84
89
|
for (const slot of this.subscriptions.values()) clearTimeout(slot.expiry);
|
|
85
90
|
this.subscriptions.clear();
|
|
86
91
|
this.contexts.clear();
|
|
@@ -123,19 +128,21 @@ export class SharedAppServer extends EventEmitter {
|
|
|
123
128
|
await this.connect();
|
|
124
129
|
this.requireWritable(args.threadId);
|
|
125
130
|
// 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
|
-
|
|
131
|
+
const { thread } = await this.request('thread/read', { threadId: args.threadId, includeTurns: false });
|
|
132
|
+
const status = typeof thread.status === 'string' ? thread.status : thread.status?.type;
|
|
133
|
+
if (status === 'active') {
|
|
134
|
+
const page = await this.request('thread/turns/list', {
|
|
135
|
+
threadId: args.threadId, limit: 1, sortDirection: 'desc', itemsView: 'summary',
|
|
136
|
+
});
|
|
137
|
+
const active = page.data?.[0];
|
|
138
|
+
if (active?.status !== 'inProgress') throw new Error('活动轮次已变化,请刷新后再发送。');
|
|
132
139
|
return this.request('turn/steer', { threadId: args.threadId, expectedTurnId: active.id, input: args.input, ...(args.clientUserMessageId ? { clientUserMessageId: args.clientUserMessageId } : {}) });
|
|
133
140
|
}
|
|
134
141
|
return this.request('turn/start', args);
|
|
135
142
|
}
|
|
136
143
|
|
|
137
144
|
respondApproval(threadId, body) {
|
|
138
|
-
if (!this.ready || this.socket?.readyState !== WebSocket.OPEN) throw new Error('
|
|
145
|
+
if (!this.ready || this.socket?.readyState !== WebSocket.OPEN) throw new Error('Codex 内核未就绪');
|
|
139
146
|
this.requireWritable(threadId);
|
|
140
147
|
return this.serverRequests.respond(threadId, body, message => new Promise((resolve, reject) => {
|
|
141
148
|
this.socket.send(JSON.stringify(message), error => error ? reject(error) : resolve());
|
|
@@ -159,14 +166,20 @@ export class SharedAppServer extends EventEmitter {
|
|
|
159
166
|
if (method === 'thread/tokenUsage/updated') {
|
|
160
167
|
this.contexts.set(id, { ...this.contexts.get(id), tokenUsage: params.tokenUsage });
|
|
161
168
|
return;
|
|
162
|
-
}
|
|
163
|
-
const record = this.threads.get(id);
|
|
164
|
-
if (!record) return;
|
|
165
|
-
const
|
|
169
|
+
}
|
|
170
|
+
const record = this.threads.get(id);
|
|
171
|
+
if (!record) return;
|
|
172
|
+
const current = params.turn?.id ? record.thread.turns?.find(turn => turn.id === params.turn.id) : null;
|
|
173
|
+
const groupedHistory = current?.items?.some(item => item?.type === 'lazyOperationGroup');
|
|
174
|
+
const appliedParams = groupedHistory && method === 'turn/completed' && Array.isArray(params.turn?.items)
|
|
175
|
+
? { ...params, turn: { ...params.turn,
|
|
176
|
+
items: params.turn.items.filter(item => ['userMessage', 'agentMessage'].includes(item?.type)) } }
|
|
177
|
+
: params;
|
|
178
|
+
const turn = events.applyNotification(record.thread, method, appliedParams);
|
|
166
179
|
if (turn) {
|
|
167
180
|
const sequence = ++record.sequence;
|
|
168
181
|
record.changes.set(turn.id, { sequence, turn });
|
|
169
|
-
record.changeBytes += Buffer.byteLength(JSON.stringify({ method, params }));
|
|
182
|
+
record.changeBytes += Buffer.byteLength(JSON.stringify({ method, params: appliedParams }));
|
|
170
183
|
if (record.changeBytes > syncBufferMaxBytes) {
|
|
171
184
|
record.recoverAfter = sequence;
|
|
172
185
|
record.changes.clear(); record.changeBytes = 0;
|
|
@@ -177,7 +190,7 @@ export class SharedAppServer extends EventEmitter {
|
|
|
177
190
|
if (method === 'turn/completed') record.thread.turns = record.thread.turns.slice(-10);
|
|
178
191
|
}
|
|
179
192
|
|
|
180
|
-
async timeline(threadId) {
|
|
193
|
+
async timeline(threadId, pageOptions = {}) {
|
|
181
194
|
await this.connect();
|
|
182
195
|
const slot = this.slot(threadId);
|
|
183
196
|
const task = slot.tail.then(async () => {
|
|
@@ -191,26 +204,34 @@ export class SharedAppServer extends EventEmitter {
|
|
|
191
204
|
let result;
|
|
192
205
|
try {
|
|
193
206
|
result = await this.request('thread/resume', { threadId, excludeTurns: true,
|
|
194
|
-
initialTurnsPage: { limit:
|
|
207
|
+
initialTurnsPage: { limit: 1, sortDirection: 'desc', itemsView: 'summary' } });
|
|
195
208
|
} catch (error) {
|
|
196
209
|
if (!isThreadWriterConflict(error)) throw error;
|
|
197
|
-
const read = await
|
|
198
|
-
|
|
210
|
+
const [read, page] = await Promise.all([
|
|
211
|
+
this.request('thread/read', { threadId, includeTurns: false }),
|
|
212
|
+
this.request('thread/turns/list', {
|
|
213
|
+
threadId, limit: historyPageMaxTurns, sortDirection: 'desc', itemsView: 'summary',
|
|
214
|
+
}),
|
|
215
|
+
]);
|
|
216
|
+
const compact = await Promise.all([...(page.data || [])].reverse().map(turn => this.compactTurn(threadId, turn)));
|
|
217
|
+
const turns = this.fitTurns({ thread: read.thread }, compact, pageOptions);
|
|
199
218
|
const thread = { ...read.thread, turns, readOnly: true,
|
|
200
219
|
readOnlyReason: '该对话正在另一个 Codex 实例中使用。' };
|
|
201
|
-
record = { thread, nextCursor: null, sequence: 0, recoverAfter: 0,
|
|
220
|
+
record = { thread, nextCursor: page.nextCursor ?? null, sequence: 0, recoverAfter: 0,
|
|
202
221
|
changes: new Map(), changeBytes: 0, tokens: new Map(), readOnly: true };
|
|
203
222
|
this.threads.set(threadId, record);
|
|
204
223
|
result = null;
|
|
205
224
|
}
|
|
206
225
|
if (result) {
|
|
207
|
-
if (!Array.isArray(result.initialTurnsPage?.data)) throw new Error('
|
|
208
|
-
const
|
|
226
|
+
if (!Array.isArray(result.initialTurnsPage?.data)) throw new Error('Codex 内核未返回分页历史。');
|
|
227
|
+
const compact = await Promise.all([...result.initialTurnsPage.data].reverse().map(turn => this.compactTurn(threadId, turn)));
|
|
228
|
+
const thread = { ...result.thread, turns: compact };
|
|
209
229
|
record = { thread, nextCursor: result.initialTurnsPage.nextCursor ?? null, sequence: 0,
|
|
210
230
|
recoverAfter: 0, changes: new Map(), changeBytes: 0, tokens: new Map() };
|
|
211
231
|
this.threads.set(threadId, record);
|
|
212
232
|
this.contexts.set(threadId, { ...this.contexts.get(threadId), model: result.model, thinking: result.reasoningEffort, serviceTier: result.serviceTier ?? null,
|
|
213
233
|
cwd: result.cwd, ...events.permissionState(result) });
|
|
234
|
+
await this.extendHistoryRecord(threadId, record, pageOptions);
|
|
214
235
|
}
|
|
215
236
|
}
|
|
216
237
|
const id = randomUUID(), expires = Date.now() + syncTokenTtlMs;
|
|
@@ -250,7 +271,8 @@ export class SharedAppServer extends EventEmitter {
|
|
|
250
271
|
if (record?.tokens.size) { this.scheduleCleanup(threadId, slot); return; }
|
|
251
272
|
slot.tail = slot.tail.then(async () => {
|
|
252
273
|
if (slot.users || this.threads.get(threadId)?.tokens.size) return;
|
|
253
|
-
this.threads.delete(threadId); this.contexts.delete(threadId); this.permissionOptions.delete(threadId);
|
|
274
|
+
this.threads.delete(threadId); this.contexts.delete(threadId); this.permissionOptions.delete(threadId);
|
|
275
|
+
this.operationGroups.delete(threadId);
|
|
254
276
|
this.subscriptions.delete(threadId); clearTimeout(slot.expiry);
|
|
255
277
|
if (this.ready && !record?.readOnly) await this.request('thread/unsubscribe', { threadId });
|
|
256
278
|
this.serverRequests.forget(threadId);
|
|
@@ -291,12 +313,135 @@ export class SharedAppServer extends EventEmitter {
|
|
|
291
313
|
snapshot, catchup, release };
|
|
292
314
|
}
|
|
293
315
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
316
|
+
pageSettings(options = {}) {
|
|
317
|
+
return {
|
|
318
|
+
maxTurns: Math.max(1, Math.min(historyPageMaxTurns, Number(options.maxTurns) || historyPageMaxTurns)),
|
|
319
|
+
maxBytes: Math.max(1, Number(options.maxBytes) || historyPageMaxBytes),
|
|
320
|
+
measure: typeof options.measure === 'function' ? options.measure : value => Buffer.byteLength(JSON.stringify(value)),
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
async compactTurn(threadId, turn) {
|
|
325
|
+
const compact = events.compactTurn(turn);
|
|
326
|
+
if (!turn?.id) return compact;
|
|
327
|
+
const batches = [];
|
|
328
|
+
let cursor;
|
|
329
|
+
do {
|
|
330
|
+
const page = await this.request('thread/items/list', {
|
|
331
|
+
threadId, turnId: turn.id, ...(cursor ? { cursor } : {}),
|
|
332
|
+
limit: 100, sortDirection: 'desc',
|
|
333
|
+
});
|
|
334
|
+
if (!Array.isArray(page.data)) throw new Error('Codex 内核未返回单轮条目索引。');
|
|
335
|
+
batches.push(page.data.map(entry => events.compactItem(entry.item)));
|
|
336
|
+
cursor = page.nextCursor ?? null;
|
|
337
|
+
} while (cursor);
|
|
338
|
+
const items = batches.reverse().flatMap(batch => batch.reverse());
|
|
339
|
+
const groups = new Map(), skeleton = [];
|
|
340
|
+
let operationItems = [];
|
|
341
|
+
const flushOperations = () => {
|
|
342
|
+
if (!operationItems.length) return;
|
|
343
|
+
const visibleItems = operationItems.filter(item => item?.type !== 'reasoning');
|
|
344
|
+
operationItems = [];
|
|
345
|
+
if (!visibleItems.length) return;
|
|
346
|
+
const groupId = `g${groups.size + 1}`;
|
|
347
|
+
const outline = operationOutline(visibleItems, false);
|
|
348
|
+
groups.set(groupId, visibleItems);
|
|
349
|
+
skeleton.push({ type: 'lazyOperationGroup', id: `${turn.id}:${groupId}`,
|
|
350
|
+
turnId: turn.id, groupId, title: outline.title, count: visibleItems.length });
|
|
351
|
+
};
|
|
352
|
+
for (const item of items) {
|
|
353
|
+
if (['userMessage', 'agentMessage', 'imageGeneration'].includes(item?.type)) {
|
|
354
|
+
flushOperations();
|
|
355
|
+
skeleton.push(item);
|
|
356
|
+
} else operationItems.push(item);
|
|
357
|
+
}
|
|
358
|
+
flushOperations();
|
|
359
|
+
let threadGroups = this.operationGroups.get(threadId);
|
|
360
|
+
if (!threadGroups) { threadGroups = new Map(); this.operationGroups.set(threadId, threadGroups); }
|
|
361
|
+
threadGroups.set(turn.id, groups);
|
|
362
|
+
return { ...compact, items: skeleton };
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
async operationGroupItems(threadId, turnId, groupId, offset) {
|
|
366
|
+
await this.connect();
|
|
367
|
+
const items = this.operationGroups.get(threadId)?.get(turnId)?.get(groupId);
|
|
368
|
+
if (!items) throw new Error('操作组已失效,请重新打开对话。');
|
|
369
|
+
const start = Math.max(0, Number(offset) || 0);
|
|
370
|
+
const pageItems = items.slice(start, start + turnItemPageSize);
|
|
371
|
+
const nextOffset = start + pageItems.length < items.length ? start + pageItems.length : null;
|
|
372
|
+
return { turnId, groupId, items: pageItems, nextOffset, total: items.length };
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
async turnContext(threadId, turnId) {
|
|
376
|
+
await this.connect();
|
|
377
|
+
if (!threadId || !turnId) throw new Error('缺少图片所属的对话或轮次。');
|
|
378
|
+
return this.compactTurn(threadId, { id: turnId });
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
fitTurns(base, turns, options = {}) {
|
|
382
|
+
const settings = this.pageSettings(options), accepted = [];
|
|
383
|
+
let firstOversized = false;
|
|
384
|
+
for (let index = turns.length - 1; index >= 0 && accepted.length < settings.maxTurns; index--) {
|
|
385
|
+
const candidate = [turns[index], ...accepted];
|
|
386
|
+
const candidateBytes = settings.measure({ ...base, thread: { ...base.thread, turns: candidate } });
|
|
387
|
+
if (accepted.length >= 2 && candidateBytes > settings.maxBytes) break;
|
|
388
|
+
accepted.unshift(turns[index]);
|
|
389
|
+
if (accepted.length === 1) firstOversized = candidateBytes > settings.maxBytes;
|
|
390
|
+
if (firstOversized || (accepted.length >= 2 && candidateBytes > settings.maxBytes)) break;
|
|
391
|
+
}
|
|
392
|
+
return accepted;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
async extendHistoryRecord(threadId, record, options = {}) {
|
|
396
|
+
const settings = this.pageSettings(options);
|
|
397
|
+
if (!record.nextCursor || !record.thread.turns.length) return;
|
|
398
|
+
const firstOversized = settings.measure({ thread: record.thread, nextCursor: record.nextCursor }) > settings.maxBytes;
|
|
399
|
+
if (firstOversized) return;
|
|
400
|
+
while (record.nextCursor && record.thread.turns.length < settings.maxTurns) {
|
|
401
|
+
const cursor = record.nextCursor;
|
|
402
|
+
const page = await this.request('thread/turns/list', {
|
|
403
|
+
threadId, cursor, limit: 1, sortDirection: 'desc', itemsView: 'summary',
|
|
404
|
+
});
|
|
405
|
+
if (!Array.isArray(page.data) || !page.data.length) {
|
|
406
|
+
record.nextCursor = page.nextCursor ?? null;
|
|
407
|
+
break;
|
|
408
|
+
}
|
|
409
|
+
const candidate = [await this.compactTurn(threadId, page.data[0]), ...record.thread.turns];
|
|
410
|
+
const value = { thread: { ...record.thread, turns: candidate }, nextCursor: page.nextCursor ?? null };
|
|
411
|
+
if (record.thread.turns.length >= 2 && settings.measure(value) > settings.maxBytes) break;
|
|
412
|
+
record.thread.turns = candidate;
|
|
413
|
+
record.nextCursor = page.nextCursor ?? null;
|
|
414
|
+
if (record.thread.turns.length >= 2 && settings.measure(value) > settings.maxBytes) break;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
async history(threadId, cursor, options = {}) {
|
|
419
|
+
await this.connect();
|
|
420
|
+
if (!cursor) throw new Error('缺少历史分页游标。');
|
|
421
|
+
const settings = this.pageSettings(options), turns = [];
|
|
422
|
+
let firstOversized = false;
|
|
423
|
+
let nextCursor = cursor;
|
|
424
|
+
while (nextCursor && turns.length < settings.maxTurns) {
|
|
425
|
+
const pageCursor = nextCursor;
|
|
426
|
+
const page = await this.request('thread/turns/list', {
|
|
427
|
+
threadId, cursor: pageCursor, limit: 1, sortDirection: 'desc', itemsView: 'summary',
|
|
428
|
+
});
|
|
429
|
+
if (!Array.isArray(page.data) || !page.data.length) {
|
|
430
|
+
nextCursor = page.nextCursor ?? null;
|
|
431
|
+
break;
|
|
432
|
+
}
|
|
433
|
+
const compact = await this.compactTurn(threadId, page.data[0]);
|
|
434
|
+
const candidate = [compact, ...turns];
|
|
435
|
+
const value = { thread: { id: threadId, turns: candidate }, nextCursor: page.nextCursor ?? null };
|
|
436
|
+
const candidateBytes = settings.measure(value);
|
|
437
|
+
if (turns.length >= 2 && candidateBytes > settings.maxBytes) break;
|
|
438
|
+
turns.unshift(compact);
|
|
439
|
+
nextCursor = page.nextCursor ?? null;
|
|
440
|
+
if (turns.length === 1) firstOversized = candidateBytes > settings.maxBytes;
|
|
441
|
+
if (firstOversized || (turns.length >= 2 && candidateBytes > settings.maxBytes)) break;
|
|
442
|
+
}
|
|
443
|
+
return { thread: { id: threadId, turns }, nextCursor };
|
|
444
|
+
}
|
|
300
445
|
|
|
301
446
|
async interrupt(threadId, turnId) {
|
|
302
447
|
await this.connect();
|
|
@@ -354,3 +499,16 @@ export class SharedAppServer extends EventEmitter {
|
|
|
354
499
|
|
|
355
500
|
close() { this.socket?.close(); }
|
|
356
501
|
}
|
|
502
|
+
|
|
503
|
+
function operationOutline(items, hasMore) {
|
|
504
|
+
const types = new Set(items.map(item => item?.type));
|
|
505
|
+
const parts = [];
|
|
506
|
+
if (types.has('fileChange')) parts.push('编辑了文件');
|
|
507
|
+
if (types.has('commandExecution')) parts.push('运行了命令');
|
|
508
|
+
if (types.has('mcpToolCall') || types.has('dynamicToolCall')) parts.push('调用了工具');
|
|
509
|
+
if (types.has('webSearch')) parts.push('搜索了网页');
|
|
510
|
+
if (types.has('imageView')) parts.push('查看了图像');
|
|
511
|
+
if (types.has('collabAgentToolCall') || types.has('subAgentActivity')) parts.push('处理了子任务');
|
|
512
|
+
const available = hasMore || items.some(item => item && !['userMessage', 'agentMessage'].includes(item.type));
|
|
513
|
+
return { available, title: parts.length ? `${parts.join(' · ')}${hasMore ? '等' : ''}` : '执行过程' };
|
|
514
|
+
}
|
|
@@ -14,18 +14,14 @@ export function sharedThreadProjectId(thread, projects) {
|
|
|
14
14
|
return events.threadProjectId({ cwd: thread.cwd }, projects);
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
//
|
|
17
|
+
// Mobile catalog order comes entirely from app-server authority, independent of Desktop UI state.
|
|
18
18
|
export class SharedCatalog {
|
|
19
|
-
constructor(server
|
|
19
|
+
constructor(server) { this.server = server; }
|
|
20
20
|
|
|
21
21
|
async projects() {
|
|
22
22
|
const native = await this.server.projects();
|
|
23
23
|
const projects = native.map(project => ({ projectId: project.id, name: project.name,
|
|
24
24
|
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
25
|
return { projects, native };
|
|
30
26
|
}
|
|
31
27
|
|
|
@@ -40,16 +36,10 @@ export class SharedCatalog {
|
|
|
40
36
|
const summaries = threads.map(thread => ({ id: thread.id, kind: 'codex', hostId: 'local',
|
|
41
37
|
title: thread.name || thread.preview || '', cwd: thread.cwd,
|
|
42
38
|
projectId: sharedThreadProjectId(thread, native), updatedAt: thread.updatedAt,
|
|
39
|
+
recencyAt: thread.recencyAt ?? thread.updatedAt,
|
|
43
40
|
status: typeof thread.status === 'string' ? thread.status : thread.status?.type || 'unknown',
|
|
44
41
|
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 };
|
|
42
|
+
return { projects, pinnedThreads: [], threads: summaries };
|
|
53
43
|
}
|
|
54
44
|
}
|
|
55
45
|
|
|
@@ -8,6 +8,7 @@ import { startBackground } from './background-process.mjs';
|
|
|
8
8
|
import { desktopHost } from './platform/windows/desktop-host.mjs';
|
|
9
9
|
import * as serviceHost from './platform/windows/service-host.mjs';
|
|
10
10
|
import { launchDesktop, portAvailable } from './desktop-launcher.mjs';
|
|
11
|
+
import { configureManagedEnvironment } from './platform/windows/user-environment.mjs';
|
|
11
12
|
|
|
12
13
|
const script = fileURLToPath(import.meta.url);
|
|
13
14
|
const recordPath = root => path.join(root, 'shared-recovery.json');
|
|
@@ -19,7 +20,7 @@ const recoverySteps = () => [
|
|
|
19
20
|
['starting-app-server', '确认 Codex 内核'], ['starting-desktop', '重新打开 Codex'],
|
|
20
21
|
['checking-execution', '确认手机控制就绪'],
|
|
21
22
|
].map(([id, label]) => ({ id, label, status: 'pending' }));
|
|
22
|
-
export const recoveryWarning = 'Codex Desktop 需要重新打开,才能接入同一个 Codex
|
|
23
|
+
export const recoveryWarning = 'Codex Desktop 需要重新打开,才能接入同一个 Codex 内核。手机连接和 Codex 内核会保持运行;旧 Desktop 实例中的任务可能中断,未发送内容请先保存。切换不会清空项目、对话历史、设置或设备授权。';
|
|
23
24
|
|
|
24
25
|
export async function recoveryStatus(stateRoot, host = desktopHost) {
|
|
25
26
|
const record = readRecord(recordPath(stateRoot));
|
|
@@ -40,11 +41,11 @@ export async function recoveryStatus(stateRoot, host = desktopHost) {
|
|
|
40
41
|
export async function planRecovery(stateRoot, host, available = portAvailable) {
|
|
41
42
|
const info = await host.inspect();
|
|
42
43
|
const action = await host.sharedServiceUrl() ? 'restart' : 'switch';
|
|
43
|
-
if (!info.desktop
|
|
44
|
+
if (!info.desktop) throw new Error('未找到 Codex Desktop;CLI 用户无需执行 Desktop 接入。');
|
|
44
45
|
const record = readRecord(path.join(stateRoot, 'managed-app-server.json')) || readRecord(path.join(stateRoot, 'shared-desktop.json'));
|
|
45
46
|
const actual = record ? await host.process(record.pid) : null;
|
|
46
47
|
if (actual && (!sameProcess(record, actual) || !actual.command?.includes('app-server') || !actual.command.includes('ws://127.0.0.1:45839'))) {
|
|
47
|
-
throw new Error('
|
|
48
|
+
throw new Error('Codex 内核进程身份与记录不符,未结束任何进程。');
|
|
48
49
|
}
|
|
49
50
|
if (!actual && !await available()) throw new Error('45839 被未识别进程占用,未关闭 Desktop 或结束任何进程。');
|
|
50
51
|
return { desktop: info.desktop, action };
|
|
@@ -63,7 +64,7 @@ export async function requestRecovery(stateRoot, { confirmed, operationId = rand
|
|
|
63
64
|
catch (error) { if (error.code === 'EEXIST') throw new Error('已有恢复操作,请先查看恢复状态。'); throw error; }
|
|
64
65
|
fs.writeFileSync(lock, JSON.stringify({ operationId })); fs.closeSync(lock);
|
|
65
66
|
const record = { operationId, status: 'scheduled', stage: 'prepared', startedAt: new Date().toISOString(),
|
|
66
|
-
message: '
|
|
67
|
+
message: 'Codex 连接请求已接受,电脑管理连接保持运行。', steps: recoverySteps(), ...planned };
|
|
67
68
|
try {
|
|
68
69
|
writeRecord(recordPath(stateRoot), record);
|
|
69
70
|
const log = path.join(stateRoot, 'shared-recovery.log');
|
|
@@ -99,6 +100,7 @@ export async function runRecovery(stateRoot, operationId, dependencies = {}) {
|
|
|
99
100
|
try {
|
|
100
101
|
stage('checking');
|
|
101
102
|
const planned = await planRecovery(stateRoot, host, available);
|
|
103
|
+
await configureManagedEnvironment(stateRoot);
|
|
102
104
|
record = { ...record, ...planned };
|
|
103
105
|
if (fs.existsSync(path.join(stateRoot, 'desktop-launch.lock'))) throw new Error('Desktop 正在启动,未与启动操作并行恢复。');
|
|
104
106
|
stage('stopping-desktop'); await host.closeDesktop(record.desktop);
|
|
@@ -109,9 +111,9 @@ export async function runRecovery(stateRoot, operationId, dependencies = {}) {
|
|
|
109
111
|
await waitFor(async () => {
|
|
110
112
|
const health = await service.status();
|
|
111
113
|
return health.running && health.mode === 'lan-shared' && health.execution?.ready === true;
|
|
112
|
-
}, '
|
|
114
|
+
}, 'Codex 内核未就绪;Bridge 和中继管理连接仍保留。');
|
|
113
115
|
record = { ...record, status: 'completed', stage: 'ready', steps: record.steps.map(step => ({ ...step, status: 'completed' })),
|
|
114
|
-
message: 'Codex Desktop 已接入 Codex
|
|
116
|
+
message: 'Codex Desktop 已接入 Codex 内核;手机控制保持就绪,原任务未自动重发。' };
|
|
115
117
|
} catch (error) { record = { ...record, status: 'failed', error: error.message,
|
|
116
118
|
steps: record.steps.map(step => ({ ...step, status: step.status === 'running' ? 'failed' : step.status })),
|
|
117
119
|
message: 'Codex Desktop 未完成接入,已停止后续步骤。' }; }
|
|
@@ -43,7 +43,7 @@ export async function streamSharedThread(response, threadId, client, normalize,
|
|
|
43
43
|
if (!threadListNotificationMethods.has(method)) return;
|
|
44
44
|
emit('thread-list-changed', { method, threadId: params.threadId || params.thread?.id || '' });
|
|
45
45
|
};
|
|
46
|
-
const onDisconnected = () => { emit('bridge-error', { error: '
|
|
46
|
+
const onDisconnected = () => { emit('bridge-error', { error: 'Codex 内核连接已断开,请重新连接。' }); response.end(); finish(); };
|
|
47
47
|
response.once('close', () => { closed = true; finish(); });
|
|
48
48
|
client.on('turn', onTurn); client.on('settings', onSettings); client.on('disconnected', onDisconnected);
|
|
49
49
|
client.on('queue-changed', onQueue);
|