@walkhi/code-relax 0.1.0-beta.22 → 0.1.0-beta.23

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.
@@ -8,7 +8,6 @@ const syncTokenTtlMs = 30_000;
8
8
  const syncBufferMaxBytes = 8 * 1024 * 1024;
9
9
  const historyPageMaxTurns = 8;
10
10
  const historyPageMaxBytes = 500 * 1024;
11
- const turnItemPageSize = 50;
12
11
  const writerConflictMessages = ['already has an active writer', 'already has a live local writer'];
13
12
 
14
13
  export class ThreadWriterConflictError extends Error {
@@ -38,13 +37,15 @@ export class SharedAppServer extends EventEmitter {
38
37
  this.epoch = randomUUID();
39
38
  this.sequence = 0;
40
39
  this.pending = new Map();
41
- this.serverRequests = new serverRequests.Store(randomUUID(), id => this.emit('requests-changed', id));
40
+ this.serverRequests = new serverRequests.Store(randomUUID(), id => {
41
+ this.emit('requests-changed', id);
42
+ this.releaseInactiveThread(id);
43
+ });
42
44
  this.threads = new Map();
43
45
  this.contexts = new Map();
44
46
  this.settingsNotifications = new Map();
45
47
  this.permissionOptions = new events.PermissionOptionsCache();
46
48
  this.subscriptions = new Map();
47
- this.operationGroups = new Map();
48
49
  this.usageCache = new events.UsageCache(value => this.emit('usage', value));
49
50
  }
50
51
 
@@ -74,9 +75,13 @@ export class SharedAppServer extends EventEmitter {
74
75
  const text = String(message.error.message || 'app-server 请求失败');
75
76
  pending.reject(writerConflictMessages.some(value => text.toLowerCase().includes(value))
76
77
  ? new ThreadWriterConflictError(pending.threadId, text)
77
- : Object.assign(new Error(text), { rpcCode: message.error.code, details: message.error.data }));
78
+ : Object.assign(new Error(text), { rpcCode: message.error.code, details: message.error.data,
79
+ ...(/^invalid cursor:/i.test(text) ? { code: 'history_cursor_expired' } : {}) }));
80
+ }
81
+ else {
82
+ try { pending.resolve(pending.transform(message.result)); }
83
+ catch (error) { pending.reject(error); }
78
84
  }
79
- else pending.resolve(message.result);
80
85
  });
