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