@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,430 +1,430 @@
1
- import WebSocket from 'ws';
2
- import { HostedHistoryAppServer } from './hosted-history-app-server.mjs';
3
- import { createDotsAvatarReader } from './dots-avatar.mjs';
4
- import { readDotsCatalog } from './dots-profile.mjs';
5
- import { DotsRoomClient } from './dots-room-client.mjs';
6
- import { uploadTaskFiles, taskFilePrompt } from './dots-task-files.mjs';
7
-
8
- const hostedAppServerUrl = 'wss://codex-cloud-backend.chatgpt.com/';
9
- export const dotsTaskRevision = turn => turn ? JSON.stringify([turn.id, turn.status]) : '';
10
-
11
- // Dots delegated tasks live in Desktop's durable host. Reuse the app-server
12
- // protocol client; only the authenticated transport differs from the local host.
13
- export class DotsAppServer extends HostedHistoryAppServer {
14
- constructor(localServer, readProfiles = async () => new Map(), historyCache = null, { uploadsRoot } = {}) {
15
- super(localServer.url, historyCache);
16
- this.historySource = 'aeon_child';
17
- this.localServer = localServer;
18
- this.uploadsRoot = uploadsRoot;
19
- this.filesClient = new DotsRoomClient(this);
20
- this.url = hostedAppServerUrl;
21
- this.threadSources = new Map();
22
- this.catalogWatches = new Map();
23
- this.readProfiles = readProfiles;
24
- this.readAvatar = createDotsAvatarReader(localServer);
25
- this.profileNames = new Map();
26
- this.liveProfiles = new Map();
27
- this.liveRoots = new Map();
28
- this.rootIdentity = null;
29
- this.catalogPages = new Map();
30
- this.profileRefresh = null;
31
- this.profileAttemptAt = 0;
32
- this.on('turn', ({ thread }) => {
33
- thread.chatRevision = dotsTaskRevision(thread.turns.at(-1));
34
- this.saveHistoryWindow(thread.id);
35
- });
36
- this.on('disconnected', () => {
37
- this.filesClient.close();
38
- this.profileIdentity = null;
39
- this.catalogPages.clear(); this.profileRefresh = null; this.profileAttemptAt = 0;
40
- this.catalogWatches.clear();
41
- });
42
- }
43
-
44
- get historyIdentity() { return this.profileIdentity; }
45
-
46
- async connect() {
47
- if (this.resetting) await this.resetting;
48
- if (this.connecting) return this.connecting;
49
- return super.connect();
50
- }
51
-
52
- async open() {
53
- await super.open();
54
- const socket = this.socket, identity = this.profileIdentity;
55
- // Load confirmed display metadata before chat snapshots can publish a name
56
- // or avatar. This reads local state, not the slow live profile request.
57
- await this.refreshProfiles().catch(error => this.emit('profile-error', error));
58
- if (!this.ready || this.socket !== socket || this.profileIdentity !== identity) throw new Error('点点连接已变化,请重新连接。');
59
- this.emit('connected');
60
- }
61
-
62
- setProfileIdentity(identity) {
63
- const key = JSON.stringify([identity.accountId, identity.userId]);
64
- if (this.rootIdentity !== key) {
65
- this.liveRoots.clear(); this.profileNames.clear(); this.liveProfiles.clear();
66
- }
67
- this.profileIdentity = identity; this.rootIdentity = key;
68
- }
69
-
70
- async openSocket() {
71
- await this.localServer.connect();
72
- const auth = await this.localServer.request('getAuthStatus', { includeToken: true, refreshToken: false });
73
- if (!['chatgpt', 'chatgptAuthTokens'].includes(auth.authMethod) || !auth.authToken) {
74
- throw new Error('读取 Dots 任务需要 Codex 内核已登录 ChatGPT。');
75
- }
76
- const identity = JSON.parse(Buffer.from(auth.authToken.split('.')[1], 'base64url').toString('utf8'));
77
- const accountId = identity['https://api.openai.com/auth']?.chatgpt_account_id;
78
- if (!accountId) throw new Error('Codex 内核没有可用于读取 Dots 任务的 ChatGPT 账号身份。');
79
- this.setProfileIdentity({ accountId, userId: identity['https://api.openai.com/auth']?.chatgpt_user_id });
80
- // Credentials stay in this process and go only to the fixed official host.
81
- return new WebSocket(hostedAppServerUrl,
82
- ['codex-app-server', 'codex-client.desktop', `openai-bearer.${auth.authToken}`], {
83
- headers: { 'ChatGPT-Account-Id': accountId, 'X-OpenAI-Product-Sku': 'codex' },
84
- handshakeTimeout: 15000,
85
- });
86
- }
87
-
88
- request(method, params, transform = value => value) {
89
- return this.nativeRequest(method, params, value => {
90
- if (method === 'thread/read' || method === 'thread/resume') {
91
- if (value.thread.threadSource !== 'aeon_child') throw new Error('该会话不是 Dots 委派任务。');
92
- value.thread = { ...value.thread, hostId: 'durable', cwd: dotsTaskCwd(value.thread) };
93
- if (method === 'thread/resume') value.cwd = value.thread.cwd;
94
- }
95
- return transform(value);
96
- });
97
- }
98
-
99
- nativeRequest(method, params, transform) {
100
- const socket = this.socket;
101
- return super.request(method, params, transform).catch(error => {
102
- if (error.code === 'app_server_request_timeout' && !error.deliveryResult && this.ready && this.socket === socket && !this.resetting) {
103
- // An OPEN hosted socket can stop answering RPCs while still delivering
104
- // notifications. Invalidate it once; reconnect through the existing
105
- // observer lifecycle, never replay the request whose outcome is unknown.
106
- this.ready = false;
107
- const resetting = new Promise(resolve => socket.once('close', resolve)).finally(() => {
108
- if (this.resetting === resetting) this.resetting = null;
109
- });
110
- this.resetting = resetting;
111
- console.warn(JSON.stringify({ time: new Date().toISOString(), event: 'dots-connection-reset',
112
- reason: error.code, method, threadId: params?.threadId }));
113
- socket.terminate();
114
- }
115
- throw error;
116
- });
117
- }
118
-
119
- watchCatalogTask(threadId) {
120
- return this.watchExecution(threadId, 'aeon_child');
121
- }
122
-
123
- watchExecution(threadId, expectedSource) {
124
- const current = this.catalogWatches.get(threadId);
125
- if (current) return current;
126
- // Hosted thread/list does not subscribe to task notifications. Resume only
127
- // metadata, with no settings overrides or history window, to register a watch.
128
- // A slow background watch does not prove the shared socket has stalled.
129
- // Keep foreground history/streams alive; this read reports its own failure.
130
- const work = super.request('thread/resume', { threadId, excludeTurns: true }).then(result => {
131
- if (result.thread.threadSource !== expectedSource) throw new Error('执行订阅返回了不同来源的对话。');
132
- }).catch(error => {
133
- if (this.catalogWatches.get(threadId) === work) this.catalogWatches.delete(threadId);
134
- throw error;
135
- });
136
- this.catalogWatches.set(threadId, work);
137
- return work;
138
- }
139
-
140
- async latestTaskState(threadId, refresh = false) {
141
- const record = this.threads.get(threadId);
142
- const epoch = this.epoch, sequence = record?.sequence;
143
- let turn;
144
- if (!refresh && record && !record.needsReload && !record.hydrating && !record.historyTrimmed) {
145
- turn = record.thread.turns.at(-1);
146
- } else {
147
- const page = await this.initialTurnsPage(threadId);
148
- if (epoch !== this.epoch) throw new Error('云端连接已变化,请重新核对任务状态。');
149
- const current = this.threads.get(threadId);
150
- // Live events received during the metadata read outrank its older result.
151
- turn = current && current.sequence !== sequence ? current.thread.turns.at(-1) : page.data[0];
152
- if (current && !current.hydrating && dotsTaskRevision(current.thread.turns.at(-1)) !== dotsTaskRevision(turn)) {
153
- current.needsReload = true;
154
- }
155
- }
156
- return { chatRevision: dotsTaskRevision(turn), lastTurnStatus: turn?.status || null };
157
- }
158
-
159
- async timeline(threadId, pageOptions = {}, knownSync) {
160
- const result = await super.timeline(threadId, { ...pageOptions, maxTurns: 1 }, knownSync);
161
- result.thread.chatRevision = dotsTaskRevision(this.threads.get(threadId)?.thread.turns.at(-1));
162
- await this.saveHistoryWindow(threadId, true);
163
- return result;
164
- }
165
-
166
- async source(threadId, expected) {
167
- let source = this.threadSources.get(threadId);
168
- if (!source) {
169
- await this.connect();
170
- const { thread } = await this.nativeRequest('thread/read', { threadId, includeTurns: false });
171
- source = thread.threadSource; this.threadSources.set(threadId, source);
172
- }
173
- if (!['aeon', 'aeon_child'].includes(source) || expected && source !== expected) throw new Error('该会话不是对应的 Dots 对话。');
174
- return source;
175
- }
176
-
177
- notification(method, params) {
178
- if (['thread/started', 'thread/archived', 'thread/unarchived', 'thread/deleted',
179
- 'thread/name/updated', 'thread/status/changed', 'thread/project/updated', 'turn/started', 'turn/completed'].includes(method)) {
180
- this.catalogPages.clear();
181
- }
182
- if (this.threadSources.get(params.threadId) === 'aeon') this.serverRequests.notification(method, params);
183
- if (params.thread?.threadSource) this.threadSources.set(params.thread.id, params.thread.threadSource);
184
- this.emit('host-notification', { method, params });
185
- if (this.threadSources.get(params.threadId || params.thread?.id) !== 'aeon') {
186
- this.invalidateHistory(method, params);
187
- super.notification(method, params);
188
- }
189
- }
190
-
191
- async unsubscribeThread() {
192
- // Page interest is local; the hosted watch/window last until socket disconnect.
193
- // Do not turn page closure into thread/stop or another execution command.
194
- }
195
-
196
- keepThreadLoaded(_threadId, record) {
197
- // Hosted watches last for the socket, independently of phone snapshot tokens.
198
- return this.ready && Boolean(record && !record.readOnly);
199
- }
200
-
201
- isThreadObserved(threadId, record) { return this.keepThreadLoaded(threadId, record); }
202
-
203
- async resumeTimeline(threadId, apply, options = {}) {
204
- // The hosted protocol loads history separately. Register the resumed thread
205
- // before requesting its page so notifications can update the hydrating record.
206
- const scope = this.historyScope(threadId), head = options.reconcileObserved ? null : await this.restoreHistoryHead(threadId);
207
- return this.request('thread/resume', { threadId, excludeTurns: true }, value => {
208
- if (scope !== this.historyScope(threadId)) throw new Error('云端账号连接已变化,请重新读取历史。');
209
- this.diskHeads.set(threadId, { head, updatedAt: value.thread.updatedAt, scope });
210
- return apply({ ...value, initialTurnsPage: { data: [], nextCursor: null } });
211
- });
212
- }
213
-
214
- async history(threadId, cursor, options = {}) {
215
- await this.connect(); await this.source(threadId, 'aeon_child');
216
- return super.history(threadId, cursor, options);
217
- }
218
-
219
- initialTurnsPage(threadId) {
220
- return this.request('thread/turns/list', {
221
- threadId, limit: 1, sortDirection: 'desc', itemsView: 'notLoaded',
222
- });
223
- }
224
-
225
- async models() {
226
- await this.connect();
227
- const models = []; let cursor;
228
- do {
229
- const result = await this.request('model/list', { limit: 100, ...(cursor ? { cursor } : {}) });
230
- models.push(...result.data); cursor = result.nextCursor;
231
- } while (cursor);
232
- // Hosted app-server has model/list but no local config/read.
233
- return { models: models.filter(model => !model.hidden), enabledReasoningEfforts: null };
234
- }
235
-
236
- async readContext(threadId, refreshSettings = false) {
237
- if (refreshSettings) {
238
- await this.connect();
239
- const previous = this.settingsNotifications.get(threadId);
240
- // Hosted thread/read can report stored defaults even after a turn override.
241
- // Resume without overrides reads the live settings, as Desktop does.
242
- const settings = await this.request('thread/resume', { threadId, excludeTurns: true });
243
- if (settings.model && this.settingsNotifications.get(threadId) === previous) {
244
- this.contexts.set(threadId, { ...this.contexts.get(threadId), model: settings.model,
245
- thinking: settings.reasoningEffort, serviceTier: settings.serviceTier ?? null });
246
- }
247
- }
248
- return this.context(threadId);
249
- }
250
-
251
- async send(args) {
252
- const { uploadFiles = [], ...turnArgs } = args;
253
- let assertCurrent;
254
- try {
255
- await this.connect();
256
- const record = this.threads.get(args.threadId);
257
- if (!record || record.needsReload) await this.timeline(args.threadId);
258
- if (uploadFiles.length) {
259
- this.requireWritable(args.threadId);
260
- const epoch = this.epoch, identity = this.profileIdentity;
261
- const current = () => {
262
- if (epoch !== this.epoch || identity !== this.profileIdentity || !this.ready) throw new Error('云端连接已变化,请核对后发送。');
263
- };
264
- assertCurrent = current;
265
- const references = await uploadTaskFiles(uploadFiles, { uploadsRoot: this.uploadsRoot, client: this.filesClient, current });
266
- current();
267
- const text = turnArgs.input.filter(item => item.type === 'text').map(item => item.text).join('\n');
268
- turnArgs.input = [{ type: 'text', text: taskFilePrompt(text, references) }, ...turnArgs.input.filter(item => item.type !== 'text')];
269
- }
270
- } catch (error) { error.deliveryUnknown = false; throw error; }
271
- return super.send(turnArgs, { assertCurrent });
272
- }
273
-
274
- async interrupt(threadId) {
275
- await this.connect();
276
- this.requireWritable(threadId);
277
- const result = await this.request('thread/stop', { threadId });
278
- const record = this.threads.get(threadId);
279
- if (record) record.needsReload = true;
280
- return result;
281
- }
282
-
283
- async taskCatalog(archived = false, refresh = false) {
284
- await this.connect();
285
- const cached = this.catalogPages.get(archived);
286
- if (cached?.work) return cached.work;
287
- if (!refresh && cached?.tasks && Date.now() - cached.at < 30000) return cached.tasks;
288
- const identity = this.profileIdentity;
289
- const entry = {};
290
- const work = this.readTaskCatalog(archived).then(tasks => {
291
- if (identity !== this.profileIdentity) throw new Error('点点账号已变化,请重新读取目录。');
292
- entry.tasks = tasks; entry.at = Date.now();
293
- return tasks;
294
- }).finally(() => { entry.work = null; });
295
- entry.work = work;
296
- this.catalogPages.set(archived, entry);
297
- return work;
298
- }
299
-
300
- async readTaskCatalog(archived) {
301
- const tasks = [];
302
- let cursor;
303
- do {
304
- const page = await this.request('thread/list', {
305
- // Hosted recency_at can omit existing delegated threads with no cursor.
306
- // Fetch by updated_at; the merged catalog still uses each thread's recency.
307
- limit: 100, sortKey: 'updated_at', sortDirection: 'desc', archived,
308
- ...(cursor ? { cursor } : {}),
309
- });
310
- for (const thread of page.data) this.threadSources.set(thread.id, thread.threadSource);
311
- tasks.push(...page.data.filter(thread => ['aeon', 'aeon_child'].includes(thread.threadSource))
312
- .map(thread => ({ ...thread, cwd: dotsTaskCwd(thread) })));
313
- cursor = page.nextCursor;
314
- } while (cursor);
315
- return tasks;
316
- }
317
-
318
- async catalog(archived = false, refresh = false) {
319
- const tasks = await this.taskCatalog(archived, refresh);
320
- await this.refreshProfiles();
321
- this.refreshLiveProfiles();
322
- // Subscription failure must not discard an authoritative catalog snapshot.
323
- // Watches continue independently; failed watches are retried by the next catalog read.
324
- if (!archived) for (const thread of tasks.filter(thread => thread.threadSource === 'aeon_child')) {
325
- void this.watchCatalogTask(thread.id).catch(error => {
326
- console.error('Dots 任务订阅失败:', thread.id, error.message);
327
- });
328
- }
329
- return [...tasks.filter(thread => thread.threadSource === 'aeon_child'),
330
- ...(archived ? [] : this.liveRoots.values())].map(thread => thread.threadSource === 'aeon' ? { ...thread,
331
- name: this.profileName(thread.id), avatar: this.profileAvatar(thread.id), color: this.profileColor(thread.id) } : thread);
332
- }
333
-
334
- async tasks(archived = false) { return (await this.taskCatalog(archived)).filter(thread => thread.threadSource === 'aeon_child'); }
335
-
336
- async refreshProfiles() {
337
- const identity = this.profileIdentity;
338
- const names = identity ? await this.readProfiles(identity) : new Map();
339
- if (identity !== this.profileIdentity) throw new Error('点点账号已变化,请重新读取目录。');
340
- const byProfile = new Map([...this.liveProfiles.values()].map(profile => [profile.profileId, profile]));
341
- for (const [id, profile] of names) {
342
- if (byProfile.has(profile.profileId)) names.set(id, byProfile.get(profile.profileId));
343
- }
344
- for (const [id, profile] of this.liveProfiles) names.set(id, byProfile.get(profile.profileId));
345
- for (const [id, profile] of names) {
346
- const previous = this.profileNames.get(id);
347
- if (previous?.avatar && previous.avatarFileId === profile.avatarFileId) profile.avatar = previous.avatar;
348
- }
349
- if (identity === this.profileIdentity) this.profileNames = names;
350
- }
351
-
352
- refreshLiveProfiles() {
353
- if (this.profileRefresh || Date.now() - this.profileAttemptAt < 60000) return;
354
- const identity = this.profileIdentity;
355
- this.profileAttemptAt = Date.now();
356
- const snapshot = () => JSON.stringify([[...this.profileNames], [...this.liveRoots]]);
357
- let published = snapshot();
358
- const publish = () => {
359
- const next = snapshot();
360
- if (identity !== this.profileIdentity || published === next) return;
361
- published = next;
362
- this.emit('notification', { method: 'thread/name/updated', params: {} });
363
- };
364
- const work = (async () => {
365
- try {
366
- const { profiles: live, rootThreadIds } = await readDotsCatalog(this.localServer, identity);
367
- if (identity !== this.profileIdentity) return;
368
- const roots = new Map();
369
- for (const threadId of rootThreadIds) {
370
- const { thread } = await this.nativeRequest('thread/read', { threadId, includeTurns: false });
371
- if (identity !== this.profileIdentity) return;
372
- if (thread.id !== threadId || thread.threadSource !== 'aeon') throw new Error('点点目录返回了非主线程。');
373
- roots.set(threadId, { ...thread, cwd: dotsTaskCwd(thread) });
374
- }
375
- if (identity !== this.profileIdentity) return;
376
- this.liveProfiles = live;
377
- this.liveRoots = roots;
378
- for (const id of roots.keys()) this.threadSources.set(id, 'aeon');
379
- await this.refreshProfiles();
380
- publish();
381
- } catch (error) {
382
- if (identity !== this.profileIdentity) return;
383
- this.emit('profile-error', error);
384
- }
385
- await Promise.all([...new Set(this.profileNames.values())].map(async profile => {
386
- try { profile.avatar = await this.readAvatar(profile, identity); }
387
- catch (error) { this.emit('profile-error', error); }
388
- }));
389
- publish();
390
- if (identity === this.profileIdentity && this.liveProfiles.size) await this.readProfiles.save?.(identity, this.liveProfiles);
391
- })().catch(error => this.emit('profile-error', error))
392
- .finally(() => { if (this.profileRefresh === work) this.profileRefresh = null; });
393
- this.profileRefresh = work;
394
- }
395
-
396
- profileName(threadId) { return this.profileNames.get(threadId)?.name || 'Dots'; }
397
- profileAvatar(threadId) { return this.profileNames.get(threadId)?.avatar || ''; }
398
- profileColor(threadId) { return this.profileNames.get(threadId)?.color || ''; }
399
- }
400
-
401
- function dotsTaskCwd(thread) {
402
- return thread.environments?.[0]?.cwd || thread.cwd;
403
- }
404
-
405
- export async function readDotsTasks(localAppServerUrl) {
406
- const local = new SharedAppServer(localAppServerUrl);
407
- const cloud = new DotsAppServer(local);
408
- try {
409
- const tasks = await cloud.tasks();
410
- const summaries = await Promise.all(tasks.map(async thread => {
411
- const turns = await cloud.request('thread/turns/list', {
412
- threadId: thread.id, limit: 1, sortDirection: 'desc', itemsView: 'notLoaded',
413
- });
414
- const latest = turns.data[0];
415
- return {
416
- id: thread.id, hostId: 'durable', threadSource: thread.threadSource,
417
- title: thread.name || thread.preview || '', model: thread.model,
418
- cwd: thread.environments?.[0]?.cwd || thread.cwd,
419
- environments: thread.environments, createdAt: thread.createdAt, updatedAt: thread.updatedAt,
420
- recencyAt: thread.recencyAt ?? thread.updatedAt, status: thread.status?.type,
421
- latestTurn: latest ? { id: latest.id, status: latest.status,
422
- startedAt: latest.startedAt, completedAt: latest.completedAt, error: latest.error } : null,
423
- };
424
- }));
425
- return { source: 'codex-cloud-app-server', readAt: new Date().toISOString(), tasks: summaries };
426
- } finally {
427
- cloud.close();
428
- local.close();
429
- }
430
- }
1
+ import WebSocket from 'ws';
2
+ import { HostedHistoryAppServer } from './hosted-history-app-server.mjs';
3
+ import { createDotsAvatarReader } from './dots-avatar.mjs';
4
+ import { readDotsCatalog } from './dots-profile.mjs';
5
+ import { DotsRoomClient } from './dots-room-client.mjs';
6
+ import { uploadTaskFiles, taskFilePrompt } from './dots-task-files.mjs';
7
+
8
+ const hostedAppServerUrl = 'wss://codex-cloud-backend.chatgpt.com/';
9
+ export const dotsTaskRevision = turn => turn ? JSON.stringify([turn.id, turn.status]) : '';
10
+
11
+ // Dots delegated tasks live in Desktop's durable host. Reuse the app-server
12
+ // protocol client; only the authenticated transport differs from the local host.
13
+ export class DotsAppServer extends HostedHistoryAppServer {
14
+ constructor(localServer, readProfiles = async () => new Map(), historyCache = null, { uploadsRoot } = {}) {
15
+ super(localServer.url, historyCache);
16
+ this.historySource = 'aeon_child';
17
+ this.localServer = localServer;
18
+ this.uploadsRoot = uploadsRoot;
19
+ this.filesClient = new DotsRoomClient(this);
20
+ this.url = hostedAppServerUrl;
21
+ this.threadSources = new Map();
22
+ this.catalogWatches = new Map();
23
+ this.readProfiles = readProfiles;
24
+ this.readAvatar = createDotsAvatarReader(localServer);
25
+ this.profileNames = new Map();
26
+ this.liveProfiles = new Map();
27
+ this.liveRoots = new Map();
28
+ this.rootIdentity = null;
29
+ this.catalogPages = new Map();
30
+ this.profileRefresh = null;
31
+ this.profileAttemptAt = 0;
32
+ this.on('turn', ({ thread }) => {
33
+ thread.chatRevision = dotsTaskRevision(thread.turns.at(-1));
34
+ this.saveHistoryWindow(thread.id);
35
+ });
36
+ this.on('disconnected', () => {
37
+ this.filesClient.close();
38
+ this.profileIdentity = null;
39
+ this.catalogPages.clear(); this.profileRefresh = null; this.profileAttemptAt = 0;
40
+ this.catalogWatches.clear();
41
+ });
42
+ }
43
+
44
+ get historyIdentity() { return this.profileIdentity; }
45
+
46
+ async connect() {
47
+ if (this.resetting) await this.resetting;
48
+ if (this.connecting) return this.connecting;
49
+ return super.connect();
50
+ }
51
+
52
+ async open() {
53
+ await super.open();
54
+ const socket = this.socket, identity = this.profileIdentity;
55
+ // Load confirmed display metadata before chat snapshots can publish a name
56
+ // or avatar. This reads local state, not the slow live profile request.
57
+ await this.refreshProfiles().catch(error => this.emit('profile-error', error));
58
+ if (!this.ready || this.socket !== socket || this.profileIdentity !== identity) throw new Error('点点连接已变化,请重新连接。');
59
+ this.emit('connected');
60
+ }
61
+
62
+ setProfileIdentity(identity) {
63
+ const key = JSON.stringify([identity.accountId, identity.userId]);
64
+ if (this.rootIdentity !== key) {
65
+ this.liveRoots.clear(); this.profileNames.clear(); this.liveProfiles.clear();
66
+ }
67
+ this.profileIdentity = identity; this.rootIdentity = key;
68
+ }
69
+
70
+ async openSocket() {
71
+ await this.localServer.connect();
72
+ const auth = await this.localServer.request('getAuthStatus', { includeToken: true, refreshToken: false });
73
+ if (!['chatgpt', 'chatgptAuthTokens'].includes(auth.authMethod) || !auth.authToken) {
74
+ throw new Error('读取 Dots 任务需要 Codex 内核已登录 ChatGPT。');
75
+ }
76
+ const identity = JSON.parse(Buffer.from(auth.authToken.split('.')[1], 'base64url').toString('utf8'));
77
+ const accountId = identity['https://api.openai.com/auth']?.chatgpt_account_id;
78
+ if (!accountId) throw new Error('Codex 内核没有可用于读取 Dots 任务的 ChatGPT 账号身份。');
79
+ this.setProfileIdentity({ accountId, userId: identity['https://api.openai.com/auth']?.chatgpt_user_id });
80
+ // Credentials stay in this process and go only to the fixed official host.
81
+ return new WebSocket(hostedAppServerUrl,
82
+ ['codex-app-server', 'codex-client.desktop', `openai-bearer.${auth.authToken}`], {
83
+ headers: { 'ChatGPT-Account-Id': accountId, 'X-OpenAI-Product-Sku': 'codex' },
84
+ handshakeTimeout: 15000,
85
+ });
86
+ }
87
+
88
+ request(method, params, transform = value => value) {
89
+ return this.nativeRequest(method, params, value => {
90
+ if (method === 'thread/read' || method === 'thread/resume') {
91
+ if (value.thread.threadSource !== 'aeon_child') throw new Error('该会话不是 Dots 委派任务。');
92
+ value.thread = { ...value.thread, hostId: 'durable', cwd: dotsTaskCwd(value.thread) };
93
+ if (method === 'thread/resume') value.cwd = value.thread.cwd;
94
+ }
95
+ return transform(value);
96
+ });
97
+ }
98
+
99
+ nativeRequest(method, params, transform) {
100
+ const socket = this.socket;
101
+ return super.request(method, params, transform).catch(error => {
102
+ if (error.code === 'app_server_request_timeout' && !error.deliveryResult && this.ready && this.socket === socket && !this.resetting) {
103
+ // An OPEN hosted socket can stop answering RPCs while still delivering
104
+ // notifications. Invalidate it once; reconnect through the existing
105
+ // observer lifecycle, never replay the request whose outcome is unknown.
106
+ this.ready = false;
107
+ const resetting = new Promise(resolve => socket.once('close', resolve)).finally(() => {
108
+ if (this.resetting === resetting) this.resetting = null;
109
+ });
110
+ this.resetting = resetting;
111
+ console.warn(JSON.stringify({ time: new Date().toISOString(), event: 'dots-connection-reset',
112
+ reason: error.code, method, threadId: params?.threadId }));
113
+ socket.terminate();
114
+ }
115
+ throw error;
116
+ });
117
+ }
118
+
119
+ watchCatalogTask(threadId) {
120
+ return this.watchExecution(threadId, 'aeon_child');
121
+ }
122
+
123
+ watchExecution(threadId, expectedSource) {
124
+ const current = this.catalogWatches.get(threadId);
125
+ if (current) return current;
126
+ // Hosted thread/list does not subscribe to task notifications. Resume only
127
+ // metadata, with no settings overrides or history window, to register a watch.
128
+ // A slow background watch does not prove the shared socket has stalled.
129
+ // Keep foreground history/streams alive; this read reports its own failure.
130
+ const work = super.request('thread/resume', { threadId, excludeTurns: true }).then(result => {
131
+ if (result.thread.threadSource !== expectedSource) throw new Error('执行订阅返回了不同来源的对话。');
132
+ }).catch(error => {
133
+ if (this.catalogWatches.get(threadId) === work) this.catalogWatches.delete(threadId);
134
+ throw error;
135
+ });
136
+ this.catalogWatches.set(threadId, work);
137
+ return work;
138
+ }
139
+
140
+ async latestTaskState(threadId, refresh = false) {
141
+ const record = this.threads.get(threadId);
142
+ const epoch = this.epoch, sequence = record?.sequence;
143
+ let turn;
144
+ if (!refresh && record && !record.needsReload && !record.hydrating && !record.historyTrimmed) {
145
+ turn = record.thread.turns.at(-1);
146
+ } else {
147
+ const page = await this.initialTurnsPage(threadId);
148
+ if (epoch !== this.epoch) throw new Error('云端连接已变化,请重新核对任务状态。');
149
+ const current = this.threads.get(threadId);
150
+ // Live events received during the metadata read outrank its older result.
151
+ turn = current && current.sequence !== sequence ? current.thread.turns.at(-1) : page.data[0];
152
+ if (current && !current.hydrating && dotsTaskRevision(current.thread.turns.at(-1)) !== dotsTaskRevision(turn)) {
153
+ current.needsReload = true;
154
+ }
155
+ }
156
+ return { chatRevision: dotsTaskRevision(turn), lastTurnStatus: turn?.status || null };
157
+ }
158
+
159
+ async timeline(threadId, pageOptions = {}, knownSync) {
160
+ const result = await super.timeline(threadId, { ...pageOptions, maxTurns: 1 }, knownSync);
161
+ result.thread.chatRevision = dotsTaskRevision(this.threads.get(threadId)?.thread.turns.at(-1));
162
+ await this.saveHistoryWindow(threadId, true);
163
+ return result;
164
+ }
165
+
166
+ async source(threadId, expected) {
167
+ let source = this.threadSources.get(threadId);
168
+ if (!source) {
169
+ await this.connect();
170
+ const { thread } = await this.nativeRequest('thread/read', { threadId, includeTurns: false });
171
+ source = thread.threadSource; this.threadSources.set(threadId, source);
172
+ }
173
+ if (!['aeon', 'aeon_child'].includes(source) || expected && source !== expected) throw new Error('该会话不是对应的 Dots 对话。');
174
+ return source;
175
+ }
176
+
177
+ notification(method, params) {
178
+ if (['thread/started', 'thread/archived', 'thread/unarchived', 'thread/deleted',
179
+ 'thread/name/updated', 'thread/status/changed', 'thread/project/updated', 'turn/started', 'turn/completed'].includes(method)) {
180
+ this.catalogPages.clear();
181
+ }
182
+ if (this.threadSources.get(params.threadId) === 'aeon') this.serverRequests.notification(method, params);
183
+ if (params.thread?.threadSource) this.threadSources.set(params.thread.id, params.thread.threadSource);
184
+ this.emit('host-notification', { method, params });
185
+ if (this.threadSources.get(params.threadId || params.thread?.id) !== 'aeon') {
186
+ this.invalidateHistory(method, params);
187
+ super.notification(method, params);
188
+ }
189
+ }
190
+
191
+ async unsubscribeThread() {
192
+ // Page interest is local; the hosted watch/window last until socket disconnect.
193
+ // Do not turn page closure into thread/stop or another execution command.
194
+ }
195
+
196
+ keepThreadLoaded(_threadId, record) {
197
+ // Hosted watches last for the socket, independently of phone snapshot tokens.
198
+ return this.ready && Boolean(record && !record.readOnly);
199
+ }
200
+
201
+ isThreadObserved(threadId, record) { return this.keepThreadLoaded(threadId, record); }
202
+
203
+ async resumeTimeline(threadId, apply, options = {}) {
204
+ // The hosted protocol loads history separately. Register the resumed thread
205
+ // before requesting its page so notifications can update the hydrating record.
206
+ const scope = this.historyScope(threadId), head = options.reconcileObserved ? null : await this.restoreHistoryHead(threadId);
207
+ return this.request('thread/resume', { threadId, excludeTurns: true }, value => {
208
+ if (scope !== this.historyScope(threadId)) throw new Error('云端账号连接已变化,请重新读取历史。');
209
+ this.diskHeads.set(threadId, { head, updatedAt: value.thread.updatedAt, scope });
210
+ return apply({ ...value, initialTurnsPage: { data: [], nextCursor: null } });
211
+ });
212
+ }
213
+
214
+ async history(threadId, cursor, options = {}) {
215
+ await this.connect(); await this.source(threadId, 'aeon_child');
216
+ return super.history(threadId, cursor, options);
217
+ }
218
+
219
+ initialTurnsPage(threadId) {
220
+ return this.request('thread/turns/list', {
221
+ threadId, limit: 1, sortDirection: 'desc', itemsView: 'notLoaded',
222
+ });
223
+ }
224
+
225
+ async models() {
226
+ await this.connect();
227
+ const models = []; let cursor;
228
+ do {
229
+ const result = await this.request('model/list', { limit: 100, ...(cursor ? { cursor } : {}) });
230
+ models.push(...result.data); cursor = result.nextCursor;
231
+ } while (cursor);
232
+ // Hosted app-server has model/list but no local config/read.
233
+ return { models: models.filter(model => !model.hidden), enabledReasoningEfforts: null };
234
+ }
235
+
236
+ async readContext(threadId, refreshSettings = false) {
237
+ if (refreshSettings) {
238
+ await this.connect();
239
+ const previous = this.settingsNotifications.get(threadId);
240
+ // Hosted thread/read can report stored defaults even after a turn override.
241
+ // Resume without overrides reads the live settings, as Desktop does.
242
+ const settings = await this.request('thread/resume', { threadId, excludeTurns: true });
243
+ if (settings.model && this.settingsNotifications.get(threadId) === previous) {
244
+ this.contexts.set(threadId, { ...this.contexts.get(threadId), model: settings.model,
245
+ thinking: settings.reasoningEffort, serviceTier: settings.serviceTier ?? null });
246
+ }
247
+ }
248
+ return this.context(threadId);
249
+ }
250
+
251
+ async send(args) {
252
+ const { uploadFiles = [], ...turnArgs } = args;
253
+ let assertCurrent;
254
+ try {
255
+ await this.connect();
256
+ const record = this.threads.get(args.threadId);
257
+ if (!record || record.needsReload) await this.timeline(args.threadId);
258
+ if (uploadFiles.length) {
259
+ this.requireWritable(args.threadId);
260
+ const epoch = this.epoch, identity = this.profileIdentity;
261
+ const current = () => {
262
+ if (epoch !== this.epoch || identity !== this.profileIdentity || !this.ready) throw new Error('云端连接已变化,请核对后发送。');
263
+ };
264
+ assertCurrent = current;
265
+ const references = await uploadTaskFiles(uploadFiles, { uploadsRoot: this.uploadsRoot, client: this.filesClient, current });
266
+ current();
267
+ const text = turnArgs.input.filter(item => item.type === 'text').map(item => item.text).join('\n');
268
+ turnArgs.input = [{ type: 'text', text: taskFilePrompt(text, references) }, ...turnArgs.input.filter(item => item.type !== 'text')];
269
+ }
270
+ } catch (error) { error.deliveryUnknown = false; throw error; }
271
+ return super.send(turnArgs, { assertCurrent });
272
+ }
273
+
274
+ async interrupt(threadId) {
275
+ await this.connect();
276
+ this.requireWritable(threadId);
277
+ const result = await this.request('thread/stop', { threadId });
278
+ const record = this.threads.get(threadId);
279
+ if (record) record.needsReload = true;
280
+ return result;
281
+ }
282
+
283
+ async taskCatalog(archived = false, refresh = false) {
284
+ await this.connect();
285
+ const cached = this.catalogPages.get(archived);
286
+ if (cached?.work) return cached.work;
287
+ if (!refresh && cached?.tasks && Date.now() - cached.at < 30000) return cached.tasks;
288
+ const identity = this.profileIdentity;
289
+ const entry = {};
290
+ const work = this.readTaskCatalog(archived).then(tasks => {
291
+ if (identity !== this.profileIdentity) throw new Error('点点账号已变化,请重新读取目录。');
292
+ entry.tasks = tasks; entry.at = Date.now();
293
+ return tasks;
294
+ }).finally(() => { entry.work = null; });
295
+ entry.work = work;
296
+ this.catalogPages.set(archived, entry);
297
+ return work;
298
+ }
299
+
300
+ async readTaskCatalog(archived) {
301
+ const tasks = [];
302
+ let cursor;
303
+ do {
304
+ const page = await this.request('thread/list', {
305
+ // Hosted recency_at can omit existing delegated threads with no cursor.
306
+ // Fetch by updated_at; the merged catalog still uses each thread's recency.
307
+ limit: 100, sortKey: 'updated_at', sortDirection: 'desc', archived,
308
+ ...(cursor ? { cursor } : {}),
309
+ });
310
+ for (const thread of page.data) this.threadSources.set(thread.id, thread.threadSource);
311
+ tasks.push(...page.data.filter(thread => ['aeon', 'aeon_child'].includes(thread.threadSource))
312
+ .map(thread => ({ ...thread, cwd: dotsTaskCwd(thread) })));
313
+ cursor = page.nextCursor;
314
+ } while (cursor);
315
+ return tasks;
316
+ }
317
+
318
+ async catalog(archived = false, refresh = false) {
319
+ const tasks = await this.taskCatalog(archived, refresh);
320
+ await this.refreshProfiles();
321
+ this.refreshLiveProfiles();
322
+ // Subscription failure must not discard an authoritative catalog snapshot.
323
+ // Watches continue independently; failed watches are retried by the next catalog read.
324
+ if (!archived) for (const thread of tasks.filter(thread => thread.threadSource === 'aeon_child')) {
325
+ void this.watchCatalogTask(thread.id).catch(error => {
326
+ console.error('Dots 任务订阅失败:', thread.id, error.message);
327
+ });
328
+ }
329
+ return [...tasks.filter(thread => thread.threadSource === 'aeon_child'),
330
+ ...(archived ? [] : this.liveRoots.values())].map(thread => thread.threadSource === 'aeon' ? { ...thread,
331
+ name: this.profileName(thread.id), avatar: this.profileAvatar(thread.id), color: this.profileColor(thread.id) } : thread);
332
+ }
333
+
334
+ async tasks(archived = false) { return (await this.taskCatalog(archived)).filter(thread => thread.threadSource === 'aeon_child'); }
335
+
336
+ async refreshProfiles() {
337
+ const identity = this.profileIdentity;
338
+ const names = identity ? await this.readProfiles(identity) : new Map();
339
+ if (identity !== this.profileIdentity) throw new Error('点点账号已变化,请重新读取目录。');
340
+ const byProfile = new Map([...this.liveProfiles.values()].map(profile => [profile.profileId, profile]));
341
+ for (const [id, profile] of names) {
342
+ if (byProfile.has(profile.profileId)) names.set(id, byProfile.get(profile.profileId));
343
+ }
344
+ for (const [id, profile] of this.liveProfiles) names.set(id, byProfile.get(profile.profileId));
345
+ for (const [id, profile] of names) {
346
+ const previous = this.profileNames.get(id);
347
+ if (previous?.avatar && previous.avatarFileId === profile.avatarFileId) profile.avatar = previous.avatar;
348
+ }
349
+ if (identity === this.profileIdentity) this.profileNames = names;
350
+ }
351
+
352
+ refreshLiveProfiles() {
353
+ if (this.profileRefresh || Date.now() - this.profileAttemptAt < 60000) return;
354
+ const identity = this.profileIdentity;
355
+ this.profileAttemptAt = Date.now();
356
+ const snapshot = () => JSON.stringify([[...this.profileNames], [...this.liveRoots]]);
357
+ let published = snapshot();
358
+ const publish = () => {
359
+ const next = snapshot();
360
+ if (identity !== this.profileIdentity || published === next) return;
361
+ published = next;
362
+ this.emit('notification', { method: 'thread/name/updated', params: {} });
363
+ };
364
+ const work = (async () => {
365
+ try {
366
+ const { profiles: live, rootThreadIds } = await readDotsCatalog(this.localServer, identity);
367
+ if (identity !== this.profileIdentity) return;
368
+ const roots = new Map();
369
+ for (const threadId of rootThreadIds) {
370
+ const { thread } = await this.nativeRequest('thread/read', { threadId, includeTurns: false });
371
+ if (identity !== this.profileIdentity) return;
372
+ if (thread.id !== threadId || thread.threadSource !== 'aeon') throw new Error('点点目录返回了非主线程。');
373
+ roots.set(threadId, { ...thread, cwd: dotsTaskCwd(thread) });
374
+ }
375
+ if (identity !== this.profileIdentity) return;
376
+ this.liveProfiles = live;
377
+ this.liveRoots = roots;
378
+ for (const id of roots.keys()) this.threadSources.set(id, 'aeon');
379
+ await this.refreshProfiles();
380
+ publish();
381
+ } catch (error) {
382
+ if (identity !== this.profileIdentity) return;
383
+ this.emit('profile-error', error);
384
+ }
385
+ await Promise.all([...new Set(this.profileNames.values())].map(async profile => {
386
+ try { profile.avatar = await this.readAvatar(profile, identity); }
387
+ catch (error) { this.emit('profile-error', error); }
388
+ }));
389
+ publish();
390
+ if (identity === this.profileIdentity && this.liveProfiles.size) await this.readProfiles.save?.(identity, this.liveProfiles);
391
+ })().catch(error => this.emit('profile-error', error))
392
+ .finally(() => { if (this.profileRefresh === work) this.profileRefresh = null; });
393
+ this.profileRefresh = work;
394
+ }
395
+
396
+ profileName(threadId) { return this.profileNames.get(threadId)?.name || 'Dots'; }
397
+ profileAvatar(threadId) { return this.profileNames.get(threadId)?.avatar || ''; }
398
+ profileColor(threadId) { return this.profileNames.get(threadId)?.color || ''; }
399
+ }
400
+
401
+ function dotsTaskCwd(thread) {
402
+ return thread.environments?.[0]?.cwd || thread.cwd;
403
+ }
404
+
405
+ export async function readDotsTasks(localAppServerUrl) {
406
+ const local = new SharedAppServer(localAppServerUrl);
407
+ const cloud = new DotsAppServer(local);
408
+ try {
409
+ const tasks = await cloud.tasks();
410
+ const summaries = await Promise.all(tasks.map(async thread => {
411
+ const turns = await cloud.request('thread/turns/list', {
412
+ threadId: thread.id, limit: 1, sortDirection: 'desc', itemsView: 'notLoaded',
413
+ });
414
+ const latest = turns.data[0];
415
+ return {
416
+ id: thread.id, hostId: 'durable', threadSource: thread.threadSource,
417
+ title: thread.name || thread.preview || '', model: thread.model,
418
+ cwd: thread.environments?.[0]?.cwd || thread.cwd,
419
+ environments: thread.environments, createdAt: thread.createdAt, updatedAt: thread.updatedAt,
420
+ recencyAt: thread.recencyAt ?? thread.updatedAt, status: thread.status?.type,
421
+ latestTurn: latest ? { id: latest.id, status: latest.status,
422
+ startedAt: latest.startedAt, completedAt: latest.completedAt, error: latest.error } : null,
423
+ };
424
+ }));
425
+ return { source: 'codex-cloud-app-server', readAt: new Date().toISOString(), tasks: summaries };
426
+ } finally {
427
+ cloud.close();
428
+ local.close();
429
+ }
430
+ }