dsh-caduceus 0.7.1 → 0.12.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.
@@ -18,7 +18,7 @@
18
18
  */
19
19
  import { createUserMessage } from '@deepseek-ai/dsh-llm';
20
20
  import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
21
- import { join } from 'node:path';
21
+ import { basename, join } from 'node:path';
22
22
  import { planSlashLine } from '../commands/dispatch.js';
23
23
  import { helpText, parseCommand } from '../commands/parse.js';
24
24
  import { evaluateGate, logGate } from '../gate.js';
@@ -26,7 +26,13 @@ import { silentLogger } from '../logger.js';
26
26
  import { formatScopeLine, sessionIdTimestamp, sessionTimeLabel, shortSessionId, } from '../observability.js';
27
27
  import { buildDoctorReport, renderDoctorJson, renderDoctorText, } from '../doctor.js';
28
28
  import { writeOwnership } from '../ownership.js';
29
+ import { defaultExportCacheDir } from '../home.js';
29
30
  import { extractMediaMarkers, resolveMedia } from '../media.js';
31
+ import { aggregateUsage, renderUsageReport } from '../usage.js';
32
+ import { aggregateMetrics, formatMetricsLine } from '../metrics.js';
33
+ import { presetLabel, t } from '../i18n.js';
34
+ import { buildPermissionCard, renderPermissionText, } from '../render/permission-card.js';
35
+ import { readSessionLogFile, renderExportJsonl, renderExportMarkdown, truncateExport, writeExportFile, } from '../export.js';
30
36
  import { finalCard, finalCardPlainText, progressCard, } from '../render/cards.js';
31
37
  import { CardStream, systemClock } from '../render/stream.js';
32
38
  import { mintSessionId, scopeKey, SCOPE_KEY_SEPARATOR } from '../session-key.js';
