@walkhi/code-relax 0.1.0-beta.83 → 0.1.0-beta.91

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.
@@ -1 +1 @@
1
- {"layout":"npm-platform-packages","version":"0.1.0-beta.83"}
1
+ {"layout":"npm-platform-packages","version":"0.1.0-beta.91"}
@@ -36,10 +36,11 @@
36
36
  }
37
37
  return null;
38
38
  }
39
- // A successful submission is enough to dismiss a question; model consumption
40
- // can happen later. Store identities only, never answers or replayable writes.
39
+ // A successful submission can precede model consumption. Keep its answer for
40
+ // display across page recreation, never as a replayable send request.
41
41
  function createAnswerStore({ storage, scope, onError }) {
42
42
  const records = new Map();
43
+ const previews = new Map();
43
44
  const key = thread => 'codexRemoteAnsweredQuestions:' + JSON.stringify([
44
45
  scope(), thread.hostId || 'local', thread.id,
45
46
  ]);
@@ -63,7 +64,37 @@
63
64
  catch (error) { onError?.(error); }
64
65
  }
65
66
  }
66
- return { key, has: (thread, id) => read(key(thread)).has(id), remember };
67
+ function readPreviews(owner) {
68
+ if (!previews.has(owner)) {
69
+ try {
70
+ const values = JSON.parse(storage.getItem(owner + ':replies') || '[]');
71
+ previews.set(owner, new Map(Array.isArray(values) ? values.filter(value =>
72
+ Array.isArray(value) && typeof value[0] === 'string' && typeof value[1] === 'string') : []));
73
+ } catch (error) { previews.set(owner, new Map()); onError?.(error); }
74
+ }
75
+ return previews.get(owner);
76
+ }
77
+ function savePreviews(owner) {
78
+ try {
79
+ const values = [...readPreviews(owner)];
80
+ if (values.length) storage.setItem(owner + ':replies', JSON.stringify(values));
81
+ else storage.removeItem(owner + ':replies');
82
+ } catch (error) { onError?.(error); }
83
+ }
84
+ function submitted(owner, replies) {
85
+ const values = readPreviews(owner);
86
+ for (const reply of replies) values.set(reply.questionItemId, reply.answer);
87
+ savePreviews(owner);
88
+ remember(owner, replies.map(reply => reply.questionItemId));
89
+ }
90
+ function reconcile(thread, ids) {
91
+ const owner = key(thread), values = readPreviews(owner);
92
+ let changed = false;
93
+ for (const id of ids) if (values.delete(id)) changed = true;
94
+ if (changed) savePreviews(owner);
95
+ }
96
+ return { key, has: (thread, id) => read(key(thread)).has(id), remember, submitted, reconcile,
97
+ answer: (thread, id) => readPreviews(key(thread)).get(id) };
67
98
  }
68
99
  return { parseQuestionReply, questionIds, fromNotification, createAnswerStore };
69
100
  });
@@ -4,7 +4,27 @@ import { createHash, randomUUID } from 'node:crypto';
4
4
 
5
5
  // Only submission markers are durable. Credentials never enter this store.
