@walkhi/code-relax 0.1.0-beta.30 → 0.1.0-beta.32

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 (187) hide show
  1. package/README.md +95 -89
  2. package/dist/bin/cleanup-accounts.mjs +63 -63
  3. package/dist/bin/codex-remote.mjs +374 -367
  4. package/dist/bin/manage-relay-admin.mjs +20 -20
  5. package/dist/bin/migrate-relay-db.mjs +109 -109
  6. package/dist/bin/publish-android-release.mjs +63 -58
  7. package/dist/bin/publish-harmony-release.mjs +46 -0
  8. package/dist/bin/release-upload-server.mjs +174 -0
  9. package/dist/bin/self-relay-admin.mjs +8 -8
  10. package/dist/bin/self-relay-demo.mjs +33 -33
  11. package/dist/bin/self-relay-server.mjs +66 -66
  12. package/dist/build-platform.json +1 -1
  13. package/dist/get-lan-networks.ps1 +40 -40
  14. package/dist/get-shared-service-url.ps1 +6 -6
  15. package/dist/shared/app-server-events.cjs +422 -412
  16. package/dist/shared/code-browser.cjs +159 -159
  17. package/dist/shared/file-changes.cjs +31 -31
  18. package/dist/shared/file-citations.cjs +117 -117
  19. package/dist/shared/file-mentions.cjs +92 -92
  20. package/dist/shared/message-delivery.cjs +54 -54
  21. package/dist/shared/p2p-data-channel.cjs +72 -72
  22. package/dist/shared/p2p-diagnostics.cjs +28 -28
  23. package/dist/shared/p2p-file-channel.cjs +78 -74
  24. package/dist/shared/plan-pricing.cjs +32 -32
  25. package/dist/shared/question-attention.cjs +69 -0
  26. package/dist/shared/server-requests.cjs +219 -219
  27. package/dist/shared/timeline-sync.cjs +173 -173
  28. package/dist/skills/code-relax/SKILL.md +22 -22
  29. package/dist/src/archived-threads.mjs +22 -0
  30. package/dist/src/background-process.mjs +18 -18
  31. package/dist/src/bounded-process-log.mjs +63 -63
  32. package/dist/src/celsius-client.mjs +107 -107
  33. package/dist/src/code-browser.mjs +100 -100
  34. package/dist/src/desktop-launcher.mjs +46 -46
  35. package/dist/src/dots-approval-timeline.mjs +99 -99
  36. package/dist/src/dots-avatar.mjs +42 -42
  37. package/dist/src/dots-chat-projection.mjs +50 -50
  38. package/dist/src/dots-chat-server.mjs +461 -461
  39. package/dist/src/dots-message-images.mjs +319 -319
  40. package/dist/src/dots-profile.mjs +77 -77
  41. package/dist/src/dots-room-activity.mjs +94 -94
  42. package/dist/src/dots-room-client.mjs +135 -135
  43. package/dist/src/dots-room-projection.mjs +94 -94
  44. package/dist/src/dots-schedules.mjs +66 -66
  45. package/dist/src/dots-summary-tasks.mjs +32 -32
  46. package/dist/src/dots-task-catalog.mjs +430 -430
  47. package/dist/src/dots-task-files.mjs +52 -52
  48. package/dist/src/execution-health.mjs +26 -26
  49. package/dist/src/file-transfer-range.mjs +22 -22
  50. package/dist/src/file-upload-session.mjs +130 -130
  51. package/dist/src/hosted-history-app-server.mjs +103 -103
  52. package/dist/src/hosted-history-cache.mjs +113 -113
  53. package/dist/src/lan-socket-server.mjs +265 -263
  54. package/dist/src/managed-app-server.mjs +244 -244
  55. package/dist/src/message-images.mjs +9 -9
  56. package/dist/src/message-upload-files.mjs +74 -74
  57. package/dist/src/onboarding.mjs +12 -12
  58. package/dist/src/plan-pricing.mjs +47 -47
  59. package/dist/src/platform/README.md +12 -12
  60. package/dist/src/platform/codex-protocol.mjs +46 -46
  61. package/dist/src/platform/codex-runtime.mjs +3 -3
  62. package/dist/src/platform/credential-store.mjs +3 -3
  63. package/dist/src/platform/desktop-host.mjs +3 -3
  64. package/dist/src/platform/desktop-project-registration.mjs +3 -3
  65. package/dist/src/platform/desktop-project-store.mjs +208 -149
  66. package/dist/src/platform/file-opener.mjs +9 -0
  67. package/dist/src/platform/linux/README.md +3 -3
  68. package/dist/src/platform/linux/codex-runtime.mjs +56 -0
  69. package/dist/src/platform/linux/credential-store.mjs +28 -0
  70. package/dist/src/platform/linux/desktop-host.mjs +83 -0
  71. package/dist/src/platform/linux/desktop-installation.mjs +36 -0
  72. package/dist/src/platform/linux/desktop-project-registration.mjs +11 -0
  73. package/dist/src/platform/linux/network-info.mjs +11 -0
  74. package/dist/src/platform/linux/process.mjs +42 -0
  75. package/dist/src/platform/linux/project-locations.mjs +17 -0
  76. package/dist/src/platform/linux/resident-startup.mjs +52 -0
  77. package/dist/src/platform/linux/retained-runtime.mjs +76 -0
  78. package/dist/src/platform/linux/service-host.mjs +18 -0
  79. package/dist/src/platform/linux/system.mjs +49 -0
  80. package/dist/src/platform/linux/user-environment.mjs +7 -0
  81. package/dist/src/platform/macos/README.md +3 -3
  82. package/dist/src/platform/macos/codex-runtime.mjs +125 -125
  83. package/dist/src/platform/macos/credential-store.mjs +10 -10
  84. package/dist/src/platform/macos/desktop-host.mjs +82 -82
  85. package/dist/src/platform/macos/desktop-project-registration.mjs +11 -10
  86. package/dist/src/platform/macos/network-info.mjs +8 -28
  87. package/dist/src/platform/macos/project-locations.mjs +8 -8
  88. package/dist/src/platform/macos/resident-startup.mjs +84 -84
  89. package/dist/src/platform/macos/retained-runtime.mjs +127 -127
  90. package/dist/src/platform/macos/service-host.mjs +18 -18
  91. package/dist/src/platform/macos/system.mjs +29 -29
  92. package/dist/src/platform/macos/user-environment.mjs +7 -7
  93. package/dist/src/platform/native-package.mjs +58 -46
  94. package/dist/src/platform/network-info.mjs +3 -3
  95. package/dist/src/platform/path-identity.mjs +5 -5
  96. package/dist/src/platform/posix/network-candidates.mjs +23 -0
  97. package/dist/src/platform/posix/runtime-paths.mjs +25 -25
  98. package/dist/src/platform/project-locations.mjs +3 -3
  99. package/dist/src/platform/runtime-paths.mjs +8 -8
  100. package/dist/src/platform/select.mjs +5 -5
  101. package/dist/src/platform/service-host.mjs +3 -3
  102. package/dist/src/platform/user-environment.mjs +3 -3
  103. package/dist/src/platform/windows/IsolatedProcess.cs +79 -79
  104. package/dist/src/platform/windows/activate-desktop.ps1 +48 -48
  105. package/dist/src/platform/windows/background-process.mjs +30 -30
  106. package/dist/src/platform/windows/codex-runtime.mjs +215 -215
  107. package/dist/src/platform/windows/credential-store.mjs +27 -27
  108. package/dist/src/platform/windows/credential-store.ps1 +49 -49
  109. package/dist/src/platform/windows/desktop-helper.mjs +179 -179
  110. package/dist/src/platform/windows/desktop-host.mjs +58 -58
  111. package/dist/src/platform/windows/desktop-info.ps1 +14 -14
  112. package/dist/src/platform/windows/desktop-project-registration.mjs +4 -4
  113. package/dist/src/platform/windows/desktop-runtime.ps1 +10 -10
  114. package/dist/src/platform/windows/desktop-shortcuts.ps1 +21 -21
  115. package/dist/src/platform/windows/dots-profile.mjs +49 -49
  116. package/dist/src/platform/windows/launch-worker.mjs +41 -41
  117. package/dist/src/platform/windows/network-info.mjs +34 -34
  118. package/dist/src/platform/windows/project-locations.mjs +16 -16
  119. package/dist/src/platform/windows/resident-start.js +20 -20
  120. package/dist/src/platform/windows/resident-start.ps1 +7 -7
  121. package/dist/src/platform/windows/resident-startup.ps1 +83 -83
  122. package/dist/src/platform/windows/resolve-state-root.ps1 +14 -14
  123. package/dist/src/platform/windows/runtime-paths.mjs +20 -20
  124. package/dist/src/platform/windows/rustdesk-video-probe.mjs +167 -167
  125. package/dist/src/platform/windows/service-host.mjs +31 -31
  126. package/dist/src/platform/windows/start-hidden-console.ps1 +49 -49
  127. package/dist/src/platform/windows/stop-desktop.ps1 +22 -22
  128. package/dist/src/platform/windows/user-environment.mjs +50 -50
  129. package/dist/src/platform/windows/user-environment.ps1 +41 -41
  130. package/dist/src/project-archive.mjs +30 -0
  131. package/dist/src/project-create.mjs +121 -119
  132. package/dist/src/project-management.mjs +59 -69
  133. package/dist/src/project-remove.mjs +55 -0
  134. package/dist/src/project-update.mjs +56 -0
  135. package/dist/src/runtime-discovery.mjs +47 -47
  136. package/dist/src/self-relay/account-cleanup.mjs +30 -30
  137. package/dist/src/self-relay/account-connections.mjs +52 -52
  138. package/dist/src/self-relay/admin-cloud.mjs +101 -101
  139. package/dist/src/self-relay/admin-page.mjs +80 -80
  140. package/dist/src/self-relay/admin-server.mjs +74 -74
  141. package/dist/src/self-relay/admin-state.mjs +127 -127
  142. package/dist/src/self-relay/admin-style.mjs +14 -14
  143. package/dist/src/self-relay/android-releases.mjs +61 -61
  144. package/dist/src/self-relay/client.mjs +61 -61
  145. package/dist/src/self-relay/connector.mjs +439 -438
  146. package/dist/src/self-relay/debug-allowlist.mjs +33 -33
  147. package/dist/src/self-relay/demo.mjs +37 -37
  148. package/dist/src/self-relay/device-info.mjs +27 -0
  149. package/dist/src/self-relay/device-store.mjs +92 -84
  150. package/dist/src/self-relay/diagnostics.mjs +54 -54
  151. package/dist/src/self-relay/email-accounts.mjs +183 -183
  152. package/dist/src/self-relay/file-wire.mjs +13 -13
  153. package/dist/src/self-relay/lan-authorizations.mjs +71 -42
  154. package/dist/src/self-relay/lifecycle.mjs +264 -250
  155. package/dist/src/self-relay/long-pairing.mjs +40 -40
  156. package/dist/src/self-relay/mysql-state.mjs +118 -105
  157. package/dist/src/self-relay/p2p-probe.mjs +159 -159
  158. package/dist/src/self-relay/privacy-page.mjs +26 -26
  159. package/dist/src/self-relay/public-releases.mjs +52 -0
  160. package/dist/src/self-relay/rtc-file-bridge.mjs +41 -41
  161. package/dist/src/self-relay/server.mjs +1006 -932
  162. package/dist/src/self-relay/usage-daily.mjs +55 -55
  163. package/dist/src/self-relay/usage-meter.mjs +141 -141
  164. package/dist/src/self-relay/windows-route-helper.mjs +75 -75
  165. package/dist/src/self-relay/wire.mjs +36 -36
  166. package/dist/src/server.mjs +1861 -1778
  167. package/dist/src/service-control.mjs +82 -82
  168. package/dist/src/service-doctor.mjs +110 -110
  169. package/dist/src/service-health.mjs +33 -33
  170. package/dist/src/service-install.mjs +91 -91
  171. package/dist/src/service-lifecycle.mjs +138 -138
  172. package/dist/src/service-restart.mjs +81 -81
  173. package/dist/src/shared-app-server.mjs +615 -611
  174. package/dist/src/shared-catalog.mjs +132 -132
  175. package/dist/src/shared-recovery.mjs +146 -146
  176. package/dist/src/shared-thread-stream.mjs +164 -164
  177. package/dist/src/skill-install.mjs +51 -51
  178. package/dist/src/thread-title-generation.mjs +150 -150
  179. package/dist/src/turn-model-history.mjs +47 -47
  180. package/dist/src/usage-reset.mjs +17 -17
  181. package/dist/src/workstation-downloads.mjs +120 -120
  182. package/dist/tools/rtc-route-helper.ps1 +99 -99
  183. package/dist/tools/start-rtc-route-helper.ps1 +15 -15
  184. package/dist/web/vendor/markdown-it.LICENSE.txt +22 -22
  185. package/dist/web/vendor/markdown-it.umd.min.js +13 -13
  186. package/package.json +7 -6
  187. package/tools/postinstall.mjs +127 -127