@@ -94,6 +100,11 @@ export class Bridge {
94
100
  #queue = new ScopeQueue();
95
101
  #states = new Map();
96
102
  #chatIdBySession = new Map();
103
+ #permissionCards = new Map();
104
+ #stopCards = new Map();
105
+ #stopClicks = new Map();
106
+ #sessionMetrics = new Map();
107
+ #metricsFallbackWarned = new Set();
97
108
  #scopeBySession = new Map();
98
109
  constructor(options) {
99
110
  this.#options = options;
@@ -210,10 +221,14 @@ export class Bridge {
210
221
  const state = this.#states.get(key);
211
222
  if (state === undefined)
212
223
  return;
224
+ state.logEvents.push(event);
225
+ if (state.logEvents.length > 5000)
226
+ state.logEvents.shift();
213
227
  state.status.apply(event);
214
228
  switch (event.type) {
215
229
  case 'turn/start': {
216
230
  state.running = true;
231
+ state.stopRequested = false;
217
232
  state.lastOutput = '';
218
233
  state.progress?.dispose();
219
234
  state.progress = this.#makeCardStream(state);
@@ -266,6 +281,13 @@ export class Bridge {
266
281
  await this.#renderProgress(state);
267
282
  break;
268
283
  }
284
+ case 'compaction/start':
285
+ case 'compaction/end':
286
+ case 'team/member':
287
+ case 'team/task': {
288
+ await this.#renderProgress(state);
289
+ break;
290
+ }
269
291
  default:
270
292
  // Informational events only refresh the tracker.
271
293
  break;
@@ -370,10 +392,11 @@ export class Bridge {
370
392
  const anomaly = fails.length > 0 && failAt !== undefined
371
393
  ? `\n最近异常:${fails.join(',')} @ ${sessionTimeLabel(failAt, this.#now())}`
372
394
  : '';
395
+ const permissionLine = this.#permissionLineFor(state);
373
396
  await this.#replyText(state, `${selfLine === undefined ? '' : `${selfLine}\n`}${statusText(state.status.get(), {
374
397
  ...(title === undefined ? {} : { title }),
375
398
  ...(state.sessionId === undefined ? {} : { sessionId: state.sessionId }),
376
- })}${anomaly}`);
399
+ })}${permissionLine === undefined ? '' : `\n${permissionLine}`}${anomaly}`);
377
400
  return;
378
401
  }
379
402
  case 'stop': {
@@ -429,6 +452,18 @@ export class Bridge {
429
452
  await this.#replyText(state, result.text);
430
453
  return;
431
454
  }
455
+ case 'export': {
456
+ await this.#exportSession(state, command.json === true);
457
+ return;
458
+ }
459
+ case 'permission': {
460
+ await this.#handlePermission(state, command.preset);
461
+ return;
462
+ }
463
+ case 'usage-report': {
464
+ await this.#handleUsage(state);
465
+ return;
466
+ }
432
467
  case 'whoami': {
433
468
  const openId = state.ownerOpenId ?? '';
434
469
  const identity = this.#isAdmin(openId) ? '管理员' : '访客';
@@ -601,6 +636,207 @@ export class Bridge {
601
636
  return;
602
637
  }
603
638
  }
639
+ /** `/permission`: Chinese overview; switches still go through dsh's command. */
640
+ /** Permission-card button callback: run the same dsh switch, then patch the card. */
641
+ async handlePermissionCardAction(action, name) {
642
+ const meta = this.#permissionCards.get(action.messageId);
643
+ if (meta === undefined)
644
+ return;
645
+ const state = this.#states.get(meta.key);
646
+ if (state === undefined)
647
+ return;
648
+ if (meta.ownerOpenId !== undefined && action.operatorOpenId !== meta.ownerOpenId) {
649
+ this.#logger.warn(`permission card ignored for non-owner ${action.operatorOpenId}`);
650
+ return;
651
+ }
652
+ const service = this.#options.permissionPresetsProvider?.();
653
+ const sessionId = state.sessionId;
654
+ if (service === undefined || sessionId === undefined)
655
+ return;
656
+ if (!service.names.includes(name)) {
657
+ await this.#replyText(state, t(this.#lang()).unknownPreset(name, service.names.join(', ')));
658
+ return;
659
+ }
660
+ const agent = this.#agents.get(sessionId);
661
+ if (agent === undefined)
662
+ return;
663
+ const result = await this.#options.runHarnessCommand?.(agent, `/permission ${name}`);
664
+ if (result === undefined) {
665
+ await this.#replyText(state, '切换失败:dsh 命令未返回结果。');
666
+ return;
667
+ }
668
+ const view = this.#permissionViewFor(state);
669
+ if (view !== undefined) {
670
+ try {
671
+ await this.#transport.updateCard(action.messageId, buildPermissionCard(view));
672
+ }
673
+ catch (error) {
674
+ this.#logger.warn(`permission card patch failed: ${errorMessage(error)}`);
675
+ }
676
+ }
677
+ await this.#replyText(state, translatePermissionOutput(result.text, name, this.#lang()));
678
+ }
679
+ /** `/usage`: aggregate durable assistant/message usage from the session log. */
680
+ async #handleUsage(state) {
681
+ const sessionId = state.sessionId ?? this.#sessionMap.get(state.key);
682
+ if (sessionId === undefined) {
683
+ await this.#replyText(state, '读不到会话日志(当前没有会话)');
684
+ return;
685
+ }
686
+ const provider = this.#options.usageProvider;
687
+ if (provider === undefined) {
688
+ await this.#replyText(state, '读不到会话日志(sessionQuery 服务不可用)');
689
+ return;
690
+ }
691
+ let events;
692
+ try {
693
+ events = await provider(sessionId);
694
+ }
695
+ catch (error) {
696
+ await this.#replyText(state, `读不到会话日志(${errorMessage(error)})`);
697
+ return;
698
+ }
699
+ if (events === undefined) {
700
+ await this.#replyText(state, '读不到会话日志(sessionQuery 服务不可用)');
701
+ return;
702
+ }
703
+ const metricsLine = formatMetricsLine(aggregateMetrics(events), this.#config.metrics ?? 'compact');
704
+ await this.#replyText(state, renderUsageReport(aggregateUsage(events)) +
705
+ (metricsLine.length === 0 ? '' : `\n${metricsLine}`));
706
+ }
707
+ #lang() {
708
+ return this.#options.languageProvider?.() ?? 'zh';
709
+ }
710
+ #permissionViewFor(state) {
711
+ const service = this.#options.permissionPresetsProvider?.();
712
+ const sessionId = state.sessionId;
713
+ if (service === undefined || sessionId === undefined)
714
+ return undefined;
715
+ const agent = this.#agents.get(sessionId);
716
+ if (agent === undefined)
717
+ return undefined;
718
+ const current = service.current(agent.session);
719
+ const presets = service.names.map((name) => {
720
+ try {
721
+ const spec = service.resolve?.(name);
722
+ return {
723
+ name,
724
+ resolved: spec !== undefined,
725
+ ...(spec?.sandbox === undefined ? {} : { sandbox: String(spec.sandbox) }),
726
+ ...(spec?.approval === undefined ? {} : { approval: String(spec.approval) }),
727
+ };
728
+ }
729
+ catch {
730
+ return { name, resolved: false };
731
+ }
732
+ });
733
+ return { lang: this.#lang(), current, presets };
734
+ }
735
+ #permissionLineFor(state) {
736
+ const view = this.#permissionViewFor(state);
737
+ if (view === undefined)
738
+ return undefined;
739
+ const current = view.presets.find((preset) => preset.name === view.current);
740
+ const approval = current?.approval ?? '?';
741
+ return t(view.lang).approvalLine(view.current, t(view.lang).approvalLabel(approval));
742
+ }
743
+ async #handlePermission(state, preset) {
744
+ const resolved = await this.#tryResolve(state);
745
+ if (resolved === undefined) {
746
+ await this.#replyText(state, '当前没有可用会话。');
747
+ return;
748
+ }
749
+ const service = this.#options.permissionPresetsProvider?.();
750
+ if (service === undefined) {
751
+ // No service to validate/translate against: keep dsh's original output,
752
+ // explicitly labelled so the user knows it is the upstream text.
753
+ const raw = await this.#runDshCommandLine(resolved.agent, preset === undefined ? '/permission' : `/permission ${preset}`);
754
+ await this.#replyText(state, `(dsh 原文)\n${raw}`);
755
+ return;
756
+ }
757
+ if (preset === undefined) {
758
+ const view = this.#permissionViewFor(state);
759
+ if (view === undefined) {
760
+ const raw = await this.#runDshCommandLine(resolved.agent, '/permission');
761
+ await this.#replyText(state, `(dsh 原文)\n${raw}`);
762
+ return;
763
+ }
764
+ const card = buildPermissionCard(view);
765
+ try {
766
+ const sent = await this.#sendNewCard(state, card);
767
+ this.#permissionCards.set(sent.messageId, {
768
+ key: state.key,
769
+ ...(state.ownerOpenId === undefined ? {} : { ownerOpenId: state.ownerOpenId }),
770
+ });
771
+ }
772
+ catch {
773
+ await this.#replyText(state, renderPermissionText(view));
774
+ }
775
+ return;
776
+ }
777
+ if (!service.names.includes(preset)) {
778
+ await this.#replyText(state, `未知档位 ${preset},可用:${service.names.join(', ')}`);
779
+ return;
780
+ }
781
+ const result = await this.#options.runHarnessCommand?.(resolved.agent, `/permission ${preset}`);
782
+ if (result === undefined) {
783
+ await this.#replyText(state, service === undefined
784
+ ? '(dsh 原文)\n权限服务不可用,dsh 命令未返回结果。'
785
+ : '切换失败:dsh 命令未返回结果。');
786
+ return;
787
+ }
788
+ await this.#replyText(state, translatePermissionOutput(result.text, preset, this.#lang()));
789
+ }
790
+ async #runDshCommandLine(agent, line) {
791
+ const result = await this.#options.runHarnessCommand?.(agent, line);
792
+ return result === undefined
793
+ ? '权限服务不可用,dsh 命令未返回结果。'
794
+ : result.text;
795
+ }
796
+ /** `/export`: write a local markdown/JSONL file and hand it to the MEDIA chain. */
797
+ async #exportSession(state, json) {
798
+ const sessionId = state.sessionId ?? this.#sessionMap.get(state.key);
799
+ if (sessionId === undefined) {
800
+ await this.#replyText(state, '当前没有可导出的会话。');
801
+ return;
802
+ }
803
+ let data;
804
+ try {
805
+ data = await this.#options.exportProvider?.(sessionId);
806
+ if (data === undefined)
807
+ data = readSessionLogFile(sessionId);
808
+ }
809
+ catch (error) {
810
+ await this.#replyText(state, `导出失败:${errorMessage(error)}`);
811
+ return;
812
+ }
813
+ if (data === undefined || (data.messages.length === 0 && data.rawJsonl === undefined)) {
814
+ await this.#replyText(state, '本会话没有可导出的日志。');
815
+ return;
816
+ }
817
+ const extension = json ? 'jsonl' : 'md';
818
+ const content = json
819
+ ? truncateExport(data.rawJsonl ?? renderExportJsonl(data.messages))
820
+ : renderExportMarkdown({
821
+ sessionId,
822
+ ...(state.title === undefined ? {} : { title: state.title }),
823
+ now: this.#now(),
824
+ messages: data.messages,
825
+ });
826
+ let path;
827
+ try {
828
+ path = writeExportFile(defaultExportCacheDir(), this.#scopeKind(state), sessionId, extension, content, this.#now());
829
+ }
830
+ catch (error) {
831
+ await this.#replyText(state, `导出失败:写入文件失败(${errorMessage(error)})。`);
832
+ return;
833
+ }
834
+ const label = json ? 'JSONL' : 'markdown';
835
+ await this.#replyText(state, `MEDIA:${path}\n已导出会话 ${sessionId.slice(0, 8)}(${data.messages.length} 条消息,${label})。`);
836
+ await this.#sendOutboundMedia(state, [
837
+ { path, name: basename(path), image: false },
838
+ ]);
839
+ }
604
840
  async #settleTurn(state, event) {
