@xmanrui/dsh-im 3.0.7 → 3.1.0

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.
Files changed (48) hide show
  1. package/README.en.md +25 -2
  2. package/README.md +25 -2
  3. package/THIRD_PARTY_NOTICES.md +20 -0
  4. package/lib/client.js +542 -47
  5. package/lib/index.js +232 -225
  6. package/package.json +3 -2
  7. package/plugin-src/client/i18n.js +56 -0
  8. package/plugin-src/client/index.js +19 -2
  9. package/plugin-src/client/styles.js +34 -1
  10. package/plugin-src/client/update-panel.js +367 -0
  11. package/plugin-src/host/channels/dingtalk/index.mjs +1 -1
  12. package/plugin-src/host/channels/dingtalk/production.mjs +3 -11
  13. package/plugin-src/host/channels/discord/index.mjs +1 -1
  14. package/plugin-src/host/channels/feishu/index.mjs +1 -1
  15. package/plugin-src/host/channels/feishu/production.mjs +3 -13
  16. package/plugin-src/host/channels/office/production.mjs +3 -3
  17. package/plugin-src/host/channels/qq/index.mjs +1 -1
  18. package/plugin-src/host/channels/qq/production.mjs +3 -11
  19. package/plugin-src/host/channels/shared/production.mjs +3 -11
  20. package/plugin-src/host/channels/slack/index.mjs +1 -1
  21. package/plugin-src/host/channels/slack/production.mjs +4 -3
  22. package/plugin-src/host/channels/telegram/index.mjs +1 -1
  23. package/plugin-src/host/channels/wecom/index.mjs +1 -1
  24. package/plugin-src/host/channels/wecom/production.mjs +3 -11
  25. package/plugin-src/host/channels/weixin/index.mjs +1 -1
  26. package/plugin-src/host/channels/weixin/production.mjs +3 -11
  27. package/plugin-src/host/channels/whatsapp/index.mjs +1 -1
  28. package/plugin-src/host/channels/whatsapp/production.mjs +3 -11
  29. package/plugin-src/host/harness-connection.mjs +15 -0
  30. package/plugin-src/host/index.mjs +10 -1
  31. package/plugin-src/host/update-rpc.mjs +50 -0
  32. package/plugin-src/host/update-runtime.mjs +338 -0
  33. package/plugin-src/host/update-service.mjs +385 -0
  34. package/scripts/verify-package.mjs +12 -0
  35. package/src/channels/dingtalk/dingtalk-bridge.mjs +5 -1
  36. package/src/channels/feishu/bridge.mjs +11 -3
  37. package/src/channels/feishu/feishu-cards.mjs +2 -0
  38. package/src/channels/qq/qq-bridge.mjs +5 -1
  39. package/src/channels/shared/bot-workspace-store.mjs +3 -0
  40. package/src/channels/shared/harness-client.mjs +252 -281
  41. package/src/channels/shared/harness-mux.mjs +123 -0
  42. package/src/channels/shared/history-command.mjs +193 -0
  43. package/src/channels/shared/i18n-en/shared-b.mjs +1 -0
  44. package/src/channels/shared/i18n-en/shared-c.mjs +40 -0
  45. package/src/channels/shared/text-harness-bridge.mjs +5 -1
  46. package/src/channels/shared/workspace-command.mjs +1 -0
  47. package/src/channels/wecom/wecom-bridge.mjs +5 -1
  48. package/src/channels/weixin/weixin-bridge.mjs +5 -1