@@ -1,611 +1,615 @@
1
- import WebSocket from 'ws';
2
- import { EventEmitter } from 'node:events';
3
- import { randomUUID } from 'node:crypto';
4
- import events from '../shared/app-server-events.cjs';
5
- import messageDelivery from '../shared/message-delivery.cjs';
6
- import serverRequests from '../shared/server-requests.cjs';
7
-
8
- const syncTokenTtlMs = 30_000;
9
- const syncBufferMaxBytes = 8 * 1024 * 1024;
10
- const historyPageMaxTurns = 8;
11
- const historyPageMaxBytes = 500 * 1024;
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
-
30
- // Connect to the Desktop's existing execution service; never spawn or adopt a task.
31
- export class SharedAppServer extends EventEmitter {
32
- constructor(url) {
33
- super();
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 = '';
38
- this.epoch = randomUUID();
39
- this.sequence = 0;
40
- this.pending = new Map();
41
- this.serverRequests = new serverRequests.Store(randomUUID(), id => {
42
- this.emit('requests-changed', id);
43
- this.releaseInactiveThread(id);
44
- });
45
- this.threads = new Map();
46
- this.contexts = new Map();
47
- this.settingsNotifications = new Map();
48
- this.permissionOptions = new events.PermissionOptionsCache();
49
- this.subscriptions = new Map();
50
- this.usageCache = new events.UsageCache(value => this.emit('usage', value));
51
- }
52
-
53
- async connect() {
54
- if (this.socket?.readyState === WebSocket.OPEN && this.ready) return;
55
- if (this.connecting) return this.connecting;
56
- this.connecting = this.open().finally(() => { this.connecting = null; });
57
- return this.connecting;
58
- }
59
-
60
- openSocket() {
61
- return new WebSocket(this.url, { handshakeTimeout: 5000 });
62
- }
63
-
64
- async open() {
65
- const socket = await this.openSocket();
66
- this.socket = socket;
67
- this.ready = false;
68
- this.serverVersion = '';
69
- this.serverRequests.reset(randomUUID());
70
- socket.on('message', bytes => {
71
- let message;
72
- try { message = JSON.parse(bytes.toString()); } catch { socket.close(); return; }
73
- if (message.method && message.id !== undefined) { this.serverRequests.receive(message); return; }
74
- if (message.method) { this.notification(message.method, message.params || {}); return; }
75
- const pending = this.pending.get(message.id);
76
- if (!pending) return;
77
- this.pending.delete(message.id);
78
- clearTimeout(pending.timer);
79
- if (message.error) {
80
- const text = String(message.error.message || 'app-server 请求失败');
81
- pending.reject(writerConflictMessages.some(value => text.toLowerCase().includes(value))
82
- ? new ThreadWriterConflictError(pending.threadId, text)
83
- : Object.assign(new Error(text), { rpcCode: message.error.code, details: message.error.data,
84
- ...(/^invalid cursor:/i.test(text) ? { code: 'history_cursor_expired' } : {}) }));
85
- }
86
- else {
87
- try { pending.resolve(pending.transform(message.result)); }
88
- catch (error) { pending.reject(error); }
89
- }
90
- });
91
- socket.on('close', () => {
92
- this.ready = false;
93
- this.serverVersion = '';
94
- for (const pending of this.pending.values()) {
95
- clearTimeout(pending.timer);
96
- pending.reject(new Error('Codex 内核连接断开;发送结果可能未确认,请检查对话后再操作。'));
97
- }
98
- this.pending.clear();
99
- this.serverRequests.reset(randomUUID());
100
- this.threads.clear();
101
- for (const slot of this.subscriptions.values()) clearTimeout(slot.expiry);
102
- this.subscriptions.clear();
103
- this.contexts.clear();
104
- this.settingsNotifications.clear();
105
- this.permissionOptions.clear();
106
- this.usageCache.reset();
107
- this.emit('disconnected');
108
- });
109
- socket.on('error', () => {}); // open/request reports the connection error to the caller.
110
- await new Promise((resolve, reject) => { socket.once('open', resolve); socket.once('error', reject); });
111
- try {
112
- const initialized = await this.request('initialize', { clientInfo: { name: 'codex_remote_shared', version: '1.0.0' }, capabilities: { experimentalApi: true } });
113
- // The first user-agent product reports the running server build, not the
114
- // selected executable that may only take effect after a future restart.
115
- this.serverVersion = String(initialized?.userAgent || '').match(/^[^\s/]+\/([^\s]+)/)?.[1] || '';
116
- socket.send(JSON.stringify({ method: 'initialized' }));
117
- this.ready = true;
118
- this.usageCache.read(() => this.request('account/rateLimits/read')).catch(error => this.emit('usage-error', error));
119
- } catch (error) { socket.close(); throw error; }
120
- }
121
-
122
- request(method, params, transform = value => value) {
123
- const id = ++this.sequence;
124
- return new Promise((resolve, reject) => {
125
- const timer = setTimeout(() => {
126
- const error = Object.assign(new Error(`${method} 超时;不会自动重发,请先检查对话。`),
127
- { code: 'app_server_request_timeout' });
128
- if ((method === 'turn/start' || method === 'turn/steer') && this.pending.has(id)) {
129
- messageDelivery.retainLateResult(this.pending.get(id), error);
130
- } else { this.pending.delete(id); reject(error); }
131
- }, 30000);
132
- this.pending.set(id, { resolve, reject, timer, transform, threadId: params?.threadId });
133
- this.socket.send(JSON.stringify({ id, method, params }), error => {
134
- const pending = this.pending.get(id);
135
- if (!error || !pending) return;
136
- this.pending.delete(id);
137
- clearTimeout(timer); pending.reject(error);
138
- });
139
- });
140
- }
141
-
142
- async projects() {
143
- await this.connect();
144
- return events.readProjects((method, params) => this.request(method, params));
145
- }
146
-
147
- async send(args, { assertCurrent = () => {} } = {}) {
148
- let submitted = false;
149
- try {
150
- await this.connect();
151
- this.requireWritable(args.threadId);
152
- // Reading the live thread checks this service owns it without resume changing settings.
153
- const { thread } = await this.request('thread/read', { threadId: args.threadId, includeTurns: false });
154
- assertCurrent();
155
- const status = typeof thread.status === 'string' ? thread.status : thread.status?.type;
156
- if (status === 'active') {
157
- const page = await this.request('thread/turns/list', {
158
- threadId: args.threadId, limit: 1, sortDirection: 'desc', itemsView: 'notLoaded',
159
- });
160
- const active = page.data?.[0];
161
- assertCurrent();
162
- if (active?.status !== 'inProgress') throw new Error('活动轮次已变化,请刷新后再发送。');
163
- const { turnTrigger, ...source } = events.mobileTurnSource(args.clientUserMessageId, args.responsesapiClientMetadata?.code_relax_input === 'speech_to_text');
164
- submitted = true;
165
- const result = await this.request('turn/steer', { threadId: args.threadId, expectedTurnId: active.id, input: args.input, ...source, ...(args.clientUserMessageId ? { clientUserMessageId: args.clientUserMessageId } : {}) });
166
- return { result, usage: { kind: 'steer', turnId: active.id } };
167
- }
168
- submitted = true;
169
- const result = await this.request('turn/start', args);
170
- return { result, usage: { kind: 'start', turnId: result?.turn?.id || null } };
171
- } catch (error) {
172
- if (!submitted || messageDelivery.rejectedBeforeSubmission(error)) error.deliveryUnknown = false;
173
- throw error;
174
- }
175
- }
176
-
177
- respondApproval(threadId, body) {
178
- if (!this.ready || this.socket?.readyState !== WebSocket.OPEN) throw new Error('Codex 内核未就绪');
179
- this.requireWritable(threadId);
180
- return this.serverRequests.respond(threadId, body, message => new Promise((resolve, reject) => {
181
- this.socket.send(JSON.stringify(message), error => error ? reject(error) : resolve());
182
- }));
183
- }
184
-
185
- notification(method, params) {
186
- this.serverRequests.notification(method, params);
187
- this.emit('notification', { method, params });
188
- if (method === 'thread/queue/changed') { this.emit('queue-changed', params.threadId); return; }
189
- if (method === 'account/rateLimits/updated') { this.usageCache.update(params); return; }
190
- const id = params.threadId;
191
- if (method === 'thread/settings/updated') {
192
- const settings = params.threadSettings;
193
- this.contexts.set(id, { ...this.contexts.get(id), model: settings.model, thinking: settings.effort, serviceTier: settings.serviceTier ?? null,
194
- cwd: settings.cwd, ...events.permissionState(settings) });
195
- this.settingsNotifications.set(id, settings);
196
- this.emit('settings', id);
197
- return;
198
- }
199
- if (method === 'thread/tokenUsage/updated') {
200
- this.contexts.set(id, { ...this.contexts.get(id), tokenUsage: params.tokenUsage });
201
- return;
202
- }
203
- const record = this.threads.get(id);
204
- if (!record) return;
205
- const stub = record.thread.turns?.find(turn => turn.cacheStub && turn.id === (params.turn?.id || params.turnId));
206
- if (stub && !['thread/status/changed', 'thread/name/updated'].includes(method)) {
207
- record.needsReload = true;
208
- this.emit('history-invalidated', id);
209
- return;
210
- }
211
- if (!this.isThreadObserved(id, record)) {
212
- // Keep task/approval lifecycle metadata, not an invisible growing transcript.
213
- record.needsReload = true;
214
- if (params.turn) events.applyNotification(record.thread, method, { ...params, turn: { ...params.turn, items: [] } });
215
- else if (method === 'thread/status/changed') record.thread.status = params.status;
216
- record.thread.turns = (record.thread.turns || []).slice(-1).map(turn => ({ ...turn, items: [] }));
217
- record.changes.clear(); record.changeBytes = 0;
218
- if (['turn/completed', 'thread/status/changed'].includes(method)) this.releaseInactiveThread(id);
219
- return;
220
- }
221
- const current = record.thread.turns?.find(turn => turn.id === (params.turn?.id || params.turnId));
222
- if (method === 'item/completed' && !record.hydrating && current?.itemPage?.olderCursor && !current.items.some(item => item.id === params.item?.id)) return;
223
- // Current app-server completion summaries carry the terminal answer, not
224
- // an unrequested history page. Its ID may be new during snapshot handoff.
225
- const visibleParams = (record.hydrating || current?.itemPage?.olderCursor) && method === 'turn/completed'
226
- && params.turn?.itemsView !== 'summary' && Array.isArray(params.turn?.items)
227
- ? { ...params, turn: { ...params.turn,
228
- items: params.turn.items.filter(item => current?.items?.some(loaded => loaded.id === item.id)) } }
229
- : params;
230
- const appliedParams = { ...visibleParams,
231
- ...(visibleParams.item ? { item: events.compactItem(visibleParams.item) } : {}),
232
- ...(visibleParams.turn ? { turn: events.compactTurn(visibleParams.turn) } : {}) };
233
- const turn = events.applyNotification(record.thread, method, appliedParams);
234
- if (turn) {
235
- const sequence = ++record.sequence;
236
- record.changes.set(turn.id, { sequence, turn });
237
- record.changeBytes += Buffer.byteLength(JSON.stringify({ method, params: appliedParams }));
238
- if (record.changeBytes > syncBufferMaxBytes) {
239
- record.recoverAfter = sequence;
240
- record.changes.clear(); record.changeBytes = 0;
241
- }
242
- this.emit('turn', { thread: record.thread, turn, sequence,
243
- immediate: ['turn/completed', 'item/completed'].includes(method) });
244
- }
245
- if (method === 'turn/completed' && record.thread.turns.length > 10) {
246
- record.thread.turns = record.thread.turns.slice(-10);
247
- // Native cursors are opaque; obtain a matching boundary before the next snapshot.
248
- record.historyTrimmed = true;
249
- }
250
- if (['turn/completed', 'thread/status/changed'].includes(method)) this.releaseInactiveThread(id);
251
- }
252
-
253
- matchesUpdatedAt(thread, updatedAt) {
254
- const status = typeof thread.status === 'string' ? thread.status : thread.status?.type;
255
- const latest = thread.turns?.at(-1);
256
- return Number(updatedAt) > 0 && status !== 'active' && latest?.status !== 'inProgress'
257
- && Math.floor(Number(thread.updatedAt)) === Math.floor(Number(updatedAt));
258
- }
259
-
260
- resumeTimeline(threadId, apply) {
261
- return this.request('thread/resume', { threadId, excludeTurns: true,
262
- initialTurnsPage: { limit: 1, sortDirection: 'desc', itemsView: 'notLoaded' } }, value => {
263
- if (!Array.isArray(value.initialTurnsPage?.data)) throw new Error('Codex 内核未返回分页历史。');
264
- return apply(value);
265
- });
266
- }
267
-
268
- initialTurnsPage(_threadId, resumed) { return resumed.initialTurnsPage; }
269
-
270
- async timeline(threadId, pageOptions = {}, knownSync) {
271
- await this.connect();
272
- const slot = this.slot(threadId);
273
- slot.readers++;
274
- const task = slot.tail.then(async () => {
275
- let record = this.threads.get(threadId), metadataReused = false;
276
- // Seconds are the product's cache-update granularity. Active work still
277
- // uses the received sequence so an in-progress reply can keep advancing.
278
- const knownToken = record?.tokens.get(knownSync?.id);
279
- const unchanged = Boolean(!pageOptions.reconcileObserved && record && !record.readOnly && !record.needsReload && !record.hydrating
280
- && (this.matchesUpdatedAt(record.thread, knownSync?.updatedAt)
281
- || (knownSync?.epoch === this.epoch && Number.isSafeInteger(knownSync.seq)
282
- && knownToken && knownToken.expires > Date.now()
283
- && knownToken.sequence === knownSync.seq && record.sequence === knownSync.seq)));
284
- if (unchanged) {
285
- const id = randomUUID();
286
- record.tokens.set(id, { sequence: record.sequence, expires: Date.now() + syncTokenTtlMs });
287
- this.scheduleCleanup(threadId, slot);
288
- return { thread: { ...record.thread, turns: [] }, notModified: true,
289
- sync: { id, epoch: this.epoch, seq: record.sequence } };
290
- }
291
- // Snapshot refreshes must not reset the sequence or tokens of live subscribers.
292
- const previous = record?.readOnly ? null : record;
293
- if (record?.readOnly || record?.needsReload) this.contexts.delete(threadId);
294
- if (pageOptions.reconcileObserved || !record || record.readOnly || record.needsReload || record.historyTrimmed || record.metadataOnly) {
295
- let result, snapshot;
296
- try {
297
- result = await this.resumeTimeline(threadId, value => {
298
- const retained = new Map((previous?.thread.turns || []).map(turn => [turn.id, turn]));
299
- record = { thread: { ...value.thread, turns: [...value.initialTurnsPage.data].reverse().map(turn => ({
300
- ...turn, items: retained.get(turn.id)?.items || [],
301
- ...(retained.get(turn.id)?.itemPage ? { itemPage: retained.get(turn.id).itemPage } : {}),
302
- })) },
303
- nextCursor: value.initialTurnsPage.nextCursor ?? null,
304
- sequence: previous?.sequence ?? 0, recoverAfter: previous?.recoverAfter ?? 0,
305
- changes: previous?.changes ?? new Map(), changeBytes: previous?.changeBytes ?? 0,
306
- tokens: previous?.tokens ?? new Map(), hydrating: true };
307
- this.threads.set(threadId, record);
308
- snapshot = events.beginSnapshot(record.thread);
309
- return value;
310
- }, pageOptions);
311
- } catch (error) {
312
- snapshot?.close();
313
- if (!isThreadWriterConflict(error)) throw error;
314
- record = await this.readOnlyRecord(threadId, pageOptions, '该对话正在另一个 Codex 实例中使用。');
315
- result = null;
316
- }
317
- if (result) try {
318
- const page = await this.initialTurnsPage(threadId, result);
319
- if (!Array.isArray(page?.data)) throw new Error('Codex 内核未返回分页历史。');
320
- const live = new Map(record.thread.turns.map(turn => [turn.id, turn]));
321
- record.thread.turns = [...page.data].reverse().map(turn => {
322
- const current = live.get(turn.id);
323
- live.delete(turn.id);
324
- return current ? Object.assign(current, { ...turn, ...current }) : { ...turn, items: [] };
325
- }).concat([...live.values()]);
326
- record.nextCursor = page.nextCursor ?? null;
327
- metadataReused = Boolean(!pageOptions.reconcileObserved && !snapshot.changed()
328
- && this.matchesUpdatedAt(record.thread, knownSync?.updatedAt));
329
- if (metadataReused) {
330
- record.metadataOnly = true;
331
- record.thread.turns = record.thread.turns.map(turn => ({ ...turn, items: [], cacheStub: true }));
332
- } else await Promise.all(page.data.map(turn => this.compactTurn(threadId, turn, null, page => snapshot.merge(page), 20)));
333
- record.hydrating = false;
334
- this.contexts.set(threadId, { ...this.contexts.get(threadId), model: result.model, thinking: result.reasoningEffort, serviceTier: result.serviceTier ?? null,
335
- cwd: result.cwd, ...events.permissionState(result) });
336
- } finally { snapshot?.close(); }
337
- }
338
- const id = randomUUID(), expires = Date.now() + syncTokenTtlMs;
339
- record.tokens.set(id, { sequence: record.sequence, expires });
340
- this.scheduleCleanup(threadId, slot);
341
- return { thread: metadataReused ? { ...record.thread, turns: [] } : record.thread,
342
- ...(metadataReused ? { notModified: true } : {}),
343
- nextCursor: this.historyCursor(threadId, record.thread.turns[0], record.nextCursor),
344
- sync: { id, epoch: this.epoch, seq: record.sequence } };
345
- }).finally(() => { slot.readers--; });
346
- slot.tail = task.catch(() => {});
347
- task.catch(() => {
348
- const record = this.threads.get(threadId);
349
- if (record?.hydrating) { record.needsReload = true; record.hydrating = false; this.scheduleCleanup(threadId, slot); }
350
- if (!slot.users && !this.threads.has(threadId) && this.subscriptions.get(threadId) === slot) {
351
- clearTimeout(slot.expiry);
352
- this.subscriptions.delete(threadId);
353
- }
354
- });
355
- return task;
356
- }
357
-
358
- slot(threadId) {
359
- let slot = this.subscriptions.get(threadId);
360
- if (!slot) { slot = { users: 0, readers: 0, tail: Promise.resolve(), expiry: null }; this.subscriptions.set(threadId, slot); }
361
- return slot;
362
- }
363
-
364
- keepSnapshotAlive(threadId, sync) {
365
- const token = this.threads.get(threadId)?.tokens.get(sync?.id);
366
- const slot = this.subscriptions.get(threadId);
367
- if (!token || !slot || sync.epoch !== this.epoch) return;
368
- token.expires = Date.now() + syncTokenTtlMs;
369
- this.scheduleCleanup(threadId, slot);
370
- }
371
-
372
- scheduleCleanup(threadId, slot) {
373
- clearTimeout(slot.expiry);
374
- const record = this.threads.get(threadId);
375
- const expiries = record ? [...record.tokens.values()].map(token => token.expires) : [];
376
- const next = expiries.length ? Math.max(1, Math.min(...expiries) - Date.now()) : 1;
377
- slot.expiry = setTimeout(() => this.cleanup(threadId, slot), next);
378
- }
379
-
380
- cleanup(threadId, slot) {
381
- const record = this.threads.get(threadId), now = Date.now();
382
- if (record) for (const [id, token] of record.tokens) if (token.expires <= now) record.tokens.delete(id);
383
- if (slot.users || slot.readers) { clearTimeout(slot.expiry); slot.expiry = null; return; }
384
- if (record?.tokens.size) { this.scheduleCleanup(threadId, slot); return; }
385
- if (this.keepThreadLoaded(threadId, record)) { clearTimeout(slot.expiry); slot.expiry = null; return; }
386
- slot.tail = slot.tail.then(async () => {
387
- if (this.subscriptions.get(threadId) !== slot) return;
388
- if (slot.users || slot.readers || this.threads.get(threadId)?.tokens.size || this.keepThreadLoaded(threadId, this.threads.get(threadId))) return;
389
- this.threads.delete(threadId); this.contexts.delete(threadId); this.permissionOptions.delete(threadId);
390
- this.subscriptions.delete(threadId); clearTimeout(slot.expiry);
391
- if (this.ready && !record?.readOnly) await this.unsubscribeThread(threadId);
392
- this.serverRequests.forget(threadId);
393
- }).catch(error => this.emit('subscription-error', error));
394
- }
395
-
396
- keepThreadLoaded(threadId, record) {
397
- if (!record || record.readOnly) return false;
398
- const status = typeof record.thread.status === 'string' ? record.thread.status : record.thread.status?.type;
399
- return status === 'active' || record.thread.turns?.some(turn => turn.status === 'inProgress')
400
- || this.serverRequests.list(threadId).length > 0;
401
- }
402
-
403
- releaseInactiveThread(threadId) {
404
- // Run after the current notification has updated both request and turn state.
405
- queueMicrotask(() => {
406
- const slot = this.subscriptions?.get(threadId);
407
- if (slot && !slot.users && this.ready) this.cleanup(threadId, slot);
408
- });
409
- }
410
-
411
- async acquire(threadId, requested) {
412
- const slot = this.slot(threadId);
413
- let record = this.threads.get(threadId), token;
414
- if (record && requested?.epoch === this.epoch && requested.id && Number.isSafeInteger(requested.after)) {
415
- token = record.tokens.get(requested.id);
416
- if (!token || token.expires <= Date.now() || token.sequence !== requested.after) token = undefined;
417
- }
418
- let snapshot = false, catchup, confirmed = false;
419
- if (!record || !token || record.needsReload
420
- || ((record.historyTrimmed || record.metadataOnly) && requested.after < record.recoverAfter)) {
421
- const fresh = await this.timeline(threadId, {}, { updatedAt: requested?.updatedAt });
422
- record = this.threads.get(threadId); token = record.tokens.get(fresh.sync.id);
423
- requested = { id: fresh.sync.id, epoch: fresh.sync.epoch, after: fresh.sync.seq };
424
- snapshot = fresh.notModified !== true;
425
- confirmed = !snapshot;
426
- }
427
- slot.users++; record.tokens.delete(requested.id); clearTimeout(slot.expiry);
428
- const current = record.sequence;
429
- if (!snapshot && requested.after < record.recoverAfter) snapshot = true;
430
- if (!snapshot && current > requested.after) {
431
- const turns = [...record.changes.values()].filter(change => change.sequence > requested.after && !change.turn.cacheStub)
432
- .sort((left, right) => record.thread.turns.findIndex(turn => turn.id === left.turn.id)
433
- - record.thread.turns.findIndex(turn => turn.id === right.turn.id)).map(change => change.turn);
434
- catchup = { thread: { ...record.thread, turns },
435
- sync: { id: requested.id, epoch: this.epoch, fromSeq: requested.after + 1, toSeq: current } };
436
- }
437
- const release = () => {
438
- slot.users--;
439
- // Retain the observed snapshot briefly when switching away. A later check
440
- // must match the client's consumed sequence, never just this release time.
441
- if (this.threads.get(threadId) === record && !record.readOnly) {
442
- record.tokens.set(requested.id, { sequence: record.sequence, expires: Date.now() + syncTokenTtlMs });
443
- }
444
- this.cleanup(threadId, slot);
445
- return slot.tail;
446
- };
447
- return { record: { thread: record.thread, nextCursor: this.historyCursor(threadId, record.thread.turns[0], record.nextCursor),
448
- sync: { id: requested.id, epoch: this.epoch, seq: current, ...(snapshot ? { reset: true } : {}) } },
449
- snapshot, catchup, confirmed, release };
450
- }
451
-
452
- pageSettings(options = {}) {
453
- return {
454
- maxTurns: Math.max(1, Math.min(historyPageMaxTurns, Number(options.maxTurns) || historyPageMaxTurns)),
455
- maxBytes: Math.max(1, Number(options.maxBytes) || historyPageMaxBytes),
456
- measure: typeof options.measure === 'function' ? options.measure : value => Buffer.byteLength(JSON.stringify(value)),
457
- };
458
- }
459
-
460
- historyCursor(threadId, turn, turnCursor) {
461
- const itemCursor = turn?.itemPage?.olderCursor;
462
- if (!itemCursor && !turnCursor) return null;
463
- // Upstream history cursors identify stored positions, not this observer.
464
- return JSON.stringify({ threadId, turnId: itemCursor ? turn.id : null,
465
- itemCursor: itemCursor || null, turnCursor: turnCursor || null });
466
- }
467
-
468
- async compactTurn(threadId, turn, cursor = null, apply, limit = 100) {
469
- if (!turn?.id) return events.compactTurn(turn);
470
- return this.request('thread/items/list', {
471
- threadId, turnId: turn.id, ...(cursor ? { cursor } : {}), limit, sortDirection: 'desc',
472
- }, page => {
473
- const result = this.projectItemPage(threadId, turn, cursor, page);
474
- apply?.(result); return result;
475
- });
476
- }
477
-
478
- isThreadObserved(threadId, record) {
479
- const slot = this.subscriptions.get(threadId);
480
- return Boolean(slot?.users || slot?.readers || record.tokens.size);
481
- }
482
-
483
- unsubscribeThread(threadId) { return this.request('thread/unsubscribe', { threadId }); }
484
-
485
- async readOnlyRecord(threadId, pageOptions, reason) {
486
- const [read, page] = await Promise.all([
487
- this.request('thread/read', { threadId, includeTurns: false }),
488
- this.request('thread/turns/list', { threadId, limit: 1, sortDirection: 'desc', itemsView: 'notLoaded' }),
489
- ]);
490
- const compact = await Promise.all([...(page.data || [])].reverse().map(turn => this.compactTurn(threadId, turn)));
491
- const turns = this.fitTurns({ thread: read.thread }, compact, pageOptions);
492
- const thread = { ...read.thread, turns, readOnly: true, readOnlyReason: reason };
493
- const record = { thread, nextCursor: page.nextCursor ?? null, sequence: 0, recoverAfter: 0,
494
- changes: new Map(), changeBytes: 0, tokens: new Map(), readOnly: true };
495
- this.threads.set(threadId, record);
496
- this.contexts.set(threadId, { model: thread.model, thinking: thread.reasoningEffort, cwd: thread.cwd });
497
- return record;
498
- }
499
-
500
- projectItemPage(threadId, turn, cursor, page) {
501
- const compact = events.compactTurn(turn);
502
- if (!Array.isArray(page.data)) throw new Error('Codex 内核未返回单轮条目索引。');
503
- if (page.nextCursor && page.nextCursor === cursor) throw new Error('条目分页游标未推进。');
504
- if (page.data.some(entry => entry.turnId !== turn.id)) throw new Error('条目所属轮次不匹配。');
505
- const items = [...page.data].reverse().map(entry => events.compactItem(entry.item));
506
- return { ...compact, items, itemPage: { olderCursor: page.nextCursor ?? null,
507
- hasLoadedOldest: !page.nextCursor, oldestItemId: items[0]?.id || null,
508
- newestItemId: items.at(-1)?.id || null, requestedCursor: cursor, projection: 'mobile-skeleton' } };
509
- }
510
-
511
- async turnContext(threadId, turnId) {
512
- await this.connect();
513
- if (!threadId || !turnId) throw new Error('缺少图片所属的对话或轮次。');
514
- let turn = await this.compactTurn(threadId, { id: turnId });
515
- while (turn.itemPage.olderCursor) {
516
- const older = await this.compactTurn(threadId, { id: turnId }, turn.itemPage.olderCursor);
517
- turn = { ...turn, items: [...older.items, ...turn.items], itemPage: older.itemPage };
518
- }
519
- return turn;
520
- }
521
-
522
- fitTurns(base, turns, options = {}) {
523
- const settings = this.pageSettings(options), accepted = [];
524
- let firstOversized = false;
525
- for (let index = turns.length - 1; index >= 0 && accepted.length < settings.maxTurns; index--) {
526
- const candidate = [turns[index], ...accepted];
527
- const candidateBytes = settings.measure({ ...base, thread: { ...base.thread, turns: candidate } });
528
- if (accepted.length >= 2 && candidateBytes > settings.maxBytes) break;
529
- accepted.unshift(turns[index]);
530
- if (accepted.length === 1) firstOversized = candidateBytes > settings.maxBytes;
531
- if (firstOversized || (accepted.length >= 2 && candidateBytes > settings.maxBytes)) break;
532
- }
533
- return accepted;
534
- }
535
-
536
- async history(threadId, cursor, options = {}) {
537
- await this.connect();
538
- if (!cursor) throw new Error('缺少历史分页游标。');
539
- let position;
540
- try { position = JSON.parse(cursor); } catch { position = null; }
541
- if (position?.threadId !== threadId) throw Object.assign(new Error('历史游标不属于当前对话。'), { code: 'history_cursor_expired' });
542
- if (position.itemCursor && position.turnId) {
543
- const turn = await this.compactTurn(threadId, { id: position.turnId }, position.itemCursor, undefined, options.itemLimit ?? 100);
544
- return { thread: { id: threadId, turns: [turn] },
545
- nextCursor: this.historyCursor(threadId, turn, position.turnCursor) };
546
- }
547
- const page = await this.request('thread/turns/list', {
548
- threadId, cursor: position.turnCursor, limit: 1, sortDirection: 'desc', itemsView: 'notLoaded',
549
- });
550
- if (!Array.isArray(page.data)) throw new Error('Codex 内核未返回轮次分页。');
551
- if (page.nextCursor && page.nextCursor === position.turnCursor) throw new Error('轮次分页游标未推进。');
552
- const turns = page.data.length ? [await this.compactTurn(threadId, page.data[0], null, undefined, options.itemLimit ?? 100)] : [];
553
- return { thread: { id: threadId, turns }, nextCursor: this.historyCursor(threadId, turns[0], page.nextCursor) };
554
- }
555
-
556
- async interrupt(threadId, turnId) {
557
- await this.connect();
558
- this.requireWritable(threadId);
559
- return this.request('turn/interrupt', { threadId, turnId });
560
- }
561
-
562
- async models() {
563
- await this.connect();
564
- const models = []; let cursor;
565
- do {
566
- const result = await this.request('model/list', { limit: 100, ...(cursor ? { cursor } : {}) });
567
- models.push(...result.data); cursor = result.nextCursor;
568
- } while (cursor);
569
- const config = await this.request('config/read', { includeLayers: false });
570
- return { models: models.filter(model => !model.hidden), enabledReasoningEfforts: config.config?.desktop?.['enabled-reasoning-efforts'] ?? null };
571
- }
572
-
573
- async usage() {
574
- await this.connect();
575
- return this.usageCache.read(() => this.request('account/rateLimits/read'));
576
- }
577
-
578
- async settings(threadId, model, effort, serviceTier) {
579
- await this.connect();
580
- this.requireWritable(threadId);
581
- await events.updateSettings((method, params) => this.request(method, params), this.contexts, threadId, model, effort, serviceTier);
582
- return this.context(threadId);
583
- }
584
-
585
- async permissions(threadId, mode) {
586
- await this.connect();
587
- this.requireWritable(threadId);
588
- await events.updatePermissions((method, params) => this.request(method, params), this.contexts, this.settingsNotifications, this.permissionOptions, threadId, mode);
589
- return this.context(threadId);
590
- }
591
-
592
- async readContext(threadId) {
593
- const context = this.contexts.get(threadId);
594
- if (context) await this.permissionOptions.read((method, params) => this.request(method, params), threadId, context);
595
- return this.context(threadId);
596
- }
597
-
598
- context(threadId) {
599
- const context = this.permissionOptions.context(threadId, this.contexts.get(threadId) || {}), usage = context.tokenUsage;
600
- const usedTokens = usage?.last?.totalTokens, contextWindow = usage?.modelContextWindow;
601
- return { ...context, available: Boolean(contextWindow), usedTokens, contextWindow,
602
- usedPercent: contextWindow ? 100 * usedTokens / contextWindow : 0 };
603
- }
604
-
605
- requireWritable(threadId) {
606
- const record = this.threads.get(threadId);
607
- if (record?.readOnly) throw new ThreadWriterConflictError(threadId, record.thread.readOnlyReason);
608
- }
609
-
610
- close() { this.socket?.close(); }
611
- }
1
+ import WebSocket from 'ws';
2
+ import { EventEmitter } from 'node:events';
3
+ import { randomUUID } from 'node:crypto';
4
+ import events from '../shared/app-server-events.cjs';
5
+ import messageDelivery from '../shared/message-delivery.cjs';
6
+ import serverRequests from '../shared/server-requests.cjs';
7
+
8
+ const syncTokenTtlMs = 30_000;
9
+ const syncBufferMaxBytes = 8 * 1024 * 1024;
10
+ const historyPageMaxTurns = 8;
11
+ const historyPageMaxBytes = 500 * 1024;
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
+
30
+ // Connect to the Desktop's existing execution service; never spawn or adopt a task.
31
+ export class SharedAppServer extends EventEmitter {
32
+ constructor(url) {
33
+ super();
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 = '';
38
+ this.epoch = randomUUID();
39
+ this.sequence = 0;
40
+ this.pending = new Map();
41
+ this.serverRequests = new serverRequests.Store(randomUUID(), id => {
42
+ this.emit('requests-changed', id);
43
+ this.releaseInactiveThread(id);
44
+ });
45
+ this.threads = new Map();
46
+ this.contexts = new Map();
47
+ this.settingsNotifications = new Map();
48
+ this.permissionOptions = new events.PermissionOptionsCache();
49
+ this.subscriptions = new Map();
50
+ this.usageCache = new events.UsageCache(value => this.emit('usage', value));
51
+ }
52
+
53
+ async connect() {
54
+ if (this.socket?.readyState === WebSocket.OPEN && this.ready) return;
55
+ if (this.connecting) return this.connecting;
56
+ this.connecting = this.open().finally(() => { this.connecting = null; });
57
+ return this.connecting;
58
+ }
59
+
60
+ openSocket() {
61
+ return new WebSocket(this.url, { handshakeTimeout: 5000 });
62
+ }
63
+
64
+ async open() {
65
+ const socket = await this.openSocket();
66
+ this.socket = socket;
67
+ this.ready = false;
68
+ this.serverVersion = '';
69
+ this.serverRequests.reset(randomUUID());
70
+ socket.on('message', bytes => {
71
+ let message;
72
+ try { message = JSON.parse(bytes.toString()); } catch { socket.close(); return; }
73
+ if (message.method && message.id !== undefined) { this.serverRequests.receive(message); return; }
74
+ if (message.method) { this.notification(message.method, message.params || {}); return; }
75
+ const pending = this.pending.get(message.id);
76
+ if (!pending) return;
77
+ this.pending.delete(message.id);
78
+ clearTimeout(pending.timer);
79
+ if (message.error) {
80
+ const text = String(message.error.message || 'app-server 请求失败');
81
+ pending.reject(writerConflictMessages.some(value => text.toLowerCase().includes(value))
82
+ ? new ThreadWriterConflictError(pending.threadId, text)
83
+ : Object.assign(new Error(text), { rpcCode: message.error.code, details: message.error.data,
84
+ ...(/^invalid cursor:/i.test(text) ? { code: 'history_cursor_expired' } : {}) }));
85
+ }
86
+ else {
87
+ try { pending.resolve(pending.transform(message.result)); }
88
+ catch (error) { pending.reject(error); }
89
+ }
90
+ });
91
+ socket.on('close', () => {
92
+ this.ready = false;
93
+ this.serverVersion = '';
94
+ for (const pending of this.pending.values()) {
95
+ clearTimeout(pending.timer);
96
+ pending.reject(new Error('Codex 内核连接断开;发送结果可能未确认,请检查对话后再操作。'));
97
+ }
98
+ this.pending.clear();
99
+ this.serverRequests.reset(randomUUID());
100
+ this.threads.clear();
101
+ for (const slot of this.subscriptions.values()) clearTimeout(slot.expiry);
102
+ this.subscriptions.clear();
103
+ this.contexts.clear();
104
+ this.settingsNotifications.clear();
105
+ this.permissionOptions.clear();
106
+ this.usageCache.reset();
107
+ this.emit('disconnected');
108
+ });
109
+ socket.on('error', () => {}); // open/request reports the connection error to the caller.
110
+ await new Promise((resolve, reject) => { socket.once('open', resolve); socket.once('error', reject); });
111
+ try {
112
+ const initialized = await this.request('initialize', { clientInfo: { name: 'codex_remote_shared', version: '1.0.0' }, capabilities: { experimentalApi: true } });
113
+ // The first user-agent product reports the running server build, not the
114
+ // selected executable that may only take effect after a future restart.
115
+ this.serverVersion = String(initialized?.userAgent || '').match(/^[^\s/]+\/([^\s]+)/)?.[1] || '';
116
+ socket.send(JSON.stringify({ method: 'initialized' }));
117
+ this.ready = true;
118
+ this.usageCache.read(() => this.request('account/rateLimits/read')).catch(error => this.emit('usage-error', error));
119
+ } catch (error) { socket.close(); throw error; }
120
+ }
121
+
122
+ request(method, params, transform = value => value) {
123
+ const id = ++this.sequence;
124
+ return new Promise((resolve, reject) => {
125
+ const timer = setTimeout(() => {
126
+ const error = Object.assign(new Error(`${method} 超时;不会自动重发,请先检查对话。`),
127
+ { code: 'app_server_request_timeout' });
128
+ if ((method === 'turn/start' || method === 'turn/steer') && this.pending.has(id)) {
129
+ messageDelivery.retainLateResult(this.pending.get(id), error);
130
+ } else { this.pending.delete(id); reject(error); }
131
+ }, 30000);
132
+ this.pending.set(id, { resolve, reject, timer, transform, threadId: params?.threadId });
133
+ this.socket.send(JSON.stringify({ id, method, params }), error => {
134
+ const pending = this.pending.get(id);
135
+ if (!error || !pending) return;
136
+ this.pending.delete(id);
137
+ clearTimeout(timer); pending.reject(error);
138
+ });
139
+ });
140
+ }
141
+
142
+ async projects() {
143
+ await this.connect();
144
+ return events.readProjects((method, params) => this.request(method, params));
145
+ }
146
+
147
+ async send(args, { assertCurrent = () => {} } = {}) {
148
+ let submitted = false;
149
+ try {
150
+ await this.connect();
151
+ this.requireWritable(args.threadId);
152
+ // Reading the live thread checks this service owns it without resume changing settings.
153
+ const { thread } = await this.request('thread/read', { threadId: args.threadId, includeTurns: false });
154
+ assertCurrent();
155
+ const status = typeof thread.status === 'string' ? thread.status : thread.status?.type;
156
+ if (status === 'active') {
157
+ const page = await this.request('thread/turns/list', {
158
+ threadId: args.threadId, limit: 1, sortDirection: 'desc', itemsView: 'notLoaded',
159
+ });
160
+ const active = page.data?.[0];
161
+ assertCurrent();
162
+ if (active?.status !== 'inProgress') throw new Error('活动轮次已变化,请刷新后再发送。');
163
+ const { turnTrigger, ...source } = events.mobileTurnSource(args.clientUserMessageId, args.responsesapiClientMetadata?.code_relax_input === 'speech_to_text');
164
+ submitted = true;
165
+ return await this.request('turn/steer', { threadId: args.threadId, expectedTurnId: active.id, input: args.input, ...source, ...(args.clientUserMessageId ? { clientUserMessageId: args.clientUserMessageId } : {}) },
166
+ result => ({ result, usage: { kind: 'steer', turnId: active.id } }));
167
+ }
168
+ submitted = true;
169
+ return await this.request('turn/start', args,
170
+ result => ({ result, usage: { kind: 'start', turnId: result?.turn?.id || null } }));
171
+ } catch (error) {
172
+ if (!submitted || messageDelivery.rejectedBeforeSubmission(error)) error.deliveryUnknown = false;
173
+ throw error;
174
+ }
175
+ }
176
+
177
+ respondApproval(threadId, body) {
178
+ if (!this.ready || this.socket?.readyState !== WebSocket.OPEN) throw new Error('Codex 内核未就绪');
179
+ this.requireWritable(threadId);
180
+ return this.serverRequests.respond(threadId, body, message => new Promise((resolve, reject) => {
181
+ this.socket.send(JSON.stringify(message), error => error ? reject(error) : resolve());
182
+ }));
183
+ }
184
+
185
+ notification(method, params) {
186
+ this.serverRequests.notification(method, params);
187
+ this.emit('notification', { method, params });
188
+ if (method === 'thread/queue/changed') { this.emit('queue-changed', params.threadId); return; }
189
+ if (method === 'account/rateLimits/updated') { this.usageCache.update(params); return; }
190
+ const id = params.threadId;
191
+ if (method === 'thread/settings/updated') {
192
+ const settings = params.threadSettings;
193
+ this.contexts.set(id, { ...this.contexts.get(id), model: settings.model, thinking: settings.effort, serviceTier: settings.serviceTier ?? null,
194
+ cwd: settings.cwd, ...events.permissionState(settings) });
195
+ this.settingsNotifications.set(id, settings);
196
+ this.emit('settings', id);
197
+ return;
198
+ }
199
+ if (method === 'thread/tokenUsage/updated') {
200
+ this.contexts.set(id, { ...this.contexts.get(id), tokenUsage: params.tokenUsage });
201
+ return;
202
+ }
203
+ const record = this.threads.get(id);
204
+ if (!record) return;
205
+ if (method === 'thread/name/updated') {
206
+ record.thread.name = params.threadName;
207
+ return; // Catalog metadata must not invalidate or replay message bodies.
208
+ }
209
+ const stub = record.thread.turns?.find(turn => turn.cacheStub && turn.id === (params.turn?.id || params.turnId));
210
+ if (stub && !['thread/status/changed', 'thread/name/updated'].includes(method)) {
211
+ record.needsReload = true;
212
+ this.emit('history-invalidated', id);
213
+ return;
214
+ }
215
+ if (!this.isThreadObserved(id, record)) {
216
+ // Keep task/approval lifecycle metadata, not an invisible growing transcript.
217
+ record.needsReload = true;
218
+ if (params.turn) events.applyNotification(record.thread, method, { ...params, turn: { ...params.turn, items: [] } });
219
+ else if (method === 'thread/status/changed') record.thread.status = params.status;
220
+ record.thread.turns = (record.thread.turns || []).slice(-1).map(turn => ({ ...turn, items: [] }));
221
+ record.changes.clear(); record.changeBytes = 0;
222
+ if (['turn/completed', 'thread/status/changed'].includes(method)) this.releaseInactiveThread(id);
223
+ return;
224
+ }
225
+ const current = record.thread.turns?.find(turn => turn.id === (params.turn?.id || params.turnId));
226
+ if (method === 'item/completed' && !record.hydrating && current?.itemPage?.olderCursor && !current.items.some(item => item.id === params.item?.id)) return;
227
+ // Current app-server completion summaries carry the terminal answer, not
228
+ // an unrequested history page. Its ID may be new during snapshot handoff.
229
+ const visibleParams = (record.hydrating || current?.itemPage?.olderCursor) && method === 'turn/completed'
230
+ && params.turn?.itemsView !== 'summary' && Array.isArray(params.turn?.items)
231
+ ? { ...params, turn: { ...params.turn,
232
+ items: params.turn.items.filter(item => current?.items?.some(loaded => loaded.id === item.id)) } }
233
+ : params;
234
+ const appliedParams = { ...visibleParams,
235
+ ...(visibleParams.item ? { item: events.compactItem(visibleParams.item) } : {}),
236
+ ...(visibleParams.turn ? { turn: events.compactTurn(visibleParams.turn) } : {}) };
237
+ const turn = events.applyNotification(record.thread, method, appliedParams);
238
+ if (turn) {
239
+ const sequence = ++record.sequence;
240
+ record.changes.set(turn.id, { sequence, turn });
241
+ record.changeBytes += Buffer.byteLength(JSON.stringify({ method, params: appliedParams }));
242
+ if (record.changeBytes > syncBufferMaxBytes) {
243
+ record.recoverAfter = sequence;
244
+ record.changes.clear(); record.changeBytes = 0;
245
+ }
246
+ this.emit('turn', { thread: record.thread, turn, sequence,
247
+ immediate: ['turn/completed', 'item/completed'].includes(method) });
248
+ }
249
+ if (method === 'turn/completed' && record.thread.turns.length > 10) {
250
+ record.thread.turns = record.thread.turns.slice(-10);
251
+ // Native cursors are opaque; obtain a matching boundary before the next snapshot.
252
+ record.historyTrimmed = true;
253
+ }
254
+ if (['turn/completed', 'thread/status/changed'].includes(method)) this.releaseInactiveThread(id);
255
+ }
256
+
257
+ matchesUpdatedAt(thread, updatedAt) {
258
+ const status = typeof thread.status === 'string' ? thread.status : thread.status?.type;
259
+ const latest = thread.turns?.at(-1);
260
+ return Number(updatedAt) > 0 && status !== 'active' && latest?.status !== 'inProgress'
261
+ && Math.floor(Number(thread.updatedAt)) === Math.floor(Number(updatedAt));
262
+ }
263
+
264
+ resumeTimeline(threadId, apply) {
265
+ return this.request('thread/resume', { threadId, excludeTurns: true,
266
+ initialTurnsPage: { limit: 1, sortDirection: 'desc', itemsView: 'notLoaded' } }, value => {
267
+ if (!Array.isArray(value.initialTurnsPage?.data)) throw new Error('Codex 内核未返回分页历史。');
268
+ return apply(value);
269
+ });
270
+ }
271
+
272
+ initialTurnsPage(_threadId, resumed) { return resumed.initialTurnsPage; }
273
+
274
+ async timeline(threadId, pageOptions = {}, knownSync) {
275
+ await this.connect();
276
+ const slot = this.slot(threadId);
277
+ slot.readers++;
278
+ const task = slot.tail.then(async () => {
279
+ let record = this.threads.get(threadId), metadataReused = false;
280
+ // Seconds are the product's cache-update granularity. Active work still
281
+ // uses the received sequence so an in-progress reply can keep advancing.
282
+ const knownToken = record?.tokens.get(knownSync?.id);
283
+ const unchanged = Boolean(!pageOptions.reconcileObserved && record && !record.readOnly && !record.needsReload && !record.hydrating
284
+ && (this.matchesUpdatedAt(record.thread, knownSync?.updatedAt)
285
+ || (knownSync?.epoch === this.epoch && Number.isSafeInteger(knownSync.seq)
286
+ && knownToken && knownToken.expires > Date.now()
287
+ && knownToken.sequence === knownSync.seq && record.sequence === knownSync.seq)));
288
+ if (unchanged) {
289
+ const id = randomUUID();
290
+ record.tokens.set(id, { sequence: record.sequence, expires: Date.now() + syncTokenTtlMs });
291
+ this.scheduleCleanup(threadId, slot);
292
+ return { thread: { ...record.thread, turns: [] }, notModified: true,
293
+ sync: { id, epoch: this.epoch, seq: record.sequence } };
294
+ }
295
+ // Snapshot refreshes must not reset the sequence or tokens of live subscribers.
296
+ const previous = record?.readOnly ? null : record;
297
+ if (record?.readOnly || record?.needsReload) this.contexts.delete(threadId);
298
+ if (pageOptions.reconcileObserved || !record || record.readOnly || record.needsReload || record.historyTrimmed || record.metadataOnly) {
299
+ let result, snapshot;
300
+ try {
301
+ result = await this.resumeTimeline(threadId, value => {
302
+ const retained = new Map((previous?.thread.turns || []).map(turn => [turn.id, turn]));
303
+ record = { thread: { ...value.thread, turns: [...value.initialTurnsPage.data].reverse().map(turn => ({
304
+ ...turn, items: retained.get(turn.id)?.items || [],
305
+ ...(retained.get(turn.id)?.itemPage ? { itemPage: retained.get(turn.id).itemPage } : {}),
306
+ })) },
307
+ nextCursor: value.initialTurnsPage.nextCursor ?? null,
308
+ sequence: previous?.sequence ?? 0, recoverAfter: previous?.recoverAfter ?? 0,
309
+ changes: previous?.changes ?? new Map(), changeBytes: previous?.changeBytes ?? 0,
310
+ tokens: previous?.tokens ?? new Map(), hydrating: true };
311
+ this.threads.set(threadId, record);
312
+ snapshot = events.beginSnapshot(record.thread);
313
+ return value;
314
+ }, pageOptions);
315
+ } catch (error) {
316
+ snapshot?.close();
317
+ if (!isThreadWriterConflict(error)) throw error;
318
+ record = await this.readOnlyRecord(threadId, pageOptions, '该对话正在另一个 Codex 实例中使用。');
319
+ result = null;
320
+ }
321
+ if (result) try {
322
+ const page = await this.initialTurnsPage(threadId, result);
323
+ if (!Array.isArray(page?.data)) throw new Error('Codex 内核未返回分页历史。');
324
+ const live = new Map(record.thread.turns.map(turn => [turn.id, turn]));
325
+ record.thread.turns = [...page.data].reverse().map(turn => {
326
+ const current = live.get(turn.id);
327
+ live.delete(turn.id);
328
+ return current ? Object.assign(current, { ...turn, ...current }) : { ...turn, items: [] };
329
+ }).concat([...live.values()]);
330
+ record.nextCursor = page.nextCursor ?? null;
331
+ metadataReused = Boolean(!pageOptions.reconcileObserved && !snapshot.changed()
332
+ && this.matchesUpdatedAt(record.thread, knownSync?.updatedAt));
333
+ if (metadataReused) {
334
+ record.metadataOnly = true;
335
+ record.thread.turns = record.thread.turns.map(turn => ({ ...turn, items: [], cacheStub: true }));
336
+ } else await Promise.all(page.data.map(turn => this.compactTurn(threadId, turn, null, page => snapshot.merge(page), 20)));
337
+ record.hydrating = false;
338
+ this.contexts.set(threadId, { ...this.contexts.get(threadId), model: result.model, thinking: result.reasoningEffort, serviceTier: result.serviceTier ?? null,
339
+ cwd: result.cwd, ...events.permissionState(result) });
340
+ } finally { snapshot?.close(); }
341
+ }
342
+ const id = randomUUID(), expires = Date.now() + syncTokenTtlMs;
343
+ record.tokens.set(id, { sequence: record.sequence, expires });
344
+ this.scheduleCleanup(threadId, slot);
345
+ return { thread: metadataReused ? { ...record.thread, turns: [] } : record.thread,
346
+ ...(metadataReused ? { notModified: true } : {}),
347
+ nextCursor: this.historyCursor(threadId, record.thread.turns[0], record.nextCursor),
348
+ sync: { id, epoch: this.epoch, seq: record.sequence } };
349
+ }).finally(() => { slot.readers--; });
350
+ slot.tail = task.catch(() => {});
351
+ task.catch(() => {
352
+ const record = this.threads.get(threadId);
353
+ if (record?.hydrating) { record.needsReload = true; record.hydrating = false; this.scheduleCleanup(threadId, slot); }
354
+ if (!slot.users && !this.threads.has(threadId) && this.subscriptions.get(threadId) === slot) {
355
+ clearTimeout(slot.expiry);
356
+ this.subscriptions.delete(threadId);
357
+ }
358
+ });
359
+ return task;
360
+ }
361
+
362
+ slot(threadId) {
363
+ let slot = this.subscriptions.get(threadId);
364
+ if (!slot) { slot = { users: 0, readers: 0, tail: Promise.resolve(), expiry: null }; this.subscriptions.set(threadId, slot); }
365
+ return slot;
366
+ }
367
+
368
+ keepSnapshotAlive(threadId, sync) {
369
+ const token = this.threads.get(threadId)?.tokens.get(sync?.id);
370
+ const slot = this.subscriptions.get(threadId);
371
+ if (!token || !slot || sync.epoch !== this.epoch) return;
372
+ token.expires = Date.now() + syncTokenTtlMs;
373
+ this.scheduleCleanup(threadId, slot);
374
+ }
375
+
376
+ scheduleCleanup(threadId, slot) {
377
+ clearTimeout(slot.expiry);
378
+ const record = this.threads.get(threadId);
379
+ const expiries = record ? [...record.tokens.values()].map(token => token.expires) : [];
380
+ const next = expiries.length ? Math.max(1, Math.min(...expiries) - Date.now()) : 1;
381
+ slot.expiry = setTimeout(() => this.cleanup(threadId, slot), next);
382
+ }
383
+
384
+ cleanup(threadId, slot) {
385
+ const record = this.threads.get(threadId), now = Date.now();
386
+ if (record) for (const [id, token] of record.tokens) if (token.expires <= now) record.tokens.delete(id);
387
+ if (slot.users || slot.readers) { clearTimeout(slot.expiry); slot.expiry = null; return; }
388
+ if (record?.tokens.size) { this.scheduleCleanup(threadId, slot); return; }
389
+ if (this.keepThreadLoaded(threadId, record)) { clearTimeout(slot.expiry); slot.expiry = null; return; }
390
+ slot.tail = slot.tail.then(async () => {
391
+ if (this.subscriptions.get(threadId) !== slot) return;
392
+ if (slot.users || slot.readers || this.threads.get(threadId)?.tokens.size || this.keepThreadLoaded(threadId, this.threads.get(threadId))) return;
393
+ this.threads.delete(threadId); this.contexts.delete(threadId); this.permissionOptions.delete(threadId);
394
+ this.subscriptions.delete(threadId); clearTimeout(slot.expiry);
395
+ if (this.ready && !record?.readOnly) await this.unsubscribeThread(threadId);
396
+ this.serverRequests.forget(threadId);
397
+ }).catch(error => this.emit('subscription-error', error));
398
+ }
399
+
400
+ keepThreadLoaded(threadId, record) {
401
+ if (!record || record.readOnly) return false;
402
+ const status = typeof record.thread.status === 'string' ? record.thread.status : record.thread.status?.type;
403
+ return status === 'active' || record.thread.turns?.some(turn => turn.status === 'inProgress')
404
+ || this.serverRequests.list(threadId).length > 0;
405
+ }
406
+
407
+ releaseInactiveThread(threadId) {
408
+ // Run after the current notification has updated both request and turn state.
409
+ queueMicrotask(() => {
410
+ const slot = this.subscriptions?.get(threadId);
411
+ if (slot && !slot.users && this.ready) this.cleanup(threadId, slot);
412
+ });
413
+ }
414
+
415
+ async acquire(threadId, requested) {
416
+ const slot = this.slot(threadId);
417
+ let record = this.threads.get(threadId), token;
418
+ if (record && requested?.epoch === this.epoch && requested.id && Number.isSafeInteger(requested.after)) {
419
+ token = record.tokens.get(requested.id);
420
+ if (!token || token.expires <= Date.now() || token.sequence !== requested.after) token = undefined;
421
+ }
422
+ let snapshot = false, catchup, confirmed = false;
423
+ if (!record || !token || record.needsReload
424
+ || ((record.historyTrimmed || record.metadataOnly) && requested.after < record.recoverAfter)) {
425
+ const fresh = await this.timeline(threadId, {}, { updatedAt: requested?.updatedAt });
426
+ record = this.threads.get(threadId); token = record.tokens.get(fresh.sync.id);
427
+ requested = { id: fresh.sync.id, epoch: fresh.sync.epoch, after: fresh.sync.seq };
428
+ snapshot = fresh.notModified !== true;
429
+ confirmed = !snapshot;
430
+ }
431
+ slot.users++; record.tokens.delete(requested.id); clearTimeout(slot.expiry);
432
+ const current = record.sequence;
433
+ if (!snapshot && requested.after < record.recoverAfter) snapshot = true;
434
+ if (!snapshot && current > requested.after) {
435
+ const turns = [...record.changes.values()].filter(change => change.sequence > requested.after && !change.turn.cacheStub)
436
+ .sort((left, right) => record.thread.turns.findIndex(turn => turn.id === left.turn.id)
437
+ - record.thread.turns.findIndex(turn => turn.id === right.turn.id)).map(change => change.turn);
438
+ catchup = { thread: { ...record.thread, turns },
439
+ sync: { id: requested.id, epoch: this.epoch, fromSeq: requested.after + 1, toSeq: current } };
440
+ }
441
+ const release = () => {
442
+ slot.users--;
443
+ // Retain the observed snapshot briefly when switching away. A later check
444
+ // must match the client's consumed sequence, never just this release time.
445
+ if (this.threads.get(threadId) === record && !record.readOnly) {
446
+ record.tokens.set(requested.id, { sequence: record.sequence, expires: Date.now() + syncTokenTtlMs });
447
+ }
448
+ this.cleanup(threadId, slot);
449
+ return slot.tail;
450
+ };
451
+ return { record: { thread: record.thread, nextCursor: this.historyCursor(threadId, record.thread.turns[0], record.nextCursor),
452
+ sync: { id: requested.id, epoch: this.epoch, seq: current, ...(snapshot ? { reset: true } : {}) } },
453
+ snapshot, catchup, confirmed, release };
454
+ }
455
+
456
+ pageSettings(options = {}) {
457
+ return {
458
+ maxTurns: Math.max(1, Math.min(historyPageMaxTurns, Number(options.maxTurns) || historyPageMaxTurns)),
459
+ maxBytes: Math.max(1, Number(options.maxBytes) || historyPageMaxBytes),
460
+ measure: typeof options.measure === 'function' ? options.measure : value => Buffer.byteLength(JSON.stringify(value)),
461
+ };
462
+ }
463
+
464
+ historyCursor(threadId, turn, turnCursor) {
465
+ const itemCursor = turn?.itemPage?.olderCursor;
466
+ if (!itemCursor && !turnCursor) return null;
467
+ // Upstream history cursors identify stored positions, not this observer.
468
+ return JSON.stringify({ threadId, turnId: itemCursor ? turn.id : null,
469
+ itemCursor: itemCursor || null, turnCursor: turnCursor || null });
470
+ }
471
+
472
+ async compactTurn(threadId, turn, cursor = null, apply, limit = 100) {
473
+ if (!turn?.id) return events.compactTurn(turn);
474
+ return this.request('thread/items/list', {
475
+ threadId, turnId: turn.id, ...(cursor ? { cursor } : {}), limit, sortDirection: 'desc',
476
+ }, page => {
477
+ const result = this.projectItemPage(threadId, turn, cursor, page);
478
+ apply?.(result); return result;
479
+ });
480
+ }
481
+
482
+ isThreadObserved(threadId, record) {
483
+ const slot = this.subscriptions.get(threadId);
484
+ return Boolean(slot?.users || slot?.readers || record.tokens.size);
485
+ }
486
+
487
+ unsubscribeThread(threadId) { return this.request('thread/unsubscribe', { threadId }); }
488
+
489
+ async readOnlyRecord(threadId, pageOptions, reason) {
490
+ const [read, page] = await Promise.all([
491
+ this.request('thread/read', { threadId, includeTurns: false }),
492
+ this.request('thread/turns/list', { threadId, limit: 1, sortDirection: 'desc', itemsView: 'notLoaded' }),
493
+ ]);
494
+ const compact = await Promise.all([...(page.data || [])].reverse().map(turn => this.compactTurn(threadId, turn)));
495
+ const turns = this.fitTurns({ thread: read.thread }, compact, pageOptions);
496
+ const thread = { ...read.thread, turns, readOnly: true, readOnlyReason: reason };
497
+ const record = { thread, nextCursor: page.nextCursor ?? null, sequence: 0, recoverAfter: 0,
498
+ changes: new Map(), changeBytes: 0, tokens: new Map(), readOnly: true };
499
+ this.threads.set(threadId, record);
500
+ this.contexts.set(threadId, { model: thread.model, thinking: thread.reasoningEffort, cwd: thread.cwd });
501
+ return record;
502
+ }
503
+
504
+ projectItemPage(threadId, turn, cursor, page) {
505
+ const compact = events.compactTurn(turn);
506
+ if (!Array.isArray(page.data)) throw new Error('Codex 内核未返回单轮条目索引。');
507
+ if (page.nextCursor && page.nextCursor === cursor) throw new Error('条目分页游标未推进。');
508
+ if (page.data.some(entry => entry.turnId !== turn.id)) throw new Error('条目所属轮次不匹配。');
509
+ const items = [...page.data].reverse().map(entry => events.compactItem(entry.item));
510
+ return { ...compact, items, itemPage: { olderCursor: page.nextCursor ?? null,
511
+ hasLoadedOldest: !page.nextCursor, oldestItemId: items[0]?.id || null,
512
+ newestItemId: items.at(-1)?.id || null, requestedCursor: cursor, projection: 'mobile-skeleton' } };
513
+ }
514
+
515
+ async turnContext(threadId, turnId) {
516
+ await this.connect();
517
+ if (!threadId || !turnId) throw new Error('缺少图片所属的对话或轮次。');
518
+ let turn = await this.compactTurn(threadId, { id: turnId });
519
+ while (turn.itemPage.olderCursor) {
520
+ const older = await this.compactTurn(threadId, { id: turnId }, turn.itemPage.olderCursor);
521
+ turn = { ...turn, items: [...older.items, ...turn.items], itemPage: older.itemPage };
522
+ }
523
+ return turn;
524
+ }
525
+
526
+ fitTurns(base, turns, options = {}) {
527
+ const settings = this.pageSettings(options), accepted = [];
528
+ let firstOversized = false;
529
+ for (let index = turns.length - 1; index >= 0 && accepted.length < settings.maxTurns; index--) {
530
+ const candidate = [turns[index], ...accepted];
531
+ const candidateBytes = settings.measure({ ...base, thread: { ...base.thread, turns: candidate } });
532
+ if (accepted.length >= 2 && candidateBytes > settings.maxBytes) break;
533
+ accepted.unshift(turns[index]);
534
+ if (accepted.length === 1) firstOversized = candidateBytes > settings.maxBytes;
535
+ if (firstOversized || (accepted.length >= 2 && candidateBytes > settings.maxBytes)) break;
536
+ }
537
+ return accepted;
538
+ }
539
+
540
+ async history(threadId, cursor, options = {}) {
541
+ await this.connect();
542
+ if (!cursor) throw new Error('缺少历史分页游标。');
543
+ let position;
544
+ try { position = JSON.parse(cursor); } catch { position = null; }
545
+ if (position?.threadId !== threadId) throw Object.assign(new Error('历史游标不属于当前对话。'), { code: 'history_cursor_expired' });
546
+ if (position.itemCursor && position.turnId) {
547
+ const turn = await this.compactTurn(threadId, { id: position.turnId }, position.itemCursor, undefined, options.itemLimit ?? 100);
548
+ return { thread: { id: threadId, turns: [turn] },
549
+ nextCursor: this.historyCursor(threadId, turn, position.turnCursor) };
550
+ }
551
+ const page = await this.request('thread/turns/list', {
552
+ threadId, cursor: position.turnCursor, limit: 1, sortDirection: 'desc', itemsView: 'notLoaded',
553
+ });
554
+ if (!Array.isArray(page.data)) throw new Error('Codex 内核未返回轮次分页。');
555
+ if (page.nextCursor && page.nextCursor === position.turnCursor) throw new Error('轮次分页游标未推进。');
556
+ const turns = page.data.length ? [await this.compactTurn(threadId, page.data[0], null, undefined, options.itemLimit ?? 100)] : [];
557
+ return { thread: { id: threadId, turns }, nextCursor: this.historyCursor(threadId, turns[0], page.nextCursor) };
558
+ }
559
+
560
+ async interrupt(threadId, turnId) {
561
+ await this.connect();
562
+ this.requireWritable(threadId);
563
+ return this.request('turn/interrupt', { threadId, turnId });
564
+ }
565
+
566
+ async models() {
567
+ await this.connect();
568
+ const models = []; let cursor;
569
+ do {
570
+ const result = await this.request('model/list', { limit: 100, ...(cursor ? { cursor } : {}) });
571
+ models.push(...result.data); cursor = result.nextCursor;
572
+ } while (cursor);
573
+ const config = await this.request('config/read', { includeLayers: false });
574
+ return { models: models.filter(model => !model.hidden), enabledReasoningEfforts: config.config?.desktop?.['enabled-reasoning-efforts'] ?? null };
575
+ }
576
+
577
+ async usage() {
578
+ await this.connect();
579
+ return this.usageCache.read(() => this.request('account/rateLimits/read'));
580
+ }
581
+
582
+ async settings(threadId, model, effort, serviceTier) {
583
+ await this.connect();
584
+ this.requireWritable(threadId);
585
+ await events.updateSettings((method, params) => this.request(method, params), this.contexts, threadId, model, effort, serviceTier);
586
+ return this.context(threadId);
587
+ }
588
+
589
+ async permissions(threadId, mode) {
590
+ await this.connect();
591
+ this.requireWritable(threadId);
592
+ await events.updatePermissions((method, params) => this.request(method, params), this.contexts, this.settingsNotifications, this.permissionOptions, threadId, mode);
593
+ return this.context(threadId);
594
+ }
595
+
596
+ async readContext(threadId) {
597
+ const context = this.contexts.get(threadId);
598
+ if (context) await this.permissionOptions.read((method, params) => this.request(method, params), threadId, context);
599
+ return this.context(threadId);
600
+ }
601
+
602
+ context(threadId) {
603
+ const context = this.permissionOptions.context(threadId, this.contexts.get(threadId) || {}), usage = context.tokenUsage;
604
+ const usedTokens = usage?.last?.totalTokens, contextWindow = usage?.modelContextWindow;
605
+ return { ...context, available: Boolean(contextWindow), usedTokens, contextWindow,
606
+ usedPercent: contextWindow ? 100 * usedTokens / contextWindow : 0 };
607
+ }
608
+
609
+ requireWritable(threadId) {
610
+ const record = this.threads.get(threadId);
611
+ if (record?.readOnly) throw new ThreadWriterConflictError(threadId, record.thread.readOnlyReason);
612
+ }
613
+
614
+ close() { this.socket?.close(); }
615
+ }