605
841
  state.running = false;
606
842
  const kind = turnEndReasonKind(event.data);
@@ -647,6 +883,8 @@ export class Bridge {
647
883
  await this.#replyText(state, notice);
648
884
  }
649
885
  await this.#finishReactions(state, kind);
886
+ await this.#refreshSessionMetrics(state);
887
+ state.stopRequested = false;
650
888
  state.lastOutput = '';
651
889
  }
652
890
  /** Extract MEDIA markers, validate paths, and annotate the final card body. */
@@ -699,7 +937,7 @@ export class Bridge {
699
937
  }
700
938
  }
701
939
  catch (error) {
702
- this.#logger.warn(`media send failed for ${entry.name}: ${errorMessage(error)}`);
940
+ this.#logger.warn(`media send failed for ${entry.name} (${entry.path}): ${errorMessage(error)}`);
703
941
  }
704
942
  }
705
943
  }
@@ -867,19 +1105,52 @@ export class Bridge {
867
1105
  return undefined;
868
1106
  }
869
1107
  }
870
- async #stop(state) {
1108
+ async #cancelCurrentTurn(state) {
871
1109
  const sessionId = state.sessionId ?? this.#sessionMap.get(state.key);
872
- if (sessionId === undefined) {
873
- await this.#replyText(state, '本聊天/话题还没有会话。');
1110
+ if (sessionId === undefined)
1111
+ return { ok: false, reason: '本聊天/话题还没有会话。' };
1112
+ const agent = this.#agents.get(sessionId);
1113
+ if (agent === undefined)
1114
+ return { ok: false, reason: '当前没有在跑的会话。' };
1115
+ try {
1116
+ agent.cancel({ kind: 'user' });
1117
+ return { ok: true };
1118
+ }
1119
+ catch (error) {
1120
+ return { ok: false, reason: `取消失败:${errorMessage(error)}` };
1121
+ }
1122
+ }
1123
+ async #stop(state) {
1124
+ const result = await this.#cancelCurrentTurn(state);
1125
+ if (!result.ok) {
1126
+ await this.#replyText(state, result.reason);
874
1127
  return;
875
1128
  }