6
6
  export class BrowserAuthOperations {
7
- constructor(stateRoot) { this.directory = path.join(stateRoot, 'browser-auth-operations'); }
7
+ constructor(stateRoot, codexDataRoot) {
8
+ this.directory = path.join(stateRoot, 'browser-auth-operations');
9
+ this.desktopStateFile = codexDataRoot && path.join(codexDataRoot, '.codex-global-state.json');
10
+ }
11
+ async desktopStatus(key, conversationId) {
12
+ if (!this.desktopStateFile) return null;
13
+ let state;
14
+ try { state = JSON.parse(await fs.readFile(this.desktopStateFile, 'utf8')); }
15
+ catch (error) { if (error.code === 'ENOENT') return null; throw error; }
16
+ const [accountId, userId, , , , , challengeId] = key;
17
+ if (!accountId || !userId || !conversationId || !challengeId) return null;
18
+ // Desktop 26.1027.3384.0 operation journal; read only the scoped outcome.
19
+ const entry = state['electron-browser-auth-operations']?.entries?.find(entry => {
20
+ const c = entry.context;
21
+ return c?.accountId === accountId && c.userId === userId && c.hostId === 'durable'
22
+ && c.conversationId === conversationId && c.challengeId === challengeId;
23
+ });
24
+ const status = entry?.operation?.fillStatus;
25
+ return ['unknown', 'submitted', 'declined', 'cancelled', 'unavailable', 'expired',
26
+ 'origin_changed', 'page_changed', 'locator_invalid', 'submission_failed'].includes(status) ? status : null;
27
+ }
8
28
  file(key) { return path.join(this.directory, createHash('sha256').update(JSON.stringify(key)).digest('hex') + '.json'); }
9
29
  async read(key) {
10
30
  try { return JSON.parse(await fs.readFile(this.file(key), 'utf8')).status; }
@@ -232,7 +232,7 @@ export class DotsChatServer extends HostedHistoryAppServer {
232
232
  || record.thread.turns.some(turn => turn.items.length) || saved?.turns?.some(turn => turn.items.length))) {
233
233
  throw new Error('点点最新消息尚未确认,请稍后重新同步。');
234
234
  }
235
- const incoming = dotsRoomTurns(room, page.items), observed = record.thread.turns.length ? record.thread.turns : saved?.turns || [];
235
+ const incoming = (await this.roomTurns(threadId, room, page.items)), observed = record.thread.turns.length ? record.thread.turns : saved?.turns || [];
236
236
  const overlap = connectedPrefix(observed, incoming);
237
237
  const turns = overlap ? mergeTurns(observed, incoming) : incoming;
238
238
  const previousCursor = record.thread.turns.length ? record.nextCursor : saved?.nextCursor;
@@ -332,7 +332,7 @@ export class DotsChatServer extends HostedHistoryAppServer {
332
332
  let after = record.thread.turns.filter(turn => turn.roomMessage).at(-1)?.id;
333
333
  do {
334
334
  const page = await this.rooms.messages(room.id, { after }); check();
335
- this.publish(threadId, record, mergeTurns(record.thread.turns, dotsRoomTurns(room, page.items)));
335
+ this.publish(threadId, record, mergeTurns(record.thread.turns, (await this.roomTurns(threadId, room, page.items))));
336
336
  for (const message of page.items) updates.delete(message.id);
337
337
  if (after && page.after === after) throw new Error('点点增量游标未推进。');
338
338
  after = page.after;
@@ -342,7 +342,7 @@ export class DotsChatServer extends HostedHistoryAppServer {
342
342
  const page = await this.rooms.messages(room.id, { around: id, limit: 1 }); check();
343
343
  const message = page.items.find(message => message.id === id);
344
344
  if (!message) throw new Error('点点消息更新尚未确认。');
345
- const turns = dotsRoomTurns(room, [message]);
345
+ const turns = (await this.roomTurns(threadId, room, [message]));
346
346
  if (record.thread.turns.some(turn => turn.id === id)) this.publish(threadId, record, mergeTurns(record.thread.turns, turns));
347
347
  else {
348
348
  const first = record.thread.turns.find(turn => turn.roomMessage);
@@ -385,7 +385,7 @@ export class DotsChatServer extends HostedHistoryAppServer {
385
385
  if (record !== load.record || record?.needsReload || record?.recoverAfter > load.sequence) {
386
386
  throw new Error('点点历史读取期间消息已变化,请重试加载。');
387
387
  }
388
- const turns = dotsRoomTurns(room, page.items).map(turn => {
388
+ const turns = (await this.roomTurns(threadId, room, page.items)).map(turn => {
389
389
  const changed = record?.changes.get(turn.id);
390
390
  return changed?.sequence > load.sequence ? { ...changed.turn, historyOnly: false } : turn;
391
391
  });
@@ -466,6 +466,18 @@ export class DotsChatServer extends HostedHistoryAppServer {
466
466
  const turn = { id: `activity:${params.turnId}:${item.id}`, status: 'completed', items: [item] };
467
467
  this.publish(params.threadId, record, mergeTurns(record.thread.turns, [turn]));
468
468
  }
469
+ async roomTurns(threadId, room, messages) {
470
+ const epoch = this.epoch, record = this.threads.get(threadId), turns = dotsRoomTurns(room, messages);
471
+ for (const item of turns.flatMap(turn => turn.items)) {
472
+ if (item.type !== 'approval' || !serverRequests.browserAuth(item)) continue;
473
+ const key = this.browserAuthKey(threadId, { roomMessageId: item.roomMessageId, id: item.requestId }, item.params);
474
+ const status = await this.browserAuthOperations?.desktopStatus(key, item.params.threadId)
475
+ || await this.browserAuthOperations?.read(key);
476
+ if (status) item.authenticationStatus = status;
477
+ }
478
+ if (epoch !== this.epoch || record !== this.threads.get(threadId)) throw new Error('点点连接已变化,请重新核对。');
479
+ return turns;
480
+ }
469
481
  browserAuthKey(threadId, body, params) {
470
482
  const identity = this.historyIdentity;
471
483
  return [identity?.accountId, identity?.userId, threadId, this.bindings.get(threadId)?.id,
@@ -481,7 +493,21 @@ export class DotsChatServer extends HostedHistoryAppServer {
481
493
  if (!request.params || typeof request.params.mode !== 'string') throw new Error('此房间请求格式无效');
482
494
  const key = JSON.stringify([room.id, body.roomMessageId, body.id]);
483
495
  const auth = serverRequests.browserAuth({ method: serverRequests.elicitation, params: request.params });
484
- const authStatus = auth && await this.browserAuthOperations?.read(this.browserAuthKey(threadId, body, request.params));
496
+ const authKey = auth && this.browserAuthKey(threadId, body, request.params);
497
+ const authStatus = auth && (await this.browserAuthOperations?.desktopStatus(authKey, request.params.threadId)
498
+ || await this.browserAuthOperations?.read(authKey));
499
+ // Inspect is authoritative for this request, even when room history is older.
500
+ const record = this.threads.get(threadId);
501
+ const previous = record?.thread.turns.flatMap(turn => turn.items).find(item => item.type === 'approval'
502
+ && item.roomMessageId === body.roomMessageId && item.requestId === body.id);
503
+ if (previous && ['resolved', 'expired'].includes(current.runtime_status) && (previous.status !== current.runtime_status || previous.authenticationStatus !== (authStatus || null))) {
504
+ const turns = structuredClone(record.thread.turns);
505
+ const updated = turns.flatMap(turn => turn.items).find(item => item.id === previous.id);
506
+ Object.assign(updated, { status: current.runtime_status, params: request.params, authenticationStatus: authStatus || null });
507
+ await this.approvalTimeline.observe(threadId, [updated], turns);
508
+ if (epoch !== this.epoch || this.threads.get(threadId) !== record) throw new Error('点点连接已变化,请重新核对。');
509
+ this.publish(threadId, record, turns);
510
+ }
485
511
  return { requestId: request.request_id, params: request.params, fingerprint: current.request_fingerprint,
486
512
  status: current.runtime_status, canRespond: current.can_respond === true && current.runtime_status === 'pending',
487
513
  submissionStatus: this.roomApprovalResponses.get(key) || null, authenticationStatus: authStatus || null,
@@ -498,7 +524,8 @@ export class DotsChatServer extends HostedHistoryAppServer {
498
524
  }
499
525
  async authenticateApproval(threadId, body) {
500
526
  if (!this.browserAuthOperations) throw new Error('安全登录提交服务尚未就绪');
501
- return this.submitRoomApproval(threadId, body, async (current, markSent) => {
527
+ let fillStatus;
528
+ const response = await this.submitRoomApproval(threadId, body, async (current, markSent) => {
502
529
  const auth = serverRequests.browserAuth({ method: serverRequests.elicitation, params: current.params });
503
530
  serverRequests.validateBrowserAuthFields(auth, body.fields);
504
531
  const key = this.browserAuthKey(threadId, body, current.params);
@@ -516,11 +543,16 @@ export class DotsChatServer extends HostedHistoryAppServer {
516
543
  throw Object.assign(new Error(`安全登录提交${status ? `失败(HTTP ${status})` : '结果未确认'},请在官网核对;不会重复发送登录信息`),
517
544
  { deliveryUnknown: error.deliveryUnknown });
518
545
  }
519
- console.info(JSON.stringify({ time: new Date().toISOString(), event: 'browser-auth', phase: 'fill-result', requestId: body.id, submitted: filled?.status === 'submitted' }));
520
- if (filled?.status !== 'submitted') throw new Error('安全登录未确认完成,请在官网核对;不会重新发送登录信息');
521
- await this.browserAuthOperations.complete(key, 'submitted');
522
- return { action: 'accept' };
546
+ const known = ['submitted', 'declined', 'cancelled', 'unavailable', 'expired', 'origin_changed', 'page_changed', 'locator_invalid', 'submission_failed'];
547
+ fillStatus = known.includes(filled?.status) ? filled.status : 'unknown';
548
+ console.info(JSON.stringify({ time: new Date().toISOString(), event: 'browser-auth', phase: 'fill-result', requestId: body.id, fillStatus }));
549
+ if (fillStatus === 'unknown') throw new Error('安全登录结果未确认,请在桌面端核对;不会重新发送登录信息');
550
+ await this.browserAuthOperations.complete(key, fillStatus);
551
+ // Desktop replies to every definitive fill outcome, not just success.
552
+ return { action: fillStatus === 'submitted' ? 'accept' : 'decline' };
523
553
  });
554
+ return { ...response, authenticationStatus: fillStatus,
555
+ result: { action: fillStatus === 'submitted' ? 'accept' : 'decline' } };
524
556
  }
525
557
  async submitRoomApproval(threadId, body, prepareResult) {
526
558
  this.requireWritable(threadId);
@@ -552,7 +584,14 @@ export class DotsChatServer extends HostedHistoryAppServer {
552
584
  const record = this.threads.get(threadId);
553
585
  const turns = record && structuredClone(record.thread.turns);
554
586
  const applied = turns?.flatMap(turn => turn.items).find(item => item.roomMessageId === body.roomMessageId);
555
- if (applied) { applied.status = 'resolved'; applied.result = resultBody; this.publish(threadId, record, turns); }
587
+ if (applied) {
588
+ applied.status = 'resolved'; applied.result = resultBody;
589
+ if (serverRequests.browserAuth({ method: serverRequests.elicitation, params: current.params })) {
590
+ applied.authenticationStatus = await this.browserAuthOperations?.read(this.browserAuthKey(threadId, body, current.params));
591
+ }
592
+ await this.approvalTimeline.observe(threadId, [applied], turns);
593
+ if (epoch === this.epoch && this.threads.get(threadId) === record) this.publish(threadId, record, turns);
594
+ }
556
595
  }
557
596
  return result;
558
597
  } catch (error) {
@@ -87,7 +87,7 @@ export function createFileUploadSession(root) {
87
87
  const previous = files.get(message.fileKey);
88
88
  if (previous) {
89
89
  if (previous.originalName !== message.name || previous.size !== message.size ||
90
- previous.expected !== message.sha256?.toLowerCase() || previous.previewOf !== message.previewOf) throw new Error('续传文件身份不一致');
90
+ previous.previewOf !== message.previewOf) throw new Error('续传文件身份不一致');
91
91
  return previous.result || { type: 'started', id: previous.id, offset: previous.offset, chunkBytes: FILE_CHUNK_BYTES };
92
92
  }
93
93
  if (committed) throw new Error('文件批次已提交');
@@ -101,14 +101,13 @@ export function createFileUploadSession(root) {
101
101
  if (message.previewOf && (!parent || parent.preview || message.size > FILE_CHUNK_BYTES)) throw new Error('预览图关联无效或超过 256 KiB');
102
102
  if (!parent && completed.size >= MAX_FILE_COUNT) throw new Error('每条消息最多 4 个文件');
103
103
  if (!parent && relayed && completedBytes + message.size > MAX_TOTAL_BYTES) throw new Error('服务器转发仅支持每批合计不超过 40 MiB,请使用局域网或 RTC 直连');
104
- if (!/^[a-f0-9]{64}$/i.test(message.sha256 || '')) throw new Error('文件校验值无效');
105
104
  const id = crypto.randomUUID();
106
105
  await fs.mkdir(directory, { recursive: true });
107
106
  const partial = path.join(directory, `.upload-${id}`);
108
107
  const handle = await fs.open(partial, 'wx');
109
108
  upload = { id, partial, handle, name: parent ? `${id}.preview.jpg` : message.name, size: message.size, parent,
110
109
  originalName: message.name, previewOf: message.previewOf,
111
- expected: message.sha256.toLowerCase(), hash: crypto.createHash('sha256'), offset: 0 };
110
+ offset: 0 };
112
111
  files.set(message.fileKey, upload);
113
112
  return { type: 'started', id, offset: 0, chunkBytes: FILE_CHUNK_BYTES };
114
113
  }
@@ -126,13 +125,12 @@ export function createFileUploadSession(root) {
126
125
  throw new Error('文件分片大小无效');
127
126
  }
128
127
  await current.handle.writeFile(bytes);
129
- current.hash.update(bytes);
130
128
  current.offset += bytes.length;
131
129
  return { type: 'offset', id: current.id, offset: current.offset };
132
130
  }
133
131
  if (message.type === 'finish') {
134
- if (current.offset !== current.size || current.hash.digest('hex') !== current.expected) {
135
- await clear(); throw new Error('文件大小或 SHA-256 校验失败');
132
+ if (current.offset !== current.size) {
133
+ await clear(); throw new Error('文件大小不一致');
136
134
  }
137
135
  await current.handle.sync();
138
136
  await current.handle.close();
@@ -140,7 +138,7 @@ export function createFileUploadSession(root) {
140
138
  let saved;
141
139
  try { saved = await uniqueDestination(directory, current.name, current.partial); }
142
140
  catch (error) { await fs.unlink(current.partial).catch(() => {}); throw error; }
143
- const result = { type: 'completed', id: current.id, ...saved, size: current.size, sha256: current.expected };
141
+ const result = { type: 'completed', id: current.id, ...saved, size: current.size };
144
142
  current.result = result;
145
143
  if (current.parent) current.parent.preview = result;
146
144
  else { completed.set(current.id, result); completedBytes += current.size; }
@@ -116,9 +116,7 @@ export function attachLanSocket(server, { token, dispatch, authorizeDevice = ()
116
116
  if (stopped || closing || ws.readyState !== WebSocket.OPEN) return;
117
117
  if (message.type === 'device-auth' && !authorizeDevice(message.clientId, message.credential)) throw new Error('设备授权已失效');
118
118
  const same = [...activeUploads.values()].filter(item => item.identity === identity && item.purpose === purpose);
119
- const uploads = [...activeUploads.values()].filter(item => item.purpose === 'upload').length;
120
- if (!identity || same.length >= (purpose === 'upload' ? 1 : 3) || activeUploads.size >= 8 ||
121
- (purpose === 'upload' && uploads >= 2)) throw new Error('文件身份无效或文件任务过多');
119
+ if (!identity || (purpose === 'download' && same.length >= 3) || activeUploads.size >= 8) throw new Error('文件身份无效或文件任务过多');
122
120
  if (message.type === 'device-auth') deviceConnections.set(ws, {
123
121
  clientId: message.clientId, credential: message.credential,
124
122
  revoke() { discardUpload = true; stop(); ws.close(4401, 'Device authorization revoked'); },
@@ -128,7 +126,6 @@ export function attachLanSocket(server, { token, dispatch, authorizeDevice = ()
128
126
  if (purpose === 'upload') {
129
127
  uploadEntry = uploadSessions.get(uploadId);
130
128
  if (!uploadEntry) {
131
- if ([...uploadSessions.values()].filter(entry => !entry.committed).length >= 2) throw new Error('待完成上传批次过多,请稍后再试');
132
129
  if (uploadSessions.size >= 32) {
133
130
  const oldest = [...uploadSessions].find(([, entry]) => entry.committed && !entry.ws);
134
131
  if (!oldest) throw new Error('上传批次过多,请稍后再试');
@@ -142,7 +139,7 @@ export function attachLanSocket(server, { token, dispatch, authorizeDevice = ()
142
139
  Object.assign(uploadEntry, { ws, released });
143
140
  upload = uploadEntry.session;
144
141
  }
145
- reply({ type: 'ready', version: 4, purpose, maxDownloadBytes: fileRelay ? FILE_READ_LIMIT : null, maxFileBytes: fileRelay ? MAX_FILE_BYTES : null, maxFileCount: MAX_FILE_COUNT,
142
+ reply({ type: 'ready', version: 5, purpose, maxDownloadBytes: fileRelay ? FILE_READ_LIMIT : null, maxFileBytes: fileRelay ? MAX_FILE_BYTES : null, maxFileCount: MAX_FILE_COUNT,
146
143
  maxTotalBytes: fileRelay ? MAX_TOTAL_BYTES : null, chunkBytes: FILE_CHUNK_BYTES, uploadSessionId: uploadId });
147
144
  return;
148
145
  }
@@ -1,115 +1,111 @@
1
- import fs from 'node:fs/promises';
2
- import { openAsBlob } from 'node:fs';
3
- import path from 'node:path';
4
- import crypto from 'node:crypto';
5
- import sharp from 'sharp';
6
- import media from '../shared/code-browser.cjs';
7
- import { validateInlineImage } from './message-images.mjs';
8
- import { createFileUploadSession, FILE_CHUNK_BYTES, MAX_FILE_COUNT } from './file-upload-session.mjs';
9
- import { fileRevision } from './file-transfer-range.mjs';
10
-
11
- const mimeTypes = { pdf: 'application/pdf', txt: 'text/plain', md: 'text/markdown', csv: 'text/csv',
12
- json: 'application/json', zip: 'application/zip', doc: 'application/msword',
13
- docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
14
- xls: 'application/vnd.ms-excel', xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
15
- ppt: 'application/vnd.ms-powerpoint', pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation' };
16
-
17
- // Reuse the incoming-file contract without sending workstation bytes to the phone.
18
- export async function stageWorkstationMessageFile(file, uploadsRoot) {
19
- const handle = await fs.open(file.path, 'r');
20
- const session = createFileUploadSession(uploadsRoot);
21
- try {
22
- const stat = await handle.stat();
23
- if (!stat.isFile() || stat.size < 1) throw new Error('附件为空。');
24
- const validate = async () => {
25
- if (fileRevision(await handle.stat()) !== file.revision || fileRevision(await fs.stat(file.path)) !== file.revision) {
26
- throw new Error('电脑文件已变化,请重新添加。');
27
- }
28
- };
29
- await validate();
30
- const bytes = Buffer.alloc(FILE_CHUNK_BYTES);
31
- const hash = crypto.createHash('sha256');
32
- const readPart = async offset => {
33
- const length = Math.min(bytes.length, stat.size - offset);
34
- let count = 0;
35
- while (count < length) {
36
- const { bytesRead } = await handle.read(bytes, count, length - count, offset + count);
37
- if (!bytesRead) throw new Error('电脑文件读取不完整,请重新添加。');
38
- count += bytesRead;
39
- }
40
- return bytes.subarray(0, length);
41
- };
42
- for (let offset = 0; offset < stat.size; offset += FILE_CHUNK_BYTES) hash.update(await readPart(offset));
43
- await validate();
44
- const sha256 = hash.digest('hex');
45
- const started = await session.receive({ type: 'start', fileKey: '0', name: file.name, size: stat.size, sha256 });
46
- for (let offset = 0; offset < stat.size; offset += FILE_CHUNK_BYTES) {
47
- await session.receive({ type: 'chunk', id: started.id, offset, data: await readPart(offset) });
48
- }
49
- await validate();
50
- const result = await session.receive({ type: 'finish', id: started.id });
51
- await session.receive({ type: 'commit' });
52
- return { ...result, mediaKind: media.mediaKind(file),
53
- mimeType: media.mediaMime(file) || mimeTypes[path.extname(file.name).slice(1).toLowerCase()] || 'application/octet-stream' };
54
- } finally { await handle.close(); await session.close(); }
55
- }
56
-
57
- async function localFile(file, directory) {
58
- if (typeof file?.path !== 'string' || typeof file.name !== 'string' || !file.name || /[\r\n\0]/.test(file.name)) {
59
- throw new Error('附件信息无效,请重新选择。');
60
- }
61
- const root = await fs.realpath(directory), resolved = await fs.realpath(file.path);
62
- if (path.relative(root, path.dirname(resolved)) !== '') throw new Error('附件必须来自本次工作站上传目录。');
63
- const handle = await fs.open(resolved, 'r');
64
- try {
65
- const stat = await handle.stat();
66
- if (!stat.isFile() || stat.size < 1) throw new Error('附件为空。');
67
- const bytes = await openAsBlob(resolved);
68
- if (bytes.size !== stat.size) throw new Error('附件在读取时发生变化,请重新上传。');
69
- const mimeType = media.mediaMime(file) || mimeTypes[path.extname(file.name).slice(1).toLowerCase()] || 'application/octet-stream';
70
- if (!/^[a-z0-9.+-]+\/[a-z0-9.+-]+$/i.test(mimeType)) throw new Error('附件类型无效。');
71
- return { name: file.name, mimeType, bytes, path: resolved };
72
- } finally { await handle.close(); }
73
- }
74
-
75
- export async function readMessageUploads(body, uploadsRoot) {
76
- const images = body.images ?? [], originals = body.originalImages ?? [], files = body.files ?? [];
77
- if (![images, originals, files].every(Array.isArray) || images.length + originals.length > 4 || files.length + originals.length > MAX_FILE_COUNT) {
78
- throw new Error('每条消息最多 4 张图片、4 个文件。');
79
- }
80
- const uploads = [];
81
- let imageBytes = 0;
82
- for (const image of images) {
83
- let file;
84
- if (typeof image === 'string' && image.startsWith('data:')) {
85
- validateInlineImage(image);
86
- const mimeType = image.slice(5, image.indexOf(';'));
87
- file = { name: `image.${mimeType.split('/')[1]}`, mimeType, bytes: Buffer.from(image.slice(image.indexOf(',') + 1), 'base64') };
88
- } else {
89
- if (typeof image !== 'string' || !/^[a-f0-9]{64}$/.test(image)) throw new Error('图片标识无效。');
90
- let extension = 'webp';
91
- try { await fs.access(path.join(uploadsRoot, `${image}.webp`)); }
92
- catch (error) { if (error.code !== 'ENOENT') throw error; extension = 'png'; }
93
- file = await localFile({ path: path.join(uploadsRoot, `${image}.${extension}`), name: `image.${extension}` }, uploadsRoot);
94
- }
95
- imageBytes += file.bytes.size ?? file.bytes.length;
96
- if (imageBytes > 8 * 1024 * 1024) throw new Error('压缩图片合计不能超过 8 MiB。');
97
- uploads.push(file);
98
- }
99
- for (const original of originals) {
100
- if (typeof original !== 'string' || !/\.(png|jpe?g|webp|gif)$/i.test(original)) throw new Error('原图路径无效。');
101
- }
102
- for (const file of [...originals.map(value => ({ path: value, name: path.basename(value) })), ...files]) {
103
- const uploaded = await localFile(file, path.join(uploadsRoot, 'incoming'));
104
- uploads.push(uploaded);
105
- }
106
- for (const file of uploads) {
107
- if (/^image\/(?:png|jpeg|webp|gif)$/.test(file.mimeType)) {
108
- const metadata = await sharp(file.path || file.bytes).metadata();
109
- file.width = metadata.width; file.height = metadata.height;
110
- if (!file.width || !file.height) throw new Error('无法读取 图片尺寸。');
111
- }
112
- }
113
- if (uploads.filter(file => file.width).length > 4) throw new Error('每条消息最多 4 张图片。');
114
- return uploads;
115
- }
1
+ import fs from 'node:fs/promises';
2
+ import { openAsBlob } from 'node:fs';
3
+ import path from 'node:path';
4
+ import sharp from 'sharp';
5
+ import media from '../shared/code-browser.cjs';
6
+ import { validateInlineImage } from './message-images.mjs';
7
+ import { createFileUploadSession, FILE_CHUNK_BYTES, MAX_FILE_COUNT } from './file-upload-session.mjs';
8
+ import { fileRevision } from './file-transfer-range.mjs';
9
+
10
+ const mimeTypes = { pdf: 'application/pdf', txt: 'text/plain', md: 'text/markdown', csv: 'text/csv',
11
+ json: 'application/json', zip: 'application/zip', doc: 'application/msword',
12
+ docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
13
+ xls: 'application/vnd.ms-excel', xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
14
+ ppt: 'application/vnd.ms-powerpoint', pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation' };
15
+
16
+ // Reuse the incoming-file contract without sending workstation bytes to the phone.
17
+ export async function stageWorkstationMessageFile(file, uploadsRoot) {
18
+ const handle = await fs.open(file.path, 'r');
19
+ const session = createFileUploadSession(uploadsRoot);
20
+ try {
21
+ const stat = await handle.stat();
22
+ if (!stat.isFile() || stat.size < 1) throw new Error('附件为空。');
23
+ const validate = async () => {
24
+ if (fileRevision(await handle.stat()) !== file.revision || fileRevision(await fs.stat(file.path)) !== file.revision) {
25
+ throw new Error('电脑文件已变化,请重新添加。');
26
+ }
27
+ };
28
+ await validate();
29
+ const bytes = Buffer.alloc(FILE_CHUNK_BYTES);
30
+ const readPart = async offset => {
31
+ const length = Math.min(bytes.length, stat.size - offset);
32
+ let count = 0;
33
+ while (count < length) {
34
+ const { bytesRead } = await handle.read(bytes, count, length - count, offset + count);
35
+ if (!bytesRead) throw new Error('电脑文件读取不完整,请重新添加。');
36
+ count += bytesRead;
37
+ }
38
+ return bytes.subarray(0, length);
39
+ };
40
+ await validate();
41
+ const started = await session.receive({ type: 'start', fileKey: '0', name: file.name, size: stat.size });
42
+ for (let offset = 0; offset < stat.size; offset += FILE_CHUNK_BYTES) {
43
+ await session.receive({ type: 'chunk', id: started.id, offset, data: await readPart(offset) });
44
+ }
45
+ await validate();
46
+ const result = await session.receive({ type: 'finish', id: started.id });
47
+ await session.receive({ type: 'commit' });
48
+ return { ...result, mediaKind: media.mediaKind(file),
49
+ mimeType: media.mediaMime(file) || mimeTypes[path.extname(file.name).slice(1).toLowerCase()] || 'application/octet-stream' };
50
+ } finally { await handle.close(); await session.close(); }
51
+ }
52
+
53
+ async function localFile(file, directory) {
54
+ if (typeof file?.path !== 'string' || typeof file.name !== 'string' || !file.name || /[\r\n\0]/.test(file.name)) {
55
+ throw new Error('附件信息无效,请重新选择。');
56
+ }
57
+ const root = await fs.realpath(directory), resolved = await fs.realpath(file.path);
58
+ if (path.relative(root, path.dirname(resolved)) !== '') throw new Error('附件必须来自本次工作站上传目录。');
59
+ const handle = await fs.open(resolved, 'r');
60
+ try {
61
+ const stat = await handle.stat();
62
+ if (!stat.isFile() || stat.size < 1) throw new Error('附件为空。');
63
+ const bytes = await openAsBlob(resolved);
64
+ if (bytes.size !== stat.size) throw new Error('附件在读取时发生变化,请重新上传。');
65
+ const mimeType = media.mediaMime(file) || mimeTypes[path.extname(file.name).slice(1).toLowerCase()] || 'application/octet-stream';
66
+ if (!/^[a-z0-9.+-]+\/[a-z0-9.+-]+$/i.test(mimeType)) throw new Error('附件类型无效。');
67
+ return { name: file.name, mimeType, bytes, path: resolved };
68
+ } finally { await handle.close(); }
69
+ }
70
+
71
+ export async function readMessageUploads(body, uploadsRoot) {
72
+ const images = body.images ?? [], originals = body.originalImages ?? [], files = body.files ?? [];
73
+ if (![images, originals, files].every(Array.isArray) || images.length + originals.length > 4 || files.length + originals.length > MAX_FILE_COUNT) {
74
+ throw new Error('每条消息最多 4 张图片、4 个文件。');
75
+ }
76
+ const uploads = [];
77
+ let imageBytes = 0;
78
+ for (const image of images) {
79
+ let file;
80
+ if (typeof image === 'string' && image.startsWith('data:')) {
81
+ validateInlineImage(image);
82
+ const mimeType = image.slice(5, image.indexOf(';'));
83
+ file = { name: `image.${mimeType.split('/')[1]}`, mimeType, bytes: Buffer.from(image.slice(image.indexOf(',') + 1), 'base64') };
84
+ } else {
85
+ if (typeof image !== 'string' || !/^[a-f0-9]{64}$/.test(image)) throw new Error('图片标识无效。');
86
+ let extension = 'webp';
87
+ try { await fs.access(path.join(uploadsRoot, `${image}.webp`)); }
88
+ catch (error) { if (error.code !== 'ENOENT') throw error; extension = 'png'; }
89
+ file = await localFile({ path: path.join(uploadsRoot, `${image}.${extension}`), name: `image.${extension}` }, uploadsRoot);
90
+ }
91
+ imageBytes += file.bytes.size ?? file.bytes.length;
92
+ if (imageBytes > 8 * 1024 * 1024) throw new Error('压缩图片合计不能超过 8 MiB。');
93
+ uploads.push(file);
94
+ }
95
+ for (const original of originals) {
96
+ if (typeof original !== 'string' || !/\.(png|jpe?g|webp|gif)$/i.test(original)) throw new Error('原图路径无效。');
97
+ }
98
+ for (const file of [...originals.map(value => ({ path: value, name: path.basename(value) })), ...files]) {
99
+ const uploaded = await localFile(file, path.join(uploadsRoot, 'incoming'));
100
+ uploads.push(uploaded);
101
+ }
102
+ for (const file of uploads) {
103
+ if (/^image\/(?:png|jpeg|webp|gif)$/.test(file.mimeType)) {
104
+ const metadata = await sharp(file.path || file.bytes).metadata();
105
+ file.width = metadata.width; file.height = metadata.height;
106
+ if (!file.width || !file.height) throw new Error('无法读取 图片尺寸。');
107
+ }
108
+ }
109
+ if (uploads.filter(file => file.width).length > 4) throw new Error('每条消息最多 4 张图片。');
110
+ return uploads;
111
+ }
@@ -5,7 +5,7 @@ import { digest } from './wire.mjs';
5
5
  export function accountConnections() {
6
6
  const sessions = new Map();
7
7
  return {
8
- track(token, onInvalid) {
8
+ track(token, onInvalid, refresh) {
9
9
  const key = digest(token);
10
10
  const entries = sessions.get(key) || new Set();
11
11
  sessions.set(key, entries);
@@ -25,10 +25,21 @@ export function accountConnections() {
25
25
  if (active && Date.now() >= expires) invalidate();
26
26
  return active;
27
27
  }
28
- function schedule() {
28
+ function schedule(delay = 86400000) {
29
+ clearTimeout(timer);
29
30
  if (!valid()) return;
30
- // Login lasts 30 days, longer than Node's maximum timer delay.
31
- timer = setTimeout(schedule, Math.min(expires - Date.now(), 0x7fffffff));
31
+ timer = setTimeout(async () => {
32
+ if (!valid()) return;
33
+ if (!refresh) { schedule(); return; }
34
+ try {
35
+ const user = await refresh();
36
+ // A logout/disconnect during the database request must win.
37
+ if (active) entry.activate(user.expiresAt);
38
+ } catch (error) {
39
+ if (error.status === 401 || error.status === 403) invalidate();
40
+ else if (active) schedule(60000);
41
+ }
42
+ }, Math.min(expires - Date.now(), delay));
32
43
  timer.unref();
33
44
  }
34
45
  const entry = {
@@ -134,12 +134,24 @@ export function openEmailAccounts(pool, configFile, providerOverride) {
134
134
  finally { db.release(); }
135
135
  }
136
136
 
137
- async function current(token) {
137
+ async function current(token, { renew = false } = {}) {
138
138
  if (typeof token !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(token)) throw error(401, 'Unauthorized');
139
- const [rows] = await pool.execute(`SELECT u.id,i.normalized_value AS email,s.expires_at FROM user_sessions s
139
+ const query = `SELECT u.id,i.normalized_value AS email,s.expires_at FROM user_sessions s
140
140
  JOIN users u ON u.id=s.user_id JOIN user_login_identities i ON i.user_id=u.id AND i.kind='email'
141
- WHERE s.token_hash=? AND s.revoked_at IS NULL AND s.expires_at>UTC_TIMESTAMP(3) AND u.status='active'`, [tokenHash(token)]);
141
+ WHERE s.token_hash=? AND s.revoked_at IS NULL AND s.expires_at>UTC_TIMESTAMP(3) AND u.status='active'`;
142
+ let [rows] = await pool.execute(query, [tokenHash(token)]);
142
143
  if (!rows.length) throw error(401, 'Unauthorized');
144
+ // Review identities keep their fixed authorization window, even when review login is disabled.
145
+ if (renew && rows[0].email !== config.reviewLogin?.email
146
+ && Date.parse(isoTime(rows[0].expires_at)) < Date.now() + 29 * 86400000) {
147
+ await pool.execute(`UPDATE user_sessions s JOIN users u ON u.id=s.user_id
148
+ SET s.expires_at=GREATEST(s.expires_at,UTC_TIMESTAMP(3) + INTERVAL 30 DAY)
149
+ WHERE s.token_hash=? AND s.revoked_at IS NULL AND s.expires_at>UTC_TIMESTAMP(3)
150
+ AND s.expires_at<UTC_TIMESTAMP(3) + INTERVAL 29 DAY AND u.status='active'`, [tokenHash(token)]);
151
+ // Recheck revocation/expiry after the conditional write; never resurrect an invalid session.
152
+ [rows] = await pool.execute(query, [tokenHash(token)]);
153
+ if (!rows.length) throw error(401, 'Unauthorized');
154
+ }
143
155
  return { id: rows[0].id, email: rows[0].email, expiresAt: isoTime(rows[0].expires_at) };
144
156
  }
145
157
 
@@ -266,7 +266,7 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
266
266
  }
267
267
  const bearer = /^Bearer ([A-Za-z0-9_-]{43})$/.exec(request.headers.authorization || '')?.[1];
268
268
  if (target.pathname === '/account/me' && request.method === 'GET') {
269
- const user = await accounts.current(bearer);
269
+ const user = await accounts.current(bearer, { renew: true });
270
270
  accountReply(response, 200, { user, usage: await accounts.usage(user.id) }); return;
271
271
  }
272
272
  if (target.pathname === '/account/logout' && request.method === 'POST') {
@@ -557,9 +557,10 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
557
557
  !(['pair', 'resume'].includes(message.type) && message.management === true)) {
558
558
  if (!['pair', 'resume', 'observe', 'file-client'].includes(message.type)) return fail();
559
559
  if (!accounts || !validSecret(message.accountToken)) return fail(4431, 'Account login required');
560
- accountConnection = accountSockets.track(message.accountToken, invalidateAccountConnection);
560
+ accountConnection = accountSockets.track(message.accountToken, invalidateAccountConnection,
561
+ () => accounts.current(message.accountToken, { renew: true }));
561
562
  try {
562
- const user = await accounts.current(message.accountToken);
563
+ const user = await accounts.current(message.accountToken, { renew: true });
563
564
  if (!accountConnection.activate(user.expiresAt)) return;
564
565
  userId = user.id;
565
566
  ws.adminInfo.account = { id: user.id, email: user.email };
@@ -14,6 +14,7 @@ import fileMentions from '../shared/file-mentions.cjs';
14
14
  import fileCitations from '../shared/file-citations.cjs';
15
15
  import { validateInlineImage } from './message-images.mjs';
16
16
  import { ThreadWriterConflictError } from './shared-app-server.mjs';
17
+ import { readThreadGoal, changeThreadGoal } from './thread-goals.mjs';
17
18
  import { ManagedAppServer } from './managed-app-server.mjs';
18
19
  import { RuntimeDiscovery } from './runtime-discovery.mjs';
19
20
  import { streamSharedThread, threadListNotificationMethods } from './shared-thread-stream.mjs';
@@ -125,7 +126,7 @@ const hostedHistoryCache = new HostedHistoryCache(stateRoot);
125
126
  const dotsServer = new DotsAppServer(managedAppServer.client, desktopDotsProfiles(codexDataRoot, stateRoot), hostedHistoryCache, { uploadsRoot });
126
127
  const dotsImages = new DotsMessageImages({ localServer: managedAppServer.client, identity: () => dotsServer.profileIdentity,
127
128
  cache: attachmentCache, capabilityKey: imageCapabilityKey, maxEntries: MAX_CACHED_ATTACHMENTS, maxBytes: MAX_IMAGE_PREVIEW_BYTES });
128
- const dotsChat = new DotsChatServer(dotsServer, undefined, { uploadsRoot, browserAuthOperations: new BrowserAuthOperations(stateRoot) });
129
+ const dotsChat = new DotsChatServer(dotsServer, undefined, { uploadsRoot, browserAuthOperations: new BrowserAuthOperations(stateRoot, codexDataRoot) });
129
130
  managedAppServer.on('state', state => { if (state.ready) dotsChat.startBackgroundActivity(); });
130
131
  const runtimeDiscovery = new RuntimeDiscovery({ stateRoot, host: desktopHost,
131
132
  url: () => managedAppServer.snapshot().url, diagnose: runtimeDiagnostic });
@@ -954,6 +955,22 @@ async function handleRequest(request, response) {
954
955
  }
955
956
  }
956
957
 
958
+ const goalMatch = /^\/api\/threads\/([^/]+)\/goal$/.exec(url.pathname);
959
+ if (goalMatch && ['GET', 'POST'].includes(request.method)) {
960
+ if (rootChat) throw new RequestError(405, '点点主聊天不提供线程目标操作。');
961
+ const threadId = decodeURIComponent(goalMatch[1]);
962
+ if (request.method === 'GET') sendJson(response, 200, await readThreadGoal(execution, threadId));
963
+ else {
964
+ await requireExecutionWritable(threadId, execution);
965
+ const body = await readJsonBody(request);
966
+ await execution.connect();
967
+ sendJson(response, 200, await changeThreadGoal(execution, threadId, body, {
968
+ beforeActivate: () => execution.prepareGoalActivation(threadId, { useGlobalPermissions: hostId !== 'durable' }),
969
+ }));
970
+ }
971
+ return;
972
+ }
973
+
957
974
  const renameMatch = /^\/api\/threads\/([^/]+)\/name$/.exec(url.pathname);
958
975
  if (request.method === 'POST' && renameMatch) {
959
976
  if (rootChat) throw new RequestError(405, '点点主聊天名称由点点资料管理。');
@@ -194,14 +194,18 @@ export class SharedAppServer extends EventEmitter {
194
194
 
195
195
  async send(args, { assertCurrent = () => {}, useGlobalPermissions = true } = {}) {
196
196
  let submitted = false;
197
+ let sendPhase = 'connect';
197
198
  try {
198
199
  await this.connect();
200
+ sendPhase = 'writable';
199
201
  this.requireWritable(args.threadId);
200
202
  // Reading the live thread checks this service owns it without resume changing settings.
203
+ sendPhase = 'thread-read';
201
204
  const { thread } = await this.request('thread/read', { threadId: args.threadId, includeTurns: false });
202
205
  assertCurrent();
203
206
  const status = typeof thread.status === 'string' ? thread.status : thread.status?.type;
204
207
  if (status === 'active') {
208
+ sendPhase = 'active-turn';
205
209
  const page = await this.request('thread/turns/list', {
206
210
  threadId: args.threadId, limit: 1, sortDirection: 'desc', itemsView: 'notLoaded',
207
211
  });
@@ -210,21 +214,37 @@ export class SharedAppServer extends EventEmitter {
210
214
  if (active?.status !== 'inProgress') throw new Error('活动轮次已变化,请刷新后再发送。');
211
215
  const { turnTrigger, ...source } = events.mobileTurnSource(args.clientUserMessageId, args.responsesapiClientMetadata?.code_relax_input === 'speech_to_text');
212
216
  submitted = true;
217
+ sendPhase = 'turn-steer';
213
218
  return await this.request('turn/steer', { threadId: args.threadId, expectedTurnId: active.id, input: args.input, ...source, ...(args.clientUserMessageId ? { clientUserMessageId: args.clientUserMessageId } : {}) },
214
219
  result => ({ result, usage: { kind: 'steer', turnId: active.id } }));
215
220
  }
221
+ sendPhase = 'permissions';
216
222
  const permissions = useGlobalPermissions ? await events.globalPermissionOverrides((method, params) => this.request(method, params),
217
223
  { ...this.contexts.get(args.threadId), desktopPermissionMode: await desktopPermissionMode(), model: args.model || this.contexts.get(args.threadId)?.model }, args.threadId) : {};
218
224
  assertCurrent();
219
225
  submitted = true;
226
+ sendPhase = 'turn-start';
220
227
  return await this.request('turn/start', { ...args, ...permissions },
221
228
  result => ({ result, usage: { kind: 'start', turnId: result?.turn?.id || null } }));
222
229
  } catch (error) {
223
230
  if (!submitted || messageDelivery.rejectedBeforeSubmission(error)) error.deliveryUnknown = false;
231
+ error.details = { ...(error.details && typeof error.details === 'object' ? error.details : {}), sendPhase,
232
+ ...(Number.isInteger(error.rpcCode) ? { rpcCode: error.rpcCode } : {}) };
224
233
  throw error;
225
234
  }
226
235
  }
227
236
 
237
+ async prepareGoalActivation(threadId, { useGlobalPermissions = true } = {}) {
238
+ this.requireWritable(threadId);
239
+ const record = this.threads.get(threadId);
240
+ if (!record || record.needsReload || record.hydrating) throw new Error('请等待对话同步完成后再开始目标');
241
+ if (!useGlobalPermissions) return;
242
+ const request = (method, params) => this.request(method, params);
243
+ const permissions = await events.globalPermissionOverrides(request,
244
+ { ...this.contexts.get(threadId), desktopPermissionMode: await desktopPermissionMode() }, threadId);
245
+ if (Object.keys(permissions).length) await request('thread/settings/update', { threadId, ...permissions });
246
+ }
247
+
228
248
  async findSentMessage(threadId, clientId, anchorItemId) {
229
249
  await this.connect();
230
250
  return messageDelivery.findMessage((method, params) => this.request(method, params), threadId, clientId, anchorItemId);
@@ -241,6 +261,7 @@ export class SharedAppServer extends EventEmitter {
241
261
  notification(method, params) {
242
262
  this.serverRequests.notification(method, params);
243
263
  this.emit('notification', { method, params });
264
+ if (['thread/goal/updated', 'thread/goal/cleared'].includes(method)) return;
244
265
  if (method === 'thread/queue/changed') { this.emit('queue-changed', params.threadId); return; }
245
266
  if (method === 'account/rateLimits/updated') { this.usageCache.update(params); return; }
246
267
  const id = params.threadId;
@@ -94,6 +94,11 @@ export async function streamSharedThread(response, threadId, client, normalize,
94
94
  flush();
95
95
  };
96
96
  const onSettings = id => { if (id === threadId) emit('settings', client.context(id)); };
97
+ const onGoal = ({ method, params }) => {
98
+ if (params.threadId === threadId && ['thread/goal/updated', 'thread/goal/cleared'].includes(method)) {
99
+ emit('goal', { threadId, goal: method === 'thread/goal/cleared' ? null : params.goal });
100
+ }
101
+ };
97
102
  const onRequests = id => { if (id === threadId) emit('approvals', { requests: client.serverRequests.list(threadId) }); };
98
103
  const onQueue = id => { if (id === threadId) emit('queue-changed', {}); };
99
104
  const onDisconnected = () => { emit('bridge-error', { error: 'Codex 内核连接已断开,请重新连接。' }); response.end(); finish(); };
@@ -102,6 +107,7 @@ export async function streamSharedThread(response, threadId, client, normalize,
102
107
  const onChatError = value => { if (value.threadId === threadId) emit('chat-error', value); };
103
108
  response.once('close', () => { closed = true; finish(); });
104
109
  client.on('turn', onTurn); client.on('settings', onSettings); client.on('disconnected', onDisconnected);
110
+ client.on('notification', onGoal);
105
111
  client.on('timeline-reset', onReset);
106
112
  client.on('queue-changed', onQueue);
107
113
  client.on('requests-changed', onRequests);
@@ -154,6 +160,7 @@ export async function streamSharedThread(response, threadId, client, normalize,
154
160
  clearTimeout(timer);
155
161
  releaseActivity?.();
156
162
  client.off('turn', onTurn); client.off('settings', onSettings); client.off('disconnected', onDisconnected);
163
+ client.off('notification', onGoal);
157
164
  client.off('timeline-reset', onReset);
158
165
  client.off('queue-changed', onQueue);
159
166
  client.off('requests-changed', onRequests);
@@ -0,0 +1,40 @@
1
+ // Goal state is metadata, independent of transcript snapshots and their cursors.
2
+ export async function readThreadGoal(client, threadId) {
3
+ await client.connect();
4
+ try {
5
+ return { supported: true, ...await client.request('thread/goal/get', { threadId }) };
6
+ } catch (error) {
7
+ if (error.rpcCode === -32601 || /goals feature is disabled/i.test(error.message)) {
8
+ return { supported: false, goal: null };
9
+ }
10
+ throw error;
11
+ }
12
+ }
13
+
14
+ export async function changeThreadGoal(client, threadId, body, { beforeActivate } = {}) {
15
+ if (body.action === 'clear') return client.request('thread/goal/clear', { threadId, origin: 'user' });
16
+ if (body.action !== 'set') throw new Error('无效的目标操作');
17
+ const params = { threadId, origin: 'user' };
18
+ if (body.objective !== undefined) {
19
+ if (typeof body.objective !== 'string' || !body.objective.trim() || [...body.objective.trim()].length > 4000) {
20
+ throw new Error('请输入 1 至 4000 字的目标');
21
+ }
22
+ params.objective = body.objective.trim();
23
+ }
24
+ if (body.status !== undefined) {
25
+ if (!['active', 'paused'].includes(body.status)) throw new Error('无效的目标状态');
26
+ params.status = body.status;
27
+ }
28
+ if (body.tokenBudget !== undefined) {
29
+ if (body.tokenBudget !== null && (!Number.isSafeInteger(body.tokenBudget) || body.tokenBudget <= 0)) throw new Error('Token 预算必须为正整数');
30
+ params.tokenBudget = body.tokenBudget;
31
+ }
32
+ if (Object.keys(params).length === 2) throw new Error('请选择目标操作');
33
+ // GoalService starts idle turns itself. Prepare the same settings as a normal
34
+ // send before activating; never send a second turn/start for the goal.
35
+ if (beforeActivate && (params.status === 'active' || params.status === undefined
36
+ && ((await client.request('thread/goal/get', { threadId })).goal?.status ?? 'active') === 'active')) {
37
+ await beforeActivate();
38
+ }
39
+ return client.request('thread/goal/set', params);
40
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@walkhi/code-relax",
3
- "version": "0.1.0-beta.83",
3
+ "version": "0.1.0-beta.91",
4
4
  "private": false,
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",