81
86
  socket.on('close', () => {
82
87
  this.ready = false;
@@ -88,7 +93,6 @@ export class SharedAppServer extends EventEmitter {
88
93
  this.pending.clear();
89
94
  this.serverRequests.reset(randomUUID());
90
95
  this.threads.clear();
91
- this.operationGroups.clear();
92
96
  for (const slot of this.subscriptions.values()) clearTimeout(slot.expiry);
93
97
  this.subscriptions.clear();
94
98
  this.contexts.clear();
@@ -110,14 +114,14 @@ export class SharedAppServer extends EventEmitter {
110
114
  } catch (error) { socket.close(); throw error; }
111
115
  }
112
116
 
113
- request(method, params) {
117
+ request(method, params, transform = value => value) {
114
118
  const id = ++this.sequence;
115
119
  return new Promise((resolve, reject) => {
116
120
  const timer = setTimeout(() => {
117
121
  this.pending.delete(id);
118
122
  reject(new Error(`${method} 超时;不会自动重发,请先检查对话。`));
119
123
  }, 30000);
120
- this.pending.set(id, { resolve, reject, timer, threadId: params?.threadId });
124
+ this.pending.set(id, { resolve, reject, timer, transform, threadId: params?.threadId });
121
125
  this.socket.send(JSON.stringify({ id, method, params }), error => {
122
126
  if (!error || !this.pending.delete(id)) return;
123
127
  clearTimeout(timer); reject(error);
@@ -136,18 +140,18 @@ export class SharedAppServer extends EventEmitter {
136
140
  // Reading the live thread checks this service owns it without resume changing settings.
137
141
  const { thread } = await this.request('thread/read', { threadId: args.threadId, includeTurns: false });
138
142
  const status = typeof thread.status === 'string' ? thread.status : thread.status?.type;
139
- if (status === 'active') {
143
+ if (status === 'active') {
140
144
  const page = await this.request('thread/turns/list', {
141
- threadId: args.threadId, limit: 1, sortDirection: 'desc', itemsView: 'summary',
145
+ threadId: args.threadId, limit: 1, sortDirection: 'desc', itemsView: 'notLoaded',
142
146
  });
143
147
  const active = page.data?.[0];
144
148
  if (active?.status !== 'inProgress') throw new Error('活动轮次已变化,请刷新后再发送。');
145
- const { turnTrigger, ...source } = events.mobileTurnSource(args.clientUserMessageId);
146
- const result = await this.request('turn/steer', { threadId: args.threadId, expectedTurnId: active.id, input: args.input, ...source, ...(args.clientUserMessageId ? { clientUserMessageId: args.clientUserMessageId } : {}) });
147
- return { result, usage: { kind: 'steer', turnId: active.id } };
148
- }
149
- const result = await this.request('turn/start', args);
150
- return { result, usage: { kind: 'start', turnId: result?.turn?.id || null } };
149
+ const { turnTrigger, ...source } = events.mobileTurnSource(args.clientUserMessageId, args.responsesapiClientMetadata?.code_relax_input === 'speech_to_text');
150
+ const result = await this.request('turn/steer', { threadId: args.threadId, expectedTurnId: active.id, input: args.input, ...source, ...(args.clientUserMessageId ? { clientUserMessageId: args.clientUserMessageId } : {}) });
151
+ return { result, usage: { kind: 'steer', turnId: active.id } };
152
+ }
153
+ const result = await this.request('turn/start', args);
154
+ return { result, usage: { kind: 'start', turnId: result?.turn?.id || null } };
151
155
  }
152
156
 
153
157
  respondApproval(threadId, body) {
@@ -178,11 +182,22 @@ export class SharedAppServer extends EventEmitter {
178
182
  }
179
183
  const record = this.threads.get(id);
180
184
  if (!record) return;
181
- const current = params.turn?.id ? record.thread.turns?.find(turn => turn.id === params.turn.id) : null;
182
- const groupedHistory = current?.items?.some(item => item?.type === 'lazyOperationGroup');
183
- const appliedParams = groupedHistory && method === 'turn/completed' && Array.isArray(params.turn?.items)
185
+ const slot = this.subscriptions.get(id);
186
+ if (!slot?.users && !slot?.readers && !record.tokens.size) {
187
+ // Keep task/approval lifecycle metadata, not an invisible growing transcript.
188
+ record.needsReload = true;
189
+ if (params.turn) events.applyNotification(record.thread, method, { ...params, turn: { ...params.turn, items: [] } });
190
+ else if (method === 'thread/status/changed') record.thread.status = params.status;
191
+ record.thread.turns = (record.thread.turns || []).slice(-1).map(turn => ({ ...turn, items: [] }));
192
+ record.changes.clear(); record.changeBytes = 0;
193
+ if (['turn/completed', 'thread/status/changed'].includes(method)) this.releaseInactiveThread(id);
194
+ return;
195
+ }
196
+ const current = record.thread.turns?.find(turn => turn.id === (params.turn?.id || params.turnId));
197
+ if (method === 'item/completed' && current?.itemPage?.olderCursor && !current.items.some(item => item.id === params.item?.id)) return;
198
+ const appliedParams = (record.hydrating || current?.itemPage?.olderCursor) && method === 'turn/completed' && Array.isArray(params.turn?.items)
184
199
  ? { ...params, turn: { ...params.turn,
185
- items: params.turn.items.filter(item => ['userMessage', 'agentMessage'].includes(item?.type)) } }
200
+ items: params.turn.items.filter(item => current?.items?.some(loaded => loaded.id === item.id)) } }
186
201
  : params;
187
202
  const turn = events.applyNotification(record.thread, method, appliedParams);
188
203
  if (turn) {
@@ -197,14 +212,16 @@ export class SharedAppServer extends EventEmitter {
197
212
  immediate: ['turn/completed', 'item/completed'].includes(method) });
198
213
  }
199
214
  if (method === 'turn/completed') record.thread.turns = record.thread.turns.slice(-10);
215
+ if (['turn/completed', 'thread/status/changed'].includes(method)) this.releaseInactiveThread(id);
200
216
  }
201
217
 
202
218
  async timeline(threadId, pageOptions = {}) {
203
219
  await this.connect();
204
220
  const slot = this.slot(threadId);
221
+ slot.readers++;
205
222
  const task = slot.tail.then(async () => {
206
223
  let record = this.threads.get(threadId);
207
- if (record?.readOnly) {
224
+ if (record?.readOnly || record?.needsReload) {
208
225
  this.threads.delete(threadId);
209
226
  this.contexts.delete(threadId);
210
227
  record = null;
@@ -213,13 +230,20 @@ export class SharedAppServer extends EventEmitter {
213
230
  let result;
214
231
  try {
215
232
  result = await this.request('thread/resume', { threadId, excludeTurns: true,
216
- initialTurnsPage: { limit: 1, sortDirection: 'desc', itemsView: 'summary' } });
233
+ initialTurnsPage: { limit: 1, sortDirection: 'desc', itemsView: 'notLoaded' } }, value => {
234
+ if (!Array.isArray(value.initialTurnsPage?.data)) throw new Error('Codex 内核未返回分页历史。');
235
+ record = { thread: { ...value.thread, turns: [...value.initialTurnsPage.data].reverse().map(turn => ({ ...turn, items: [] })) },
236
+ nextCursor: value.initialTurnsPage.nextCursor ?? null, sequence: 0, recoverAfter: 0,
237
+ changes: new Map(), changeBytes: 0, tokens: new Map(), hydrating: true };
238
+ this.threads.set(threadId, record);
239
+ return value;
240
+ });
217
241
  } catch (error) {
218
242
  if (!isThreadWriterConflict(error)) throw error;
219
243
  const [read, page] = await Promise.all([
220
244
  this.request('thread/read', { threadId, includeTurns: false }),
221
245
  this.request('thread/turns/list', {
222
- threadId, limit: historyPageMaxTurns, sortDirection: 'desc', itemsView: 'summary',
246
+ threadId, limit: 1, sortDirection: 'desc', itemsView: 'notLoaded',
223
247
  }),
224
248
  ]);
225
249
  const compact = await Promise.all([...(page.data || [])].reverse().map(turn => this.compactTurn(threadId, turn)));
@@ -233,11 +257,11 @@ export class SharedAppServer extends EventEmitter {
233
257
  }
234
258
  if (result) {
235
259
  if (!Array.isArray(result.initialTurnsPage?.data)) throw new Error('Codex 内核未返回分页历史。');
236
- const compact = await Promise.all([...result.initialTurnsPage.data].reverse().map(turn => this.compactTurn(threadId, turn)));
237
- const thread = { ...result.thread, turns: compact };
238
- record = { thread, nextCursor: result.initialTurnsPage.nextCursor ?? null, sequence: 0,
239
- recoverAfter: 0, changes: new Map(), changeBytes: 0, tokens: new Map() };
240
- this.threads.set(threadId, record);
260
+ await Promise.all(result.initialTurnsPage.data.map(turn => this.compactTurn(threadId, turn, null, page => {
261
+ const index = record.thread.turns.findIndex(current => current.id === turn.id);
262
+ if (index >= 0) record.thread.turns[index] = { ...page, ...record.thread.turns[index], items: page.items, itemPage: page.itemPage };
263
+ })));
264
+ record.hydrating = false;
241
265
  this.contexts.set(threadId, { ...this.contexts.get(threadId), model: result.model, thinking: result.reasoningEffort, serviceTier: result.serviceTier ?? null,
242
266
  cwd: result.cwd, ...events.permissionState(result) });
243
267
  await this.extendHistoryRecord(threadId, record, pageOptions);
@@ -246,11 +270,13 @@ export class SharedAppServer extends EventEmitter {
246
270
  const id = randomUUID(), expires = Date.now() + syncTokenTtlMs;
247
271
  record.tokens.set(id, { sequence: record.sequence, expires });
248
272
  this.scheduleCleanup(threadId, slot);
249
- return { thread: record.thread, nextCursor: record.nextCursor,
273
+ return { thread: record.thread, nextCursor: this.historyCursor(threadId, record.thread.turns[0], record.nextCursor),
250
274
  sync: { id, epoch: this.epoch, seq: record.sequence } };
251
- });
275
+ }).finally(() => { slot.readers--; });
252
276
  slot.tail = task.catch(() => {});
253
277
  task.catch(() => {
278
+ const record = this.threads.get(threadId);
279
+ if (record?.hydrating) { record.needsReload = true; record.hydrating = false; this.scheduleCleanup(threadId, slot); }
254
280
  if (!slot.users && !this.threads.has(threadId) && this.subscriptions.get(threadId) === slot) {
255
281
  clearTimeout(slot.expiry);
256
282
  this.subscriptions.delete(threadId);
@@ -261,10 +287,18 @@ export class SharedAppServer extends EventEmitter {
261
287
 
262
288
  slot(threadId) {
263
289
  let slot = this.subscriptions.get(threadId);
264
- if (!slot) { slot = { users: 0, tail: Promise.resolve(), expiry: null }; this.subscriptions.set(threadId, slot); }
290
+ if (!slot) { slot = { users: 0, readers: 0, tail: Promise.resolve(), expiry: null }; this.subscriptions.set(threadId, slot); }
265
291
  return slot;
266
292
  }
267
293
 
294
+ keepSnapshotAlive(threadId, sync) {
295
+ const token = this.threads.get(threadId)?.tokens.get(sync?.id);
296
+ const slot = this.subscriptions.get(threadId);
297
+ if (!token || !slot || sync.epoch !== this.epoch) return;
298
+ token.expires = Date.now() + syncTokenTtlMs;
299
+ this.scheduleCleanup(threadId, slot);
300
+ }
301
+
268
302
  scheduleCleanup(threadId, slot) {
269
303
  clearTimeout(slot.expiry);
270
304
  const record = this.threads.get(threadId);
@@ -276,18 +310,34 @@ export class SharedAppServer extends EventEmitter {
276
310
  cleanup(threadId, slot) {
277
311
  const record = this.threads.get(threadId), now = Date.now();
278
312
  if (record) for (const [id, token] of record.tokens) if (token.expires <= now) record.tokens.delete(id);
279
- if (slot.users) { clearTimeout(slot.expiry); slot.expiry = null; return; }
313
+ if (slot.users || slot.readers) { clearTimeout(slot.expiry); slot.expiry = null; return; }
280
314
  if (record?.tokens.size) { this.scheduleCleanup(threadId, slot); return; }
315
+ if (this.keepThreadLoaded(threadId, record)) { clearTimeout(slot.expiry); slot.expiry = null; return; }
281
316
  slot.tail = slot.tail.then(async () => {
282
- if (slot.users || this.threads.get(threadId)?.tokens.size) return;
317
+ if (this.subscriptions.get(threadId) !== slot) return;
318
+ if (slot.users || slot.readers || this.threads.get(threadId)?.tokens.size || this.keepThreadLoaded(threadId, this.threads.get(threadId))) return;
283
319
  this.threads.delete(threadId); this.contexts.delete(threadId); this.permissionOptions.delete(threadId);
284
- this.operationGroups.delete(threadId);
285
320
  this.subscriptions.delete(threadId); clearTimeout(slot.expiry);
286
321
  if (this.ready && !record?.readOnly) await this.request('thread/unsubscribe', { threadId });
287
322
  this.serverRequests.forget(threadId);
288
323
  }).catch(error => this.emit('subscription-error', error));
289
324
  }
290
325
 
326
+ keepThreadLoaded(threadId, record) {
327
+ if (!record || record.readOnly) return false;
328
+ const status = typeof record.thread.status === 'string' ? record.thread.status : record.thread.status?.type;
329
+ return status === 'active' || record.thread.turns?.some(turn => turn.status === 'inProgress')
330
+ || this.serverRequests.list(threadId).length > 0;
331
+ }
332
+
333
+ releaseInactiveThread(threadId) {
334
+ // Run after the current notification has updated both request and turn state.
335
+ queueMicrotask(() => {
336
+ const slot = this.subscriptions?.get(threadId);
337
+ if (slot && !slot.users && this.ready) this.cleanup(threadId, slot);
338
+ });
339
+ }
340
+
291
341
  async acquire(threadId, requested) {
292
342
  const slot = this.slot(threadId);
293
343
  let record = this.threads.get(threadId), token;
@@ -317,7 +367,7 @@ export class SharedAppServer extends EventEmitter {
317
367
  this.cleanup(threadId, slot);
318
368
  return slot.tail;
319
369
  };
320
- return { record: { thread: record.thread, nextCursor: record.nextCursor,
370
+ return { record: { thread: record.thread, nextCursor: this.historyCursor(threadId, record.thread.turns[0], record.nextCursor),
321
371
  sync: { id: requested.id, epoch: this.epoch, seq: current, ...(snapshot ? { reset: true } : {}) } },
322
372
  snapshot, catchup, release };
323
373
  }
@@ -330,66 +380,43 @@ export class SharedAppServer extends EventEmitter {
330
380
  };
331
381
  }
332
382
 
333
- async compactTurn(threadId, turn) {
334
- const compact = events.compactTurn(turn);
335
- if (!turn?.id) return compact;
336
- const batches = [];
337
- let cursor;
338
- do {
339
- const page = await this.request('thread/items/list', {
340
- threadId, turnId: turn.id, ...(cursor ? { cursor } : {}),
341
- limit: 100, sortDirection: 'desc',
342
- });
343
- if (!Array.isArray(page.data)) throw new Error('Codex 内核未返回单轮条目索引。');
344
- batches.push(page.data.map(entry => events.compactItem(entry.item)));
345
- cursor = page.nextCursor ?? null;
346
- } while (cursor);
347
- const items = batches.reverse().flatMap(batch => batch.reverse());
348
- const groups = new Map(), skeleton = [];
349
- let operationItems = [];
350
- const flushOperations = () => {
351
- if (!operationItems.length) return;
352
- const visibleItems = operationItems.filter(item => item?.type !== 'reasoning');
353
- operationItems = [];
354
- if (!visibleItems.length) return;
355
- const subAgentItems = visibleItems.filter(isSubAgentItem);
356
- const detailItems = visibleItems.filter(item => !isSubAgentItem(item));
357
- if (detailItems.length) {
358
- const groupId = `g${groups.size + 1}`;
359
- groups.set(groupId, detailItems);
360
- skeleton.push({ type: 'lazyOperationGroup', id: `${turn.id}:${groupId}`,
361
- turnId: turn.id, groupId,
362
- operationTypes: [...new Set(detailItems.map(item => item.type).filter(Boolean))] });
363
- }
364
- skeleton.push(...subAgentItems);
365
- };
366
- for (const item of items) {
367
- if (['userMessage', 'agentMessage', 'imageGeneration', 'imageView'].includes(item?.type)) {
368
- flushOperations();
369
- skeleton.push(item);
370
- } else operationItems.push(item);
371
- }
372
- flushOperations();
373
- let threadGroups = this.operationGroups.get(threadId);
374
- if (!threadGroups) { threadGroups = new Map(); this.operationGroups.set(threadId, threadGroups); }
375
- threadGroups.set(turn.id, groups);
376
- return { ...compact, items: skeleton };
383
+ historyCursor(threadId, turn, turnCursor) {
384
+ const itemCursor = turn?.itemPage?.olderCursor;
385
+ if (!itemCursor && !turnCursor) return null;
386
+ return JSON.stringify({ threadId, epoch: this.epoch, turnId: itemCursor ? turn.id : null,
387
+ itemCursor: itemCursor || null, turnCursor: turnCursor || null });
377
388
  }
378
389
 
379
- async operationGroupItems(threadId, turnId, groupId, offset) {
380
- await this.connect();
381
- const items = this.operationGroups.get(threadId)?.get(turnId)?.get(groupId);
382
- if (!items) throw new Error('操作组已失效,请重新打开对话。');
383
- const start = Math.max(0, Number(offset) || 0);
384
- const pageItems = items.slice(start, start + turnItemPageSize);
385
- const nextOffset = start + pageItems.length < items.length ? start + pageItems.length : null;
386
- return { turnId, groupId, items: pageItems, nextOffset, total: items.length };
390
+ async compactTurn(threadId, turn, cursor = null, apply) {
391
+ if (!turn?.id) return events.compactTurn(turn);
392
+ return this.request('thread/items/list', {
393
+ threadId, turnId: turn.id, ...(cursor ? { cursor } : {}), limit: 100, sortDirection: 'desc',
394
+ }, page => {
395
+ const result = this.projectItemPage(threadId, turn, cursor, page);
396
+ apply?.(result); return result;
397
+ });
398
+ }
399
+
400
+ projectItemPage(threadId, turn, cursor, page) {
401
+ const compact = events.compactTurn(turn);
402
+ if (!Array.isArray(page.data)) throw new Error('Codex 内核未返回单轮条目索引。');
403
+ if (page.nextCursor && page.nextCursor === cursor) throw new Error('条目分页游标未推进。');
404
+ if (page.data.some(entry => entry.turnId !== turn.id)) throw new Error('条目所属轮次不匹配。');
405
+ const items = [...page.data].reverse().map(entry => events.compactItem(entry.item));
406
+ return { ...compact, items, itemPage: { olderCursor: page.nextCursor ?? null,
407
+ hasLoadedOldest: !page.nextCursor, oldestItemId: items[0]?.id || null,
408
+ newestItemId: items.at(-1)?.id || null, requestedCursor: cursor, projection: 'mobile-skeleton' } };
387
409
  }
388
410
 
389
411
  async turnContext(threadId, turnId) {
390
412
  await this.connect();
391
413
  if (!threadId || !turnId) throw new Error('缺少图片所属的对话或轮次。');
392
- return this.compactTurn(threadId, { id: turnId });
414
+ let turn = await this.compactTurn(threadId, { id: turnId });
415
+ while (turn.itemPage.olderCursor) {
416
+ const older = await this.compactTurn(threadId, { id: turnId }, turn.itemPage.olderCursor);
417
+ turn = { ...turn, items: [...older.items, ...turn.items], itemPage: older.itemPage };
418
+ }
419
+ return turn;
393
420
  }
394
421
 
395
422
  fitTurns(base, turns, options = {}) {
@@ -408,13 +435,13 @@ export class SharedAppServer extends EventEmitter {
408
435
 
409
436
  async extendHistoryRecord(threadId, record, options = {}) {
410
437
  const settings = this.pageSettings(options);
411
- if (!record.nextCursor || !record.thread.turns.length) return;
438
+ if (!record.nextCursor || !record.thread.turns.length || record.thread.turns[0].itemPage?.olderCursor) return;
412
439
  const firstOversized = settings.measure({ thread: record.thread, nextCursor: record.nextCursor }) > settings.maxBytes;
413
440
  if (firstOversized) return;
414
441
  while (record.nextCursor && record.thread.turns.length < settings.maxTurns) {
415
442
  const cursor = record.nextCursor;
416
443
  const page = await this.request('thread/turns/list', {
417
- threadId, cursor, limit: 1, sortDirection: 'desc', itemsView: 'summary',
444
+ threadId, cursor, limit: 1, sortDirection: 'desc', itemsView: 'notLoaded',
418
445
  });
419
446
  if (!Array.isArray(page.data) || !page.data.length) {
420
447
  record.nextCursor = page.nextCursor ?? null;
@@ -425,6 +452,7 @@ export class SharedAppServer extends EventEmitter {
425
452
  if (record.thread.turns.length >= 2 && settings.measure(value) > settings.maxBytes) break;
426
453
  record.thread.turns = candidate;
427
454
  record.nextCursor = page.nextCursor ?? null;
455
+ if (candidate[0].itemPage?.olderCursor) break;
428
456
  if (record.thread.turns.length >= 2 && settings.measure(value) > settings.maxBytes) break;
429
457
  }
430
458
  }
@@ -432,29 +460,21 @@ export class SharedAppServer extends EventEmitter {
432
460
  async history(threadId, cursor, options = {}) {
433
461
  await this.connect();
434
462
  if (!cursor) throw new Error('缺少历史分页游标。');
435
- const settings = this.pageSettings(options), turns = [];
436
- let firstOversized = false;
437
- let nextCursor = cursor;
438
- while (nextCursor && turns.length < settings.maxTurns) {
439
- const pageCursor = nextCursor;
440
- const page = await this.request('thread/turns/list', {
441
- threadId, cursor: pageCursor, limit: 1, sortDirection: 'desc', itemsView: 'summary',
442
- });
443
- if (!Array.isArray(page.data) || !page.data.length) {
444
- nextCursor = page.nextCursor ?? null;
445
- break;
446
- }
447
- const compact = await this.compactTurn(threadId, page.data[0]);
448
- const candidate = [compact, ...turns];
449
- const value = { thread: { id: threadId, turns: candidate }, nextCursor: page.nextCursor ?? null };
450
- const candidateBytes = settings.measure(value);
451
- if (turns.length >= 2 && candidateBytes > settings.maxBytes) break;
452
- turns.unshift(compact);
453
- nextCursor = page.nextCursor ?? null;
454
- if (turns.length === 1) firstOversized = candidateBytes > settings.maxBytes;
455
- if (firstOversized || (turns.length >= 2 && candidateBytes > settings.maxBytes)) break;
463
+ let position;
464
+ try { position = JSON.parse(cursor); } catch { position = null; }
465
+ if (position?.threadId !== threadId || position.epoch !== this.epoch) throw Object.assign(new Error('历史游标已失效,需重新核对历史。'), { code: 'history_cursor_expired' });
466
+ if (position.itemCursor && position.turnId) {
467
+ const turn = await this.compactTurn(threadId, { id: position.turnId }, position.itemCursor);
468
+ return { thread: { id: threadId, turns: [turn] },
469
+ nextCursor: this.historyCursor(threadId, turn, position.turnCursor) };
456
470
  }
457
- return { thread: { id: threadId, turns }, nextCursor };
471
+ const page = await this.request('thread/turns/list', {
472
+ threadId, cursor: position.turnCursor, limit: 1, sortDirection: 'desc', itemsView: 'notLoaded',
473
+ });
474
+ if (!Array.isArray(page.data)) throw new Error('Codex 内核未返回轮次分页。');
475
+ if (page.nextCursor && page.nextCursor === position.turnCursor) throw new Error('轮次分页游标未推进。');
476
+ const turns = page.data.length ? [await this.compactTurn(threadId, page.data[0])] : [];
477
+ return { thread: { id: threadId, turns }, nextCursor: this.historyCursor(threadId, turns[0], page.nextCursor) };
458
478
  }
459
479
 
460
480
  async interrupt(threadId, turnId) {
@@ -513,7 +533,3 @@ export class SharedAppServer extends EventEmitter {
513
533
 
514
534
  close() { this.socket?.close(); }
515
535
  }
516
-
517
- function isSubAgentItem(item) {
518
- return item?.type === 'collabAgentToolCall' || item?.type === 'subAgentActivity';
519
- }
@@ -1,33 +1,42 @@
1
1
  // Event-driven subscription adapter. Normal desktop-tool polling remains in its own adapter.
2
- import timelineSync from '../shared/timeline-sync.cjs';
3
- const threadListNotificationMethods = new Set([
4
- 'thread/started', 'thread/status/changed', 'thread/name/updated',
5
- 'thread/archived', 'thread/unarchived', 'thread/deleted',
6
- 'thread/project/updated', 'project/changed', 'turn/started', 'turn/completed',
7
- ]);
8
- export async function streamSharedThread(response, threadId, client, normalize, requestedSync, known) {
2
+ import timelineSync from '../shared/timeline-sync.cjs';
3
+ const threadListNotificationMethods = new Set([
4
+ 'thread/started', 'thread/status/changed', 'thread/name/updated',
5
+ 'thread/archived', 'thread/unarchived', 'thread/deleted',
6
+ 'thread/project/updated', 'project/changed', 'turn/started', 'turn/completed',
7
+ ]);
8
+ export async function streamSharedThread(response, threadId, client, normalize, requestedSync) {
9
9
  response.startEvents();
10
- let closed = false, timer, subscription, streamReady = false;
10
+ let closed = false, timer, subscription, streamReady = false, flushing = false;
11
11
  const pending = new Map(), sentTurns = new Map();
12
12
  let fromSeq, toSeq, syncId = requestedSync?.id, epoch = requestedSync?.epoch;
13
13
  let finish;
14
14
  const done = new Promise(resolve => { finish = resolve; });
15
- const emit = (name, value) => { if (!closed) response.event(name, value); };
16
- const flush = () => {
15
+ const emit = (name, value) => { if (!closed) return response.event(name, value); };
16
+ const flush = async () => {
17
17
  clearTimeout(timer); timer = null;
18
18
  const record = client.threads.get(threadId);
19
- if (!streamReady || !record || !pending.size) return;
20
- const base = normalize({ thread: { ...record.thread, turns: [] }, sync: { id: syncId, epoch, fromSeq, toSeq } });
21
- const turns = [], turnPatches = [];
22
- for (const turn of pending.values()) {
23
- const normalized = normalize({ thread: { ...record.thread, turns: [turn] } }).turns[0];
24
- const patch = timelineSync.diffTurn(sentTurns.get(turn.id), normalized);
25
- if (patch) turnPatches.push(patch); else turns.push(normalized);
26
- sentTurns.set(turn.id, normalized);
19
+ if (closed || flushing || !streamReady || !record || !pending.size) return;
20
+ flushing = true;
21
+ try {
22
+ const base = normalize({ thread: { ...record.thread, turns: [] }, sync: { id: syncId, epoch, fromSeq, toSeq } });
23
+ const turns = [], turnPatches = [];
24
+ for (const turn of pending.values()) {
25
+ const normalized = normalize({ thread: { ...record.thread, turns: [turn] } }).turns[0];
26
+ const patch = timelineSync.diffTurn(sentTurns.get(turn.id), normalized);
27
+ if (patch) turnPatches.push(patch); else turns.push(normalized);
28
+ sentTurns.set(turn.id, normalized);
29
+ }
30
+ const retained = new Set(record.thread.turns.map(turn => turn.id));
31
+ for (const id of sentTurns.keys()) if (!retained.has(id)) sentTurns.delete(id);
32
+ pending.clear();
33
+ fromSeq = toSeq = undefined;
34
+ await emit('update', { ...base, turns, ...(turnPatches.length ? { turnPatches } : {}) });
35
+ } catch (error) { emit('bridge-error', { error: error.message }); response.end(); finish(); }
36
+ finally {
37
+ flushing = false;
38
+ if (!closed && pending.size && !timer) timer = setTimeout(flush, 0);
27
39
  }
28
- emit('update', { ...base, turns, ...(turnPatches.length ? { turnPatches } : {}) });
29
- pending.clear();
30
- fromSeq = toSeq = undefined;
31
40
  };
32
41
  const onTurn = ({ thread, turn, sequence, immediate }) => {
33
42
  if (thread.id !== threadId) return;
@@ -36,45 +45,42 @@ export async function streamSharedThread(response, threadId, client, normalize,
36
45
  pending.set(turn.id, turn);
37
46
  if (immediate) flush(); else if (!timer) timer = setTimeout(flush, 50);
38
47
  };
39
- const onSettings = id => { if (id === threadId) emit('settings', client.context(id)); };
40
- const onRequests = id => { if (id === threadId) emit('approvals', { requests: client.serverRequests.list(threadId) }); };
41
- const onQueue = id => { if (id === threadId) emit('queue-changed', {}); };
42
- const onNotification = ({ method, params }) => {
43
- if (!threadListNotificationMethods.has(method)) return;
44
- emit('thread-list-changed', { method, threadId: params.threadId || params.thread?.id || '' });
45
- };
46
- const onDisconnected = () => { emit('bridge-error', { error: 'Codex 内核连接已断开,请重新连接。' }); response.end(); finish(); };
48
+ const onSettings = id => { if (id === threadId) emit('settings', client.context(id)); };
49
+ const onRequests = id => { if (id === threadId) emit('approvals', { requests: client.serverRequests.list(threadId) }); };
50
+ const onQueue = id => { if (id === threadId) emit('queue-changed', {}); };
51
+ const onNotification = ({ method, params }) => {
52
+ if (!threadListNotificationMethods.has(method)) return;
53
+ emit('thread-list-changed', { method, threadId: params.threadId || params.thread?.id || '' });
54
+ };
55
+ const onDisconnected = () => { emit('bridge-error', { error: 'Codex 内核连接已断开,请重新连接。' }); response.end(); finish(); };
47
56
  response.once('close', () => { closed = true; finish(); });
48
- client.on('turn', onTurn); client.on('settings', onSettings); client.on('disconnected', onDisconnected);
49
- client.on('queue-changed', onQueue);
50
- client.on('requests-changed', onRequests);
51
- client.on('notification', onNotification);
57
+ client.on('turn', onTurn); client.on('settings', onSettings); client.on('disconnected', onDisconnected);
58
+ client.on('queue-changed', onQueue);
59
+ client.on('requests-changed', onRequests);
60
+ client.on('notification', onNotification);
52
61
  try {
53
- subscription = await client.acquire(threadId, requestedSync);
54
- syncId = subscription.record.sync.id; epoch = subscription.record.sync.epoch;
55
- streamReady = true;
56
- if (!closed) {
57
- const normalized = normalize(subscription.record);
58
- const latest = normalized.turns.at(-1);
59
- const unchanged = subscription.snapshot && known?.turnId && known.turnId === latest?.id
60
- && known.updatedAt > 0 && known.updatedAt === Number(normalized.thread.updatedAt)
61
- && normalized.thread.status === 'idle' && latest?.status !== 'inProgress'
62
- && known.turnStatus && known.turnStatus !== 'inProgress';
63
- if (subscription.snapshot) emit('update', unchanged ? { ...normalized, turns: [], notModified: true } : normalized);
64
- else if (subscription.catchup) emit('update', normalize(subscription.catchup));
65
- for (const turn of normalized.turns) sentTurns.set(turn.id, turn);
62
+ subscription = await client.acquire(threadId, requestedSync);
63
+ syncId = subscription.record.sync.id; epoch = subscription.record.sync.epoch;
64
+ if (!closed) {
65
+ const normalized = normalize(subscription.record);
66
+ // The acquired snapshot/catchup already includes pre-acquire notifications.
67
+ pending.clear(); fromSeq = toSeq = undefined;
68
+ if (subscription.snapshot) await emit('update', normalized);
69
+ else if (subscription.catchup) await emit('update', normalize(subscription.catchup));
70
+ for (const turn of normalized.turns) sentTurns.set(turn.id, turn);
66
71
  emit('queue-changed', {});
67
72
  onRequests(threadId);
73
+ streamReady = true;
68
74
  flush();
69
75
  }
70
76
  await done;
71
77
  } catch (error) { emit('bridge-error', { error: error.message }); response.end(); }
72
78
  finally {
73
79
  clearTimeout(timer);
74
- client.off('turn', onTurn); client.off('settings', onSettings); client.off('disconnected', onDisconnected);
75
- client.off('queue-changed', onQueue);
76
- client.off('requests-changed', onRequests);
77
- client.off('notification', onNotification);
80
+ client.off('turn', onTurn); client.off('settings', onSettings); client.off('disconnected', onDisconnected);
81
+ client.off('queue-changed', onQueue);
82
+ client.off('requests-changed', onRequests);
83
+ client.off('notification', onNotification);
78
84
  if (subscription) await subscription.release();
79
85
  }
80
86
  }
@@ -0,0 +1,47 @@
1
+ import fs from 'node:fs';
2
+
3
+ // Read authoritative turn contexts locally; never send rollout text to a terminal.
4
+ export class TurnModelHistory {
5
+ constructor() { this.files = new Map(); }
6
+
7
+ async read(file, turnIds) {
8
+ if (!file || !turnIds.length) return [];
9
+ let state = this.files.get(file);
10
+ if (!state) {
11
+ state = { offset: 0, lastModel: '', turns: new Map(), changes: new Map(), tail: Promise.resolve() };
12
+ this.files.set(file, state);
13
+ if (this.files.size > 16) this.files.delete(this.files.keys().next().value);
14
+ }
15
+ const task = state.tail.then(async () => {
16
+ const stat = await fs.promises.stat(file);
17
+ if (stat.size < state.offset) {
18
+ state.offset = 0; state.lastModel = ''; state.turns.clear(); state.changes.clear();
19
+ }
20
+ if (stat.size > state.offset) {
21
+ let pending = Buffer.alloc(0);
22
+ const stream = fs.createReadStream(file, { start: state.offset, end: stat.size - 1 });
23
+ for await (const chunk of stream) {
24
+ const data = pending.length ? Buffer.concat([pending, chunk]) : chunk;
25
+ let start = 0, end;
26
+ while ((end = data.indexOf(10, start)) !== -1) {
27
+ const line = data.subarray(start, end);
28
+ if (line.subarray(0, 160).includes(Buffer.from('"turn_context"'))) {
29
+ const event = JSON.parse(line.toString('utf8'));
30
+ const { turn_id: turnId, model } = event.payload || {};
31
+ if (event.type === 'turn_context' && typeof turnId === 'string' && typeof model === 'string' && model && !state.turns.has(turnId)) {
32
+ if (state.lastModel && state.lastModel !== model) state.changes.set(turnId, { turnId, fromModel: state.lastModel, toModel: model });
33
+ state.turns.set(turnId, model); state.lastModel = model;
34
+ }
35
+ }
36
+ state.offset += end - start + 1;
37
+ start = end + 1;
38
+ }
39
+ pending = data.subarray(start);
40
+ }
41
+ }
42
+ return turnIds.flatMap(id => state.changes.has(id) ? [state.changes.get(id)] : []);
43
+ });
44
+ state.tail = task.catch(() => {});
45
+ return task;
46
+ }
47
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@walkhi/code-relax",
3
- "version": "0.1.0-beta.22",
3
+ "version": "0.1.0-beta.23",
4
4
  "license": "UNLICENSED",
5
5
  "type": "module",
6
6
  "scripts": {