@@ -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,
@@ -620,12 +621,15 @@ export class FeishuHarnessBridge {
620
621
  .finally(() => this.#acceptedMessageIds.delete(messageId));
621
622
  return processing;
622
623
  }
623
- const commandRunner = hasInboundFiles(commandMessage) ? null : isControlCommand(commandText)
624
+ const commandRunner = isHistoryCommand(commandText) ? runHistoryCommand
625
+ : hasInboundFiles(commandMessage) ? null : isControlCommand(commandText)
624
626
  ? runControlCommand
625
627
  : (isModelCommand(commandText)
626
628
  ? runModelCommand
627
629
  : (isPresetCommand(commandText) ? runPresetCommand : null));
628
- if (commandRunner && addressed) {
630
+ // In all-message group mode, history must still be refused locally rather
631
+ // than becoming a normal prompt when no mention is present.
632
+ if (commandRunner && (addressed || commandRunner === runHistoryCommand)) {
629
633
  const processing = this.#processFastCommand(
630
634
  event,
631
635
  messageId,
@@ -913,6 +917,7 @@ export class FeishuHarnessBridge {
913
917
  key,
914
918
  {
915
919
  signal: this.#signal,
920
+ isDirect: event.message.chat_type === 'p2p',
916
921
  hasImages: hasInboundImages(message),
917
922
  hasFiles: hasInboundFiles(message),
918
923
  pendingInteraction: this.#hasPendingInteraction(key),
@@ -2083,7 +2088,10 @@ export class FeishuHarnessBridge {
2083
2088
  try {
2084
2089
  const bound = await this.#harness.bindWorkspaceSession(key, sessionId);
2085
2090
  const title = String(bound?.title ?? '').replace(/\s+/gu, ' ').trim() || t('暂无标题');
2086
- await this.#send(chatId, t('已绑定会话「{title}」\nID:{id}', { title, id: bound?.sessionId ?? sessionId }));
2091
+ await this.#send(chatId, [
2092
+ t('已绑定会话「{title}」\nID:{id}', { title, id: bound?.sessionId ?? sessionId }),
2093
+ t('发送 /history 查看最近对话。'),
2094
+ ].join('\n'));
2087
2095
  await this.#sendMenuCard(key, chatId, { updateMessageId });
2088
2096
  } catch (error) {
2089
2097
  await this.#sendFailure(chatId, error, {
@@ -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)) },
@@ -1,5 +1,6 @@
1
1
  import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
2
2
  import { runCompactCommand } from '../shared/compact-command.mjs';
3
+ import { isHistoryCommand, runHistoryCommand } from '../shared/history-command.mjs';
3
4
  import {
4
5
  isControlCommand,
5
6
  runControlCommand,
@@ -78,6 +79,7 @@ function helpText() {
78
79
  t('直接发送文字、图片或文件即可继续当前会话。'),
79
80
  t('/new 开启一个全新会话'),
80
81
  t('/compact 压缩当前会话的较早上下文'),
82
+ t('/history [数量] 查看最近历史消息(默认 3 条,最多 5 条)'),
81
83
  t('/workspace 工作区绝对路径 切换工作区'),
82
84
  t('/workspacelist 列出工作区绝对路径'),
83
85
  t('/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题'),
@@ -468,7 +470,8 @@ export class QqHarnessBridge {
468
470
  return this.#finishBatchResult(message, messageId, result);
469
471
  }
470
472
  }
471
- const commandRunner = hasQqFileAttachments(message) ? null : isControlCommand(commandText)
473
+ const commandRunner = isHistoryCommand(commandText) ? runHistoryCommand
474
+ : hasQqFileAttachments(message) ? null : isControlCommand(commandText)
472
475
  ? runControlCommand
473
476
  : (isModelCommand(commandText)
474
477
  ? runModelCommand
@@ -602,6 +605,7 @@ export class QqHarnessBridge {
602
605
  this.#status.lastMessageAt = new Date().toISOString();
603
606
  const result = await runner(text, this.#harness, this.#state, key, {
604
607
  signal: this.#signal,
608
+ isDirect: message.kind === 'c2c',
605
609
  hasImages: hasQqImageAttachments(message),
606
610
  hasFiles: hasQqFileAttachments(message),
607
611
  pendingInteraction: this.#pendingInteractions.has(key)
@@ -834,6 +834,9 @@ export function createBotWorkspaceScope(
834
834
  models(...args) {
835
835
  return invokeCurrentSession('getSessionModels', args, 'model listing');
836
836
  },
837
+ readHistory(...args) {
838
+ return invokeCurrentSession('readSessionHistory', args, 'history read');
839
+ },
837
840
  selectModel(...args) {
838
841
  return invokeCurrentSession('selectSessionModel', args, 'model selection');
839
842
  },