876
- const agent = this.#agents.get(sessionId);
877
- if (agent === undefined) {
878
- await this.#replyText(state, '当前没有在跑的会话。');
1129
+ await this.#replyText(state, '已停止当前回合。后台任务仍在运行,完成后结果会回来。');
1130
+ }
1131
+ /** Stop button callback: same cancel path as `/stop`, once per second. */
1132
+ async handleStopCardAction(action) {
1133
+ const key = this.#stopCards.get(action.messageId);
1134
+ if (key === undefined)
1135
+ return;
1136
+ const state = this.#states.get(key);
1137
+ if (state === undefined || !state.running)
1138
+ return;
1139
+ const now = this.#now();
1140
+ const last = this.#stopClicks.get(action.messageId);
1141
+ if (last !== undefined && now - last < 1000)
1142
+ return;
1143
+ this.#stopClicks.set(action.messageId, now);
1144
+ const result = await this.#cancelCurrentTurn(state);
1145
+ if (!result.ok) {
1146
+ this.#logger.warn(`stop card failed: ${result.reason}`);
879
1147
  return;
880
1148
  }
881
- agent.cancel({ kind: 'user' });
882
- await this.#replyText(state, '已请求停止当前一轮(cancel kind=user)。');
1149
+ state.stopRequested = true;
1150
+ if (state.progress?.messageId === action.messageId) {
1151
+ await this.#renderProgress(state);
1152
+ await state.progress?.flush();
1153
+ }
883
1154
  }
