@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.
Files changed (80) hide show
  1. package/README.md +53 -64
  2. package/dist/bin/codex-remote.mjs +90 -71
  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 +64 -24
  7. package/dist/shared/file-mentions.cjs +35 -0
  8. package/dist/shared/p2p-data-channel.cjs +50 -0
  9. package/dist/skills/code-relax/SKILL.md +1 -1
  10. package/dist/src/desktop-launcher.mjs +16 -21
  11. package/dist/src/file-upload-session.mjs +124 -0
  12. package/dist/src/lan-socket-server.mjs +59 -10
  13. package/dist/src/managed-app-server.mjs +36 -14
  14. package/dist/src/message-images.mjs +0 -18
  15. package/dist/src/onboarding.mjs +6 -8
  16. package/dist/src/platform/README.md +1 -1
  17. package/dist/src/platform/windows/IsolatedProcess.cs +7 -3
  18. package/dist/src/platform/windows/background-process.mjs +7 -2
  19. package/dist/src/platform/windows/codex-runtime.mjs +117 -0
  20. package/dist/src/platform/windows/desktop-host.mjs +17 -3
  21. package/dist/src/platform/windows/desktop-info.ps1 +2 -4
  22. package/dist/src/platform/windows/desktop-shortcuts.ps1 +7 -14
  23. package/dist/src/platform/windows/launch-worker.mjs +42 -0
  24. package/dist/src/platform/windows/resident-startup.ps1 +58 -18
  25. package/dist/src/platform/windows/service-host.mjs +4 -16
  26. package/dist/src/platform/windows/start-hidden-console.ps1 +18 -8
  27. package/dist/src/platform/windows/stop-desktop.ps1 +7 -4
  28. package/dist/src/runtime-discovery.mjs +43 -0
  29. package/dist/src/self-relay/admin-cloud.mjs +101 -0
  30. package/dist/src/self-relay/admin-page.mjs +71 -0
  31. package/dist/src/self-relay/admin-server.mjs +61 -0
  32. package/dist/src/self-relay/admin-state.mjs +122 -0
  33. package/dist/src/self-relay/client.mjs +2 -0
  34. package/dist/src/self-relay/connector.mjs +107 -20
  35. package/dist/src/self-relay/demo.mjs +4 -5
  36. package/dist/src/self-relay/lifecycle.mjs +58 -70
  37. package/dist/src/self-relay/p2p-probe.mjs +123 -83
  38. package/dist/src/self-relay/server.mjs +224 -29
  39. package/dist/src/server.mjs +616 -1299
  40. package/dist/src/service-doctor.mjs +14 -26
  41. package/dist/src/service-install.mjs +1 -4
  42. package/dist/src/service-lifecycle.mjs +12 -12
  43. package/dist/src/shared-app-server.mjs +206 -46
  44. package/dist/src/shared-catalog.mjs +5 -25
  45. package/dist/src/shared-recovery.mjs +11 -9
  46. package/dist/src/shared-thread-stream.mjs +1 -1
  47. package/dist/src/skill-install.mjs +0 -13
  48. package/package.json +16 -18
  49. package/tools/postinstall.mjs +140 -3
  50. package/dist/src/app-server-client.mjs +0 -468
  51. package/dist/src/app-server-tasks.mjs +0 -355
  52. package/dist/src/debug-ui.mjs +0 -131
  53. package/dist/src/platform/windows/desktop-monitor.mjs +0 -34
  54. package/dist/src/platform/windows/desktop-shortcut-run.ps1 +0 -5
  55. package/dist/src/platform/windows/desktop-tools.mjs +0 -48
  56. package/dist/src/platform/windows/launch-worker.ps1 +0 -13
  57. package/dist/src/thread-catalog.mjs +0 -42
  58. package/dist/tools/find-desktop-pipe.mjs +0 -10
  59. package/dist/web/approval-ui.js +0 -188
  60. package/dist/web/capabilities.js +0 -57
  61. package/dist/web/chat-transport.js +0 -196
  62. package/dist/web/chat.css +0 -736
  63. package/dist/web/chat.js +0 -5149
  64. package/dist/web/client-platform.js +0 -11
  65. package/dist/web/harmony-platform.js +0 -51
  66. package/dist/web/highlight-powershell.min.js +0 -39
  67. package/dist/web/highlight.min.js +0 -1262
  68. package/dist/web/history-cache.js +0 -146
  69. package/dist/web/index.html +0 -222
  70. package/dist/web/lan-session.js +0 -122
  71. package/dist/web/p2p-diagnostics.js +0 -28
  72. package/dist/web/p2p-probe.js +0 -83
  73. package/dist/web/resources.json +0 -1
  74. package/dist/web/self-relay-session.js +0 -119
  75. package/dist/web/server-requests.js +0 -67
  76. package/dist/web/timeline-sync.js +0 -67
  77. package/dist/web/vendor/README.md +0 -14
  78. package/dist/web/vendor/highlight-powershell.min.js +0 -39
  79. package/dist/web/vendor/highlight.LICENSE.txt +0 -29
  80. 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
- 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,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
- '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);
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', '手机连通性未验证。首次启动请允许记录的 Node 在受信任的专用网络通信;若曾拒绝,可在 Windows 安全中心的防火墙“允许应用通过防火墙”中检查该 Node。不要关闭整个防火墙。');
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.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,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('完整控制必须使用本机回环 WebSocket。');
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.usageCache = new events.UsageCache(value => this.emit('usage', value));
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 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('活动轮次已变化,请刷新后再发送。');
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 turn = events.applyNotification(record.thread, method, params);
166
- if (turn) {
167
- const sequence = ++record.sequence;
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: 10, sortDirection: 'desc', itemsView: 'full' } });
213
+ initialTurnsPage: { limit: 1, sortDirection: 'desc', itemsView: 'summary' } });
195
214
  } catch (error) {
196
215
  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) : [];
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 thread = { ...result.thread, turns: [...result.initialTurnsPage.data].reverse() };
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
- 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
- }
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
- if (thread.projectId) return thread.projectId;
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
- // Native IDs remain stable when optional Desktop presentation data arrives.
7
+ // Mobile catalog order comes entirely from app-server authority, independent of Desktop UI state.
18
8
  export class SharedCatalog {
19
- constructor(server, desktop) { this.server = server; this.desktop = desktop; }
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
- 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 };
32
+ return { projects, pinnedThreads: [], threads: summaries };
53
33
  }
54
34
  }
55
35