@modesto_ma/dsh-channels 0.1.0 → 0.3.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 (50) hide show
  1. package/README.md +89 -55
  2. package/docs/access-control-design.md +5 -5
  3. package/docs/feasibility-and-design.md +54 -26
  4. package/docs/feishu-onboarding.md +26 -20
  5. package/docs/persistence.md +4 -4
  6. package/docs/refactor-plan.md +6 -5
  7. package/docs/workspace-isolation-design.md +78 -0
  8. package/lib/client.js +1715 -882
  9. package/lib/client.js.map +1 -1
  10. package/lib/index.js +1556 -680
  11. package/lib/index.js.map +1 -1
  12. package/package.json +1 -1
  13. package/src/cards/index.ts +1 -0
  14. package/src/cards/onboarding.ts +23 -0
  15. package/src/cards/primitives.ts +20 -0
  16. package/src/cards/streaming.ts +310 -291
  17. package/src/cleanup.ts +256 -119
  18. package/src/client/api.ts +20 -0
  19. package/src/client/catalog.ts +1 -1
  20. package/src/client/index.ts +1 -1
  21. package/src/client/panel/access-card.tsx +294 -0
  22. package/src/client/panel/access-tab.tsx +46 -0
  23. package/src/client/panel/advanced-tab.tsx +80 -0
  24. package/src/client/panel/cleanup-card.tsx +106 -0
  25. package/src/client/panel/connection-tab.tsx +214 -0
  26. package/src/client/panel/credentials-card.tsx +104 -0
  27. package/src/client/panel/index.tsx +862 -0
  28. package/src/client/panel/model-tab.tsx +23 -0
  29. package/src/client/panel/sessions-tab.tsx +21 -0
  30. package/src/client/panel/shared.tsx +488 -0
  31. package/src/client/panel/workspace-tab.tsx +40 -0
  32. package/src/client/styles.ts +24 -6
  33. package/src/config.ts +44 -12
  34. package/src/domain.ts +34 -3
  35. package/src/feishu/agent-context.ts +9 -5
  36. package/src/index.ts +32 -14
  37. package/src/routing/inbound.ts +88 -43
  38. package/src/service.ts +259 -65
  39. package/src/session/isolation.ts +277 -0
  40. package/src/session/manager.ts +63 -20
  41. package/src/session/repair.ts +2 -3
  42. package/src/session/routes.ts +2 -0
  43. package/src/session-commands.ts +41 -14
  44. package/src/settings/contract.ts +223 -47
  45. package/src/settings/directory-picker.ts +121 -0
  46. package/src/settings/routes.ts +49 -2
  47. package/src/settings/surface.ts +171 -25
  48. package/src/streamer.ts +70 -30
  49. package/src/workspaces.ts +50 -28
  50. package/src/client/panel.tsx +0 -1475