884
1155
  async #rename(state, title) {
885
1156
  const service = this.#sessionTitleService();
@@ -1044,7 +1315,9 @@ export class Bridge {
1044
1315
  * agent. Missing service warns once; setPolicy errors never block the turn.
1045
1316
  */
1046
1317
  #applyApprovalPolicy(agent) {
1047
- const policy = this.#options.approvalPolicy ?? 'ask';
1318
+ const policy = this.#options.approvalPolicy;
1319
+ if (policy === undefined)
1320
+ return; // never override dsh's own default
1048
1321
  this.#logger.info(`agent approval policy=${policy}`);
1049
1322
  const provider = this.#options.approvalProvider;
1050
1323
  if (provider === undefined)
@@ -1071,11 +1344,13 @@ export class Bridge {
1071
1344
  const state = this.#states.get(key);
1072
1345
  if (state === undefined || state.ownerOpenId === undefined)
1073
1346
  return undefined;
1347
+ const permissionLine = this.#permissionLineFor(state);
1074
1348
  return {
1075
1349
  scopeKey: state.key,
1076
1350
  chatId: state.chatId,
1077
1351
  ownerOpenId: state.ownerOpenId,
1078
1352
  sessionId,
1353
+ ...(permissionLine === undefined ? {} : { permissionLine }),
1079
1354
  };
1080
1355
  }
1081
1356
  /**
@@ -1345,6 +1620,10 @@ export class Bridge {
1345
1620
  }
1346
1621
  }
1347
1622
  #bindSession(state, sessionId) {
1623
+ if (state.sessionId !== undefined && state.sessionId !== sessionId) {
1624
+ this.#sessionMetrics.delete(state.key);
1625
+ this.#metricsFallbackWarned.delete(state.key);
1626
+ }
1348
1627
  for (const [id, key] of this.#scopeBySession) {
1349
1628
  if (key === state.key && id !== sessionId)
1350
1629
  this.#scopeBySession.delete(id);
@@ -1437,13 +1716,18 @@ export class Bridge {
1437
1716
  lastOutput: '',
1438
1717
  reactionQueue: [],
1439
1718
  reactionStarts: new Map(),
1719
+ logEvents: [],
1440
1720
  };
1441
1721
  this.#states.set(key, created);
1442
1722
  return created;
1443
1723
  }
1444
1724
  #makeCardStream(state) {
1445
1725
  const channel = {
1446
- create: (card) => this.#sendNewCard(state, card),
1726
+ create: async (card) => {
1727
+ const sent = await this.#sendNewCard(state, card);
1728
+ this.#stopCards.set(sent.messageId, state.key);
1729
+ return sent;
1730
+ },
1447
1731
  update: async (messageId, card) => {
1448
1732
  await this.#transport.updateCard(messageId, card);
1449
1733
  },
@@ -1467,10 +1751,119 @@ export class Bridge {
1467
1751
  if (stream === undefined)
1468
1752
  return;
1469
1753
  const snapshot = state.status.get();
1470
- await stream.set(this.#progressCardFor(state, snapshot));
1754
+ await stream.set(await this.#progressCardFor(state, snapshot));
1755
+ }
1756
+ /** Full-session metrics for a card; cold cache reads the log once. */
1757
+ async #metricsFor(state, snapshot) {
1758
+ const sessionId = state.sessionId ?? this.#sessionMap.get(state.key);
1759
+ if (sessionId === undefined)
1760
+ return aggregateMetrics(state.logEvents);
1761
+ let cached = this.#sessionMetrics.get(state.key);
1762
+ if (cached === undefined || cached.sessionId !== sessionId) {
1763
+ const provider = this.#options.usageProvider;
1764
+ if (provider === undefined) {
1765
+ if (!this.#metricsFallbackWarned.has(state.key)) {
1766
+ this.#metricsFallbackWarned.add(state.key);
1767
+ this.#logger.warn('metrics fallback: process buffer');
1768
+ }
1769
+ return aggregateMetrics(state.logEvents);
1770
+ }
1771
+ try {
1772
+ const events = await provider(sessionId);
1773
+ if (events !== undefined) {
1774
+ cached = {
1775
+ sessionId,
1776
+ metrics: aggregateMetrics(events),
1777
+ refreshedAtSeq: events.length,
1778
+ turn: snapshot.turn,
1779
+ };
1780
+ this.#sessionMetrics.set(state.key, cached);
1781
+ }
1782
+ }
1783
+ catch {
1784
+ // fall through to the process buffer
1785
+ }
1786
+ if (cached === undefined || cached.sessionId !== sessionId) {
1787
+ if (!this.#metricsFallbackWarned.has(state.key)) {
1788
+ this.#metricsFallbackWarned.add(state.key);
1789
+ this.#logger.warn('metrics fallback: process buffer');
1790
+ }
1791
+ return aggregateMetrics(state.logEvents);
1792
+ }
1793
+ }
1794
+ const base = cached.metrics;
1795
+ // During a turn whose events are not in the cached snapshot yet, add the
1796
+ // in-memory turn delta so numbers move monotonically until turn/end.
1797
+ if (!state.running || snapshot.turn <= cached.turn)
1798
+ return base;
1799
+ const usage = snapshot.usageTurn;
1800
+ const input = base.inputTokens + (usage?.input ?? 0);
1801
+ const output = base.outputTokens + (usage?.output ?? 0);
1802
+ const cacheRead = base.cacheReadTokens + (usage?.cacheRead ?? 0);
1803
+ const denominator = input + cacheRead;
1804
+ const elapsed = snapshot.startedAt === undefined ? undefined : this.#now() - snapshot.startedAt;
1805
+ const tokensPerSecond = elapsed !== undefined && elapsed >= 1000 && (usage?.output ?? 0) > 0
1806
+ ? (usage?.output ?? 0) / (elapsed / 1000)
1807
+ : undefined;
1808
+ return {
1809
+ rounds: Math.max(base.rounds, snapshot.turn),
1810
+ steps: base.steps + snapshot.steps,
1811
+ totalTokens: base.totalTokens + (usage?.total ?? 0),
1812
+ inputTokens: input,
1813
+ outputTokens: output,
1814
+ cacheReadTokens: cacheRead,
1815
+ ...(tokensPerSecond === undefined ? {} : { tokensPerSecond }),
1816
+ ...(denominator === 0 ? {} : { cacheHitPercent: Math.round((cacheRead / denominator) * 100) }),
1817
+ ...(base.contextPercent === undefined ? {} : { contextPercent: base.contextPercent }),
1818
+ };
1819
+ }
1820
+ /** Recompute the full-session metrics once after each turn/end. */
1821
+ async #refreshSessionMetrics(state) {
1822
+ const sessionId = state.sessionId ?? this.#sessionMap.get(state.key);
1823
+ if (sessionId === undefined)
1824
+ return;
1825
+ const provider = this.#options.usageProvider;
1826
+ if (provider === undefined)
1827
+ return;
1828
+ try {
1829
+ const events = await provider(sessionId);
1830
+ if (events === undefined)
1831
+ return;
1832
+ this.#sessionMetrics.set(state.key, {
1833
+ sessionId,
1834
+ metrics: aggregateMetrics(events),
1835
+ refreshedAtSeq: events.length,
1836
+ turn: state.status.get().turn,
1837
+ });
1838
+ this.#metricsFallbackWarned.delete(state.key);
1839
+ }
1840
+ catch (error) {
1841
+ this.#logger.warn(`metrics refresh failed: ${errorMessage(error)}`);
1842
+ }
1471
1843
  }
