@xmanrui/dsh-im 3.0.8 → 3.1.1

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.
@@ -0,0 +1,385 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { mkdir, open, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises';
3
+ import { isAbsolute, join } from 'node:path';
4
+ import semver from 'semver';
5
+
6
+ import manifest from '../../package.json' with { type: 'json' };
7
+ import { withSessionBindingLock } from '../../src/channels/shared/session-binding-lock.mjs';
8
+ import { NPM_REGISTRY, PACKAGE_NAME } from './update-runtime.mjs';
9
+
10
+ const ACTIVE_STATES = new Set(['installing', 'verifying']);
11
+ const JOB_STATES = new Set([...ACTIVE_STATES, 'restart-required', 'completed', 'failed', 'interrupted']);
12
+ const MAX_METADATA_BYTES = 256 * 1024;
13
+ const SNAPSHOT_FILES = ['package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml'];
14
+ const NO_LOCK = Symbol('no update lock');
15
+
16
+ export function updateError(code) {
17
+ return Object.assign(new Error(code), { code });
18
+ }
19
+
20
+ /** Read only the fixed npm package; neither RPC callers nor registry metadata choose a command. */
21
+ export async function fetchNpmRelease(fetchImpl = globalThis.fetch, timeoutMs = 10_000) {
22
+ let response;
23
+ try {
24
+ response = await fetchImpl(`${NPM_REGISTRY}${encodeURIComponent(PACKAGE_NAME)}/latest`, {
25
+ headers: { accept: 'application/json' },
26
+ redirect: 'error',
27
+ signal: AbortSignal.timeout(timeoutMs),
28
+ });
29
+ if (!response.ok) throw updateError('check-failed');
30
+ if (Number(response.headers.get('content-length')) > MAX_METADATA_BYTES) {
31
+ throw updateError('invalid-release');
32
+ }
33
+ const chunks = [];
34
+ let length = 0;
35
+ for await (const chunk of response.body) {
36
+ length += chunk.byteLength;
37
+ if (length > MAX_METADATA_BYTES) throw updateError('invalid-release');
38
+ chunks.push(Buffer.from(chunk));
39
+ }
40
+ const value = JSON.parse(Buffer.concat(chunks).toString('utf8'));
41
+ const version = value.version;
42
+ if (value.name !== PACKAGE_NAME || typeof version !== 'string'
43
+ || semver.valid(version) !== version || semver.prerelease(version)) {
44
+ throw updateError('invalid-release');
45
+ }
46
+ const nodeRange = value.engines?.node;
47
+ if (nodeRange !== undefined && (typeof nodeRange !== 'string' || !semver.validRange(nodeRange))) {
48
+ throw updateError('invalid-release');
49
+ }
50
+ const tarball = new URL(value.dist?.tarball);
51
+ const integrity = value.dist?.integrity;
52
+ if (tarball.origin !== new URL(NPM_REGISTRY).origin || tarball.username || tarball.password
53
+ || tarball.search || tarball.hash
54
+ || tarball.pathname !== `/@xmanrui/dsh-im/-/dsh-im-${version}.tgz`
55
+ || typeof integrity !== 'string' || !/^sha512-[A-Za-z0-9+/]{86}==$/.test(integrity)) {
56
+ throw updateError('invalid-release');
57
+ }
58
+ return { version, nodeRange: nodeRange ?? '*', integrity, tarball: tarball.href };
59
+ } catch (error) {
60
+ if (error?.code === 'invalid-release' || error instanceof SyntaxError || error instanceof TypeError && response?.ok) {
61
+ throw updateError('invalid-release');
62
+ }
63
+ throw updateError('check-failed');
64
+ }
65
+ }
66
+
67
+ function pathsFor(environment) {
68
+ if (!isAbsolute(environment.homeDir ?? '') || !isAbsolute(environment.profileDir ?? '')) return null;
69
+ const key = createHash('sha256').update(environment.profileDir).digest('hex').slice(0, 24);
70
+ const directory = join(environment.homeDir, 'updates', 'dsh-im', key);
71
+ return {
72
+ directory,
73
+ state: join(directory, 'state.json'),
74
+ lock: join(directory, 'install.lock'),
75
+ backup: join(directory, 'before.json'),
76
+ };
77
+ }
78
+
79
+ async function readJson(path, missing = null) {
80
+ try {
81
+ if ((await stat(path)).size > 10 * 1024 * 1024) throw updateError('state-unavailable');
82
+ return JSON.parse(await readFile(path, 'utf8'));
83
+ } catch (error) {
84
+ if (error.code === 'ENOENT') return missing;
85
+ throw updateError('state-unavailable');
86
+ }
87
+ }
88
+
89
+ async function writeJson(path, value) {
90
+ const temporary = `${path}.${randomUUID()}.tmp`;
91
+ try {
92
+ await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600, flag: 'wx' });
93
+ await rename(temporary, path);
94
+ } catch {
95
+ throw updateError('state-unavailable');
96
+ } finally {
97
+ await unlink(temporary).catch((error) => {
98
+ if (error.code !== 'ENOENT') throw updateError('state-unavailable');
99
+ });
100
+ }
101
+ }
102
+
103
+ function processAlive(pid) {
104
+ if (!Number.isSafeInteger(pid) || pid <= 0) return false;
105
+ try {
106
+ process.kill(pid, 0);
107
+ return true;
108
+ } catch (error) {
109
+ return error.code !== 'ESRCH';
110
+ }
111
+ }
112
+
113
+ function publicJob(job) {
114
+ if (!job) return null;
115
+ const { id, state, targetVersion, message } = job;
116
+ return { id, state, targetVersion, message };
117
+ }
118
+
119
+ /** One update job per profile. Persist intent before starting pnpm, and never apply a restart here. */
120
+ export function createUpdateService({
121
+ runtime,
122
+ runningVersion = manifest.version,
123
+ nodeVersion = process.versions.node,
124
+ fetchImpl = globalThis.fetch,
125
+ now = Date.now,
126
+ checkTimeoutMs = 10_000,
127
+ confirmationTtlMs = 10 * 60_000,
128
+ installTimeoutMs = 15 * 60_000,
129
+ } = {}) {
130
+ const queue = {};
131
+ let checked = null;
132
+ let checking = null;
133
+ let lastCheckAt = -Infinity;
134
+ let activeJob = null;
135
+ let activeTask = null;
136
+ let abortController = null;
137
+ let unsavedJob = null;
138
+ let disposed = false;
139
+
140
+ function assertActive() {
141
+ if (disposed) throw updateError('disposed');
142
+ }
143
+
144
+ async function readJob(environment) {
145
+ const paths = pathsFor(environment);
146
+ if (!paths) return null;
147
+ // A failed final write must not make this Host display the old "installing"
148
+ // record forever. Keep the lock and expose the outcome we actually observed.
149
+ if (unsavedJob?.statePath === paths.state) return unsavedJob.job;
150
+ let job = await readJson(paths.state);
151
+ const lock = await readJson(paths.lock, NO_LOCK);
152
+ if (!job) {
153
+ if (lock !== NO_LOCK) {
154
+ return { id: 'locked', state: 'interrupted', message: 'recovery-required', targetVersion: null };
155
+ }
156
+ return null;
157
+ }
158
+ if (!JOB_STATES.has(job.state) || typeof job.id !== 'string' || !semver.valid(job.targetVersion)) {
159
+ throw updateError('state-unavailable');
160
+ }
161
+ if (ACTIVE_STATES.has(job.state) && job.id !== activeJob?.id) {
162
+ if (lock === NO_LOCK || lock?.id !== job.id || !processAlive(lock?.pid)) {
163
+ job = { ...job, state: 'interrupted', message: 'recovery-required' };
164
+ }
165
+ }
166
+ if (job.id !== activeJob?.id && !ACTIVE_STATES.has(job.state)) {
167
+ if (lock !== NO_LOCK) return { ...job, state: 'interrupted', message: 'recovery-required' };
168
+ // A later Host can retry after a verified manual repair. Never infer this
169
+ // from version equality while an old process lock is still present.
170
+ if (['failed', 'interrupted'].includes(job.state) && environment.packageValid === true
171
+ && environment.installedVersion === runningVersion && !environment.blockedReason) {
172
+ return job.targetVersion === runningVersion
173
+ ? { ...job, state: 'completed', message: 'recovered' }
174
+ : { ...job, recoverable: true };
175
+ }
176
+ }
177
+ if (job.state === 'restart-required' || job.state === 'completed') {
178
+ if (environment.installedVersion !== job.targetVersion || environment.packageValid !== true) {
179
+ return { ...job, state: 'interrupted', message: 'installation-changed' };
180
+ }
181
+ return { ...job, state: runningVersion === job.targetVersion ? 'completed' : 'restart-required' };
182
+ }
183
+ return job;
184
+ }
185
+
186
+ function snapshot(environment, job) {
187
+ let blockedReason = environment.blockedReason ?? checked?.blockedReason ?? null;
188
+ if (job?.state === 'interrupted' && !job.recoverable
189
+ || job?.state === 'failed' && environment.installedVersion !== runningVersion) {
190
+ blockedReason = 'recovery-required';
191
+ } else if (job?.state === 'restart-required' || environment.installedVersion && environment.installedVersion !== runningVersion) {
192
+ blockedReason = 'pending-restart';
193
+ }
194
+ const busy = ACTIVE_STATES.has(job?.state);
195
+ const canInstall = Boolean(environment.eligible && !blockedReason && !busy && checked?.checkId
196
+ && now() < checked.expiresAt && checked.installationKey === environment.installationKey
197
+ && semver.valid(runningVersion) && semver.gt(checked.release.version, runningVersion));
198
+ return {
199
+ runningVersion,
200
+ installedVersion: environment.installedVersion ?? null,
201
+ latestVersion: checked?.release.version ?? null,
202
+ profileName: environment.profileName ?? null,
203
+ environmentKind: environment.environmentKind ?? 'cli',
204
+ canInstall,
205
+ blockedReason,
206
+ checkedAt: checked?.checkedAt ?? null,
207
+ checkId: canInstall ? checked.checkId : null,
208
+ job: publicJob(job),
209
+ };
210
+ }
211
+
212
+ async function status() {
213
+ assertActive();
214
+ const environment = await runtime.inspect();
215
+ return snapshot(environment, await readJob(environment));
216
+ }
217
+
218
+ async function check() {
219
+ assertActive();
220
+ if (checking) return checking;
221
+ // Collapse double clicks without turning a failed request into a cached success.
222
+ if (checked?.checkId && now() - lastCheckAt < 2_000) return status();
223
+ lastCheckAt = now();
224
+ checking = (async () => {
225
+ try {
226
+ const environment = await runtime.inspect({ preflight: true });
227
+ const release = await fetchNpmRelease(fetchImpl, checkTimeoutMs);
228
+ assertActive();
229
+ checked = {
230
+ release,
231
+ checkId: randomUUID(),
232
+ checkedAt: now(),
233
+ expiresAt: now() + confirmationTtlMs,
234
+ installationKey: environment.installationKey,
235
+ profileDir: environment.profileDir,
236
+ blockedReason: environment.blockedReason
237
+ ?? (!semver.satisfies(nodeVersion, release.nodeRange) ? 'incompatible-node' : null),
238
+ };
239
+ return snapshot(environment, await readJob(environment));
240
+ } catch (error) {
241
+ if (checked) checked = { ...checked, checkId: null, expiresAt: 0 };
242
+ throw error;
243
+ } finally {
244
+ checking = null;
245
+ }
246
+ })();
247
+ return checking;
248
+ }
249
+
250
+ async function releaseLock(paths, id) {
251
+ const lock = await readJson(paths.lock);
252
+ if (lock?.id === id) await unlink(paths.lock);
253
+ }
254
+
255
+ async function backupProfile(environment, paths, job) {
256
+ const files = {};
257
+ for (const filename of SNAPSHOT_FILES) {
258
+ try {
259
+ const path = join(environment.profileDir, filename);
260
+ if ((await stat(path)).size > 3 * 1024 * 1024) throw updateError('state-unavailable');
261
+ files[filename] = await readFile(path, 'utf8');
262
+ } catch (error) {
263
+ if (error.code !== 'ENOENT') throw updateError('state-unavailable');
264
+ }
265
+ }
266
+ // Keep only the previous attempt's small manifests, never credentials or the entire home.
267
+ await writeJson(paths.backup, { jobId: job.id, previousVersion: job.previousVersion, files });
268
+ }
269
+
270
+ function rememberUnsavedJob(paths) {
271
+ activeJob = { ...activeJob, state: 'interrupted', message: 'state-unavailable' };
272
+ unsavedJob = { statePath: paths.state, job: activeJob };
273
+ }
274
+
275
+ async function execute(paths, job, originalEnvironment) {
276
+ const deadline = AbortSignal.timeout(installTimeoutMs);
277
+ try {
278
+ await runtime.install(job.targetVersion, {
279
+ signal: AbortSignal.any([abortController.signal, deadline]),
280
+ expectedInstallationKey: originalEnvironment.installationKey,
281
+ });
282
+ if (disposed) throw updateError('interrupted');
283
+ activeJob = { ...activeJob, state: 'verifying', updatedAt: now() };
284
+ await writeJson(paths.state, activeJob);
285
+ const environment = await runtime.inspect();
286
+ if (environment.homeDir !== originalEnvironment.homeDir || environment.profileDir !== originalEnvironment.profileDir
287
+ || environment.profileName !== originalEnvironment.profileName || environment.blockedReason === 'installation-changed') {
288
+ throw updateError('installation-changed');
289
+ }
290
+ if (environment.installedVersion !== job.targetVersion || environment.packageValid !== true) {
291
+ throw updateError('verify-failed');
292
+ }
293
+ if (environment.blockedReason && environment.blockedReason !== 'pending-restart') throw updateError('verify-failed');
294
+ activeJob = { ...activeJob, state: 'restart-required', message: null, updatedAt: now() };
295
+ await writeJson(paths.state, activeJob);
296
+ } catch (error) {
297
+ const timedOut = deadline.aborted || error.code === 'install-timeout';
298
+ const interrupted = disposed || abortController.signal.aborted
299
+ || !timedOut && ['interrupted', 'install-interrupted'].includes(error.code);
300
+ activeJob = {
301
+ ...activeJob,
302
+ state: interrupted ? 'interrupted' : 'failed',
303
+ message: interrupted ? 'interrupted' : timedOut ? 'install-timeout'
304
+ : ['verify-failed', 'state-unavailable', 'installation-changed', 'registry-conflict'].includes(error.code)
305
+ ? error.code : 'install-failed',
306
+ updatedAt: now(),
307
+ };
308
+ try {
309
+ await writeJson(paths.state, activeJob);
310
+ } catch {
311
+ // Retain the lock when the final record cannot be saved; the next Host must inspect it.
312
+ rememberUnsavedJob(paths);
313
+ return;
314
+ }
315
+ }
316
+ await releaseLock(paths, job.id);
317
+ }
318
+
319
+ function install({ checkId, requestId }) {
320
+ return withSessionBindingLock(queue, 'install', async () => {
321
+ assertActive();
322
+ let environment = await runtime.inspect({ preflight: true });
323
+ const previous = await readJob(environment);
324
+ if (previous?.requestId === requestId) return snapshot(environment, previous);
325
+ if (ACTIVE_STATES.has(previous?.state) || previous?.state === 'restart-required') throw updateError('update-busy');
326
+ const confirmation = checked;
327
+ if (!confirmation || !checkId || confirmation.checkId !== checkId || now() >= confirmation.expiresAt) {
328
+ throw updateError('check-expired');
329
+ }
330
+ if (environment.profileDir !== confirmation.profileDir || environment.installationKey !== confirmation.installationKey) {
331
+ throw updateError('installation-changed');
332
+ }
333
+ if (!snapshot(environment, previous).canInstall) throw updateError(environment.blockedReason ?? 'update-busy');
334
+ const paths = pathsFor(environment);
335
+ if (!paths) throw updateError('state-unavailable');
336
+ const job = {
337
+ id: randomUUID(), requestId, state: 'installing', message: null,
338
+ targetVersion: confirmation.release.version, previousVersion: environment.installedVersion,
339
+ startedAt: now(), updatedAt: now(),
340
+ };
341
+ let locked = false;
342
+ try {
343
+ await mkdir(paths.directory, { recursive: true, mode: 0o700 });
344
+ const lock = await open(paths.lock, 'wx', 0o600);
345
+ locked = true;
346
+ try {
347
+ await lock.writeFile(JSON.stringify({ id: job.id, pid: process.pid, startedAt: now() }));
348
+ } finally {
349
+ await lock.close();
350
+ }
351
+ const currentRelease = await fetchNpmRelease(fetchImpl, checkTimeoutMs);
352
+ if (JSON.stringify(currentRelease) !== JSON.stringify(confirmation.release)) throw updateError('check-expired');
353
+ environment = await runtime.inspect({ preflight: true });
354
+ if (environment.profileDir !== confirmation.profileDir || environment.installationKey !== confirmation.installationKey) {
355
+ throw updateError('installation-changed');
356
+ }
357
+ if (!environment.eligible || environment.blockedReason) throw updateError(environment.blockedReason ?? 'update-busy');
358
+ assertActive();
359
+ await backupProfile(environment, paths, job);
360
+ await writeJson(paths.state, job);
361
+ assertActive();
362
+ } catch (error) {
363
+ if (locked) await releaseLock(paths, job.id);
364
+ if (error.code === 'EEXIST') throw updateError('update-busy');
365
+ if (error.code?.startsWith('E')) throw updateError('state-unavailable');
366
+ throw error;
367
+ }
368
+ activeJob = job;
369
+ abortController = new AbortController();
370
+ activeTask = execute(paths, job, environment).catch(() => {
371
+ // Keep unknown cleanup failures local and keep the on-disk lock for manual recovery.
372
+ rememberUnsavedJob(paths);
373
+ });
374
+ return snapshot(environment, job);
375
+ });
376
+ }
377
+
378
+ async function close() {
379
+ disposed = true;
380
+ abortController?.abort();
381
+ if (activeTask) await activeTask;
382
+ }
383
+
384
+ return Object.freeze({ status, check, install, close });
385
+ }
@@ -28,6 +28,10 @@ const required = [
28
28
  'plugin-src/client/channels/dingtalk/index.js',
29
29
  'plugin-src/client/channels/slack/index.js',
30
30
  'plugin-src/client/i18n.js',
31
+ 'plugin-src/client/update-panel.js',
32
+ 'plugin-src/host/update-service.mjs',
33
+ 'plugin-src/host/update-runtime.mjs',
34
+ 'plugin-src/host/update-rpc.mjs',
31
35
  'plugin-src/host/channels/feishu/index.mjs',
32
36
  'plugin-src/host/channels/weixin/index.mjs',
33
37
  'plugin-src/host/channels/dingtalk/index.mjs',
@@ -143,6 +147,14 @@ for (const marker of ['/feishu', '/weixin', '/dingtalk', '/wecom', '/qq', '/slac
143
147
  throw new Error(`host bundle does not contain the internal ${marker} RPC provider`);
144
148
  }
145
149
  }
150
+ for (const marker of ['update.status', 'update.check', 'update.install']) {
151
+ if (!host.includes(marker) || !client.includes(marker)) {
152
+ throw new Error(`update RPC endpoint missing from Host or Client bundle: ${marker}`);
153
+ }
154
+ }
155
+ if (!host.includes('https://registry.npmjs.org/') || !host.includes('desktopPnpm')) {
156
+ throw new Error('host bundle is missing the npm updater or Desktop package-management adapter');
157
+ }
146
158
  for (const marker of ['/session Session ID', 'bindWorkspaceSession', 'session-subagent-unsupported']) {
147
159
  if (!host.includes(marker)) {
148
160
  throw new Error(`host bundle does not contain the Session binding marker: ${marker}`);
@@ -13,6 +13,7 @@ import {
13
13
  } from '../shared/harness-question.mjs';
14
14
  import { HarnessApprovalQueue } from '../shared/harness-approval.mjs';
15
15
  import { runCompactCommand } from '../shared/compact-command.mjs';
16
+ import { isHistoryCommand, runHistoryCommand } from '../shared/history-command.mjs';
16
17
  import {
17
18
  isControlCommand,
18
19
  runControlCommand,
@@ -67,6 +68,7 @@ const HELP_TEXT_LINES = [
67
68
  '直接发送文字、图片或文件即可继续当前会话。',
68
69
  '/new 开启一个全新会话',
69
70
  '/compact 压缩当前会话的较早上下文',
71
+ '/history [数量] 查看最近历史消息(默认 3 条,最多 5 条)',
70
72
  '/workspace 工作区绝对路径 切换工作区',
71
73
  '/workspacelist 列出工作区绝对路径',
72
74
  '/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题',
@@ -522,7 +524,8 @@ export class DingtalkHarnessBridge {
522
524
  ));
523
525
  }
524
526
  }
525
- const commandRunner = hasInboundFiles(promptMessage) ? null : isControlCommand(commandText)
527
+ const commandRunner = isHistoryCommand(commandText) ? runHistoryCommand
528
+ : hasInboundFiles(promptMessage) ? null : isControlCommand(commandText)
526
529
  ? runControlCommand
527
530
  : (isModelCommand(commandText)
528
531
  ? runModelCommand
@@ -789,6 +792,7 @@ export class DingtalkHarnessBridge {
789
792
  key,
790
793
  {
791
794
  signal: this.#signal,
795
+ isDirect: String(message.conversationType) === '1',
792
796
  hasImages: hasInboundImages(prompt),
793
797
  hasFiles: hasInboundFiles(prompt),
794
798
  pendingInteraction: this.#pendingInteractions.has(key)
@@ -30,6 +30,7 @@ import {
30
30
  isBatchInputCommand,
31
31
  } from '../shared/batch-input.mjs';
32
32
  import { runCompactCommand } from '../shared/compact-command.mjs';
33
+ import { isHistoryCommand, runHistoryCommand } from '../shared/history-command.mjs';
33
34
  import {
34
35
  isControlCommand,
35
36
  runControlCommand,
@@ -48,6 +49,7 @@ import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
48
49
  import { deliverOutboundArtifacts } from '../shared/semantic/artifact-delivery.mjs';
49
50
  import {
50
51
  createDeliveryReceipt,
52
+ providerMessageIdsFor,
51
53
  } from '../shared/semantic/delivery.mjs';
52
54
  import {
53
55
  channelDeliveryFailure,
@@ -620,12 +622,15 @@ export class FeishuHarnessBridge {
620
622
  .finally(() => this.#acceptedMessageIds.delete(messageId));
621
623
  return processing;
622
624
  }
623
- const commandRunner = hasInboundFiles(commandMessage) ? null : isControlCommand(commandText)
625
+ const commandRunner = isHistoryCommand(commandText) ? runHistoryCommand
626
+ : hasInboundFiles(commandMessage) ? null : isControlCommand(commandText)
624
627
  ? runControlCommand
625
628
  : (isModelCommand(commandText)
626
629
  ? runModelCommand
627
630
  : (isPresetCommand(commandText) ? runPresetCommand : null));
628
- if (commandRunner && addressed) {
631
+ // In all-message group mode, history must still be refused locally rather
632
+ // than becoming a normal prompt when no mention is present.
633
+ if (commandRunner && (addressed || commandRunner === runHistoryCommand)) {
629
634
  const processing = this.#processFastCommand(
630
635
  event,
631
636
  messageId,
@@ -913,6 +918,7 @@ export class FeishuHarnessBridge {
913
918
  key,
914
919
  {
915
920
  signal: this.#signal,
921
+ isDirect: event.message.chat_type === 'p2p',
916
922
  hasImages: hasInboundImages(message),
917
923
  hasFiles: hasInboundFiles(message),
918
924
  pendingInteraction: this.#hasPendingInteraction(key),
@@ -2083,7 +2089,10 @@ export class FeishuHarnessBridge {
2083
2089
  try {
2084
2090
  const bound = await this.#harness.bindWorkspaceSession(key, sessionId);
2085
2091
  const title = String(bound?.title ?? '').replace(/\s+/gu, ' ').trim() || t('暂无标题');
2086
- await this.#send(chatId, t('已绑定会话「{title}」\nID:{id}', { title, id: bound?.sessionId ?? sessionId }));
2092
+ await this.#send(chatId, [
2093
+ t('已绑定会话「{title}」\nID:{id}', { title, id: bound?.sessionId ?? sessionId }),
2094
+ t('发送 /history 查看最近对话。'),
2095
+ ].join('\n'));
2087
2096
  await this.#sendMenuCard(key, chatId, { updateMessageId });
2088
2097
  } catch (error) {
2089
2098
  await this.#sendFailure(chatId, error, {
@@ -3307,7 +3316,7 @@ export class FeishuHarnessBridge {
3307
3316
  createDeliveryReceipt({
3308
3317
  deliveryId: messageId,
3309
3318
  presentation: 'feishu-cardkit',
3310
- providerMessageIds: stream?.messageId ? [stream.messageId] : [],
3319
+ providerMessageIds: providerMessageIdsFor(stream),
3311
3320
  }),
3312
3321
  );
3313
3322
  this.#status.streamResponses = (this.#status.streamResponses ?? 0) + 1;
@@ -497,6 +497,7 @@ export function menuHelpText() {
497
497
  '/status 连接状态',
498
498
  '/version 查看插件版本',
499
499
  '/compact 压缩当前会话上下文',
500
+ '/history [数量] 查看最近历史消息(默认 3 条,最多 5 条)',
500
501
  '/archived on/off 会话列表显示/隐藏归档',
501
502
  '',
502
503
  '👁 关注',
@@ -593,6 +594,7 @@ export function helpCard(extraTextLines = []) {
593
594
  { tag: 'div', text: markdown([
594
595
  t(HELP_TEXT_COMMANDS),
595
596
  t('`/version` — 查看插件版本'),
597
+ t('/history [数量] 查看最近历史消息(默认 3 条,最多 5 条)'),
596
598
  ].join('\n') + extraText) },
597
599
  { tag: 'hr' },
598
600
  { tag: 'div', text: markdown(t(HELP_NUMBER_FALLBACK)) },