@walkhi/code-relax 0.1.0-beta.10 → 0.1.0-beta.12

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 (88) hide show
  1. package/README.md +44 -54
  2. package/dist/bin/codex-remote.mjs +34 -60
  3. package/dist/bin/migrate-relay-db.mjs +104 -0
  4. package/dist/bin/self-relay-server.mjs +17 -10
  5. package/dist/shared/app-server-events.cjs +15 -3
  6. package/dist/shared/file-mentions.cjs +35 -0
  7. package/dist/shared/server-requests.cjs +86 -58
  8. package/dist/skills/code-relax/SKILL.md +1 -1
  9. package/dist/src/desktop-launcher.mjs +6 -11
  10. package/dist/src/file-upload-session.mjs +124 -0
  11. package/dist/src/lan-socket-server.mjs +51 -8
  12. package/dist/src/managed-app-server.mjs +21 -10
  13. package/dist/src/onboarding.mjs +3 -3
  14. package/dist/src/platform/windows/codex-runtime.mjs +138 -4
  15. package/dist/src/platform/windows/desktop-host.mjs +1 -2
  16. package/dist/src/platform/windows/desktop-runtime.ps1 +10 -0
  17. package/dist/src/platform/windows/desktop-shortcuts.ps1 +6 -13
  18. package/dist/src/platform/windows/resident-start.js +8 -0
  19. package/dist/src/platform/windows/resident-startup.ps1 +6 -3
  20. package/dist/src/platform/windows/service-host.mjs +0 -9
  21. package/dist/src/self-relay/admin-page.mjs +1 -1
  22. package/dist/src/self-relay/admin-state.mjs +8 -6
  23. package/dist/src/self-relay/connector.mjs +84 -12
  24. package/dist/src/self-relay/email-accounts.mjs +125 -0
  25. package/dist/src/self-relay/lifecycle.mjs +52 -100
  26. package/dist/src/self-relay/mysql-state.mjs +104 -0
  27. package/dist/src/self-relay/p2p-probe.mjs +1 -1
  28. package/dist/src/self-relay/server.mjs +155 -38
  29. package/dist/src/server.mjs +736 -610
  30. package/dist/src/service-doctor.mjs +1 -1
  31. package/dist/src/service-install.mjs +1 -4
  32. package/dist/src/shared-app-server.mjs +274 -272
  33. package/dist/src/shared-catalog.mjs +1 -11
  34. package/dist/src/shared-recovery.mjs +5 -5
  35. package/dist/src/skill-install.mjs +0 -13
  36. package/package.json +2 -1
  37. package/dist/src/debug-ui.mjs +0 -131
  38. package/dist/src/platform/windows/desktop-shortcut-run.ps1 +0 -5
  39. package/dist/web/activity-view.js +0 -283
  40. package/dist/web/approval-ui.js +0 -188
  41. package/dist/web/capabilities.js +0 -50
  42. package/dist/web/chat-transport.js +0 -196
  43. package/dist/web/chat.css +0 -778
  44. package/dist/web/chat.js +0 -3815
  45. package/dist/web/client-platform.js +0 -11
  46. package/dist/web/community.css +0 -82
  47. package/dist/web/community.html +0 -36
  48. package/dist/web/composer-controller.js +0 -97
  49. package/dist/web/conversation-controller.js +0 -99
  50. package/dist/web/disclosure-state-controller.js +0 -95
  51. package/dist/web/draft-controller.js +0 -103
  52. package/dist/web/harmony-platform.js +0 -52
  53. package/dist/web/highlight-powershell.min.js +0 -39
  54. package/dist/web/highlight.min.js +0 -1262
  55. package/dist/web/history-cache.js +0 -240
  56. package/dist/web/history-controller.js +0 -167
  57. package/dist/web/index.html +0 -288
  58. package/dist/web/lan-session.js +0 -122
  59. package/dist/web/link-action-controller.js +0 -212
  60. package/dist/web/message-send-controller.js +0 -177
  61. package/dist/web/message-view.js +0 -98
  62. package/dist/web/p2p-data-channel.js +0 -50
  63. package/dist/web/p2p-diagnostics.js +0 -28
  64. package/dist/web/p2p-probe.js +0 -123
  65. package/dist/web/page-resume.js +0 -28
  66. package/dist/web/pending-message-store.js +0 -108
  67. package/dist/web/queue-controller.js +0 -82
  68. package/dist/web/resources.json +0 -1
  69. package/dist/web/self-relay-session.js +0 -129
  70. package/dist/web/server-requests.js +0 -67
  71. package/dist/web/station-connection-controller.js +0 -75
  72. package/dist/web/task-list-view.js +0 -289
  73. package/dist/web/thread-attention-controller.js +0 -124
  74. package/dist/web/thread-context-controller.js +0 -30
  75. package/dist/web/thread-list-controller.js +0 -61
  76. package/dist/web/thread-list-sync.js +0 -86
  77. package/dist/web/thread-title-controller.js +0 -57
  78. package/dist/web/timeline-formatters.js +0 -249
  79. package/dist/web/timeline-reducer.js +0 -77
  80. package/dist/web/timeline-renderer.js +0 -161
  81. package/dist/web/timeline-scroll-controller.js +0 -50
  82. package/dist/web/timeline-sync.js +0 -67
  83. package/dist/web/usage-controller.js +0 -258
  84. package/dist/web/vendor/README.md +0 -14
  85. package/dist/web/vendor/highlight-powershell.min.js +0 -39
  86. package/dist/web/vendor/highlight.LICENSE.txt +0 -29
  87. package/dist/web/vendor/highlight.min.js +0 -1262
  88. package/dist/web/vendor/lucide.LICENSE.txt +0 -17