1472
- #progressCardFor(state, snapshot) {
1473
- return progressCardForSnapshot(snapshot, state.title, recentEventText(snapshot), state.lastOutput, this.#now(), state.sessionId !== undefined && this.#presetFailures.has(state.sessionId));
1844
+ async #progressCardFor(state, snapshot) {
1845
+ const extraLines = [];
1846
+ if (snapshot.compaction === 'running')
1847
+ extraLines.push('上下文压缩中…');
1848
+ else if (snapshot.compaction === 'done') {
1849
+ extraLines.push(snapshot.compactionKept === undefined
1850
+ ? '已压缩'
1851
+ : `已压缩 · 保留 ${snapshot.compactionKept} 条`);
1852
+ }
1853
+ else if (snapshot.compaction === 'failed')
1854
+ extraLines.push('上下文压缩失败');
1855
+ if (snapshot.teamLine !== undefined)
1856
+ extraLines.push(snapshot.teamLine);
1857
+ const metricsMode = this.#config.metrics ?? 'compact';
1858
+ if (metricsMode !== 'off') {
1859
+ const metricsLine = formatMetricsLine(await this.#metricsFor(state, snapshot), metricsMode);
1860
+ if (metricsLine.length > 0)
1861
+ extraLines.push(metricsLine);
1862
+ }
1863
+ if (state.stopRequested === true) {
1864
+ extraLines.push('已停止当前回合。后台任务仍在运行,完成后结果会回来。');
1865
+ }
1866
+ return progressCardForSnapshot(snapshot, state.title, recentEventText(snapshot), state.lastOutput, this.#now(), state.sessionId !== undefined && this.#presetFailures.has(state.sessionId), extraLines, state.running && state.stopRequested !== true);
1474
1867
  }
1475
1868
  async #replyText(state, text) {
1476
1869
  if (text.length === 0)
@@ -1490,9 +1883,11 @@ export class Bridge {
1490
1883
  }
1491
1884
  }
1492
1885
  /** Build the process card for one snapshot (exported for render tests). */
1493
- export function progressCardForSnapshot(snapshot, sessionTitle, recentEvent, tail, now, presetMissing = false) {
1886
+ export function progressCardForSnapshot(snapshot, sessionTitle, recentEvent, tail, now, presetMissing = false, extraLines = [], stopButton = false) {
1494
1887
  return progressCard({
1495
1888
  title: '飞书会话 · 过程',
1889
+ ...(extraLines.length === 0 ? {} : { extraLines }),
1890
+ ...(stopButton ? { stopButton: true } : {}),
1496
1891
  stateLabel: statusLabel(snapshot.state),
1497
1892
  steps: snapshot.steps,
1498
1893
  tools: snapshot.tools,
@@ -1515,3 +1910,16 @@ function imageMediaType(contentType) {
1515
1910
  }
1516
1911
  return 'image/jpeg';
1517
1912
  }
1913
+ function translatePermissionOutput(text, requested, lang) {
1914
+ const dict = t(lang);
1915
+ const success = /^preset\s+(\S+)\s*$/.exec(text.trim());
1916
+ if (success !== null) {
1917
+ const name = success[1];
1918
+ return dict.switchSucceeded(presetLabel(lang, name), name);
1919
+ }
1920
+ const unknown = /^unknown preset "([^"]+)" \(available: ([^)]*)\)\s*$/.exec(text.trim());
1921
+ if (unknown !== null) {
1922
+ return dict.unknownPreset(unknown[1], unknown[2]);
1923
+ }
1924
+ return dict.switchedElsewhere(text);
1925
+ }
@@ -13,6 +13,7 @@ import type { HarnessCommandResult } from '../commands/harness.js';
13
13
  import type { LoggerLike } from '../logger.js';
14
14
  import type { Clock } from '../render/stream.js';
15
15
  import type { DoctorInputs, DoctorSessionInfo } from '../doctor.js';
16
+ import type { SessionExportProvider } from '../export.js';
16
17
  import type { InteractiveRuntime } from '../interactive/runtime.js';
17
18
  import type { SessionStore } from '../session-key.js';
18
19
  import type { Transport } from '../transport/types.js';
@@ -20,6 +21,15 @@ import type { Transport } from '../transport/types.js';
20
21
  export type BusyMode = 'queue' | 'steer' | 'interrupt';
21
22
  /** Session approval policy understood by dsh's approval service. */
22
23
  export type ApprovalPolicy = 'ask' | 'never';
24
+ /** The slice of dsh's permissionPresets service the bridge needs. */
25
+ export interface PermissionPresetsLike {
26
+ readonly names: readonly string[];
27
+ current(session: unknown): string;
28
+ resolve?(name: string): {
29
+ readonly sandbox?: unknown;
30
+ readonly approval?: unknown;
31
+ };
32
+ }
23
33
  /** The slice of dsh's attachment service the bridge needs. */
24
34
  export interface AttachmentStoreLike {
25
35
  admitPromptContent(content: readonly unknown[]): Promise<readonly unknown[]>;
@@ -120,6 +130,8 @@ export interface BridgeConfig {
120
130
  readonly maxSendImageBytes?: number;
121
131
  /** Allowed roots for outbound MEDIA paths; empty = session cwd. */
122
132
  readonly mediaRoots?: readonly string[];
133
+ /** Process-card/usage metrics mode (default compact). */
134
+ readonly metrics?: import('../metrics.js').MetricsMode;
123
135
  /** Admin allowlist; empty disables access control (default). */
124
136
  readonly admins?: readonly string[];
125
137
  }
@@ -153,9 +165,17 @@ export interface BridgeOptions {
153
165
  readonly rootCtx?: unknown;
154
166
  /** Lazily resolve dsh's attachment service (image admission / file refs). */
155
167
  readonly attachmentProvider?: () => AttachmentStoreLike | undefined;
168
+ /** Optional session-log provider; falls back to reading $DSH_HOME on disk. */
169
+ readonly exportProvider?: SessionExportProvider;
170
+ /** Lazily resolve dsh's permissionPresets service. */
171
+ readonly permissionPresetsProvider?: () => PermissionPresetsLike | undefined;
172
+ /** Session log reader for `/usage` (`sessionQuery.readSession().events`). */
173
+ readonly usageProvider?: (sessionId: string) => Promise<readonly unknown[] | undefined> | readonly unknown[] | undefined;
174
+ /** Locale source (dsh settings/env); default zh. */
175
+ readonly languageProvider?: () => import('../i18n.js').Lang;
156
176
  /** Lazily resolve dsh's approval service (agent-scoped setPolicy). */
157
177
  readonly approvalProvider?: () => ApprovalServiceLike | undefined;
158
- /** Desired session approval policy (default `ask`). */
178
+ /** Explicit session approval policy; undefined leaves dsh untouched. */
159
179
  readonly approvalPolicy?: ApprovalPolicy;
160
180
  /** `/doctor` data collector (wired by the gateway; not available in tests by default). */
161
181
  readonly doctorInputs?: (session: DoctorSessionInfo) => DoctorInputs | Promise<DoctorInputs>;
@@ -4,8 +4,8 @@
4
4
  *
5
5
  * The surface owns eight commands; every other slash line is forwarded to the
6
6
  * harness command registry (`runHarnessCommand`), except Web-only commands that
7
- * make no sense on a chat surface. `/export` is the required minimum; the list
8
- * is intentionally small and explicit.
7
+ * make no sense on a chat surface. `/export` is now implemented by the surface
8
+ * itself, so the list is intentionally empty; keep the seam for future entries.
9
9
  *
10
10
  * @module dsh-caduceus/commands/dispatch
11
11
  */
@@ -4,14 +4,14 @@
4
4
  *
5
5
  * The surface owns eight commands; every other slash line is forwarded to the
6
6
  * harness command registry (`runHarnessCommand`), except Web-only commands that
7
- * make no sense on a chat surface. `/export` is the required minimum; the list
8
- * is intentionally small and explicit.
7
+ * make no sense on a chat surface. `/export` is now implemented by the surface
8
+ * itself, so the list is intentionally empty; keep the seam for future entries.
9
9
  *
10
10
  * @module dsh-caduceus/commands/dispatch
11
11
  */
12
12
  import { parseCommand } from './parse.js';
13
13
  /** Commands that exist only in the Web UI and are refused on Feishu. */
14
- export const WEB_ONLY_DENYLIST = ['export'];
14
+ export const WEB_ONLY_DENYLIST = [];
15
15
  /** True when a slash-command name must not be forwarded to the harness. */
16
16
  export function isWebOnlyCommand(name) {
17
17
  return WEB_ONLY_DENYLIST.includes(name.toLowerCase());