@walkhi/code-relax 0.1.0-beta.27 → 0.1.0-beta.28

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 (63) hide show
  1. package/README.md +80 -67
  2. package/dist/bin/codex-remote.mjs +286 -239
  3. package/dist/shared/app-server-events.cjs +102 -16
  4. package/dist/shared/code-browser.cjs +159 -0
  5. package/dist/shared/file-changes.cjs +31 -0
  6. package/dist/shared/file-citations.cjs +117 -82
  7. package/dist/shared/file-mentions.cjs +80 -35
  8. package/dist/shared/message-delivery.cjs +38 -0
  9. package/dist/shared/p2p-file-channel.cjs +74 -0
  10. package/dist/shared/server-requests.cjs +149 -41
  11. package/dist/shared/service-endpoints.cjs +11 -11
  12. package/dist/shared/timeline-sync.cjs +173 -156
  13. package/dist/skills/code-relax/SKILL.md +2 -1
  14. package/dist/src/bounded-process-log.mjs +63 -0
  15. package/dist/src/celsius-client.mjs +107 -0
  16. package/dist/src/code-browser.mjs +101 -0
  17. package/dist/src/desktop-launcher.mjs +30 -30
  18. package/dist/src/dots-approval-timeline.mjs +99 -0
  19. package/dist/src/dots-avatar.mjs +42 -0
  20. package/dist/src/dots-chat-projection.mjs +50 -0
  21. package/dist/src/dots-chat-server.mjs +412 -0
  22. package/dist/src/dots-message-images.mjs +319 -0
  23. package/dist/src/dots-profile.mjs +77 -0
  24. package/dist/src/dots-room-activity.mjs +94 -0
  25. package/dist/src/dots-room-client.mjs +135 -0
  26. package/dist/src/dots-room-projection.mjs +94 -0
  27. package/dist/src/dots-schedules.mjs +66 -0
  28. package/dist/src/dots-summary-tasks.mjs +32 -0
  29. package/dist/src/dots-task-catalog.mjs +411 -0
  30. package/dist/src/file-transfer-range.mjs +22 -0
  31. package/dist/src/file-upload-session.mjs +130 -124
  32. package/dist/src/hosted-history-app-server.mjs +103 -0
  33. package/dist/src/hosted-history-cache.mjs +113 -0
  34. package/dist/src/lan-socket-server.mjs +205 -160
  35. package/dist/src/managed-app-server.mjs +241 -238
  36. package/dist/src/message-upload-files.mjs +74 -0
  37. package/dist/src/platform/windows/activate-desktop.ps1 +48 -0
  38. package/dist/src/platform/windows/codex-runtime.mjs +253 -251
  39. package/dist/src/platform/windows/desktop-host.mjs +38 -32
  40. package/dist/src/platform/windows/desktop-info.ps1 +2 -2
  41. package/dist/src/platform/windows/desktop-project-registration.mjs +153 -0
  42. package/dist/src/platform/windows/dots-profile.mjs +49 -0
  43. package/dist/src/platform/windows/launch-worker.mjs +41 -42
  44. package/dist/src/platform/windows/project-locations.mjs +16 -0
  45. package/dist/src/project-create.mjs +119 -0
  46. package/dist/src/project-management.mjs +69 -0
  47. package/dist/src/self-relay/account-connections.mjs +52 -0
  48. package/dist/src/self-relay/admin-page.mjs +80 -80
  49. package/dist/src/self-relay/admin-style.mjs +14 -9
  50. package/dist/src/self-relay/connector.mjs +22 -4
  51. package/dist/src/self-relay/file-wire.mjs +13 -0
  52. package/dist/src/self-relay/p2p-probe.mjs +152 -123
  53. package/dist/src/self-relay/rtc-file-bridge.mjs +41 -0
  54. package/dist/src/self-relay/server.mjs +894 -849
  55. package/dist/src/server.mjs +471 -199
  56. package/dist/src/service-doctor.mjs +72 -61
  57. package/dist/src/service-lifecycle.mjs +51 -43
  58. package/dist/src/shared-app-server.mjs +166 -107
  59. package/dist/src/shared-catalog.mjs +132 -40
  60. package/dist/src/shared-thread-stream.mjs +103 -25
  61. package/dist/src/usage-reset.mjs +17 -0
  62. package/dist/src/workstation-downloads.mjs +120 -0
  63. package/package.json +1 -1
@@ -2,6 +2,7 @@ import WebSocket from 'ws';
2
2
  import { EventEmitter } from 'node:events';
3
3
  import { randomUUID } from 'node:crypto';
4
4
  import events from '../shared/app-server-events.cjs';
5
+ import messageDelivery from '../shared/message-delivery.cjs';
5
6
  import serverRequests from '../shared/server-requests.cjs';
6
7
 
7
8
  const syncTokenTtlMs = 30_000;
@@ -56,8 +57,12 @@ export class SharedAppServer extends EventEmitter {
56
57
  return this.connecting;
57
58
  }
58
59
 