@@ -4,36 +4,37 @@ import { randomUUID } from 'node:crypto';
4
4
  import events from '../shared/app-server-events.cjs';
5
5
  import serverRequests from '../shared/server-requests.cjs';
6
6
 
7
- const syncTokenTtlMs = 30_000;
8
- const syncBufferMaxBytes = 8 * 1024 * 1024;
9
- const historyPageMaxTurns = 8;
10
- const historyPageMaxBytes = 500 * 1024;
11
- const turnItemPageSize = 50;
12
- const writerConflictMessages = ['already has an active writer', 'already has a live local writer'];
13
-
14
- export class ThreadWriterConflictError extends Error {
15
- constructor(threadId, message) {
16
- super('该对话正在另一个 Codex 实例中使用。');
17
- this.name = 'ThreadWriterConflictError';
18
- this.code = 'thread_writer_owned_elsewhere';
19
- this.threadId = threadId;
20
- this.readOnly = true;
21
- this.details = message;
22
- }
23
- }
24
-
25
- export function isThreadWriterConflict(error) {
26
- const message = String(error?.message || error).toLowerCase();
27
- return error instanceof ThreadWriterConflictError || writerConflictMessages.some(text => message.includes(text));
28
- }
7
+ const syncTokenTtlMs = 30_000;
8
+ const syncBufferMaxBytes = 8 * 1024 * 1024;
9
+ const historyPageMaxTurns = 8;
10
+ const historyPageMaxBytes = 500 * 1024;
11
+ const turnItemPageSize = 50;
12
+ const writerConflictMessages = ['already has an active writer', 'already has a live local writer'];
13
+
14
+ export class ThreadWriterConflictError extends Error {
15
+ constructor(threadId, message) {
16
+ super('该对话正在另一个 Codex 实例中使用。');
17
+ this.name = 'ThreadWriterConflictError';
18
+ this.code = 'thread_writer_owned_elsewhere';
19
+ this.threadId = threadId;
20
+ this.readOnly = true;
21
+ this.details = message;
22
+ }
23
+ }
24
+
25
+ export function isThreadWriterConflict(error) {
26
+ const message = String(error?.message || error).toLowerCase();
27
+ return error instanceof ThreadWriterConflictError || writerConflictMessages.some(text => message.includes(text));
28
+ }
29
29
 
