@walkhi/code-relax 0.1.0-beta.11 → 0.1.0-beta.13

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.
@@ -4,37 +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。');
36
- this.url = url;
37
- this.serverVersion = '';
35
+ if (address.protocol !== 'ws:' || address.hostname !== '127.0.0.1') throw new Error('Codex 内核必须使用本机回环 WebSocket。');
36
+ this.url = url;
37
+ this.serverVersion = '';
38
38
  this.epoch = randomUUID();
39
39
  this.sequence = 0;
40
40
  this.pending = new Map();
@@ -42,10 +42,10 @@ export class SharedAppServer extends EventEmitter {
42
42
  this.threads = new Map();
43
43
  this.contexts = new Map();
44
44
  this.settingsNotifications = new Map();
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));
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));
49
49
  }
50
50
 
51
51
  async connect() {
@@ -57,9 +57,9 @@ export class SharedAppServer extends EventEmitter {
57
57
 
58
58
  async open() {
59
59
  const socket = new WebSocket(this.url, { handshakeTimeout: 5000 });
60
- this.socket = socket;
61
- this.ready = false;
62
- this.serverVersion = '';
60
+ this.socket = socket;
61
+ this.ready = false;
62
+ this.serverVersion = '';
63
63
  this.serverRequests.reset(randomUUID());
64
64
  socket.on('message', bytes => {
65
65
  let message;
@@ -70,25 +70,25 @@ export class SharedAppServer extends EventEmitter {
70
70
  if (!pending) return;
71
71
  this.pending.delete(message.id);
72
72
  clearTimeout(pending.timer);
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
- }
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
+ }
79
79
  else pending.resolve(message.result);
80
80
  });
81
- socket.on('close', () => {
82
- this.ready = false;
83
- this.serverVersion = '';
81
+ socket.on('close', () => {
82
+ this.ready = false;
83
+ this.serverVersion = '';
84
84
  for (const pending of this.pending.values()) {
85
85
  clearTimeout(pending.timer);
86
- pending.reject(new Error('Codex 内核连接断开;发送结果可能未确认,请检查对话后再操作。'));
86
+ pending.reject(new Error('Codex 内核连接断开;发送结果可能未确认,请检查对话后再操作。'));
87
87
  }
88
- this.pending.clear();
89
- this.serverRequests.reset(randomUUID());
90
- this.threads.clear();
91
- this.operationGroups.clear();
88
+ this.pending.clear();
89
+ this.serverRequests.reset(randomUUID());
90
+ this.threads.clear();
91
+ this.operationGroups.clear();
92
92
  for (const slot of this.subscriptions.values()) clearTimeout(slot.expiry);
93
93
  this.subscriptions.clear();
94
94
  this.contexts.clear();
@@ -100,10 +100,10 @@ export class SharedAppServer extends EventEmitter {
100
100
  socket.on('error', () => {}); // open/request reports the connection error to the caller.
101
101
  await new Promise((resolve, reject) => { socket.once('open', resolve); socket.once('error', reject); });
102
102
  try {
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] || '';
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] || '';
107
107
  socket.send(JSON.stringify({ method: 'initialized' }));
108
108
  this.ready = true;
109
109
  this.usageCache.read(() => this.request('account/rateLimits/read')).catch(error => this.emit('usage-error', error));
@@ -117,7 +117,7 @@ export class SharedAppServer extends EventEmitter {
117
117
  this.pending.delete(id);
118
118
  reject(new Error(`${method} 超时;不会自动重发,请先检查对话。`));
119
119
  }, 30000);
120
- this.pending.set(id, { resolve, reject, timer, threadId: params?.threadId });
120
+ this.pending.set(id, { resolve, reject, timer, threadId: params?.threadId });
121
121
  this.socket.send(JSON.stringify({ id, method, params }), error => {
122
122
  if (!error || !this.pending.delete(id)) return;
123
123
  clearTimeout(timer); reject(error);
@@ -130,26 +130,26 @@ export class SharedAppServer extends EventEmitter {
130
130
  return events.readProjects((method, params) => this.request(method, params));
131
131
  }
132
132
 
133
- async send(args) {
134
- await this.connect();
135
- this.requireWritable(args.threadId);
133
+ async send(args) {
134
+ await this.connect();
135
+ this.requireWritable(args.threadId);
136
136
  // Reading the live thread checks this service owns it without resume changing settings.
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('活动轮次已变化,请刷新后再发送。');
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('活动轮次已变化,请刷新后再发送。');
145
145
  return this.request('turn/steer', { threadId: args.threadId, expectedTurnId: active.id, input: args.input, ...(args.clientUserMessageId ? { clientUserMessageId: args.clientUserMessageId } : {}) });
146
146
  }
147
147
  return this.request('turn/start', args);
148
148
  }
149
149
 
150
- respondApproval(threadId, body) {
151
- if (!this.ready || this.socket?.readyState !== WebSocket.OPEN) throw new Error('Codex 内核未就绪');
152
- this.requireWritable(threadId);
150
+ respondApproval(threadId, body) {
151
+ if (!this.ready || this.socket?.readyState !== WebSocket.OPEN) throw new Error('Codex 内核未就绪');
152
+ this.requireWritable(threadId);
153
153
  return this.serverRequests.respond(threadId, body, message => new Promise((resolve, reject) => {
154
154
  this.socket.send(JSON.stringify(message), error => error ? reject(error) : resolve());
155
155
  }));
@@ -172,20 +172,20 @@ export class SharedAppServer extends EventEmitter {
172
172
  if (method === 'thread/tokenUsage/updated') {
173
173
  this.contexts.set(id, { ...this.contexts.get(id), tokenUsage: params.tokenUsage });
174
174
  return;
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;
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;
187
187
  record.changes.set(turn.id, { sequence, turn });
188
- record.changeBytes += Buffer.byteLength(JSON.stringify({ method, params: appliedParams }));
188
+ record.changeBytes += Buffer.byteLength(JSON.stringify({ method, params: appliedParams }));
189
189
  if (record.changeBytes > syncBufferMaxBytes) {
190
190
  record.recoverAfter = sequence;
191
191
  record.changes.clear(); record.changeBytes = 0;
@@ -196,51 +196,51 @@ export class SharedAppServer extends EventEmitter {
196
196
  if (method === 'turn/completed') record.thread.turns = record.thread.turns.slice(-10);
197
197
  }
198
198
 
199
- async timeline(threadId, pageOptions = {}) {
199
+ async timeline(threadId, pageOptions = {}) {
200
200
  await this.connect();
201
201
  const slot = this.slot(threadId);
202
202
  const task = slot.tail.then(async () => {
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
- }
242
- }
243
- const id = randomUUID(), expires = Date.now() + syncTokenTtlMs;
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
+ }
242
+ }
243
+ const id = randomUUID(), expires = Date.now() + syncTokenTtlMs;
244
244
  record.tokens.set(id, { sequence: record.sequence, expires });
245
245
  this.scheduleCleanup(threadId, slot);
246
246
  return { thread: record.thread, nextCursor: record.nextCursor,
@@ -277,15 +277,15 @@ export class SharedAppServer extends EventEmitter {
277
277
  if (record?.tokens.size) { this.scheduleCleanup(threadId, slot); return; }
278
278
  slot.tail = slot.tail.then(async () => {
279
279
  if (slot.users || this.threads.get(threadId)?.tokens.size) return;
280
- this.threads.delete(threadId); this.contexts.delete(threadId); this.permissionOptions.delete(threadId);
281
- this.operationGroups.delete(threadId);
280
+ this.threads.delete(threadId); this.contexts.delete(threadId); this.permissionOptions.delete(threadId);
281
+ this.operationGroups.delete(threadId);
282
282
  this.subscriptions.delete(threadId); clearTimeout(slot.expiry);
283
- if (this.ready && !record?.readOnly) await this.request('thread/unsubscribe', { threadId });
284
- this.serverRequests.forget(threadId);
283
+ if (this.ready && !record?.readOnly) await this.request('thread/unsubscribe', { threadId });
284
+ this.serverRequests.forget(threadId);
285
285
  }).catch(error => this.emit('subscription-error', error));
286
286
  }
287
287
 
288
- async acquire(threadId, requested) {
288
+ async acquire(threadId, requested) {
289
289
  const slot = this.slot(threadId);
290
290
  let record = this.threads.get(threadId), token;
291
291
  if (record && requested?.epoch === this.epoch && requested.id && Number.isSafeInteger(requested.after)) {
@@ -294,7 +294,7 @@ export class SharedAppServer extends EventEmitter {
294
294
  }
295
295
  let snapshot = false, catchup;
296
296
  if (!record || !token) {
297
- const fresh = await this.timeline(threadId);
297
+ const fresh = await this.timeline(threadId);
298
298
  record = this.threads.get(threadId); token = record.tokens.get(fresh.sync.id);
299
299
  requested = { id: fresh.sync.id, epoch: fresh.sync.epoch, after: fresh.sync.seq };
300
300
  snapshot = true;
@@ -319,145 +319,145 @@ export class SharedAppServer extends EventEmitter {
319
319
  snapshot, catchup, release };
320
320
  }
321
321
 
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
- }
456
-
457
- async interrupt(threadId, turnId) {
458
- await this.connect();
459
- this.requireWritable(threadId);
460
- 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 });
461
461
  }
462
462
 
463
463
  async models() {
@@ -476,16 +476,16 @@ export class SharedAppServer extends EventEmitter {
476
476
  return this.usageCache.read(() => this.request('account/rateLimits/read'));
477
477
  }
478
478
 
479
- async settings(threadId, model, effort, serviceTier) {
480
- await this.connect();
481
- this.requireWritable(threadId);
479
+ async settings(threadId, model, effort, serviceTier) {
480
+ await this.connect();
481
+ this.requireWritable(threadId);
482
482
  await events.updateSettings((method, params) => this.request(method, params), this.contexts, this.settingsNotifications, threadId, model, effort, serviceTier);
483
483
  return this.context(threadId);
484
484
  }
485
485
 
486
- async permissions(threadId, mode) {
487
- await this.connect();
488
- this.requireWritable(threadId);
486
+ async permissions(threadId, mode) {
487
+ await this.connect();
488
+ this.requireWritable(threadId);
489
489
  await events.updatePermissions((method, params) => this.request(method, params), this.contexts, this.settingsNotifications, this.permissionOptions, threadId, mode);
490
490
  return this.context(threadId);
491
491
  }
@@ -496,21 +496,21 @@ export class SharedAppServer extends EventEmitter {
496
496
  return this.context(threadId);
497
497
  }
498
498
 
499
- context(threadId) {
499
+ context(threadId) {
500
500
  const context = this.permissionOptions.context(threadId, this.contexts.get(threadId) || {}), usage = context.tokenUsage;
501
501
  const usedTokens = usage?.last?.totalTokens, contextWindow = usage?.modelContextWindow;
502
502
  return { ...context, available: Boolean(contextWindow), usedTokens, contextWindow,
503
503
  usedPercent: contextWindow ? 100 * usedTokens / contextWindow : 0 };
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
- }
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
+ }