@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.
Files changed (91) hide show
  1. package/README.md +14 -15
  2. package/dist/bin/codex-remote.mjs +81 -26
  3. package/dist/bin/self-relay-admin.mjs +8 -0
  4. package/dist/bin/self-relay-demo.mjs +1 -1
  5. package/dist/bin/self-relay-server.mjs +5 -2
  6. package/dist/shared/app-server-events.cjs +50 -22
  7. package/dist/shared/p2p-data-channel.cjs +50 -0
  8. package/dist/skills/code-relax/SKILL.md +1 -1
  9. package/dist/src/desktop-launcher.mjs +11 -11
  10. package/dist/src/lan-socket-server.mjs +8 -2
  11. package/dist/src/managed-app-server.mjs +27 -7
  12. package/dist/src/message-images.mjs +0 -18
  13. package/dist/src/onboarding.mjs +3 -5
  14. package/dist/src/platform/README.md +1 -1
  15. package/dist/src/platform/windows/IsolatedProcess.cs +7 -3
  16. package/dist/src/platform/windows/background-process.mjs +7 -2
  17. package/dist/src/platform/windows/codex-runtime.mjs +117 -0
  18. package/dist/src/platform/windows/desktop-host.mjs +16 -1
  19. package/dist/src/platform/windows/desktop-info.ps1 +2 -4
  20. package/dist/src/platform/windows/desktop-shortcut-run.ps1 +1 -1
  21. package/dist/src/platform/windows/desktop-shortcuts.ps1 +1 -1
  22. package/dist/src/platform/windows/launch-worker.mjs +42 -0
  23. package/dist/src/platform/windows/resident-startup.ps1 +58 -18
  24. package/dist/src/platform/windows/service-host.mjs +4 -7
  25. package/dist/src/platform/windows/start-hidden-console.ps1 +18 -8
  26. package/dist/src/platform/windows/stop-desktop.ps1 +7 -4
  27. package/dist/src/runtime-discovery.mjs +43 -0
  28. package/dist/src/self-relay/admin-cloud.mjs +101 -0
  29. package/dist/src/self-relay/admin-page.mjs +71 -0
  30. package/dist/src/self-relay/admin-server.mjs +61 -0
  31. package/dist/src/self-relay/admin-state.mjs +122 -0
  32. package/dist/src/self-relay/client.mjs +2 -0
  33. package/dist/src/self-relay/connector.mjs +23 -8
  34. package/dist/src/self-relay/demo.mjs +4 -5
  35. package/dist/src/self-relay/lifecycle.mjs +53 -49
  36. package/dist/src/self-relay/p2p-probe.mjs +123 -83
  37. package/dist/src/self-relay/server.mjs +157 -17
  38. package/dist/src/server.mjs +484 -1274
  39. package/dist/src/service-doctor.mjs +13 -25
  40. package/dist/src/service-lifecycle.mjs +12 -12
  41. package/dist/src/shared-app-server.mjs +192 -34
  42. package/dist/src/shared-catalog.mjs +4 -14
  43. package/dist/src/shared-recovery.mjs +8 -6
  44. package/dist/src/shared-thread-stream.mjs +1 -1
  45. package/dist/web/activity-view.js +283 -0
  46. package/dist/web/capabilities.js +2 -9
  47. package/dist/web/chat-transport.js +21 -21
  48. package/dist/web/chat.css +148 -106
  49. package/dist/web/chat.js +1454 -2788
  50. package/dist/web/community.css +82 -0
  51. package/dist/web/community.html +36 -0
  52. package/dist/web/composer-controller.js +97 -0
  53. package/dist/web/conversation-controller.js +99 -0
  54. package/dist/web/disclosure-state-controller.js +95 -0
  55. package/dist/web/draft-controller.js +103 -0
  56. package/dist/web/harmony-platform.js +3 -2
  57. package/dist/web/history-cache.js +112 -18
  58. package/dist/web/history-controller.js +167 -0
  59. package/dist/web/index.html +113 -47
  60. package/dist/web/link-action-controller.js +212 -0
  61. package/dist/web/message-send-controller.js +177 -0
  62. package/dist/web/message-view.js +98 -0
  63. package/dist/web/p2p-data-channel.js +50 -0
  64. package/dist/web/p2p-probe.js +67 -27
  65. package/dist/web/page-resume.js +28 -0
  66. package/dist/web/pending-message-store.js +108 -0
  67. package/dist/web/queue-controller.js +82 -0
  68. package/dist/web/resources.json +1 -1
  69. package/dist/web/self-relay-session.js +28 -18
  70. package/dist/web/station-connection-controller.js +75 -0
  71. package/dist/web/task-list-view.js +289 -0
  72. package/dist/web/thread-attention-controller.js +124 -0
  73. package/dist/web/thread-context-controller.js +30 -0
  74. package/dist/web/thread-list-controller.js +61 -0
  75. package/dist/web/thread-list-sync.js +86 -0
  76. package/dist/web/thread-title-controller.js +57 -0
  77. package/dist/web/timeline-formatters.js +249 -0
  78. package/dist/web/timeline-reducer.js +77 -0
  79. package/dist/web/timeline-renderer.js +161 -0
  80. package/dist/web/timeline-scroll-controller.js +50 -0
  81. package/dist/web/usage-controller.js +258 -0
  82. package/dist/web/vendor/lucide.LICENSE.txt +17 -0
  83. package/package.json +16 -18
  84. package/tools/postinstall.mjs +140 -3
  85. package/dist/src/app-server-client.mjs +0 -468
  86. package/dist/src/app-server-tasks.mjs +0 -355
  87. package/dist/src/platform/windows/desktop-monitor.mjs +0 -34
  88. package/dist/src/platform/windows/desktop-tools.mjs +0 -48
  89. package/dist/src/platform/windows/launch-worker.ps1 +0 -13
  90. package/dist/src/thread-catalog.mjs +0 -42
  91. 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