30
30
  // Connect to the Desktop's existing execution service; never spawn or adopt a task.
31
31
  export class SharedAppServer extends EventEmitter {
32
32
  constructor(url) {
33
33
  super();
34
34
  const address = new URL(url);
35
- if (address.protocol !== 'ws:' || address.hostname !== '127.0.0.1') throw new Error('Codex 内核必须使用本机回环 WebSocket。');
35
+ if (address.protocol !== 'ws:' || address.hostname !== '127.0.0.1') throw new Error('Codex 内核必须使用本机回环 WebSocket。');
36
36
  this.url = url;
37
+ this.serverVersion = '';
37
38
  this.epoch = randomUUID();
38
39
  this.sequence = 0;
39
40
  this.pending = new Map();
@@ -41,10 +42,10 @@ export class SharedAppServer extends EventEmitter {
41
42
  this.threads = new Map();
42
43
  this.contexts = new Map();
43
44
  this.settingsNotifications = new Map();
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));
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));
48
49
  }
49
50
 
50
51
  async connect() {
@@ -58,6 +59,7 @@ export class SharedAppServer extends EventEmitter {
58
59
  const socket = new WebSocket(this.url, { handshakeTimeout: 5000 });
59
60
  this.socket = socket;
60
61
  this.ready = false;
62
+ this.serverVersion = '';
61
63
  this.serverRequests.reset(randomUUID());
62
64
  socket.on('message', bytes => {
63
65
  let message;
@@ -68,24 +70,25 @@ export class SharedAppServer extends EventEmitter {
68
70
  if (!pending) return;
69
71
  this.pending.delete(message.id);
70
72
  clearTimeout(pending.timer);
71
- if (message.error) {
72
- const text = String(message.error.message || 'app-server 请求失败');
73
- pending.reject(writerConflictMessages.some(value => text.toLowerCase().includes(value))
74
- ? new ThreadWriterConflictError(pending.threadId, text)
75
- : Object.assign(new Error(text), { rpcCode: message.error.code, details: message.error.data }));
76
- }
73
+ if (message.error) {
74
+ const text = String(message.error.message || 'app-server 请求失败');
75
+ pending.reject(writerConflictMessages.some(value => text.toLowerCase().includes(value))
76
+ ? new ThreadWriterConflictError(pending.threadId, text)
77
+ : Object.assign(new Error(text), { rpcCode: message.error.code, details: message.error.data }));
78
+ }
77
79
  else pending.resolve(message.result);
78
80
  });
79
81
  socket.on('close', () => {
80
82
  this.ready = false;
83
+ this.serverVersion = '';
81
84
  for (const pending of this.pending.values()) {
82
85
  clearTimeout(pending.timer);
83
- pending.reject(new Error('Codex 内核连接断开;发送结果可能未确认,请检查对话后再操作。'));
86
+ pending.reject(new Error('Codex 内核连接断开;发送结果可能未确认,请检查对话后再操作。'));
84
87
  }
85
- this.pending.clear();
86
- this.serverRequests.reset(randomUUID());
87
- this.threads.clear();
88
- this.operationGroups.clear();
88
+ this.pending.clear();
89
+ this.serverRequests.reset(randomUUID());
90
+ this.threads.clear();
91
+ this.operationGroups.clear();
89
92
  for (const slot of this.subscriptions.values()) clearTimeout(slot.expiry);
90
93
  this.subscriptions.clear();
91
94
  this.contexts.clear();
@@ -97,7 +100,10 @@ export class SharedAppServer extends EventEmitter {
97
100
  socket.on('error', () => {}); // open/request reports the connection error to the caller.
98
101
  await new Promise((resolve, reject) => { socket.once('open', resolve); socket.once('error', reject); });
99
102
  try {
100
- 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] || '';
101
107
  socket.send(JSON.stringify({ method: 'initialized' }));
102
108
  this.ready = true;
103
109
  this.usageCache.read(() => this.request('account/rateLimits/read')).catch(error => this.emit('usage-error', error));
@@ -111,7 +117,7 @@ export class SharedAppServer extends EventEmitter {
111
117
  this.pending.delete(id);
112
118
  reject(new Error(`${method} 超时;不会自动重发,请先检查对话。`));
113
119
  }, 30000);
114
- this.pending.set(id, { resolve, reject, timer, threadId: params?.threadId });
120
+ this.pending.set(id, { resolve, reject, timer, threadId: params?.threadId });
115
121
  this.socket.send(JSON.stringify({ id, method, params }), error => {
116
122
  if (!error || !this.pending.delete(id)) return;
117
123
  clearTimeout(timer); reject(error);
@@ -124,26 +130,26 @@ export class SharedAppServer extends EventEmitter {
124
130
  return events.readProjects((method, params) => this.request(method, params));
125
131
  }
126
132
 
127
- async send(args) {
128
- await this.connect();
129
- this.requireWritable(args.threadId);
133
+ async send(args) {
134
+ await this.connect();
135
+ this.requireWritable(args.threadId);
130
136
  // Reading the live thread checks this service owns it without resume changing settings.
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('活动轮次已变化,请刷新后再发送。');
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('活动轮次已变化,请刷新后再发送。');
139
145
  return this.request('turn/steer', { threadId: args.threadId, expectedTurnId: active.id, input: args.input, ...(args.clientUserMessageId ? { clientUserMessageId: args.clientUserMessageId } : {}) });
140
146
  }
141
147
  return this.request('turn/start', args);
142
148
  }
143
149
 
144
- respondApproval(threadId, body) {
145
- if (!this.ready || this.socket?.readyState !== WebSocket.OPEN) throw new Error('Codex 内核未就绪');
146
- this.requireWritable(threadId);
150
+ respondApproval(threadId, body) {
151
+ if (!this.ready || this.socket?.readyState !== WebSocket.OPEN) throw new Error('Codex 内核未就绪');
152
+ this.requireWritable(threadId);
147
153
  return this.serverRequests.respond(threadId, body, message => new Promise((resolve, reject) => {
148
154
  this.socket.send(JSON.stringify(message), error => error ? reject(error) : resolve());
149
155
  }));
@@ -166,20 +172,20 @@ export class SharedAppServer extends EventEmitter {
166
172
  if (method === 'thread/tokenUsage/updated') {
167
173
  this.contexts.set(id, { ...this.contexts.get(id), tokenUsage: params.tokenUsage });
168
174
  return;
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);
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);
179
185
  if (turn) {
180
186
  const sequence = ++record.sequence;
181
187
  record.changes.set(turn.id, { sequence, turn });
182
- record.changeBytes += Buffer.byteLength(JSON.stringify({ method, params: appliedParams }));
188
+ record.changeBytes += Buffer.byteLength(JSON.stringify({ method, params: appliedParams }));
183
189
  if (record.changeBytes > syncBufferMaxBytes) {
184
190
  record.recoverAfter = sequence;
185
191
  record.changes.clear(); record.changeBytes = 0;
@@ -190,49 +196,49 @@ export class SharedAppServer extends EventEmitter {
190
196
  if (method === 'turn/completed') record.thread.turns = record.thread.turns.slice(-10);
191
197
  }
192
198
 
193
- async timeline(threadId, pageOptions = {}) {
199
+ async timeline(threadId, pageOptions = {}) {
194
200
  await this.connect();
195
201
  const slot = this.slot(threadId);
196
202
  const task = slot.tail.then(async () => {
197
- let record = this.threads.get(threadId);
198
- if (record?.readOnly) {
199
- this.threads.delete(threadId);
200
- this.contexts.delete(threadId);
201
- record = null;
202
- }
203
- if (!record) {
204
- let result;
205
- try {
206
- result = await this.request('thread/resume', { threadId, excludeTurns: true,
207
- initialTurnsPage: { limit: 1, sortDirection: 'desc', itemsView: 'summary' } });
208
- } catch (error) {
209
- if (!isThreadWriterConflict(error)) throw error;
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);
218
- const thread = { ...read.thread, turns, readOnly: true,
219
- readOnlyReason: '该对话正在另一个 Codex 实例中使用。' };
220
- record = { thread, nextCursor: page.nextCursor ?? null, sequence: 0, recoverAfter: 0,
221
- changes: new Map(), changeBytes: 0, tokens: new Map(), readOnly: true };
222
- this.threads.set(threadId, record);
223
- result = null;
224
- }
225
- if (result) {
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 };
229
- record = { thread, nextCursor: result.initialTurnsPage.nextCursor ?? null, sequence: 0,
230
- recoverAfter: 0, changes: new Map(), changeBytes: 0, tokens: new Map() };
231
- this.threads.set(threadId, record);
232
- this.contexts.set(threadId, { ...this.contexts.get(threadId), model: result.model, thinking: result.reasoningEffort, serviceTier: result.serviceTier ?? null,
233
- cwd: result.cwd, ...events.permissionState(result) });
234
- await this.extendHistoryRecord(threadId, record, pageOptions);
235
- }
203
+ let record = this.threads.get(threadId);
204
+ if (record?.readOnly) {
205
+ this.threads.delete(threadId);
206
+ this.contexts.delete(threadId);
207
+ record = null;
208
+ }
209
+ if (!record) {
210
+ let result;
211
+ try {
212
+ result = await this.request('thread/resume', { threadId, excludeTurns: true,
213
+ initialTurnsPage: { limit: 1, sortDirection: 'desc', itemsView: 'summary' } });
214
+ } catch (error) {
215
+ if (!isThreadWriterConflict(error)) throw error;
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);
224
+ const thread = { ...read.thread, turns, readOnly: true,
225
+ readOnlyReason: '该对话正在另一个 Codex 实例中使用。' };
226
+ record = { thread, nextCursor: page.nextCursor ?? null, sequence: 0, recoverAfter: 0,
227
+ changes: new Map(), changeBytes: 0, tokens: new Map(), readOnly: true };
228
+ this.threads.set(threadId, record);
229
+ result = null;
230
+ }
231
+ if (result) {
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 };
235
+ record = { thread, nextCursor: result.initialTurnsPage.nextCursor ?? null, sequence: 0,
236
+ recoverAfter: 0, changes: new Map(), changeBytes: 0, tokens: new Map() };
237
+ this.threads.set(threadId, record);
238
+ this.contexts.set(threadId, { ...this.contexts.get(threadId), model: result.model, thinking: result.reasoningEffort, serviceTier: result.serviceTier ?? null,
239
+ cwd: result.cwd, ...events.permissionState(result) });
240
+ await this.extendHistoryRecord(threadId, record, pageOptions);
241
+ }
236
242
  }
237
243
  const id = randomUUID(), expires = Date.now() + syncTokenTtlMs;
238
244
  record.tokens.set(id, { sequence: record.sequence, expires });
@@ -271,11 +277,11 @@ export class SharedAppServer extends EventEmitter {
271
277
  if (record?.tokens.size) { this.scheduleCleanup(threadId, slot); return; }
272
278
  slot.tail = slot.tail.then(async () => {
273
279
  if (slot.users || this.threads.get(threadId)?.tokens.size) return;
274
- this.threads.delete(threadId); this.contexts.delete(threadId); this.permissionOptions.delete(threadId);
275
- this.operationGroups.delete(threadId);
280
+ this.threads.delete(threadId); this.contexts.delete(threadId); this.permissionOptions.delete(threadId);
281
+ this.operationGroups.delete(threadId);
276
282
  this.subscriptions.delete(threadId); clearTimeout(slot.expiry);
277
- if (this.ready && !record?.readOnly) await this.request('thread/unsubscribe', { threadId });
278
- this.serverRequests.forget(threadId);
283
+ if (this.ready && !record?.readOnly) await this.request('thread/unsubscribe', { threadId });
284
+ this.serverRequests.forget(threadId);
279
285
  }).catch(error => this.emit('subscription-error', error));
280
286
  }
281
287
 
@@ -313,140 +319,145 @@ export class SharedAppServer extends EventEmitter {
313
319
  snapshot, catchup, release };
314
320
  }
315
321
 
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
- }
445
-
446
- async interrupt(threadId, turnId) {
447
- await this.connect();
448
- this.requireWritable(threadId);
449
- return this.request('turn/interrupt', { threadId, turnId });
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', 'imageView'].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
+ }
456
+
457
+ async interrupt(threadId, turnId) {
458
+ await this.connect();
459
+ this.requireWritable(threadId);
460
+ return this.request('turn/interrupt', { threadId, turnId });
450
461
  }
451
462
 
452
463
  async models() {
@@ -465,16 +476,16 @@ export class SharedAppServer extends EventEmitter {
465
476
  return this.usageCache.read(() => this.request('account/rateLimits/read'));
466
477
  }
467
478
 
468
- async settings(threadId, model, effort, serviceTier) {
469
- await this.connect();
470
- this.requireWritable(threadId);
479
+ async settings(threadId, model, effort, serviceTier) {
480
+ await this.connect();
481
+ this.requireWritable(threadId);
471
482
  await events.updateSettings((method, params) => this.request(method, params), this.contexts, this.settingsNotifications, threadId, model, effort, serviceTier);
472
483
  return this.context(threadId);
473
484
  }
474
485
 
475
- async permissions(threadId, mode) {
476
- await this.connect();
477
- this.requireWritable(threadId);
486
+ async permissions(threadId, mode) {
487
+ await this.connect();
488
+ this.requireWritable(threadId);
478
489
  await events.updatePermissions((method, params) => this.request(method, params), this.contexts, this.settingsNotifications, this.permissionOptions, threadId, mode);
479
490
  return this.context(threadId);
480
491
  }
@@ -485,30 +496,21 @@ export class SharedAppServer extends EventEmitter {
485
496
  return this.context(threadId);
486
497
  }
487
498
 
488
- context(threadId) {
499
+ context(threadId) {
489
500
  const context = this.permissionOptions.context(threadId, this.contexts.get(threadId) || {}), usage = context.tokenUsage;
490
501
  const usedTokens = usage?.last?.totalTokens, contextWindow = usage?.modelContextWindow;
491
502
  return { ...context, available: Boolean(contextWindow), usedTokens, contextWindow,
492
503
  usedPercent: contextWindow ? 100 * usedTokens / contextWindow : 0 };
493
- }
494
-
495
- requireWritable(threadId) {
496
- const record = this.threads.get(threadId);
497
- if (record?.readOnly) throw new ThreadWriterConflictError(threadId, record.thread.readOnlyReason);
498
- }
499
-
500
- close() { this.socket?.close(); }
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
- }
504
+ }
505
+
506
+ requireWritable(threadId) {
507
+ const record = this.threads.get(threadId);
508
+ if (record?.readOnly) throw new ThreadWriterConflictError(threadId, record.thread.readOnlyReason);
509
+ }
510
+
511
+ close() { this.socket?.close(); }
512
+ }
513
+
514
+ function isSubAgentItem(item) {
515
+ return item?.type === 'collabAgentToolCall' || item?.type === 'subAgentActivity';
516
+ }