@@ -23,6 +23,7 @@ import type {
23
23
  AccessMutationResponse,
24
24
  AccessWire,
25
25
  CatalogWire,
26
+ ChannelPhaseWire,
26
27
  ChannelStatusWire,
27
28
  CleanupPreviewWire,
28
29
  CleanupResponse,
@@ -51,15 +52,30 @@ import {
51
52
  } from './overlay.ts'
52
53
  import type { OverlayLogger, OverlayPatch, OverlayStore } from './overlay.ts'
53
54
  import type { SettingsCatalog } from './catalog.ts'
55
+ import type { SettingsDirectoryPicker } from './directory-picker.ts'
54
56
 
55
57
  /** The slice of `FeishuService` the panel needs (structural, easy to fake). */
56
58
  export interface SettingsTarget {
57
59
  isRunning(): boolean
58
60
  isRestarting(): boolean
61
+ /** True while an explicit connect action is in flight (panel button busy state). */
62
+ isConnecting(): boolean
59
63
  health(): HealthSnapshot
60
64
  startedAtMs(): number | undefined
61
65
  uptimeMs(): number | undefined
62
66
  startError(): string | undefined
67
+ /**
68
+ * Whether the Feishu application credentials resolve. A false value means the
69
+ * plugin is loaded but deliberately holds no connection: the panel guides the
70
+ * user through configuring credentials instead of reporting a failure.
71
+ */
72
+ isConfigured(): boolean
73
+ /** Human-readable names of the missing configuration items (never values). */
74
+ missingConfig(): readonly string[]
75
+ /** Re-read credential presence (the credentials store is hot-reloaded on disk). */
76
+ refreshReadiness(): Promise<void>
77
+ /** Connect with the current config; throws when credentials are incomplete or the handshake fails. */
78
+ connect(): Promise<void>
63
79
  configSnapshot(): FeishuConfig
64
80
  baseConfigSnapshot(): FeishuConfig
65
81
  runningConfigSnapshot(): FeishuConfig | undefined
@@ -144,7 +160,7 @@ const SELECT_OPTIONS: Partial<Record<EditableKey, ReadonlyArray<{ value: string;
144
160
  const FIELD_META: Record<EditableKey, { label: string; help: string; min?: number; max?: number; step?: number; placeholder?: string; wide?: boolean }> = {
145
161
  baseDir: {
146
162
  label: '工作区根目录(文件落点)',
147
- help: '每个飞书话题拥有独立工作区:私聊 <baseDir>/topics/p2p/<open_id>/<topic>/,群聊 <baseDir>/topics/groups/<chat_id>/<open_id>/<topic>/(topic 来自话题 id)。必须是绝对路径(~ 开头按家目录展开),保存时会创建该目录;只影响之后新建立的工作区 —— 已有话题即使 /renew 也沿用原工作区,旧文件不会迁移。',
163
+ help: '每个用户/群成员一个工作区:私聊 <baseDir>/p2p/<open_id>/,群聊 <baseDir>/groups/<chat_id>/<open_id>/;每个话题在其中拥有独立工作目录 <工作区>/topics/<话题>/(Git 仓库工作区可自动改用 worktree)。必须是绝对路径(~ 开头按家目录展开),保存时会创建该目录;只影响之后新建立的会话 —— 已有话题即使 /renew 也沿用原目录,旧文件不会迁移。',
148
164
  placeholder: '/Users/you/.dsh/dsh-channels/workspaces',
149
165
  wide: true,
150
166
  },
@@ -164,14 +180,14 @@ const FIELD_META: Record<EditableKey, { label: string; help: string; min?: numbe
164
180
  },
165
181
  authRequestTtlDays: {
166
182
  label: '授权申请有效期(天)',
167
- help: '入群/用户授权申请卡的有效期,1–30 天;过期后卡片需重新触发申请(也可直接在「访问与授权」面板处理)。',
183
+ help: '入群/用户授权申请卡的有效期,1–30 天;过期后卡片需重新触发申请(也可在本页下方「待处理申请」中处理)。',
168
184
  min: 1,
169
185
  max: 30,
170
186
  step: 1,
171
187
  },
172
188
  acceptAttachments: { label: '接收并保存附件', help: '接收飞书图片、文件与合并转发记录,保存到工作区的 attachments/ 目录;模型不会自动读取,需要时用文件工具自行查看。关闭后附件消息不保存、不触发回复。' },
173
- workspaceRetention: { label: '过期工作区保留期', help: '超过所选天数没有活动的飞书工作区会进入下方「工作区清理」列表;「关闭」表示永不删除。DSH 会话日志不受影响。' },
174
- workspaceRetentionAuto: { label: '自动执行工作区清理', help: '开启后插件在启动时及每天执行一次过期工作区清理;关闭时只能在下方卡片手动清理。开启前请确认保留期已设置。' },
189
+ workspaceRetention: { label: '过期目录保留期', help: '超过所选天数没有活动的飞书话题目录(以及旧版本按话题建立的工作区目录)会进入下方清理列表;用户工作区本身永不删除。DSH 会话日志不受影响。' },
190
+ workspaceRetentionAuto: { label: '自动执行目录清理', help: '开启后插件在启动时及每天执行一次过期目录清理;关闭时只能在下方卡片手动清理。开启前请确认保留期已设置。' },
175
191
  provider: { label: '模型服务商', help: '留空 = 使用部署默认;例如 deepseek-official。', placeholder: '留空 = 部署默认' },
176
192
  model: { label: '模型 id', help: '留空 = 使用部署默认;例如 deepseek-v4-flash。', placeholder: '留空 = 部署默认' },
177
193
  reasoningEffort: { label: '推理档位', help: '模型声明的精确档位 id(如 high / max);留空 = 适配器默认。', placeholder: '留空 = 适配器默认' },
@@ -190,9 +206,9 @@ const GROUP_OF: Record<EditableKey, EditableFieldWire['group']> = {
190
206
  threadFallback: 'behavior',
191
207
  welcomeOnboarding: 'behavior',
192
208
  allowPermissionSwitch: 'behavior',
193
- adminOpenId: 'behavior',
194
- accessMode: 'behavior',
195
- authRequestTtlDays: 'behavior',
209
+ adminOpenId: 'access',
210
+ accessMode: 'access',
211
+ authRequestTtlDays: 'access',
196
212
  acceptAttachments: 'content',
197
213
  provider: 'model',
198
214
  model: 'model',
@@ -296,6 +312,23 @@ function withCurrentOption(
296
312
  return inherit === undefined ? [extra] : [inherit, extra, ...rest]
297
313
  }
298
314
 
315
+ /**
316
+ * Mark the workspace-root field as browsable when the host can open its native
317
+ * directory chooser. Every other host (no `directoryPicker`, or the `browse`
318
+ * backend of remote/headless sessions) keeps the plain text input.
319
+ *
320
+ * @param fields - Host field descriptors after the selector catalog is applied.
321
+ * @param available - whether a native directory chooser can be opened.
322
+ * @returns the descriptors with `browse: 'directory'` on `baseDir` when usable.
323
+ */
324
+ export function withDirectoryBrowse(
325
+ fields: readonly EditableFieldWire[],
326
+ available: boolean,
327
+ ): EditableFieldWire[] {
328
+ if (!available) return [...fields]
329
+ return fields.map(field => (field.key === 'baseDir' ? { ...field, browse: 'directory' } : field))
330
+ }
331
+
299
332
  function messageOf(error: unknown): string {
300
333
  return error instanceof Error ? error.message : String(error)
301
334
  }
@@ -370,6 +403,14 @@ export function normalizeAccessName(
370
403
  /** Build the read-only advanced card from the effective config. */
371
404
  export function readOnlyFields(config: FeishuConfig): ReadOnlyFieldWire[] {
372
405
  return [
406
+ {
407
+ key: 'topicIsolation',
408
+ label: '话题目录隔离',
409
+ value: config.topicIsolation === 'auto'
410
+ ? '自动(Git 仓库用 worktree,否则普通子目录)'
411
+ : config.topicIsolation === 'worktree' ? 'Git worktree(失败回退子目录)' : '普通子目录',
412
+ help: '每个话题的独立工作目录形式;在 profile 配置里用 topicIsolation 设置(auto / subdir / worktree)。',
413
+ },
373
414
  { key: 'routeTtlMs', label: '话题路由过期', value: formatDays(config.routeTtlMs), help: '超期话题的后续消息会被明确拒绝,而不是新建 Context-free Session。' },
374
415
  { key: 'approvalTimeoutMs', label: '审批卡片等待', value: formatSeconds(config.approvalTimeoutMs) },
375
416
  { key: 'questionTimeoutMs', label: '提问卡片等待', value: formatSeconds(config.questionTimeoutMs) },
@@ -377,12 +418,34 @@ export function readOnlyFields(config: FeishuConfig): ReadOnlyFieldWire[] {
377
418
  ]
378
419
  }
379
420
 
421
+ /**
422
+ * Derive the panel phase from readiness + runtime facts. The order matters:
423
+ * an incomplete configuration always wins (no connection is attempted), and a
424
+ * failed attempt is only reported while the channel is stopped.
425
+ */
426
+ function phaseOf(target: SettingsTarget): ChannelPhaseWire {
427
+ // A connection attempt in flight is the honest label even though `start()`
428
+ // flips `started` before the handshake finishes. A live connection outranks
429
+ // missing credentials: removing a stored value does not drop the open socket,
430
+ // so the panel keeps the connection actions available and flags the missing
431
+ // credential separately (`configured: false`).
432
+ if (target.isRestarting()) return 'connecting'
433
+ if (target.isConnecting()) return target.isConfigured() ? 'connecting' : 'unconfigured'
434
+ if (target.isRunning()) return 'connected'
435
+ if (!target.isConfigured()) return 'unconfigured'
436
+ if (target.startError() !== undefined) return 'error'
437
+ return 'idle'
438
+ }
439
+
380
440
  /** Assemble the status half of the panel payload. */
381
441
  function buildStatus(target: SettingsTarget, overlayPath: string): ChannelStatusWire {
382
442
  const health = target.health()
383
443
  const connection = health.connection
384
444
  return {
385
445
  running: target.isRunning(),
446
+ configured: target.isConfigured(),
447
+ phase: phaseOf(target),
448
+ missingConfig: [...target.missingConfig()],
386
449
  level: health.level,
387
450
  startedAt: target.startedAtMs(),
388
451
  uptimeMs: target.uptimeMs(),
@@ -402,7 +465,7 @@ function buildStatus(target: SettingsTarget, overlayPath: string): ChannelStatus
402
465
  reasons: health.reasons,
403
466
  version: DSH_CHANNELS_VERSION,
404
467
  overlayPath,
405
- busy: target.isRestarting(),
468
+ busy: target.isRestarting() || target.isConnecting(),
406
469
  }
407
470
  }
408
471
 
@@ -419,10 +482,35 @@ export class SettingsSurface {
419
482
  private readonly store: OverlayStore,
420
483
  private readonly logger?: OverlayLogger,
421
484
  private readonly catalog?: SettingsCatalog,
485
+ private readonly directoryPicker?: SettingsDirectoryPicker,
422
486
  ) {}
423
487
 
488
+ /**
489
+ * Re-read credential presence. The credentials store is hot-reloaded from
490
+ * disk, so a value edited outside the panel becomes visible within one poll
491
+ * instead of only after a restart/connect.
492
+ */
493
+ private async syncReadiness(): Promise<void> {
494
+ try {
495
+ await this.target.refreshReadiness()
496
+ } catch (error) {
497
+ this.logger?.warn?.('dsh-channels: 凭据状态刷新失败:%s', messageOf(error))
498
+ }
499
+ }
500
+
501
+ /**
502
+ * Reject panel writes while the connection lifecycle is in flight: saving an
503
+ * overlay or credentials then would race `applyOverlay()`/`start()` and can
504
+ * record a running-config snapshot that never took effect.
505
+ */
506
+ private writeBusy(): { ok: false; error: string } | undefined {
507
+ if (!this.target.isRestarting() && !this.target.isConnecting()) return undefined
508
+ return { ok: false, error: '飞书连接正在连接或重启,请稍候再试' }
509
+ }
510
+
424
511
  /** Wire status only (used by the client's 5 s poll). */
425
512
  async status(): Promise<ChannelStatusWire> {
513
+ await this.syncReadiness()
426
514
  return buildStatus(this.target, this.store.file)
427
515
  }
428
516
 
@@ -443,6 +531,7 @@ export class SettingsSurface {
443
531
 
444
532
  /** Status + editable config with provenance + read-only facts. */
445
533
  async panel(): Promise<PanelWire> {
534
+ await this.syncReadiness()
446
535
  const base = this.target.baseConfigSnapshot()
447
536
  const effective = this.target.configSnapshot()
448
537
  const overlay = await this.store.load()
@@ -452,12 +541,15 @@ export class SettingsSurface {
452
541
  ? RESTART_KEYS.filter(key => effective[key] !== running[key])
453
542
  : []
454
543
  const catalog = await this.catalogFor(effective.provider, effective.model)
455
- const fields = applyCatalog(FIELD_DESCRIPTORS, catalog, {
456
- provider: effective.provider,
457
- model: effective.model,
458
- reasoningEffort: effective.reasoningEffort,
459
- agentPreset: effective.agentPreset,
460
- })
544
+ const fields = withDirectoryBrowse(
545
+ applyCatalog(FIELD_DESCRIPTORS, catalog, {
546
+ provider: effective.provider,
547
+ model: effective.model,
548
+ reasoningEffort: effective.reasoningEffort,
549
+ agentPreset: effective.agentPreset,
550
+ }),
551
+ this.directoryPicker?.available === true,
552
+ )
461
553
 
462
554
  let credentialStates: { appId: CredentialInfo; appSecret: CredentialInfo } = {
463
555
  appId: { configured: false, writable: false },
@@ -485,7 +577,7 @@ export class SettingsSurface {
485
577
  try {
486
578
  cleanup = await this.target.cleanupPreview()
487
579
  } catch (error) {
488
- this.logger?.warn?.('dsh-channels: 工作区清理预览失败:%s', messageOf(error))
580
+ this.logger?.warn?.('dsh-channels: 目录清理预览失败:%s', messageOf(error))
489
581
  cleanup = {
490
582
  retention: effective.workspaceRetention,
491
583
  auto: effective.workspaceRetentionAuto,
@@ -507,6 +599,7 @@ export class SettingsSurface {
507
599
  access = {
508
600
  mode: effective.accessMode,
509
601
  admin: { configured: false },
602
+ loaded: false,
510
603
  groups: [],
511
604
  users: [],
512
605
  requests: [],
@@ -532,6 +625,8 @@ export class SettingsSurface {
532
625
 
533
626
  /** Validate + persist a partial edit; live fields take effect immediately. */
534
627
  async save(values: EditableValues): Promise<SaveConfigResponse> {
628
+ const busy = this.writeBusy()
629
+ if (busy !== undefined) return busy
535
630
  const unknown = Object.keys(values).filter(key => !(EDITABLE_KEYS as readonly string[]).includes(key))
536
631
  if (unknown.length > 0) {
537
632
  return { ok: false, error: `未知配置项:${unknown.join(', ')}` }
@@ -611,6 +706,8 @@ export class SettingsSurface {
611
706
 
612
707
  /** Drop every Web override and fall back to the profile config. */
613
708
  async clear(): Promise<ClearConfigResponse> {
709
+ const busy = this.writeBusy()
710
+ if (busy !== undefined) return busy
614
711
  try {
615
712
  await this.store.clear()
616
713
  } catch (error) {
@@ -622,6 +719,8 @@ export class SettingsSurface {
622
719
 
623
720
  /** Write provided credential values through the host seam (never echoed back). */
624
721
  async saveCredentials(values: SaveCredentialsRequest): Promise<SaveCredentialsResponse> {
722
+ const busy = this.writeBusy()
723
+ if (busy !== undefined) return busy
625
724
  const writes: Array<{ field: CredentialField; value: string }> = []
626
725
  const issues: FieldIssueWire[] = []
627
726
  for (const field of ['appId', 'appSecret'] as const) {
@@ -647,6 +746,8 @@ export class SettingsSurface {
647
746
 
648
747
  /** Remove the stored value behind one credential reference. */
649
748
  async clearCredential(field: CredentialField): Promise<ClearCredentialResponse> {
749
+ const busy = this.writeBusy()
750
+ if (busy !== undefined) return busy
650
751
  if (field !== 'appId' && field !== 'appSecret') {
651
752
  return { ok: false, error: '未知的凭据字段' }
652
753
  }
@@ -659,36 +760,67 @@ export class SettingsSurface {
659
760
  }
660
761
 
661
762
  /**
662
- * Credential values are resolved on connect. When the channel is stopped,
663
- * retry immediately (nothing to interrupt); when it is running, ask the user
664
- * to reconnect. A failed retry still reports the save as successful.
763
+ * Credential values are resolved on connect. Opening a connection is gated on
764
+ * readiness: saving one half of the pair stores the value but keeps the
765
+ * plugin idle until the other half exists. When the channel is stopped and
766
+ * the pair is complete, connect immediately (nothing to interrupt); when it
767
+ * is running, the user reconnects to pick the value up.
665
768
  */
666
769
  private async finishCredentialChange(kind: 'save' | 'clear'): Promise<SaveCredentialsResponse> {
667
770
  if (this.target.isRunning()) {
668
771
  return { ok: true, panel: await this.panel(), reconnect: 'pending' }
669
772
  }
670
- if (kind === 'clear') {
671
- return { ok: true, panel: await this.panel(), reconnect: 'not-needed' }
773
+ if (kind === 'clear' || !this.target.isConfigured()) {
774
+ return { ok: true, panel: await this.panel(), reconnect: kind === 'clear' ? 'not-needed' : 'unconfigured' }
672
775
  }
673
776
  try {
674
- await this.target.restart()
777
+ await this.target.connect()
675
778
  return { ok: true, panel: await this.panel(), reconnect: 'auto' }
676
779
  } catch (error) {
780
+ // The value is stored, but the channel did not come up: report the
781
+ // failure state instead of claiming a successful connection.
677
782
  return {
678
783
  ok: true,
679
784
  panel: await this.panel(),
680
- reconnect: 'auto',
785
+ reconnect: 'failed',
681
786
  autoReconnectError: messageOf(error),
682
787
  }
683
788
  }
684
789
  }
685
790
 
686
- /** Close and reopen the Feishu long connection with the latest config. */
791
+ /**
792
+ * The panel's single connect/reconnect action: reopen a running connection,
793
+ * or open the first one when the channel is stopped. Credentials must be
794
+ * complete — otherwise the panel points the user back at the credentials form
795
+ * instead of surfacing a startup error.
796
+ */
687
797
  async reconnect(): Promise<ReconnectResponse> {
798
+ // Credentials may have been edited on disk since the last poll; re-check
799
+ // presence before refusing the action.
800
+ await this.syncReadiness()
801
+ if (!this.target.isConfigured()) {
802
+ return {
803
+ ok: false,
804
+ error: '飞书应用凭据尚未配置完整,请先填写 App ID 与 App Secret 并保存',
805
+ unconfigured: true,
806
+ }
807
+ }
808
+ const wasRunning = this.target.isRunning()
688
809
  try {
689
- await this.target.restart()
810
+ if (wasRunning) await this.target.restart()
811
+ else await this.target.connect()
690
812
  } catch (error) {
691
- return { ok: false, error: `重连失败:${messageOf(error)}` }
813
+ return { ok: false, error: `${wasRunning ? '重连' : '连接'}失败:${messageOf(error)}` }
814
+ }
815
+ // `restart()` intentionally does not throw when the credentials vanished;
816
+ // verify the connection actually exists before reporting success.
817
+ if (!this.target.isRunning()) {
818
+ return {
819
+ ok: false,
820
+ unconfigured: !this.target.isConfigured(),
821
+ error: this.target.startError()
822
+ ?? (this.target.isConfigured() ? '飞书连接未建立' : '飞书应用凭据尚未配置完整,请先填写并保存凭据'),
823
+ }
692
824
  }
693
825
  return { ok: true, panel: await this.panel() }
694
826
  }
@@ -754,12 +886,26 @@ export class SettingsSurface {
754
886
  }
755
887
  }
756
888
 
889
+ /**
890
+ * Open the host's native directory chooser (the panel's「选择目录…」button).
891
+ *
892
+ * @param signal - caller lifetime; abort closes the OS dialog.
893
+ * @returns the chosen absolute path, or `null` when the operator cancels.
894
+ * @throws when the host provides no native directory chooser.
895
+ */
896
+ async pickDirectory(signal: AbortSignal): Promise<string | null> {
897
+ if (this.directoryPicker === undefined) throw new Error(PANEL_COPY.directoryPickFailed)
898
+ return await this.directoryPicker.pick(signal)
899
+ }
900
+
757
901
  /**
758
902
  * Run the workspace-retention cleanup once. Irreversible deletions are the
759
903
  * target's responsibility; the surface only reports the outcome and returns
760
904
  * a refreshed panel (its cleanup preview then reflects the remaining workspaces).
761
905
  */
762
906
  async runCleanup(): Promise<CleanupResponse> {
907
+ const busy = this.writeBusy()
908
+ if (busy !== undefined) return busy
763
909
  let result: CleanupRunWire
764
910
  try {
765
911
  result = await this.target.runWorkspaceCleanup()
package/src/streamer.ts CHANGED
@@ -11,7 +11,9 @@
11
11
  * lines: the delegation status line (子代理 / 工作流 progress, settled
12
12
  * workflow outcome, heartbeat) and the tool line (the parent's in-flight
13
13
  * tool call). The lines are INDEPENDENT — a later tool call never
14
- * supersedes the settled workflow status. Subagent children live in their
14
+ * supersedes the settled workflow status. The lines render only on the
15
+ * card that is currently streaming, so frozen segment cards never repeat
16
+ * a stale status. Subagent children live in their
15
17
  * own sessions: their `subagent/descriptor` / `tool/call` / `turn/end`
16
18
  * events are folded into the parent turn's status line through
17
19
  * `session.header.parentSession` (status only — the child's text output
@@ -19,10 +21,11 @@
19
21
  * 3. When the body outgrows one card's budget, the overflow is split at a
20
22
  * clean breakpoint and sent as a continuation segment card (segment 2, 3,
21
23
  * …) into the same topic; the stream then keeps flowing into the newest
22
- * card. Reasoning is never segmented — it stays on card 1 and is clipped
23
- * to the leftover budget (body has priority).
24
- * 4. `turnEnded(turn)` — finalize every segment card (`回复结束` / `回复失败`)
25
- * and request the Typing → DONE/No reaction swap on the triggering message.
24
+ * card. Reasoning is never segmented — it stays on card 1 inside the
25
+ * fold panel's own fixed window, independent of the body length.
26
+ * 4. `finalize(turn)` — flip every segment card to its terminal state
27
+ * (`回复结束` / `回复失败`), render the settled footer on the main card and
28
+ * request the Typing → DONE/No reaction swap on the triggering message.
26
29
  *
27
30
  * The first visible text delta flips the panel title 推理中 → 回复中; the
28
31
  * final patch flips it to 回复结束 or 回复失败. Patching is throttled to `refreshMs` and
@@ -38,7 +41,7 @@
38
41
  */
39
42
 
40
43
  import type { AssistantStreamFrame, Session, SessionEvent, TurnEndReason } from './dsh-types.ts'
41
- import { buildCard, segmentCardContent } from './cards.ts'
44
+ import { buildCard, segmentCardContent, segmentCardOptions } from './cards.ts'
42
45
  import type { CardState, PanelPhase } from './cards.ts'
43
46
  import type { FeishuChannel } from './feishu-channel.ts'
44
47
  import { cacheHitPct, foldAll, foldEvent } from './stats.ts'
@@ -49,7 +52,7 @@ import type { HeaderSample, StatsFold, UsageSample } from './stats.ts'
49
52
  * console can confirm which build of the activity status line is loaded.
50
53
  * Bump on every behavior change to this feature.
51
54
  */
52
- export const ACTIVITY_BUILD_MARKER = 'activity-status-v6'
55
+ export const ACTIVITY_BUILD_MARKER = 'activity-status-v7'
53
56
 
54
57
  /** How a segment card reached Feishu. */
55
58
  export interface SegmentDelivery {
@@ -149,8 +152,6 @@ interface TurnState {
149
152
  header: HeaderSample | undefined
150
153
  /** Provider usage produced during THIS turn; never inherited from history. */
151
154
  lastUsage: UsageSample | undefined
152
- /** Structured terminal failure for this turn, when `turn/end.kind=error`. */
153
- failure: { message: string; code?: string } | undefined
154
155
  /** Segment cards in order; `cards[0]` is the attached reply card. */
155
156
  cards: SegmentCard[]
156
157
  dirty: boolean
@@ -161,6 +162,12 @@ interface TurnState {
161
162
  disposed: boolean
162
163
  /** Unix ms when the turn was attached (heartbeat elapsed base). */
163
164
  turnStartedAt: number
165
+ /** Wall-clock start of the currently open reasoning phase (undefined = not reasoning). */
166
+ reasoningSince: number | undefined
167
+ /** Accumulated wall-clock time the turn spent reasoning, in ms. A phase
168
+ * opens at the first reasoning delta and closes when the step's output ends
169
+ * (first answer text or step end), so tool execution is never counted. */
170
+ reasoningMs: number
164
171
  /** The in-flight tool call, if any (cleared by its `tool/result`). */
165
172
  toolActivity: { name: string; label: string; since: number } | undefined
166
173
  /** Live workflow-run folds projected by the workflow tool into this session. */
@@ -202,6 +209,8 @@ interface AttemptMark {
202
209
  cardCount: number
203
210
  bodyLengths: number[]
204
211
  reasoningLength: number
212
+ reasoningMs: number
213
+ reasoningSince: number | undefined
205
214
  }
206
215
 
207
216
  /** One live `tool-workflow/run-*` fold: run identity and member progress. */
@@ -321,7 +330,6 @@ export class TurnStreamer {
321
330
  reasoning: '',
322
331
  header: undefined,
323
332
  lastUsage: undefined,
324
- failure: undefined,
325
333
  cards: [{
326
334
  messageId: cardMessageId,
327
335
  body: '',
@@ -339,6 +347,8 @@ export class TurnStreamer {
339
347
  finalized: false,
340
348
  disposed: false,
341
349
  turnStartedAt: Date.now(),
350
+ reasoningSince: undefined,
351
+ reasoningMs: 0,
342
352
  toolActivity: undefined,
343
353
  workflowRuns: new Map(),
344
354
  workflowEndStatus: undefined,
@@ -531,6 +541,9 @@ export class TurnStreamer {
531
541
  if (state === undefined || state.disposed) return
532
542
  switch (frame.type) {
533
543
  case 'start': {
544
+ // A new step starts: close any reasoning phase the previous step left
545
+ // open (defensive — its `end` frame normally does this first).
546
+ this.closeReasoning(state)
534
547
  state.attempts.set(frame.attemptId, this.attemptMark(state))
535
548
  if (state.turn === undefined) state.turn = frame.turn
536
549
  break
@@ -538,6 +551,7 @@ export class TurnStreamer {
538
551
  case 'chunk': {
539
552
  const chunk = frame.chunk
540
553
  if (chunk.type === 'text-delta') {
554
+ if (chunk.text !== '') this.closeReasoning(state)
541
555
  const last = state.cards[state.cards.length - 1]
542
556
  if (last !== undefined && !last.frozen && chunk.text !== '') {
543
557
  last.body += chunk.text
@@ -545,12 +559,17 @@ export class TurnStreamer {
545
559
  this.markDirty(session.id, state)
546
560
  }
547
561
  } else if (chunk.type === 'reasoning-delta' && chunk.text !== '') {
562
+ if (state.reasoningSince === undefined) state.reasoningSince = Date.now()
548
563
  state.reasoning += chunk.text
549
564
  this.markDirty(session.id, state)
550
565
  }
551
566
  break
552
567
  }
553
568
  case 'end': {
569
+ // The step's output is complete: a reasoning phase that ended with a
570
+ // tool call (no answer text) stops counting here, so the tool run that
571
+ // follows is not miscounted as reasoning time.
572
+ this.closeReasoning(state)
554
573
  const mark = state.attempts.get(frame.attemptId)
555
574
  state.attempts.delete(frame.attemptId)
556
575
  if (frame.outcome.kind === 'abandoned' && mark !== undefined) {
@@ -568,9 +587,18 @@ export class TurnStreamer {
568
587
  cardCount: state.cards.length,
569
588
  bodyLengths: state.cards.map(card => card.body.length),
570
589
  reasoningLength: state.reasoning.length,
590
+ reasoningMs: state.reasoningMs,
591
+ reasoningSince: state.reasoningSince,
571
592
  }
572
593
  }
573
594
 
595
+ /** Close an open reasoning phase, accumulating its wall-clock time. */
596
+ private closeReasoning(state: TurnState, now = Date.now()): void {
597
+ if (state.reasoningSince === undefined) return
598
+ state.reasoningMs += Math.max(0, now - state.reasoningSince)
599
+ state.reasoningSince = undefined
600
+ }
601
+
574
602
  /**
575
603
  * Drop one abandoned attempt's deltas. The attempt's text is a contiguous
576
604
  * suffix of the logical body: segments created during the attempt are
@@ -594,6 +622,9 @@ export class TurnStreamer {
594
622
  tail.dirty = true
595
623
  }
596
624
  state.reasoning = state.reasoning.slice(0, mark.reasoningLength)
625
+ // The abandoned attempt's reasoning time is not part of the visible turn.
626
+ state.reasoningMs = mark.reasoningMs
627
+ state.reasoningSince = mark.reasoningSince
597
628
  }
598
629
 
599
630
  private markDirty(sessionId: string, state: TurnState): void {
@@ -641,6 +672,9 @@ export class TurnStreamer {
641
672
  : context?.contextWindow ?? projected?.contextWindow,
642
673
  model: header?.model,
643
674
  effort: header?.effort,
675
+ // Only a turn that actually reasoned reports a duration; a non-reasoning
676
+ // model leaves the field undefined (rendered as `—`).
677
+ reasoningMs: state.reasoning === '' ? undefined : state.reasoningMs,
644
678
  }
645
679
  }
646
680
 
@@ -717,13 +751,10 @@ export class TurnStreamer {
717
751
  * an elapsed counter so the card never sits silently.
718
752
  * Once the turn is finalized, the line switches to the SETTLED form
719
753
  * (workflow conclusion + finished-subagent summary) — the final card keeps
720
- * the delegation status; only the tool line disappears.
754
+ * the delegation status; only the tool line disappears. A failed turn shows
755
+ * its diagnostic once, in the body: the status line never repeats it.
721
756
  */
722
757
  private delegationLine(state: TurnState, now: number): string {
723
- if (state.failure !== undefined) {
724
- const suffix = state.failure.code === undefined ? '' : ` · ${state.failure.code}`
725
- return clipActivity(`🔴 本轮运行失败${suffix}`)
726
- }
727
758
  if (state.finalized) return this.settledLine(state)
728
759
 
729
760
  let latest: WorkflowRun | undefined
@@ -832,10 +863,14 @@ export class TurnStreamer {
832
863
  }, this.heartbeatMs)
833
864
  }
834
865
 
835
- private cardState(sessionId: string, state: TurnState, body: string): CardState {
836
- // The snapshot point recomputes (and remembers) both activity lines so
837
- // every patch — including heartbeat-driven ones — carries the current
838
- // status and tool call.
866
+ /**
867
+ * Snapshot one card. `active` is true for the card currently streaming:
868
+ * only that card renders the transient activity lines, so frozen segment
869
+ * cards never repeat a stale tool/delegation line. Both lines are still
870
+ * recomputed (and remembered) on every snapshot so heartbeat/transition
871
+ * logging stays accurate.
872
+ */
873
+ private cardState(sessionId: string, state: TurnState, body: string, active = true): CardState {
839
874
  const now = Date.now()
840
875
  const status = this.delegationLine(state, now)
841
876
  if (status !== state.status) {
@@ -853,8 +888,8 @@ export class TurnStreamer {
853
888
  phase: state.phase,
854
889
  reasoning: state.reasoning,
855
890
  body,
856
- status: state.status,
857
- tool: state.tool,
891
+ status: active ? state.status : '',
892
+ tool: active ? state.tool : '',
858
893
  stats: this.statsFor(sessionId, state),
859
894
  }
860
895
  }
@@ -882,7 +917,7 @@ export class TurnStreamer {
882
917
  const card = state.cards[index]
883
918
  if (card === undefined || card.frozen || card.mode === 'text') return
884
919
 
885
- const options = index === 0 ? {} : { panel: false, segment: index + 1 }
920
+ const options = segmentCardOptions(index)
886
921
  const split = segmentCardContent(this.cardState(sessionId, state, card.body), options)
887
922
  if (split.head === '') {
888
923
  // Degenerate budget (cannot happen with the real fixed overhead):
@@ -944,10 +979,10 @@ export class TurnStreamer {
944
979
  let lastError: unknown
945
980
  for (let attempt = 0; attempt < 3; attempt++) {
946
981
  try {
947
- const snapshot = buildCard(this.cardState(sessionId, state, card.body), {
948
- panel: false,
949
- segment: index + 1,
950
- })
982
+ const snapshot = buildCard(
983
+ this.cardState(sessionId, state, card.body, index === state.cards.length - 1),
984
+ segmentCardOptions(index),
985
+ )
951
986
  const delivery = await this.hooks.sendSegmentCard(sessionId, index + 1, snapshot, card.body)
952
987
  card.sending = false
953
988
  card.messageId = delivery.messageId
@@ -996,8 +1031,11 @@ export class TurnStreamer {
996
1031
  if (!force && !card.dirty && !turnDirty) return
997
1032
  if (!force && card.nextRetryAt > now) return
998
1033
  card.dirty = false
999
- const options = index === 0 ? {} : { panel: false, segment: index + 1 }
1000
- const snapshot = buildCard(this.cardState(sessionId, state, card.body), options)
1034
+ const options = segmentCardOptions(index)
1035
+ const snapshot = buildCard(
1036
+ this.cardState(sessionId, state, card.body, index === state.cards.length - 1),
1037
+ options,
1038
+ )
1001
1039
  this.channel.updateCard(card.messageId, snapshot).catch((error: unknown) => {
1002
1040
  card.dirty = true
1003
1041
  card.failures++
@@ -1027,8 +1065,10 @@ export class TurnStreamer {
1027
1065
  ): void {
1028
1066
  if (state.finalized) return
1029
1067
  state.finalized = true
1030
- state.failure = failure
1031
1068
  state.phase = failure === undefined ? 'done' : 'error'
1069
+ // A turn that ends inside a reasoning phase (e.g. no answer text) still
1070
+ // reports the time it spent reasoning.
1071
+ this.closeReasoning(state)
1032
1072
  if (failure !== undefined) {
1033
1073
  const code = failure.code === undefined ? '' : `\n\n错误码:\`${failure.code}\``
1034
1074
  const notice = `❌ **本轮运行失败**\n\n${failure.message}${code}`
@@ -1040,7 +1080,7 @@ export class TurnStreamer {
1040
1080
  // terminal diagnostic in its own continuation card instead.
1041
1081
  state.cards.push(this.freshSegment(notice))
1042
1082
  } else {
1043
- tail.body = tail.body.trim() === '' ? notice : `${tail.body}\n\n---\n\n${notice}`
1083
+ tail.body = tail.body.trim() === '' ? notice : `${tail.body}\n\n${notice}`
1044
1084
  tail.dirty = true
1045
1085
  }
1046
1086
  }