- add('installation', 'ok', `服务 ${record.version};Node:${record.nodeExecutable}`);
17
+ installedVersion = record.version;
18
+ add('installation', 'ok', `已安装版本:${record.version};Node:${record.nodeExecutable}`);
19
19
  });
20
- let sharedUrl;
21
- await check('shared-service', async () => {
22
- sharedUrl = await host.sharedServiceUrl();
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
- 'Codex Desktop 已提供新版 CLI;当前 app-server 会继续完成任务,并在下次进程自然重启或重新登录 Windows 后采用新版。');
43
- });
44
- await check('desktop-environment', async () => {
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.desktopEnvironment(readRecord(contextPath), '');
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('完整控制必须使用本机回环 WebSocket。');
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.usageCache = new events.UsageCache(value => this.emit('usage', value));
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 live = await this.request('thread/read', { threadId: args.threadId, includeTurns: true });
130
- const active = live.thread.turns?.findLast(turn => turn.status === 'inProgress');
131
- if (!active) throw new Error('活动轮次已变化,请刷新后再发送。');
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 turn = events.applyNotification(record.thread, method, params);
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: 10, sortDirection: 'desc', itemsView: 'full' } });
207
+ initialTurnsPage: { limit: 1, sortDirection: 'desc', itemsView: 'summary' } });
195
208
  } catch (error) {
196
209
  if (!isThreadWriterConflict(error)) throw error;
197
- const read = await this.request('thread/read', { threadId, includeTurns: true });
198
- const turns = Array.isArray(read.thread?.turns) ? read.thread.turns.slice(-10) : [];
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 thread = { ...result.thread, turns: [...result.initialTurnsPage.data].reverse() };
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
- async history(threadId, cursor) {
295
- await this.connect();
296
- if (!cursor) throw new Error('缺少历史分页游标。');
297
- const result = await this.request('thread/turns/list', { threadId, cursor, limit: 10, sortDirection: 'desc', itemsView: 'full' });
298
- return { thread: { id: threadId, turns: [...result.data].reverse() }, nextCursor: result.nextCursor ?? null };
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
- // Native IDs remain stable when optional Desktop presentation data arrives.
17
+ // Mobile catalog order comes entirely from app-server authority, independent of Desktop UI state.
18
18
  export class SharedCatalog {
19
- constructor(server, desktop) { this.server = server; this.desktop = desktop; }
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
- const listing = this.desktop.snapshot?.listing;
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 内核。手机完整控制和 Codex 内核会保持运行;旧 Desktop 实例中的任务可能中断,未发送内容请先保存。切换不会清空项目、对话历史、设置或设备授权。';
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 || !info.cli) throw new Error('未找到 Codex 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: '完整控制请求已接受,电脑管理连接保持运行。', steps: recoverySteps(), ...planned };
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
- }, '完整控制未就绪;Bridge 和中继管理连接仍保留。');
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: '完整控制已断开,请重新连接。' }); response.end(); finish(); };
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);