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