60
+ openSocket() {
61
+ return new WebSocket(this.url, { handshakeTimeout: 5000 });
62
+ }
63
+
59
64
  async open() {
60
- const socket = new WebSocket(this.url, { handshakeTimeout: 5000 });
65
+ const socket = await this.openSocket();
61
66
  this.socket = socket;
62
67
  this.ready = false;
63
68
  this.serverVersion = '';
@@ -119,7 +124,8 @@ export class SharedAppServer extends EventEmitter {
119
124
  return new Promise((resolve, reject) => {
120
125
  const timer = setTimeout(() => {
121
126
  this.pending.delete(id);
122
- reject(new Error(`${method} 超时;不会自动重发,请先检查对话。`));
127
+ reject(Object.assign(new Error(`${method} 超时;不会自动重发,请先检查对话。`),
128
+ { code: 'app_server_request_timeout' }));
123
129
  }, 30000);
124
130
  this.pending.set(id, { resolve, reject, timer, transform, threadId: params?.threadId });
125
131
  this.socket.send(JSON.stringify({ id, method, params }), error => {
@@ -135,23 +141,31 @@ export class SharedAppServer extends EventEmitter {
135
141
  }
136
142
 
137
143
  async send(args) {
138
- await this.connect();
139
- this.requireWritable(args.threadId);
140
- // Reading the live thread checks this service owns it without resume changing settings.
141
- const { thread } = await this.request('thread/read', { threadId: args.threadId, includeTurns: false });
142
- const status = typeof thread.status === 'string' ? thread.status : thread.status?.type;
143
- if (status === 'active') {
144
- const page = await this.request('thread/turns/list', {
145
- threadId: args.threadId, limit: 1, sortDirection: 'desc', itemsView: 'notLoaded',
146
- });
147
- const active = page.data?.[0];
148
- if (active?.status !== 'inProgress') throw new Error('活动轮次已变化,请刷新后再发送。');
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 } };
144
+ let submitted = false;
145
+ try {
146
+ await this.connect();
147
+ this.requireWritable(args.threadId);
148
+ // Reading the live thread checks this service owns it without resume changing settings.
149
+ const { thread } = await this.request('thread/read', { threadId: args.threadId, includeTurns: false });
150
+ const status = typeof thread.status === 'string' ? thread.status : thread.status?.type;
151
+ if (status === 'active') {
152
+ const page = await this.request('thread/turns/list', {
153
+ threadId: args.threadId, limit: 1, sortDirection: 'desc', itemsView: 'notLoaded',
154
+ });
155
+ const active = page.data?.[0];
156
+ if (active?.status !== 'inProgress') throw new Error('活动轮次已变化,请刷新后再发送。');
157
+ const { turnTrigger, ...source } = events.mobileTurnSource(args.clientUserMessageId, args.responsesapiClientMetadata?.code_relax_input === 'speech_to_text');
158
+ submitted = true;
159
+ const result = await this.request('turn/steer', { threadId: args.threadId, expectedTurnId: active.id, input: args.input, ...source, ...(args.clientUserMessageId ? { clientUserMessageId: args.clientUserMessageId } : {}) });
160
+ return { result, usage: { kind: 'steer', turnId: active.id } };
161
+ }
162
+ submitted = true;
163
+ const result = await this.request('turn/start', args);
164
+ return { result, usage: { kind: 'start', turnId: result?.turn?.id || null } };
165
+ } catch (error) {
166
+ if (!submitted || messageDelivery.rejectedBeforeSubmission(error)) error.deliveryUnknown = false;
167
+ throw error;
152
168
  }
153
- const result = await this.request('turn/start', args);
154
- return { result, usage: { kind: 'start', turnId: result?.turn?.id || null } };
155
169
  }
156
170
 
157
171
  respondApproval(threadId, body) {
@@ -182,8 +196,13 @@ export class SharedAppServer extends EventEmitter {
182
196
  }
183
197
  const record = this.threads.get(id);
184
198
  if (!record) return;
185
- const slot = this.subscriptions.get(id);
186
- if (!slot?.users && !slot?.readers && !record.tokens.size) {
199
+ const stub = record.thread.turns?.find(turn => turn.cacheStub && turn.id === (params.turn?.id || params.turnId));
200
+ if (stub && !['thread/status/changed', 'thread/name/updated'].includes(method)) {
201
+ record.needsReload = true;
202
+ this.emit('history-invalidated', id);
203
+ return;
204
+ }
205
+ if (!this.isThreadObserved(id, record)) {
187
206
  // Keep task/approval lifecycle metadata, not an invisible growing transcript.
188
207
  record.needsReload = true;
189
208
  if (params.turn) events.applyNotification(record.thread, method, { ...params, turn: { ...params.turn, items: [] } });
@@ -194,11 +213,14 @@ export class SharedAppServer extends EventEmitter {
194
213
  return;
195
214
  }
196
215
  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)
216
+ if (method === 'item/completed' && !record.hydrating && current?.itemPage?.olderCursor && !current.items.some(item => item.id === params.item?.id)) return;
217
+ const visibleParams = (record.hydrating || current?.itemPage?.olderCursor) && method === 'turn/completed' && Array.isArray(params.turn?.items)
199
218
  ? { ...params, turn: { ...params.turn,
200
219
  items: params.turn.items.filter(item => current?.items?.some(loaded => loaded.id === item.id)) } }
201
220
  : params;
221
+ const appliedParams = { ...visibleParams,
222
+ ...(visibleParams.item ? { item: events.compactItem(visibleParams.item) } : {}),
223
+ ...(visibleParams.turn ? { turn: events.compactTurn(visibleParams.turn) } : {}) };
202
224
  const turn = events.applyNotification(record.thread, method, appliedParams);
203
225
  if (turn) {
204
226
  const sequence = ++record.sequence;
@@ -211,74 +233,105 @@ export class SharedAppServer extends EventEmitter {
211
233
  this.emit('turn', { thread: record.thread, turn, sequence,
212
234
  immediate: ['turn/completed', 'item/completed'].includes(method) });
213
235
  }
214
- if (method === 'turn/completed' && record.thread.turns.length > 10) {
215
- record.thread.turns = record.thread.turns.slice(-10);
216
- // Native cursors are opaque; obtain a matching boundary before the next snapshot.
217
- record.historyTrimmed = true;
218
- }
236
+ if (method === 'turn/completed' && record.thread.turns.length > 10) {
237
+ record.thread.turns = record.thread.turns.slice(-10);
238
+ // Native cursors are opaque; obtain a matching boundary before the next snapshot.
239
+ record.historyTrimmed = true;
240
+ }
219
241
  if (['turn/completed', 'thread/status/changed'].includes(method)) this.releaseInactiveThread(id);
220
242
  }
221
243
 
222
- async timeline(threadId, pageOptions = {}) {
244
+ matchesUpdatedAt(thread, updatedAt) {
245
+ const status = typeof thread.status === 'string' ? thread.status : thread.status?.type;
246
+ const latest = thread.turns?.at(-1);
247
+ return Number(updatedAt) > 0 && status !== 'active' && latest?.status !== 'inProgress'
248
+ && Math.floor(Number(thread.updatedAt)) === Math.floor(Number(updatedAt));
249
+ }
250
+
251
+ resumeTimeline(threadId, apply) {
252
+ return this.request('thread/resume', { threadId, excludeTurns: true,
253
+ initialTurnsPage: { limit: 1, sortDirection: 'desc', itemsView: 'notLoaded' } }, value => {
254
+ if (!Array.isArray(value.initialTurnsPage?.data)) throw new Error('Codex 内核未返回分页历史。');
255
+ return apply(value);
256
+ });
257
+ }
258
+
259
+ initialTurnsPage(_threadId, resumed) { return resumed.initialTurnsPage; }
260
+
261
+ async timeline(threadId, pageOptions = {}, knownSync) {
223
262
  await this.connect();
224
263
  const slot = this.slot(threadId);
225
264
  slot.readers++;
226
265
  const task = slot.tail.then(async () => {
227
- let record = this.threads.get(threadId);
228
- // Snapshot refreshes must not reset the sequence or tokens of live subscribers.
229
- const previous = record?.readOnly ? null : record;
230
- if (record?.readOnly || record?.needsReload) this.contexts.delete(threadId);
231
- if (!record || record.readOnly || record.needsReload || record.historyTrimmed) {
232
- let result;
266
+ let record = this.threads.get(threadId), metadataReused = false;
267
+ // Seconds are the product's cache-update granularity. Active work still
268
+ // uses the received sequence so an in-progress reply can keep advancing.
269
+ const knownToken = record?.tokens.get(knownSync?.id);
270
+ const unchanged = Boolean(!pageOptions.reconcileObserved && record && !record.readOnly && !record.needsReload && !record.hydrating
271
+ && (this.matchesUpdatedAt(record.thread, knownSync?.updatedAt)
272
+ || (knownSync?.epoch === this.epoch && Number.isSafeInteger(knownSync.seq)
273
+ && knownToken && knownToken.expires > Date.now()
274
+ && knownToken.sequence === knownSync.seq && record.sequence === knownSync.seq)));
275
+ if (unchanged) {
276
+ const id = randomUUID();
277
+ record.tokens.set(id, { sequence: record.sequence, expires: Date.now() + syncTokenTtlMs });
278
+ this.scheduleCleanup(threadId, slot);
279
+ return { thread: { ...record.thread, turns: [] }, notModified: true,
280
+ sync: { id, epoch: this.epoch, seq: record.sequence } };
281
+ }
282
+ // Snapshot refreshes must not reset the sequence or tokens of live subscribers.
283
+ const previous = record?.readOnly ? null : record;
284
+ if (record?.readOnly || record?.needsReload) this.contexts.delete(threadId);
285
+ if (pageOptions.reconcileObserved || !record || record.readOnly || record.needsReload || record.historyTrimmed || record.metadataOnly) {
286
+ let result, snapshot;
233
287
  try {
234
- result = await this.request('thread/resume', { threadId, excludeTurns: true,
235
- initialTurnsPage: { limit: 1, sortDirection: 'desc', itemsView: 'notLoaded' } }, value => {
236
- if (!Array.isArray(value.initialTurnsPage?.data)) throw new Error('Codex 内核未返回分页历史。');
237
- const retained = new Map((previous?.thread.turns || []).map(turn => [turn.id, turn]));
238
- record = { thread: { ...value.thread, turns: [...value.initialTurnsPage.data].reverse().map(turn => ({
239
- ...turn, items: retained.get(turn.id)?.items || [],
240
- ...(retained.get(turn.id)?.itemPage ? { itemPage: retained.get(turn.id).itemPage } : {}),
241
- })) },
242
- nextCursor: value.initialTurnsPage.nextCursor ?? null,
243
- sequence: previous?.sequence ?? 0, recoverAfter: previous?.recoverAfter ?? 0,
244
- changes: previous?.changes ?? new Map(), changeBytes: previous?.changeBytes ?? 0,
245
- tokens: previous?.tokens ?? new Map(), hydrating: true };
288
+ result = await this.resumeTimeline(threadId, value => {
289
+ const retained = new Map((previous?.thread.turns || []).map(turn => [turn.id, turn]));
290
+ record = { thread: { ...value.thread, turns: [...value.initialTurnsPage.data].reverse().map(turn => ({
291
+ ...turn, items: retained.get(turn.id)?.items || [],
292
+ ...(retained.get(turn.id)?.itemPage ? { itemPage: retained.get(turn.id).itemPage } : {}),
293
+ })) },
294
+ nextCursor: value.initialTurnsPage.nextCursor ?? null,
295
+ sequence: previous?.sequence ?? 0, recoverAfter: previous?.recoverAfter ?? 0,
296
+ changes: previous?.changes ?? new Map(), changeBytes: previous?.changeBytes ?? 0,
297
+ tokens: previous?.tokens ?? new Map(), hydrating: true };
246
298
  this.threads.set(threadId, record);
299
+ snapshot = events.beginSnapshot(record.thread);
247
300
  return value;
248
- });
301
+ }, pageOptions);
249
302
  } catch (error) {
303
+ snapshot?.close();
250
304
  if (!isThreadWriterConflict(error)) throw error;
251
- const [read, page] = await Promise.all([
252
- this.request('thread/read', { threadId, includeTurns: false }),
253
- this.request('thread/turns/list', {
254
- threadId, limit: 1, sortDirection: 'desc', itemsView: 'notLoaded',
255
- }),
256
- ]);
257
- const compact = await Promise.all([...(page.data || [])].reverse().map(turn => this.compactTurn(threadId, turn)));
258
- const turns = this.fitTurns({ thread: read.thread }, compact, pageOptions);
259
- const thread = { ...read.thread, turns, readOnly: true,
260
- readOnlyReason: '该对话正在另一个 Codex 实例中使用。' };
261
- record = { thread, nextCursor: page.nextCursor ?? null, sequence: 0, recoverAfter: 0,
262
- changes: new Map(), changeBytes: 0, tokens: new Map(), readOnly: true };
263
- this.threads.set(threadId, record);
305
+ record = await this.readOnlyRecord(threadId, pageOptions, '该对话正在另一个 Codex 实例中使用。');
264
306
  result = null;
265
307
  }
266
- if (result) {
267
- if (!Array.isArray(result.initialTurnsPage?.data)) throw new Error('Codex 内核未返回分页历史。');
268
- await Promise.all(result.initialTurnsPage.data.map(turn => this.compactTurn(threadId, turn, null, page => {
269
- const index = record.thread.turns.findIndex(current => current.id === turn.id);
270
- if (index >= 0) record.thread.turns[index] = { ...page, ...record.thread.turns[index], items: page.items, itemPage: page.itemPage };
271
- })));
308
+ if (result) try {
309
+ const page = await this.initialTurnsPage(threadId, result);
310
+ if (!Array.isArray(page?.data)) throw new Error('Codex 内核未返回分页历史。');
311
+ const live = new Map(record.thread.turns.map(turn => [turn.id, turn]));
312
+ record.thread.turns = [...page.data].reverse().map(turn => {
313
+ const current = live.get(turn.id);
314
+ live.delete(turn.id);
315
+ return current ? Object.assign(current, { ...turn, ...current }) : { ...turn, items: [] };
316
+ }).concat([...live.values()]);
317
+ record.nextCursor = page.nextCursor ?? null;
318
+ metadataReused = Boolean(!pageOptions.reconcileObserved && !snapshot.changed()
319
+ && this.matchesUpdatedAt(record.thread, knownSync?.updatedAt));
320
+ if (metadataReused) {
321
+ record.metadataOnly = true;
322
+ record.thread.turns = record.thread.turns.map(turn => ({ ...turn, items: [], cacheStub: true }));
323
+ } else await Promise.all(page.data.map(turn => this.compactTurn(threadId, turn, null, page => snapshot.merge(page), 20)));
272
324
  record.hydrating = false;
273
325
  this.contexts.set(threadId, { ...this.contexts.get(threadId), model: result.model, thinking: result.reasoningEffort, serviceTier: result.serviceTier ?? null,
274
326
  cwd: result.cwd, ...events.permissionState(result) });
275
- await this.extendHistoryRecord(threadId, record, pageOptions);
276
- }
327
+ } finally { snapshot?.close(); }
277
328
  }
278
329
  const id = randomUUID(), expires = Date.now() + syncTokenTtlMs;
279
330
  record.tokens.set(id, { sequence: record.sequence, expires });
280
331
  this.scheduleCleanup(threadId, slot);
281
- return { thread: record.thread, nextCursor: this.historyCursor(threadId, record.thread.turns[0], record.nextCursor),
332
+ return { thread: metadataReused ? { ...record.thread, turns: [] } : record.thread,
333
+ ...(metadataReused ? { notModified: true } : {}),
334
+ nextCursor: this.historyCursor(threadId, record.thread.turns[0], record.nextCursor),
282
335
  sync: { id, epoch: this.epoch, seq: record.sequence } };
283
336
  }).finally(() => { slot.readers--; });
284
337
  slot.tail = task.catch(() => {});
@@ -326,7 +379,7 @@ export class SharedAppServer extends EventEmitter {
326
379
  if (slot.users || slot.readers || this.threads.get(threadId)?.tokens.size || this.keepThreadLoaded(threadId, this.threads.get(threadId))) return;
327
380
  this.threads.delete(threadId); this.contexts.delete(threadId); this.permissionOptions.delete(threadId);
328
381
  this.subscriptions.delete(threadId); clearTimeout(slot.expiry);
329
- if (this.ready && !record?.readOnly) await this.request('thread/unsubscribe', { threadId });
382
+ if (this.ready && !record?.readOnly) await this.unsubscribeThread(threadId);
330
383
  this.serverRequests.forget(threadId);
331
384
  }).catch(error => this.emit('subscription-error', error));
332
385
  }
@@ -353,18 +406,20 @@ export class SharedAppServer extends EventEmitter {
353
406
  token = record.tokens.get(requested.id);
354
407
  if (!token || token.expires <= Date.now() || token.sequence !== requested.after) token = undefined;
355
408
  }
356
- let snapshot = false, catchup;
357
- if (!record || !token || (record.historyTrimmed && requested.after < record.recoverAfter)) {
358
- const fresh = await this.timeline(threadId);
409
+ let snapshot = false, catchup, confirmed = false;
410
+ if (!record || !token || record.needsReload
411
+ || ((record.historyTrimmed || record.metadataOnly) && requested.after < record.recoverAfter)) {
412
+ const fresh = await this.timeline(threadId, {}, { updatedAt: requested?.updatedAt });
359
413
  record = this.threads.get(threadId); token = record.tokens.get(fresh.sync.id);
360
414
  requested = { id: fresh.sync.id, epoch: fresh.sync.epoch, after: fresh.sync.seq };
361
- snapshot = true;
415
+ snapshot = fresh.notModified !== true;
416
+ confirmed = !snapshot;
362
417
  }
363
418
  slot.users++; record.tokens.delete(requested.id); clearTimeout(slot.expiry);
364
419
  const current = record.sequence;
365
420
  if (!snapshot && requested.after < record.recoverAfter) snapshot = true;
366
421
  if (!snapshot && current > requested.after) {
367
- const turns = [...record.changes.values()].filter(change => change.sequence > requested.after)
422
+ const turns = [...record.changes.values()].filter(change => change.sequence > requested.after && !change.turn.cacheStub)
368
423
  .sort((left, right) => record.thread.turns.findIndex(turn => turn.id === left.turn.id)
369
424
  - record.thread.turns.findIndex(turn => turn.id === right.turn.id)).map(change => change.turn);
370
425
  catchup = { thread: { ...record.thread, turns },
@@ -372,12 +427,17 @@ export class SharedAppServer extends EventEmitter {
372
427
  }
373
428
  const release = () => {
374
429
  slot.users--;
430
+ // Retain the observed snapshot briefly when switching away. A later check
431
+ // must match the client's consumed sequence, never just this release time.
432
+ if (this.threads.get(threadId) === record && !record.readOnly) {
433
+ record.tokens.set(requested.id, { sequence: record.sequence, expires: Date.now() + syncTokenTtlMs });
434
+ }
375
435
  this.cleanup(threadId, slot);
376
436
  return slot.tail;
377
437
  };
378
438
  return { record: { thread: record.thread, nextCursor: this.historyCursor(threadId, record.thread.turns[0], record.nextCursor),
379
439
  sync: { id: requested.id, epoch: this.epoch, seq: current, ...(snapshot ? { reset: true } : {}) } },
380
- snapshot, catchup, release };
440
+ snapshot, catchup, confirmed, release };
381
441
  }
382
442
 
383
443
  pageSettings(options = {}) {
@@ -391,20 +451,43 @@ export class SharedAppServer extends EventEmitter {
391
451
  historyCursor(threadId, turn, turnCursor) {
392
452
  const itemCursor = turn?.itemPage?.olderCursor;
393
453
  if (!itemCursor && !turnCursor) return null;
394
- return JSON.stringify({ threadId, epoch: this.epoch, turnId: itemCursor ? turn.id : null,
454
+ // Upstream history cursors identify stored positions, not this observer.
455
+ return JSON.stringify({ threadId, turnId: itemCursor ? turn.id : null,
395
456
  itemCursor: itemCursor || null, turnCursor: turnCursor || null });
396
457
  }
397
458
 
398
- async compactTurn(threadId, turn, cursor = null, apply) {
459
+ async compactTurn(threadId, turn, cursor = null, apply, limit = 100) {
399
460
  if (!turn?.id) return events.compactTurn(turn);
400
461
  return this.request('thread/items/list', {
401
- threadId, turnId: turn.id, ...(cursor ? { cursor } : {}), limit: 100, sortDirection: 'desc',
462
+ threadId, turnId: turn.id, ...(cursor ? { cursor } : {}), limit, sortDirection: 'desc',
402
463
  }, page => {
403
464
  const result = this.projectItemPage(threadId, turn, cursor, page);
404
465
  apply?.(result); return result;
405
466
  });
406
467
  }
407
468
 
469
+ isThreadObserved(threadId, record) {
470
+ const slot = this.subscriptions.get(threadId);
471
+ return Boolean(slot?.users || slot?.readers || record.tokens.size);
472
+ }
473
+
474
+ unsubscribeThread(threadId) { return this.request('thread/unsubscribe', { threadId }); }
475
+
476
+ async readOnlyRecord(threadId, pageOptions, reason) {
477
+ const [read, page] = await Promise.all([
478
+ this.request('thread/read', { threadId, includeTurns: false }),
479
+ this.request('thread/turns/list', { threadId, limit: 1, sortDirection: 'desc', itemsView: 'notLoaded' }),
480
+ ]);
481
+ const compact = await Promise.all([...(page.data || [])].reverse().map(turn => this.compactTurn(threadId, turn)));
482
+ const turns = this.fitTurns({ thread: read.thread }, compact, pageOptions);
483
+ const thread = { ...read.thread, turns, readOnly: true, readOnlyReason: reason };
484
+ const record = { thread, nextCursor: page.nextCursor ?? null, sequence: 0, recoverAfter: 0,
485
+ changes: new Map(), changeBytes: 0, tokens: new Map(), readOnly: true };
486
+ this.threads.set(threadId, record);
487
+ this.contexts.set(threadId, { model: thread.model, thinking: thread.reasoningEffort, cwd: thread.cwd });
488
+ return record;
489
+ }
490
+
408
491
  projectItemPage(threadId, turn, cursor, page) {
409
492
  const compact = events.compactTurn(turn);
410
493
  if (!Array.isArray(page.data)) throw new Error('Codex 内核未返回单轮条目索引。');
@@ -441,38 +524,14 @@ export class SharedAppServer extends EventEmitter {
441
524
  return accepted;
442
525
  }
443
526
 
444
- async extendHistoryRecord(threadId, record, options = {}) {
445
- const settings = this.pageSettings(options);
446
- if (!record.nextCursor || !record.thread.turns.length || record.thread.turns[0].itemPage?.olderCursor) return;
447
- const firstOversized = settings.measure({ thread: record.thread, nextCursor: record.nextCursor }) > settings.maxBytes;
448
- if (firstOversized) return;
449
- while (record.nextCursor && record.thread.turns.length < settings.maxTurns) {
450
- const cursor = record.nextCursor;
451
- const page = await this.request('thread/turns/list', {
452
- threadId, cursor, limit: 1, sortDirection: 'desc', itemsView: 'notLoaded',
453
- });
454
- if (!Array.isArray(page.data) || !page.data.length) {
455
- record.nextCursor = page.nextCursor ?? null;
456
- break;
457
- }
458
- const candidate = [await this.compactTurn(threadId, page.data[0]), ...record.thread.turns];
459
- const value = { thread: { ...record.thread, turns: candidate }, nextCursor: page.nextCursor ?? null };
460
- if (record.thread.turns.length >= 2 && settings.measure(value) > settings.maxBytes) break;
461
- record.thread.turns = candidate;
462
- record.nextCursor = page.nextCursor ?? null;
463
- if (candidate[0].itemPage?.olderCursor) break;
464
- if (record.thread.turns.length >= 2 && settings.measure(value) > settings.maxBytes) break;
465
- }
466
- }
467
-
468
527
  async history(threadId, cursor, options = {}) {
469
528
  await this.connect();
470
529
  if (!cursor) throw new Error('缺少历史分页游标。');
471
530
  let position;
472
531
  try { position = JSON.parse(cursor); } catch { position = null; }
473
- if (position?.threadId !== threadId || position.epoch !== this.epoch) throw Object.assign(new Error('历史游标已失效,需重新核对历史。'), { code: 'history_cursor_expired' });
532
+ if (position?.threadId !== threadId) throw Object.assign(new Error('历史游标不属于当前对话。'), { code: 'history_cursor_expired' });
474
533
  if (position.itemCursor && position.turnId) {
475
- const turn = await this.compactTurn(threadId, { id: position.turnId }, position.itemCursor);
534
+ const turn = await this.compactTurn(threadId, { id: position.turnId }, position.itemCursor, undefined, options.itemLimit ?? 100);
476
535
  return { thread: { id: threadId, turns: [turn] },
477
536
  nextCursor: this.historyCursor(threadId, turn, position.turnCursor) };
478
537
  }
@@ -481,7 +540,7 @@ export class SharedAppServer extends EventEmitter {
481
540
  });
482
541
  if (!Array.isArray(page.data)) throw new Error('Codex 内核未返回轮次分页。');
483
542
  if (page.nextCursor && page.nextCursor === position.turnCursor) throw new Error('轮次分页游标未推进。');
484
- const turns = page.data.length ? [await this.compactTurn(threadId, page.data[0])] : [];
543
+ const turns = page.data.length ? [await this.compactTurn(threadId, page.data[0], null, undefined, options.itemLimit ?? 100)] : [];
485
544
  return { thread: { id: threadId, turns }, nextCursor: this.historyCursor(threadId, turns[0], page.nextCursor) };
486
545
  }
487
546
 
@@ -510,7 +569,7 @@ export class SharedAppServer extends EventEmitter {
510
569
  async settings(threadId, model, effort, serviceTier) {
511
570
  await this.connect();
512
571
  this.requireWritable(threadId);
513
- await events.updateSettings((method, params) => this.request(method, params), this.contexts, this.settingsNotifications, threadId, model, effort, serviceTier);
572
+ await events.updateSettings((method, params) => this.request(method, params), this.contexts, threadId, model, effort, serviceTier);
514
573
  return this.context(threadId);
515
574
  }
516
575
 
@@ -1,40 +1,132 @@
1
- import events from '../shared/app-server-events.cjs';
2
-
3
- export function sharedThreadProjectId(thread, projects) {
4
- return events.catalogThreadProjectId(thread, projects);
5
- }
6
-
7
- // Mobile catalog order comes entirely from app-server authority, independent of Desktop UI state.
8
- export class SharedCatalog {
9
- constructor(server) { this.server = server; }
10
-
11
- async projects() {
12
- const native = await this.server.projects();
13
- const projects = native.map(project => ({ projectId: project.id, name: project.name,
14
- label: project.name, path: project.roots[0]?.path || '', cwd: project.roots[0]?.path || '', hostId: 'local' }));
15
- return { projects, native };
16
- }
17
-
18
- async threads(limit, archived = false) {
19
- const { projects, native } = await this.projects();
20
- const threads = []; let cursor;
21
- do {
22
- const page = await this.server.request('thread/list', { limit: Math.min(100, limit - threads.length),
23
- sortKey: 'recency_at', sortDirection: 'desc', archived, ...(cursor ? { cursor } : {}) });
24
- threads.push(...page.data); cursor = page.nextCursor;
25
- } while (cursor && threads.length < limit);
26
- const summaries = threads.map(thread => ({ id: thread.id, kind: 'codex', hostId: 'local',
27
- title: thread.name || thread.preview || '', cwd: thread.cwd,
28
- projectId: sharedThreadProjectId(thread, native), updatedAt: thread.updatedAt,
29
- recencyAt: thread.recencyAt ?? thread.updatedAt,
30
- status: typeof thread.status === 'string' ? thread.status : thread.status?.type || 'unknown',
31
- activeFlags: threadActiveFlags(thread.status) }));
32
- return { projects, pinnedThreads: [], threads: summaries };
33
- }
34
- }
35
-
36
- function threadActiveFlags(status) {
37
- return status && typeof status === 'object' && Array.isArray(status.activeFlags)
38
- ? status.activeFlags.filter(flag => typeof flag === 'string')
39
- : [];
40
- }
1
+ import events from '../shared/app-server-events.cjs';
2
+ import { projectSummary } from './project-management.mjs';
3
+
4
+ export function sharedThreadProjectId(thread, projects) {
5
+ return events.catalogThreadProjectId(thread, projects);
6
+ }
7
+
8
+ export function dotsThreadProjectId(thread, projects) {
9
+ if (thread.projectId && projects.some(project => project.id === thread.projectId)) return thread.projectId;
10
+ if (thread.environments?.length) {
11
+ for (const environment of thread.environments) {
12
+ const roots = Array.isArray(environment.runtimeWorkspaceRoots) ? environment.runtimeWorkspaceRoots : [];
13
+ for (const root of roots) {
14
+ if (!root) continue;
15
+ const projectId = events.threadProjectId({ cwd: root }, projects);
16
+ if (projectId) return projectId;
17
+ }
18
+ }
19
+ // Display grouping may use the execution directory when workspace roots
20
+ // do not map to a known project. This never changes the thread's workspace
21
+ // permissions or its native project assignment.
22
+ for (const environment of thread.environments) {
23
+ if (!environment.cwd) continue;
24
+ const projectId = events.threadProjectId({ cwd: environment.cwd }, projects);
25
+ if (projectId) return projectId;
26
+ }
27
+ }
28
+ return events.threadProjectId({ cwd: thread.cwd }, projects);
29
+ }
30
+
31
+ // Mobile catalog order comes entirely from app-server authority, independent of Desktop UI state.
32
+ export class SharedCatalog {
33
+ constructor(server, dotsServer, dotsChat) {
34
+ this.server = server; this.dotsServer = dotsServer; this.dotsChat = dotsChat;
35
+ this.states = new Map();
36
+ dotsServer?.on('disconnected', () => this.states.clear());
37
+ }
38
+
39
+ threadState(thread, refresh = false) {
40
+ const client = thread.threadSource === 'aeon' ? this.dotsChat : this.dotsServer;
41
+ const observed = client?.threads.get(thread.id);
42
+ if (client?.ready && observed && !observed.needsReload && !observed.hydrating && !observed.refresh) {
43
+ const current = observed.thread;
44
+ return { chatRevision: current.chatRevision,
45
+ ...(thread.threadSource === 'aeon' ? { chatMessageRevision: current.chatMessageRevision, updatedAt: current.updatedAt }
46
+ : { lastTurnStatus: current.turns.at(-1)?.status || null }) };
47
+ }
48
+ const identity = this.dotsServer.profileIdentity;
49
+ let entry = this.states.get(thread.id);
50
+ const version = JSON.stringify([thread.updatedAt, thread.status]);
51
+ if (!entry) { entry = { value: {} }; this.states.set(thread.id, entry); }
52
+ if (!entry.work && (refresh || entry.version !== version || Date.now() - (entry.at || 0) >= 30000)) {
53
+ const work = (async () => {
54
+ try {
55
+ const value = thread.threadSource === 'aeon'
56
+ ? await this.dotsChat.latestChatState(thread.id)
57
+ : await this.dotsServer.latestTaskState(thread.id, refresh || entry.version !== version);
58
+ if (identity !== this.dotsServer.profileIdentity || this.states.get(thread.id) !== entry) return;
59
+ const changed = JSON.stringify(entry.value) !== JSON.stringify(value);
60
+ entry.value = value; entry.version = version; entry.at = Date.now();
61
+ if (changed) this.dotsServer.emit('notification', {
62
+ method: 'thread/status/changed', params: { threadId: thread.id, ...value },
63
+ });
64
+ } catch (error) {
65
+ entry.version = version; entry.at = Date.now();
66
+ console.error('云端目录修订读取失败:', thread.id, error.message);
67
+ }
68
+ })().finally(() => { if (entry.work === work) entry.work = null; });
69
+ entry.work = work;
70
+ }
71
+ // The live chat updates its revision before notifying stream listeners.
72
+ // A directory refresh can still be in flight with an older cached value;
73
+ // never return that value after the rendered timeline has advanced.
74
+ if (thread.threadSource === 'aeon') {
75
+ const current = this.dotsChat.threads.get(thread.id)?.thread;
76
+ return current?.chatRevision === undefined ? {} : {
77
+ chatRevision: current.chatRevision, chatMessageRevision: current.chatMessageRevision, updatedAt: current.updatedAt,
78
+ };
79
+ }
80
+ return entry.value;
81
+ }
82
+
83
+ async projects() {
84
+ const native = await this.server.projects();
85
+ const projects = native.map(projectSummary);
86
+ return { projects, native };
87
+ }
88
+
89
+ async threads(limit, archived = false, scope = 'all', refresh = false) {
90
+ const { projects, native } = await this.projects();
91
+ const threads = []; let cursor;
92
+ if (scope !== 'durable') do {
93
+ const page = await this.server.request('thread/list', { limit: Math.min(100, limit - threads.length),
94
+ sortKey: 'recency_at', sortDirection: 'desc', archived, ...(cursor ? { cursor } : {}) });
95
+ threads.push(...page.data); cursor = page.nextCursor;
96
+ } while (cursor && threads.length < limit);
97
+ const summaries = threads.map(thread => ({ id: thread.id, kind: 'codex', hostId: 'local',
98
+ ...(thread.model ? { model: thread.model, reasoningEffort: thread.reasoningEffort } : {}),
99
+ title: thread.name || thread.preview || '', cwd: thread.cwd,
100
+ projectId: sharedThreadProjectId(thread, native), updatedAt: thread.updatedAt,
101
+ recencyAt: thread.recencyAt ?? thread.updatedAt,
102
+ status: typeof thread.status === 'string' ? thread.status : thread.status?.type || 'unknown',
103
+ activeFlags: threadActiveFlags(thread.status) }));
104
+ let catalogError;
105
+ if (scope !== 'local' && this.dotsServer) {
106
+ try {
107
+ const dots = await this.dotsServer.catalog(archived, refresh);
108
+ summaries.push(...dots.map(thread => ({ id: thread.id, kind: thread.threadSource === 'aeon' ? 'dots' : 'codex', hostId: 'durable',
109
+ threadSource: thread.threadSource,
110
+ ...(thread.threadSource === 'aeon_child' && thread.model ? { model: thread.model, reasoningEffort: thread.reasoningEffort } : {}),
111
+ ...this.threadState(thread, refresh),
112
+ ...(thread.threadSource === 'aeon' ? { avatar: thread.avatar || '', color: thread.color || '' } : {}),
113
+ title: thread.name || (thread.threadSource === 'aeon' ? 'Dots' : thread.preview || ''), cwd: thread.cwd,
114
+ projectId: thread.threadSource === 'aeon' ? null : dotsThreadProjectId(thread, native),
115
+ updatedAt: thread.threadSource === 'aeon' ? this.dotsChat.threads.get(thread.id)?.thread.updatedAt || 0 : thread.updatedAt,
116
+ recencyAt: thread.recencyAt ?? thread.updatedAt,
117
+ status: thread.threadSource === 'aeon' ? this.dotsChat?.threads.get(thread.id)?.thread.status || 'unknown'
118
+ : typeof thread.status === 'string' ? thread.status : thread.status?.type || 'unknown',
119
+ activeFlags: thread.threadSource === 'aeon' ? [] : threadActiveFlags(thread.status) })));
120
+ } catch (error) { catalogError = `Dots 对话读取失败:${error.message}`; }
121
+ }
122
+ summaries.sort((a, b) => b.recencyAt - a.recencyAt);
123
+ return { catalogScope: scope, projects, pinnedThreads: [], threads: [...summaries.filter(thread => thread.kind === 'dots'),
124
+ ...summaries.filter(thread => thread.kind !== 'dots').slice(0, limit)], ...(catalogError ? { catalogError } : {}) };
125
+ }
126
+ }
127
+
128
+ function threadActiveFlags(status) {
129
+ return status && typeof status === 'object' && Array.isArray(status.activeFlags)
130
+ ? status.activeFlags.filter(flag => typeof flag === 'string')
131
+ : [];
132
+ }