@walkhi/code-relax 0.1.0-beta.11 → 0.1.0-beta.12
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/codex-remote.mjs +13 -3
- package/dist/bin/migrate-relay-db.mjs +104 -0
- package/dist/bin/self-relay-server.mjs +17 -10
- package/dist/shared/server-requests.cjs +86 -58
- package/dist/src/managed-app-server.mjs +13 -4
- package/dist/src/platform/windows/codex-runtime.mjs +138 -4
- package/dist/src/platform/windows/desktop-runtime.ps1 +10 -0
- package/dist/src/platform/windows/resident-start.js +8 -0
- package/dist/src/platform/windows/resident-startup.ps1 +6 -3
- package/dist/src/self-relay/admin-state.mjs +8 -6
- package/dist/src/self-relay/email-accounts.mjs +125 -0
- package/dist/src/self-relay/lifecycle.mjs +48 -80
- package/dist/src/self-relay/mysql-state.mjs +104 -0
- package/dist/src/self-relay/server.mjs +88 -26
- package/dist/src/server.mjs +745 -726
- package/dist/src/shared-app-server.mjs +285 -285
- package/package.json +2 -1
|
@@ -4,37 +4,37 @@ import { randomUUID } from 'node:crypto';
|
|
|
4
4
|
import events from '../shared/app-server-events.cjs';
|
|
5
5
|
import serverRequests from '../shared/server-requests.cjs';
|
|
6
6
|
|
|
7
|
-
const syncTokenTtlMs = 30_000;
|
|
8
|
-
const syncBufferMaxBytes = 8 * 1024 * 1024;
|
|
9
|
-
const historyPageMaxTurns = 8;
|
|
10
|
-
const historyPageMaxBytes = 500 * 1024;
|
|
11
|
-
const turnItemPageSize = 50;
|
|
12
|
-
const writerConflictMessages = ['already has an active writer', 'already has a live local writer'];
|
|
13
|
-
|
|
14
|
-
export class ThreadWriterConflictError extends Error {
|
|
15
|
-
constructor(threadId, message) {
|
|
16
|
-
super('该对话正在另一个 Codex 实例中使用。');
|
|
17
|
-
this.name = 'ThreadWriterConflictError';
|
|
18
|
-
this.code = 'thread_writer_owned_elsewhere';
|
|
19
|
-
this.threadId = threadId;
|
|
20
|
-
this.readOnly = true;
|
|
21
|
-
this.details = message;
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export function isThreadWriterConflict(error) {
|
|
26
|
-
const message = String(error?.message || error).toLowerCase();
|
|
27
|
-
return error instanceof ThreadWriterConflictError || writerConflictMessages.some(text => message.includes(text));
|
|
28
|
-
}
|
|
7
|
+
const syncTokenTtlMs = 30_000;
|
|
8
|
+
const syncBufferMaxBytes = 8 * 1024 * 1024;
|
|
9
|
+
const historyPageMaxTurns = 8;
|
|
10
|
+
const historyPageMaxBytes = 500 * 1024;
|
|
11
|
+
const turnItemPageSize = 50;
|
|
12
|
+
const writerConflictMessages = ['already has an active writer', 'already has a live local writer'];
|
|
13
|
+
|
|
14
|
+
export class ThreadWriterConflictError extends Error {
|
|
15
|
+
constructor(threadId, message) {
|
|
16
|
+
super('该对话正在另一个 Codex 实例中使用。');
|
|
17
|
+
this.name = 'ThreadWriterConflictError';
|
|
18
|
+
this.code = 'thread_writer_owned_elsewhere';
|
|
19
|
+
this.threadId = threadId;
|
|
20
|
+
this.readOnly = true;
|
|
21
|
+
this.details = message;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function isThreadWriterConflict(error) {
|
|
26
|
+
const message = String(error?.message || error).toLowerCase();
|
|
27
|
+
return error instanceof ThreadWriterConflictError || writerConflictMessages.some(text => message.includes(text));
|
|
28
|
+
}
|
|
29
29
|
|
|
30
30
|
// Connect to the Desktop's existing execution service; never spawn or adopt a task.
|
|
31
31
|
export class SharedAppServer extends EventEmitter {
|
|
32
32
|
constructor(url) {
|
|
33
33
|
super();
|
|
34
34
|
const address = new URL(url);
|
|
35
|
-
if (address.protocol !== 'ws:' || address.hostname !== '127.0.0.1') throw new Error('Codex 内核必须使用本机回环 WebSocket。');
|
|
36
|
-
this.url = url;
|
|
37
|
-
this.serverVersion = '';
|
|
35
|
+
if (address.protocol !== 'ws:' || address.hostname !== '127.0.0.1') throw new Error('Codex 内核必须使用本机回环 WebSocket。');
|
|
36
|
+
this.url = url;
|
|
37
|
+
this.serverVersion = '';
|
|
38
38
|
this.epoch = randomUUID();
|
|
39
39
|
this.sequence = 0;
|
|
40
40
|
this.pending = new Map();
|
|
@@ -42,10 +42,10 @@ export class SharedAppServer extends EventEmitter {
|
|
|
42
42
|
this.threads = new Map();
|
|
43
43
|
this.contexts = new Map();
|
|
44
44
|
this.settingsNotifications = new Map();
|
|
45
|
-
this.permissionOptions = new events.PermissionOptionsCache();
|
|
46
|
-
this.subscriptions = new Map();
|
|
47
|
-
this.operationGroups = new Map();
|
|
48
|
-
this.usageCache = new events.UsageCache(value => this.emit('usage', value));
|
|
45
|
+
this.permissionOptions = new events.PermissionOptionsCache();
|
|
46
|
+
this.subscriptions = new Map();
|
|
47
|
+
this.operationGroups = new Map();
|
|
48
|
+
this.usageCache = new events.UsageCache(value => this.emit('usage', value));
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
async connect() {
|
|
@@ -57,9 +57,9 @@ export class SharedAppServer extends EventEmitter {
|
|
|
57
57
|
|
|
58
58
|
async open() {
|
|
59
59
|
const socket = new WebSocket(this.url, { handshakeTimeout: 5000 });
|
|
60
|
-
this.socket = socket;
|
|
61
|
-
this.ready = false;
|
|
62
|
-
this.serverVersion = '';
|
|
60
|
+
this.socket = socket;
|
|
61
|
+
this.ready = false;
|
|
62
|
+
this.serverVersion = '';
|
|
63
63
|
this.serverRequests.reset(randomUUID());
|
|
64
64
|
socket.on('message', bytes => {
|
|
65
65
|
let message;
|
|
@@ -70,25 +70,25 @@ export class SharedAppServer extends EventEmitter {
|
|
|
70
70
|
if (!pending) return;
|
|
71
71
|
this.pending.delete(message.id);
|
|
72
72
|
clearTimeout(pending.timer);
|
|
73
|
-
if (message.error) {
|
|
74
|
-
const text = String(message.error.message || 'app-server 请求失败');
|
|
75
|
-
pending.reject(writerConflictMessages.some(value => text.toLowerCase().includes(value))
|
|
76
|
-
? new ThreadWriterConflictError(pending.threadId, text)
|
|
77
|
-
: Object.assign(new Error(text), { rpcCode: message.error.code, details: message.error.data }));
|
|
78
|
-
}
|
|
73
|
+
if (message.error) {
|
|
74
|
+
const text = String(message.error.message || 'app-server 请求失败');
|
|
75
|
+
pending.reject(writerConflictMessages.some(value => text.toLowerCase().includes(value))
|
|
76
|
+
? new ThreadWriterConflictError(pending.threadId, text)
|
|
77
|
+
: Object.assign(new Error(text), { rpcCode: message.error.code, details: message.error.data }));
|
|
78
|
+
}
|
|
79
79
|
else pending.resolve(message.result);
|
|
80
80
|
});
|
|
81
|
-
socket.on('close', () => {
|
|
82
|
-
this.ready = false;
|
|
83
|
-
this.serverVersion = '';
|
|
81
|
+
socket.on('close', () => {
|
|
82
|
+
this.ready = false;
|
|
83
|
+
this.serverVersion = '';
|
|
84
84
|
for (const pending of this.pending.values()) {
|
|
85
85
|
clearTimeout(pending.timer);
|
|
86
|
-
pending.reject(new Error('Codex 内核连接断开;发送结果可能未确认,请检查对话后再操作。'));
|
|
86
|
+
pending.reject(new Error('Codex 内核连接断开;发送结果可能未确认,请检查对话后再操作。'));
|
|
87
87
|
}
|
|
88
|
-
this.pending.clear();
|
|
89
|
-
this.serverRequests.reset(randomUUID());
|
|
90
|
-
this.threads.clear();
|
|
91
|
-
this.operationGroups.clear();
|
|
88
|
+
this.pending.clear();
|
|
89
|
+
this.serverRequests.reset(randomUUID());
|
|
90
|
+
this.threads.clear();
|
|
91
|
+
this.operationGroups.clear();
|
|
92
92
|
for (const slot of this.subscriptions.values()) clearTimeout(slot.expiry);
|
|
93
93
|
this.subscriptions.clear();
|
|
94
94
|
this.contexts.clear();
|
|
@@ -100,10 +100,10 @@ export class SharedAppServer extends EventEmitter {
|
|
|
100
100
|
socket.on('error', () => {}); // open/request reports the connection error to the caller.
|
|
101
101
|
await new Promise((resolve, reject) => { socket.once('open', resolve); socket.once('error', reject); });
|
|
102
102
|
try {
|
|
103
|
-
const initialized = await this.request('initialize', { clientInfo: { name: 'codex_remote_shared', version: '1.0.0' }, capabilities: { experimentalApi: true } });
|
|
104
|
-
// The first user-agent product reports the running server build, not the
|
|
105
|
-
// selected executable that may only take effect after a future restart.
|
|
106
|
-
this.serverVersion = String(initialized?.userAgent || '').match(/^[^\s/]+\/([^\s]+)/)?.[1] || '';
|
|
103
|
+
const initialized = await this.request('initialize', { clientInfo: { name: 'codex_remote_shared', version: '1.0.0' }, capabilities: { experimentalApi: true } });
|
|
104
|
+
// The first user-agent product reports the running server build, not the
|
|
105
|
+
// selected executable that may only take effect after a future restart.
|
|
106
|
+
this.serverVersion = String(initialized?.userAgent || '').match(/^[^\s/]+\/([^\s]+)/)?.[1] || '';
|
|
107
107
|
socket.send(JSON.stringify({ method: 'initialized' }));
|
|
108
108
|
this.ready = true;
|
|
109
109
|
this.usageCache.read(() => this.request('account/rateLimits/read')).catch(error => this.emit('usage-error', error));
|
|
@@ -117,7 +117,7 @@ export class SharedAppServer extends EventEmitter {
|
|
|
117
117
|
this.pending.delete(id);
|
|
118
118
|
reject(new Error(`${method} 超时;不会自动重发,请先检查对话。`));
|
|
119
119
|
}, 30000);
|
|
120
|
-
this.pending.set(id, { resolve, reject, timer, threadId: params?.threadId });
|
|
120
|
+
this.pending.set(id, { resolve, reject, timer, threadId: params?.threadId });
|
|
121
121
|
this.socket.send(JSON.stringify({ id, method, params }), error => {
|
|
122
122
|
if (!error || !this.pending.delete(id)) return;
|
|
123
123
|
clearTimeout(timer); reject(error);
|
|
@@ -130,26 +130,26 @@ export class SharedAppServer extends EventEmitter {
|
|
|
130
130
|
return events.readProjects((method, params) => this.request(method, params));
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
-
async send(args) {
|
|
134
|
-
await this.connect();
|
|
135
|
-
this.requireWritable(args.threadId);
|
|
133
|
+
async send(args) {
|
|
134
|
+
await this.connect();
|
|
135
|
+
this.requireWritable(args.threadId);
|
|
136
136
|
// Reading the live thread checks this service owns it without resume changing settings.
|
|
137
|
-
const { thread } = await this.request('thread/read', { threadId: args.threadId, includeTurns: false });
|
|
138
|
-
const status = typeof thread.status === 'string' ? thread.status : thread.status?.type;
|
|
139
|
-
if (status === 'active') {
|
|
140
|
-
const page = await this.request('thread/turns/list', {
|
|
141
|
-
threadId: args.threadId, limit: 1, sortDirection: 'desc', itemsView: 'summary',
|
|
142
|
-
});
|
|
143
|
-
const active = page.data?.[0];
|
|
144
|
-
if (active?.status !== 'inProgress') throw new Error('活动轮次已变化,请刷新后再发送。');
|
|
137
|
+
const { thread } = await this.request('thread/read', { threadId: args.threadId, includeTurns: false });
|
|
138
|
+
const status = typeof thread.status === 'string' ? thread.status : thread.status?.type;
|
|
139
|
+
if (status === 'active') {
|
|
140
|
+
const page = await this.request('thread/turns/list', {
|
|
141
|
+
threadId: args.threadId, limit: 1, sortDirection: 'desc', itemsView: 'summary',
|
|
142
|
+
});
|
|
143
|
+
const active = page.data?.[0];
|
|
144
|
+
if (active?.status !== 'inProgress') throw new Error('活动轮次已变化,请刷新后再发送。');
|
|
145
145
|
return this.request('turn/steer', { threadId: args.threadId, expectedTurnId: active.id, input: args.input, ...(args.clientUserMessageId ? { clientUserMessageId: args.clientUserMessageId } : {}) });
|
|
146
146
|
}
|
|
147
147
|
return this.request('turn/start', args);
|
|
148
148
|
}
|
|
149
149
|
|
|
150
|
-
respondApproval(threadId, body) {
|
|
151
|
-
if (!this.ready || this.socket?.readyState !== WebSocket.OPEN) throw new Error('Codex 内核未就绪');
|
|
152
|
-
this.requireWritable(threadId);
|
|
150
|
+
respondApproval(threadId, body) {
|
|
151
|
+
if (!this.ready || this.socket?.readyState !== WebSocket.OPEN) throw new Error('Codex 内核未就绪');
|
|
152
|
+
this.requireWritable(threadId);
|
|
153
153
|
return this.serverRequests.respond(threadId, body, message => new Promise((resolve, reject) => {
|
|
154
154
|
this.socket.send(JSON.stringify(message), error => error ? reject(error) : resolve());
|
|
155
155
|
}));
|
|
@@ -172,20 +172,20 @@ export class SharedAppServer extends EventEmitter {
|
|
|
172
172
|
if (method === 'thread/tokenUsage/updated') {
|
|
173
173
|
this.contexts.set(id, { ...this.contexts.get(id), tokenUsage: params.tokenUsage });
|
|
174
174
|
return;
|
|
175
|
-
}
|
|
176
|
-
const record = this.threads.get(id);
|
|
177
|
-
if (!record) return;
|
|
178
|
-
const current = params.turn?.id ? record.thread.turns?.find(turn => turn.id === params.turn.id) : null;
|
|
179
|
-
const groupedHistory = current?.items?.some(item => item?.type === 'lazyOperationGroup');
|
|
180
|
-
const appliedParams = groupedHistory && method === 'turn/completed' && Array.isArray(params.turn?.items)
|
|
181
|
-
? { ...params, turn: { ...params.turn,
|
|
182
|
-
items: params.turn.items.filter(item => ['userMessage', 'agentMessage'].includes(item?.type)) } }
|
|
183
|
-
: params;
|
|
184
|
-
const turn = events.applyNotification(record.thread, method, appliedParams);
|
|
185
|
-
if (turn) {
|
|
186
|
-
const sequence = ++record.sequence;
|
|
175
|
+
}
|
|
176
|
+
const record = this.threads.get(id);
|
|
177
|
+
if (!record) return;
|
|
178
|
+
const current = params.turn?.id ? record.thread.turns?.find(turn => turn.id === params.turn.id) : null;
|
|
179
|
+
const groupedHistory = current?.items?.some(item => item?.type === 'lazyOperationGroup');
|
|
180
|
+
const appliedParams = groupedHistory && method === 'turn/completed' && Array.isArray(params.turn?.items)
|
|
181
|
+
? { ...params, turn: { ...params.turn,
|
|
182
|
+
items: params.turn.items.filter(item => ['userMessage', 'agentMessage'].includes(item?.type)) } }
|
|
183
|
+
: params;
|
|
184
|
+
const turn = events.applyNotification(record.thread, method, appliedParams);
|
|
185
|
+
if (turn) {
|
|
186
|
+
const sequence = ++record.sequence;
|
|
187
187
|
record.changes.set(turn.id, { sequence, turn });
|
|
188
|
-
record.changeBytes += Buffer.byteLength(JSON.stringify({ method, params: appliedParams }));
|
|
188
|
+
record.changeBytes += Buffer.byteLength(JSON.stringify({ method, params: appliedParams }));
|
|
189
189
|
if (record.changeBytes > syncBufferMaxBytes) {
|
|
190
190
|
record.recoverAfter = sequence;
|
|
191
191
|
record.changes.clear(); record.changeBytes = 0;
|
|
@@ -196,51 +196,51 @@ export class SharedAppServer extends EventEmitter {
|
|
|
196
196
|
if (method === 'turn/completed') record.thread.turns = record.thread.turns.slice(-10);
|
|
197
197
|
}
|
|
198
198
|
|
|
199
|
-
async timeline(threadId, pageOptions = {}) {
|
|
199
|
+
async timeline(threadId, pageOptions = {}) {
|
|
200
200
|
await this.connect();
|
|
201
201
|
const slot = this.slot(threadId);
|
|
202
202
|
const task = slot.tail.then(async () => {
|
|
203
|
-
let record = this.threads.get(threadId);
|
|
204
|
-
if (record?.readOnly) {
|
|
205
|
-
this.threads.delete(threadId);
|
|
206
|
-
this.contexts.delete(threadId);
|
|
207
|
-
record = null;
|
|
208
|
-
}
|
|
209
|
-
if (!record) {
|
|
210
|
-
let result;
|
|
211
|
-
try {
|
|
212
|
-
result = await this.request('thread/resume', { threadId, excludeTurns: true,
|
|
213
|
-
initialTurnsPage: { limit: 1, sortDirection: 'desc', itemsView: 'summary' } });
|
|
214
|
-
} catch (error) {
|
|
215
|
-
if (!isThreadWriterConflict(error)) throw error;
|
|
216
|
-
const [read, page] = await Promise.all([
|
|
217
|
-
this.request('thread/read', { threadId, includeTurns: false }),
|
|
218
|
-
this.request('thread/turns/list', {
|
|
219
|
-
threadId, limit: historyPageMaxTurns, sortDirection: 'desc', itemsView: 'summary',
|
|
220
|
-
}),
|
|
221
|
-
]);
|
|
222
|
-
const compact = await Promise.all([...(page.data || [])].reverse().map(turn => this.compactTurn(threadId, turn)));
|
|
223
|
-
const turns = this.fitTurns({ thread: read.thread }, compact, pageOptions);
|
|
224
|
-
const thread = { ...read.thread, turns, readOnly: true,
|
|
225
|
-
readOnlyReason: '该对话正在另一个 Codex 实例中使用。' };
|
|
226
|
-
record = { thread, nextCursor: page.nextCursor ?? null, sequence: 0, recoverAfter: 0,
|
|
227
|
-
changes: new Map(), changeBytes: 0, tokens: new Map(), readOnly: true };
|
|
228
|
-
this.threads.set(threadId, record);
|
|
229
|
-
result = null;
|
|
230
|
-
}
|
|
231
|
-
if (result) {
|
|
232
|
-
if (!Array.isArray(result.initialTurnsPage?.data)) throw new Error('Codex 内核未返回分页历史。');
|
|
233
|
-
const compact = await Promise.all([...result.initialTurnsPage.data].reverse().map(turn => this.compactTurn(threadId, turn)));
|
|
234
|
-
const thread = { ...result.thread, turns: compact };
|
|
235
|
-
record = { thread, nextCursor: result.initialTurnsPage.nextCursor ?? null, sequence: 0,
|
|
236
|
-
recoverAfter: 0, changes: new Map(), changeBytes: 0, tokens: new Map() };
|
|
237
|
-
this.threads.set(threadId, record);
|
|
238
|
-
this.contexts.set(threadId, { ...this.contexts.get(threadId), model: result.model, thinking: result.reasoningEffort, serviceTier: result.serviceTier ?? null,
|
|
239
|
-
cwd: result.cwd, ...events.permissionState(result) });
|
|
240
|
-
await this.extendHistoryRecord(threadId, record, pageOptions);
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
const id = randomUUID(), expires = Date.now() + syncTokenTtlMs;
|
|
203
|
+
let record = this.threads.get(threadId);
|
|
204
|
+
if (record?.readOnly) {
|
|
205
|
+
this.threads.delete(threadId);
|
|
206
|
+
this.contexts.delete(threadId);
|
|
207
|
+
record = null;
|
|
208
|
+
}
|
|
209
|
+
if (!record) {
|
|
210
|
+
let result;
|
|
211
|
+
try {
|
|
212
|
+
result = await this.request('thread/resume', { threadId, excludeTurns: true,
|
|
213
|
+
initialTurnsPage: { limit: 1, sortDirection: 'desc', itemsView: 'summary' } });
|
|
214
|
+
} catch (error) {
|
|
215
|
+
if (!isThreadWriterConflict(error)) throw error;
|
|
216
|
+
const [read, page] = await Promise.all([
|
|
217
|
+
this.request('thread/read', { threadId, includeTurns: false }),
|
|
218
|
+
this.request('thread/turns/list', {
|
|
219
|
+
threadId, limit: historyPageMaxTurns, sortDirection: 'desc', itemsView: 'summary',
|
|
220
|
+
}),
|
|
221
|
+
]);
|
|
222
|
+
const compact = await Promise.all([...(page.data || [])].reverse().map(turn => this.compactTurn(threadId, turn)));
|
|
223
|
+
const turns = this.fitTurns({ thread: read.thread }, compact, pageOptions);
|
|
224
|
+
const thread = { ...read.thread, turns, readOnly: true,
|
|
225
|
+
readOnlyReason: '该对话正在另一个 Codex 实例中使用。' };
|
|
226
|
+
record = { thread, nextCursor: page.nextCursor ?? null, sequence: 0, recoverAfter: 0,
|
|
227
|
+
changes: new Map(), changeBytes: 0, tokens: new Map(), readOnly: true };
|
|
228
|
+
this.threads.set(threadId, record);
|
|
229
|
+
result = null;
|
|
230
|
+
}
|
|
231
|
+
if (result) {
|
|
232
|
+
if (!Array.isArray(result.initialTurnsPage?.data)) throw new Error('Codex 内核未返回分页历史。');
|
|
233
|
+
const compact = await Promise.all([...result.initialTurnsPage.data].reverse().map(turn => this.compactTurn(threadId, turn)));
|
|
234
|
+
const thread = { ...result.thread, turns: compact };
|
|
235
|
+
record = { thread, nextCursor: result.initialTurnsPage.nextCursor ?? null, sequence: 0,
|
|
236
|
+
recoverAfter: 0, changes: new Map(), changeBytes: 0, tokens: new Map() };
|
|
237
|
+
this.threads.set(threadId, record);
|
|
238
|
+
this.contexts.set(threadId, { ...this.contexts.get(threadId), model: result.model, thinking: result.reasoningEffort, serviceTier: result.serviceTier ?? null,
|
|
239
|
+
cwd: result.cwd, ...events.permissionState(result) });
|
|
240
|
+
await this.extendHistoryRecord(threadId, record, pageOptions);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
const id = randomUUID(), expires = Date.now() + syncTokenTtlMs;
|
|
244
244
|
record.tokens.set(id, { sequence: record.sequence, expires });
|
|
245
245
|
this.scheduleCleanup(threadId, slot);
|
|
246
246
|
return { thread: record.thread, nextCursor: record.nextCursor,
|
|
@@ -277,15 +277,15 @@ export class SharedAppServer extends EventEmitter {
|
|
|
277
277
|
if (record?.tokens.size) { this.scheduleCleanup(threadId, slot); return; }
|
|
278
278
|
slot.tail = slot.tail.then(async () => {
|
|
279
279
|
if (slot.users || this.threads.get(threadId)?.tokens.size) return;
|
|
280
|
-
this.threads.delete(threadId); this.contexts.delete(threadId); this.permissionOptions.delete(threadId);
|
|
281
|
-
this.operationGroups.delete(threadId);
|
|
280
|
+
this.threads.delete(threadId); this.contexts.delete(threadId); this.permissionOptions.delete(threadId);
|
|
281
|
+
this.operationGroups.delete(threadId);
|
|
282
282
|
this.subscriptions.delete(threadId); clearTimeout(slot.expiry);
|
|
283
|
-
if (this.ready && !record?.readOnly) await this.request('thread/unsubscribe', { threadId });
|
|
284
|
-
this.serverRequests.forget(threadId);
|
|
283
|
+
if (this.ready && !record?.readOnly) await this.request('thread/unsubscribe', { threadId });
|
|
284
|
+
this.serverRequests.forget(threadId);
|
|
285
285
|
}).catch(error => this.emit('subscription-error', error));
|
|
286
286
|
}
|
|
287
287
|
|
|
288
|
-
async acquire(threadId, requested) {
|
|
288
|
+
async acquire(threadId, requested) {
|
|
289
289
|
const slot = this.slot(threadId);
|
|
290
290
|
let record = this.threads.get(threadId), token;
|
|
291
291
|
if (record && requested?.epoch === this.epoch && requested.id && Number.isSafeInteger(requested.after)) {
|
|
@@ -294,7 +294,7 @@ export class SharedAppServer extends EventEmitter {
|
|
|
294
294
|
}
|
|
295
295
|
let snapshot = false, catchup;
|
|
296
296
|
if (!record || !token) {
|
|
297
|
-
const fresh = await this.timeline(threadId);
|
|
297
|
+
const fresh = await this.timeline(threadId);
|
|
298
298
|
record = this.threads.get(threadId); token = record.tokens.get(fresh.sync.id);
|
|
299
299
|
requested = { id: fresh.sync.id, epoch: fresh.sync.epoch, after: fresh.sync.seq };
|
|
300
300
|
snapshot = true;
|
|
@@ -319,145 +319,145 @@ export class SharedAppServer extends EventEmitter {
|
|
|
319
319
|
snapshot, catchup, release };
|
|
320
320
|
}
|
|
321
321
|
|
|
322
|
-
pageSettings(options = {}) {
|
|
323
|
-
return {
|
|
324
|
-
maxTurns: Math.max(1, Math.min(historyPageMaxTurns, Number(options.maxTurns) || historyPageMaxTurns)),
|
|
325
|
-
maxBytes: Math.max(1, Number(options.maxBytes) || historyPageMaxBytes),
|
|
326
|
-
measure: typeof options.measure === 'function' ? options.measure : value => Buffer.byteLength(JSON.stringify(value)),
|
|
327
|
-
};
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
async compactTurn(threadId, turn) {
|
|
331
|
-
const compact = events.compactTurn(turn);
|
|
332
|
-
if (!turn?.id) return compact;
|
|
333
|
-
const batches = [];
|
|
334
|
-
let cursor;
|
|
335
|
-
do {
|
|
336
|
-
const page = await this.request('thread/items/list', {
|
|
337
|
-
threadId, turnId: turn.id, ...(cursor ? { cursor } : {}),
|
|
338
|
-
limit: 100, sortDirection: 'desc',
|
|
339
|
-
});
|
|
340
|
-
if (!Array.isArray(page.data)) throw new Error('Codex 内核未返回单轮条目索引。');
|
|
341
|
-
batches.push(page.data.map(entry => events.compactItem(entry.item)));
|
|
342
|
-
cursor = page.nextCursor ?? null;
|
|
343
|
-
} while (cursor);
|
|
344
|
-
const items = batches.reverse().flatMap(batch => batch.reverse());
|
|
345
|
-
const groups = new Map(), skeleton = [];
|
|
346
|
-
let operationItems = [];
|
|
347
|
-
const flushOperations = () => {
|
|
348
|
-
if (!operationItems.length) return;
|
|
349
|
-
const visibleItems = operationItems.filter(item => item?.type !== 'reasoning');
|
|
350
|
-
operationItems = [];
|
|
351
|
-
if (!visibleItems.length) return;
|
|
352
|
-
const subAgentItems = visibleItems.filter(isSubAgentItem);
|
|
353
|
-
const detailItems = visibleItems.filter(item => !isSubAgentItem(item));
|
|
354
|
-
if (detailItems.length) {
|
|
355
|
-
const groupId = `g${groups.size + 1}`;
|
|
356
|
-
groups.set(groupId, detailItems);
|
|
357
|
-
skeleton.push({ type: 'lazyOperationGroup', id: `${turn.id}:${groupId}`,
|
|
358
|
-
turnId: turn.id, groupId,
|
|
359
|
-
operationTypes: [...new Set(detailItems.map(item => item.type).filter(Boolean))] });
|
|
360
|
-
}
|
|
361
|
-
skeleton.push(...subAgentItems);
|
|
362
|
-
};
|
|
363
|
-
for (const item of items) {
|
|
364
|
-
if (['userMessage', 'agentMessage', 'imageGeneration'].includes(item?.type)) {
|
|
365
|
-
flushOperations();
|
|
366
|
-
skeleton.push(item);
|
|
367
|
-
} else operationItems.push(item);
|
|
368
|
-
}
|
|
369
|
-
flushOperations();
|
|
370
|
-
let threadGroups = this.operationGroups.get(threadId);
|
|
371
|
-
if (!threadGroups) { threadGroups = new Map(); this.operationGroups.set(threadId, threadGroups); }
|
|
372
|
-
threadGroups.set(turn.id, groups);
|
|
373
|
-
return { ...compact, items: skeleton };
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
async operationGroupItems(threadId, turnId, groupId, offset) {
|
|
377
|
-
await this.connect();
|
|
378
|
-
const items = this.operationGroups.get(threadId)?.get(turnId)?.get(groupId);
|
|
379
|
-
if (!items) throw new Error('操作组已失效,请重新打开对话。');
|
|
380
|
-
const start = Math.max(0, Number(offset) || 0);
|
|
381
|
-
const pageItems = items.slice(start, start + turnItemPageSize);
|
|
382
|
-
const nextOffset = start + pageItems.length < items.length ? start + pageItems.length : null;
|
|
383
|
-
return { turnId, groupId, items: pageItems, nextOffset, total: items.length };
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
async turnContext(threadId, turnId) {
|
|
387
|
-
await this.connect();
|
|
388
|
-
if (!threadId || !turnId) throw new Error('缺少图片所属的对话或轮次。');
|
|
389
|
-
return this.compactTurn(threadId, { id: turnId });
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
fitTurns(base, turns, options = {}) {
|
|
393
|
-
const settings = this.pageSettings(options), accepted = [];
|
|
394
|
-
let firstOversized = false;
|
|
395
|
-
for (let index = turns.length - 1; index >= 0 && accepted.length < settings.maxTurns; index--) {
|
|
396
|
-
const candidate = [turns[index], ...accepted];
|
|
397
|
-
const candidateBytes = settings.measure({ ...base, thread: { ...base.thread, turns: candidate } });
|
|
398
|
-
if (accepted.length >= 2 && candidateBytes > settings.maxBytes) break;
|
|
399
|
-
accepted.unshift(turns[index]);
|
|
400
|
-
if (accepted.length === 1) firstOversized = candidateBytes > settings.maxBytes;
|
|
401
|
-
if (firstOversized || (accepted.length >= 2 && candidateBytes > settings.maxBytes)) break;
|
|
402
|
-
}
|
|
403
|
-
return accepted;
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
async extendHistoryRecord(threadId, record, options = {}) {
|
|
407
|
-
const settings = this.pageSettings(options);
|
|
408
|
-
if (!record.nextCursor || !record.thread.turns.length) return;
|
|
409
|
-
const firstOversized = settings.measure({ thread: record.thread, nextCursor: record.nextCursor }) > settings.maxBytes;
|
|
410
|
-
if (firstOversized) return;
|
|
411
|
-
while (record.nextCursor && record.thread.turns.length < settings.maxTurns) {
|
|
412
|
-
const cursor = record.nextCursor;
|
|
413
|
-
const page = await this.request('thread/turns/list', {
|
|
414
|
-
threadId, cursor, limit: 1, sortDirection: 'desc', itemsView: 'summary',
|
|
415
|
-
});
|
|
416
|
-
if (!Array.isArray(page.data) || !page.data.length) {
|
|
417
|
-
record.nextCursor = page.nextCursor ?? null;
|
|
418
|
-
break;
|
|
419
|
-
}
|
|
420
|
-
const candidate = [await this.compactTurn(threadId, page.data[0]), ...record.thread.turns];
|
|
421
|
-
const value = { thread: { ...record.thread, turns: candidate }, nextCursor: page.nextCursor ?? null };
|
|
422
|
-
if (record.thread.turns.length >= 2 && settings.measure(value) > settings.maxBytes) break;
|
|
423
|
-
record.thread.turns = candidate;
|
|
424
|
-
record.nextCursor = page.nextCursor ?? null;
|
|
425
|
-
if (record.thread.turns.length >= 2 && settings.measure(value) > settings.maxBytes) break;
|
|
426
|
-
}
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
async history(threadId, cursor, options = {}) {
|
|
430
|
-
await this.connect();
|
|
431
|
-
if (!cursor) throw new Error('缺少历史分页游标。');
|
|
432
|
-
const settings = this.pageSettings(options), turns = [];
|
|
433
|
-
let firstOversized = false;
|
|
434
|
-
let nextCursor = cursor;
|
|
435
|
-
while (nextCursor && turns.length < settings.maxTurns) {
|
|
436
|
-
const pageCursor = nextCursor;
|
|
437
|
-
const page = await this.request('thread/turns/list', {
|
|
438
|
-
threadId, cursor: pageCursor, limit: 1, sortDirection: 'desc', itemsView: 'summary',
|
|
439
|
-
});
|
|
440
|
-
if (!Array.isArray(page.data) || !page.data.length) {
|
|
441
|
-
nextCursor = page.nextCursor ?? null;
|
|
442
|
-
break;
|
|
443
|
-
}
|
|
444
|
-
const compact = await this.compactTurn(threadId, page.data[0]);
|
|
445
|
-
const candidate = [compact, ...turns];
|
|
446
|
-
const value = { thread: { id: threadId, turns: candidate }, nextCursor: page.nextCursor ?? null };
|
|
447
|
-
const candidateBytes = settings.measure(value);
|
|
448
|
-
if (turns.length >= 2 && candidateBytes > settings.maxBytes) break;
|
|
449
|
-
turns.unshift(compact);
|
|
450
|
-
nextCursor = page.nextCursor ?? null;
|
|
451
|
-
if (turns.length === 1) firstOversized = candidateBytes > settings.maxBytes;
|
|
452
|
-
if (firstOversized || (turns.length >= 2 && candidateBytes > settings.maxBytes)) break;
|
|
453
|
-
}
|
|
454
|
-
return { thread: { id: threadId, turns }, nextCursor };
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
async interrupt(threadId, turnId) {
|
|
458
|
-
await this.connect();
|
|
459
|
-
this.requireWritable(threadId);
|
|
460
|
-
return this.request('turn/interrupt', { threadId, turnId });
|
|
322
|
+
pageSettings(options = {}) {
|
|
323
|
+
return {
|
|
324
|
+
maxTurns: Math.max(1, Math.min(historyPageMaxTurns, Number(options.maxTurns) || historyPageMaxTurns)),
|
|
325
|
+
maxBytes: Math.max(1, Number(options.maxBytes) || historyPageMaxBytes),
|
|
326
|
+
measure: typeof options.measure === 'function' ? options.measure : value => Buffer.byteLength(JSON.stringify(value)),
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
async compactTurn(threadId, turn) {
|
|
331
|
+
const compact = events.compactTurn(turn);
|
|
332
|
+
if (!turn?.id) return compact;
|
|
333
|
+
const batches = [];
|
|
334
|
+
let cursor;
|
|
335
|
+
do {
|
|
336
|
+
const page = await this.request('thread/items/list', {
|
|
337
|
+
threadId, turnId: turn.id, ...(cursor ? { cursor } : {}),
|
|
338
|
+
limit: 100, sortDirection: 'desc',
|
|
339
|
+
});
|
|
340
|
+
if (!Array.isArray(page.data)) throw new Error('Codex 内核未返回单轮条目索引。');
|
|
341
|
+
batches.push(page.data.map(entry => events.compactItem(entry.item)));
|
|
342
|
+
cursor = page.nextCursor ?? null;
|
|
343
|
+
} while (cursor);
|
|
344
|
+
const items = batches.reverse().flatMap(batch => batch.reverse());
|
|
345
|
+
const groups = new Map(), skeleton = [];
|
|
346
|
+
let operationItems = [];
|
|
347
|
+
const flushOperations = () => {
|
|
348
|
+
if (!operationItems.length) return;
|
|
349
|
+
const visibleItems = operationItems.filter(item => item?.type !== 'reasoning');
|
|
350
|
+
operationItems = [];
|
|
351
|
+
if (!visibleItems.length) return;
|
|
352
|
+
const subAgentItems = visibleItems.filter(isSubAgentItem);
|
|
353
|
+
const detailItems = visibleItems.filter(item => !isSubAgentItem(item));
|
|
354
|
+
if (detailItems.length) {
|
|
355
|
+
const groupId = `g${groups.size + 1}`;
|
|
356
|
+
groups.set(groupId, detailItems);
|
|
357
|
+
skeleton.push({ type: 'lazyOperationGroup', id: `${turn.id}:${groupId}`,
|
|
358
|
+
turnId: turn.id, groupId,
|
|
359
|
+
operationTypes: [...new Set(detailItems.map(item => item.type).filter(Boolean))] });
|
|
360
|
+
}
|
|
361
|
+
skeleton.push(...subAgentItems);
|
|
362
|
+
};
|
|
363
|
+
for (const item of items) {
|
|
364
|
+
if (['userMessage', 'agentMessage', 'imageGeneration', 'imageView'].includes(item?.type)) {
|
|
365
|
+
flushOperations();
|
|
366
|
+
skeleton.push(item);
|
|
367
|
+
} else operationItems.push(item);
|
|
368
|
+
}
|
|
369
|
+
flushOperations();
|
|
370
|
+
let threadGroups = this.operationGroups.get(threadId);
|
|
371
|
+
if (!threadGroups) { threadGroups = new Map(); this.operationGroups.set(threadId, threadGroups); }
|
|
372
|
+
threadGroups.set(turn.id, groups);
|
|
373
|
+
return { ...compact, items: skeleton };
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
async operationGroupItems(threadId, turnId, groupId, offset) {
|
|
377
|
+
await this.connect();
|
|
378
|
+
const items = this.operationGroups.get(threadId)?.get(turnId)?.get(groupId);
|
|
379
|
+
if (!items) throw new Error('操作组已失效,请重新打开对话。');
|
|
380
|
+
const start = Math.max(0, Number(offset) || 0);
|
|
381
|
+
const pageItems = items.slice(start, start + turnItemPageSize);
|
|
382
|
+
const nextOffset = start + pageItems.length < items.length ? start + pageItems.length : null;
|
|
383
|
+
return { turnId, groupId, items: pageItems, nextOffset, total: items.length };
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
async turnContext(threadId, turnId) {
|
|
387
|
+
await this.connect();
|
|
388
|
+
if (!threadId || !turnId) throw new Error('缺少图片所属的对话或轮次。');
|
|
389
|
+
return this.compactTurn(threadId, { id: turnId });
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
fitTurns(base, turns, options = {}) {
|
|
393
|
+
const settings = this.pageSettings(options), accepted = [];
|
|
394
|
+
let firstOversized = false;
|
|
395
|
+
for (let index = turns.length - 1; index >= 0 && accepted.length < settings.maxTurns; index--) {
|
|
396
|
+
const candidate = [turns[index], ...accepted];
|
|
397
|
+
const candidateBytes = settings.measure({ ...base, thread: { ...base.thread, turns: candidate } });
|
|
398
|
+
if (accepted.length >= 2 && candidateBytes > settings.maxBytes) break;
|
|
399
|
+
accepted.unshift(turns[index]);
|
|
400
|
+
if (accepted.length === 1) firstOversized = candidateBytes > settings.maxBytes;
|
|
401
|
+
if (firstOversized || (accepted.length >= 2 && candidateBytes > settings.maxBytes)) break;
|
|
402
|
+
}
|
|
403
|
+
return accepted;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
async extendHistoryRecord(threadId, record, options = {}) {
|
|
407
|
+
const settings = this.pageSettings(options);
|
|
408
|
+
if (!record.nextCursor || !record.thread.turns.length) return;
|
|
409
|
+
const firstOversized = settings.measure({ thread: record.thread, nextCursor: record.nextCursor }) > settings.maxBytes;
|
|
410
|
+
if (firstOversized) return;
|
|
411
|
+
while (record.nextCursor && record.thread.turns.length < settings.maxTurns) {
|
|
412
|
+
const cursor = record.nextCursor;
|
|
413
|
+
const page = await this.request('thread/turns/list', {
|
|
414
|
+
threadId, cursor, limit: 1, sortDirection: 'desc', itemsView: 'summary',
|
|
415
|
+
});
|
|
416
|
+
if (!Array.isArray(page.data) || !page.data.length) {
|
|
417
|
+
record.nextCursor = page.nextCursor ?? null;
|
|
418
|
+
break;
|
|
419
|
+
}
|
|
420
|
+
const candidate = [await this.compactTurn(threadId, page.data[0]), ...record.thread.turns];
|
|
421
|
+
const value = { thread: { ...record.thread, turns: candidate }, nextCursor: page.nextCursor ?? null };
|
|
422
|
+
if (record.thread.turns.length >= 2 && settings.measure(value) > settings.maxBytes) break;
|
|
423
|
+
record.thread.turns = candidate;
|
|
424
|
+
record.nextCursor = page.nextCursor ?? null;
|
|
425
|
+
if (record.thread.turns.length >= 2 && settings.measure(value) > settings.maxBytes) break;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
async history(threadId, cursor, options = {}) {
|
|
430
|
+
await this.connect();
|
|
431
|
+
if (!cursor) throw new Error('缺少历史分页游标。');
|
|
432
|
+
const settings = this.pageSettings(options), turns = [];
|
|
433
|
+
let firstOversized = false;
|
|
434
|
+
let nextCursor = cursor;
|
|
435
|
+
while (nextCursor && turns.length < settings.maxTurns) {
|
|
436
|
+
const pageCursor = nextCursor;
|
|
437
|
+
const page = await this.request('thread/turns/list', {
|
|
438
|
+
threadId, cursor: pageCursor, limit: 1, sortDirection: 'desc', itemsView: 'summary',
|
|
439
|
+
});
|
|
440
|
+
if (!Array.isArray(page.data) || !page.data.length) {
|
|
441
|
+
nextCursor = page.nextCursor ?? null;
|
|
442
|
+
break;
|
|
443
|
+
}
|
|
444
|
+
const compact = await this.compactTurn(threadId, page.data[0]);
|
|
445
|
+
const candidate = [compact, ...turns];
|
|
446
|
+
const value = { thread: { id: threadId, turns: candidate }, nextCursor: page.nextCursor ?? null };
|
|
447
|
+
const candidateBytes = settings.measure(value);
|
|
448
|
+
if (turns.length >= 2 && candidateBytes > settings.maxBytes) break;
|
|
449
|
+
turns.unshift(compact);
|
|
450
|
+
nextCursor = page.nextCursor ?? null;
|
|
451
|
+
if (turns.length === 1) firstOversized = candidateBytes > settings.maxBytes;
|
|
452
|
+
if (firstOversized || (turns.length >= 2 && candidateBytes > settings.maxBytes)) break;
|
|
453
|
+
}
|
|
454
|
+
return { thread: { id: threadId, turns }, nextCursor };
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
async interrupt(threadId, turnId) {
|
|
458
|
+
await this.connect();
|
|
459
|
+
this.requireWritable(threadId);
|
|
460
|
+
return this.request('turn/interrupt', { threadId, turnId });
|
|
461
461
|
}
|
|
462
462
|
|
|
463
463
|
async models() {
|
|
@@ -476,16 +476,16 @@ export class SharedAppServer extends EventEmitter {
|
|
|
476
476
|
return this.usageCache.read(() => this.request('account/rateLimits/read'));
|
|
477
477
|
}
|
|
478
478
|
|
|
479
|
-
async settings(threadId, model, effort, serviceTier) {
|
|
480
|
-
await this.connect();
|
|
481
|
-
this.requireWritable(threadId);
|
|
479
|
+
async settings(threadId, model, effort, serviceTier) {
|
|
480
|
+
await this.connect();
|
|
481
|
+
this.requireWritable(threadId);
|
|
482
482
|
await events.updateSettings((method, params) => this.request(method, params), this.contexts, this.settingsNotifications, threadId, model, effort, serviceTier);
|
|
483
483
|
return this.context(threadId);
|
|
484
484
|
}
|
|
485
485
|
|
|
486
|
-
async permissions(threadId, mode) {
|
|
487
|
-
await this.connect();
|
|
488
|
-
this.requireWritable(threadId);
|
|
486
|
+
async permissions(threadId, mode) {
|
|
487
|
+
await this.connect();
|
|
488
|
+
this.requireWritable(threadId);
|
|
489
489
|
await events.updatePermissions((method, params) => this.request(method, params), this.contexts, this.settingsNotifications, this.permissionOptions, threadId, mode);
|
|
490
490
|
return this.context(threadId);
|
|
491
491
|
}
|
|
@@ -496,21 +496,21 @@ export class SharedAppServer extends EventEmitter {
|
|
|
496
496
|
return this.context(threadId);
|
|
497
497
|
}
|
|
498
498
|
|
|
499
|
-
context(threadId) {
|
|
499
|
+
context(threadId) {
|
|
500
500
|
const context = this.permissionOptions.context(threadId, this.contexts.get(threadId) || {}), usage = context.tokenUsage;
|
|
501
501
|
const usedTokens = usage?.last?.totalTokens, contextWindow = usage?.modelContextWindow;
|
|
502
502
|
return { ...context, available: Boolean(contextWindow), usedTokens, contextWindow,
|
|
503
503
|
usedPercent: contextWindow ? 100 * usedTokens / contextWindow : 0 };
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
requireWritable(threadId) {
|
|
507
|
-
const record = this.threads.get(threadId);
|
|
508
|
-
if (record?.readOnly) throw new ThreadWriterConflictError(threadId, record.thread.readOnlyReason);
|
|
509
|
-
}
|
|
510
|
-
|
|
511
|
-
close() { this.socket?.close(); }
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
function isSubAgentItem(item) {
|
|
515
|
-
return item?.type === 'collabAgentToolCall' || item?.type === 'subAgentActivity';
|
|
516
|
-
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
requireWritable(threadId) {
|
|
507
|
+
const record = this.threads.get(threadId);
|
|
508
|
+
if (record?.readOnly) throw new ThreadWriterConflictError(threadId, record.thread.readOnlyReason);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
close() { this.socket?.close(); }
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function isSubAgentItem(item) {
|
|
515
|
+
return item?.type === 'collabAgentToolCall' || item?.type === 'subAgentActivity';
|
|
516
|
+
}
|