@modesto_ma/dsh-channels 0.3.0 → 0.5.0-beta.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.
Files changed (64) hide show
  1. package/README.md +123 -53
  2. package/docs/access-control-design.md +7 -5
  3. package/docs/attachment-pipeline-v2.md +310 -0
  4. package/docs/feasibility-and-design.md +1 -1
  5. package/docs/feishu-onboarding.md +127 -21
  6. package/docs/persistence.md +6 -4
  7. package/docs/refactor-plan.md +2 -2
  8. package/docs/workspace-isolation-design.md +13 -11
  9. package/lib/client.js +179 -288
  10. package/lib/client.js.map +1 -1
  11. package/lib/index.js +12125 -6382
  12. package/lib/index.js.map +1 -1
  13. package/package.json +1 -1
  14. package/src/access/cards.ts +9 -9
  15. package/src/access/controller.ts +10 -10
  16. package/src/access/index.ts +1 -0
  17. package/src/access/policy.ts +10 -10
  18. package/src/access/requests.ts +32 -31
  19. package/src/access/store.ts +12 -12
  20. package/src/access/targets.ts +56 -0
  21. package/src/cards/approval.ts +96 -26
  22. package/src/cards/index.ts +1 -0
  23. package/src/cards/onboarding.ts +1 -0
  24. package/src/cards/picker.ts +115 -0
  25. package/src/cards/streaming.ts +56 -7
  26. package/src/client/api.ts +5 -25
  27. package/src/client/panel/access-card.tsx +24 -15
  28. package/src/client/panel/access-tab.tsx +3 -3
  29. package/src/client/panel/connection-tab.tsx +70 -1
  30. package/src/client/panel/index.tsx +11 -62
  31. package/src/client/panel/shared.tsx +0 -23
  32. package/src/client/panel/workspace-tab.tsx +2 -18
  33. package/src/client/styles.ts +2 -0
  34. package/src/commands/registry.ts +2 -2
  35. package/src/config.ts +190 -32
  36. package/src/domain.ts +28 -18
  37. package/src/feishu/agent-context.ts +65 -19
  38. package/src/feishu/inbound-attachments.ts +440 -3
  39. package/src/feishu/manifest.ts +247 -0
  40. package/src/feishu/message-read.ts +1330 -0
  41. package/src/feishu/native-attachments.ts +998 -0
  42. package/src/feishu/scopes.ts +171 -0
  43. package/src/feishu/send-file.ts +543 -0
  44. package/src/feishu/slash-commands.ts +134 -0
  45. package/src/feishu/tools.ts +2305 -0
  46. package/src/feishu-channel.ts +322 -6
  47. package/src/host/agent.ts +55 -0
  48. package/src/host/attachments.ts +210 -0
  49. package/src/host/index.ts +1 -0
  50. package/src/index.ts +5 -4
  51. package/src/interactions/approvals.ts +187 -7
  52. package/src/interactions/pending.ts +3 -3
  53. package/src/routing/inbound.ts +620 -49
  54. package/src/service.ts +808 -375
  55. package/src/session/manager.ts +67 -13
  56. package/src/session/routes.ts +2 -3
  57. package/src/settings/contract.ts +90 -89
  58. package/src/settings/overlay.ts +124 -13
  59. package/src/settings/routes.ts +3 -17
  60. package/src/settings/surface.ts +156 -82
  61. package/src/streamer.ts +94 -5
  62. package/src/workspaces.ts +39 -26
  63. package/src/cleanup.ts +0 -535
  64. package/src/client/panel/cleanup-card.tsx +0 -106
@@ -12,6 +12,7 @@
12
12
 
13
13
  import { DSH_CHANNELS_VERSION } from '../session-export.ts'
14
14
  import type { FeishuConfig } from '../config.ts'
15
+ import { FEISHU_USER_ID_PATTERN } from '../domain.ts'
15
16
  import type { HealthSnapshot } from '../health.ts'
16
17
  import { prepareWorkspaceBaseDir } from '../workspaces.ts'
17
18
  import {
@@ -25,9 +26,6 @@ import type {
25
26
  CatalogWire,
26
27
  ChannelPhaseWire,
27
28
  ChannelStatusWire,
28
- CleanupPreviewWire,
29
- CleanupResponse,
30
- CleanupRunWire,
31
29
  ClearConfigResponse,
32
30
  ClearCredentialResponse,
33
31
  ConfigValue,
@@ -45,10 +43,19 @@ import type {
45
43
  SaveConfigResponse,
46
44
  SaveCredentialsRequest,
47
45
  SaveCredentialsResponse,
46
+ ScopeCheckWire,
47
+ SlashCommandSyncWire,
48
48
  } from './contract.ts'
49
49
  import {
50
+ configValueOf,
51
+ deleteOverlayKey,
52
+ mergeOverlay,
53
+ mergeOverlayPatch,
54
+ overlayValueOf,
55
+ overlayValues,
50
56
  parseOverlay,
51
57
  pickEditable,
58
+ setOverlayValue,
52
59
  } from './overlay.ts'
53
60
  import type { OverlayLogger, OverlayPatch, OverlayStore } from './overlay.ts'
54
61
  import type { SettingsCatalog } from './catalog.ts'
@@ -64,6 +71,10 @@ export interface SettingsTarget {
64
71
  startedAtMs(): number | undefined
65
72
  uptimeMs(): number | undefined
66
73
  startError(): string | undefined
74
+ /** Post-connect scope self-check for the open connection, once it finished. */
75
+ scopeCheck(): ScopeCheckWire | undefined
76
+ /** Post-connect native slash-command panel sync for the open connection, once it finished. */
77
+ slashCommandSync(): SlashCommandSyncWire | undefined
67
78
  /**
68
79
  * Whether the Feishu application credentials resolve. A false value means the
69
80
  * plugin is loaded but deliberately holds no connection: the panel guides the
@@ -79,25 +90,21 @@ export interface SettingsTarget {
79
90
  configSnapshot(): FeishuConfig
80
91
  baseConfigSnapshot(): FeishuConfig
81
92
  runningConfigSnapshot(): FeishuConfig | undefined
82
- applyOverlay(overlay: Partial<FeishuConfig>): void
93
+ applyOverlay(overlay: OverlayPatch): void
83
94
  restart(): Promise<void>
84
95
  credentialStates(): Promise<{ appId: CredentialInfo; appSecret: CredentialInfo }>
85
96
  setCredential(field: CredentialField, value: string): Promise<void>
86
97
  clearCredential(field: CredentialField): Promise<void>
87
- /** What the workspace-retention cleanup would delete right now (read-only scan). */
88
- cleanupPreview(): Promise<CleanupPreviewWire>
89
- /** Run the workspace cleanup once; partial failures are reported per workspace. */
90
- runWorkspaceCleanup(): Promise<CleanupRunWire>
91
98
  /** Current admission snapshot: mode, administrator, whitelists, pending requests. */
92
99
  accessSnapshot(): AccessWire
93
100
  /** Authorize one group (panel entry); resolves with the refreshed snapshot. */
94
101
  addAccessGroup(chatId: string, name?: string): Promise<AccessMutationResponse>
95
102
  /** Revoke one group authorization (takes effect on the next message). */
96
103
  removeAccessGroup(chatId: string): Promise<AccessMutationResponse>
97
- /** Authorize one user `open_id` (global across authorized groups). */
98
- addAccessUser(openId: string, name?: string): Promise<AccessMutationResponse>
104
+ /** Authorize one user `user_id` (global across authorized groups). */
105
+ addAccessUser(userId: string, name?: string): Promise<AccessMutationResponse>
99
106
  /** Revoke one user authorization (takes effect on the next message). */
100
- removeAccessUser(openId: string): Promise<AccessMutationResponse>
107
+ removeAccessUser(userId: string): Promise<AccessMutationResponse>
101
108
  /** Settle one pending request (panel shares this path with the Feishu DM card). */
102
109
  resolveAccessRequest(token: string, decision: 'allow' | 'deny'): Promise<AccessMutationResponse>
103
110
  }
@@ -117,18 +124,26 @@ const OPTIONAL_TEXT_KEYS = new Set<EditableKey>([
117
124
  'model',
118
125
  'reasoningEffort',
119
126
  'agentPreset',
120
- 'adminOpenId',
127
+ 'adminUserId',
121
128
  ])
122
129
 
123
130
  const BOOLEAN_KEYS = new Set<EditableKey>([
124
131
  'requireMention',
125
132
  'welcomeOnboarding',
133
+ 'slashCommandPanel',
126
134
  'allowPermissionSwitch',
127
135
  'acceptAttachments',
128
- 'workspaceRetentionAuto',
136
+ 'prefetchImages',
137
+ 'feishuTools.attachmentFetch',
138
+ 'feishuTools.topicMessages',
139
+ 'feishuTools.sendFile',
129
140
  ])
130
141
 
131
142
  const SELECT_OPTIONS: Partial<Record<EditableKey, ReadonlyArray<{ value: string; label: string }>>> = {
143
+ attachmentStore: [
144
+ { value: 'native', label: 'DSH 原生附件库(默认)' },
145
+ { value: 'archive', label: '工作区 attachments/(旧版)' },
146
+ ],
132
147
  dmMode: [
133
148
  { value: 'open', label: '开放私聊' },
134
149
  { value: 'disabled', label: '禁用私聊' },
@@ -137,13 +152,6 @@ const SELECT_OPTIONS: Partial<Record<EditableKey, ReadonlyArray<{ value: string;
137
152
  { value: 'degrade', label: '话题被拒时降级为会话级' },
138
153
  { value: 'strict', label: '话题被拒时直接失败' },
139
154
  ],
140
- workspaceRetention: [
141
- { value: 'off', label: '关闭(不删除)' },
142
- { value: '7d', label: '7 天' },
143
- { value: '30d', label: '30 天' },
144
- { value: '90d', label: '90 天' },
145
- { value: '180d', label: '180 天' },
146
- ],
147
155
  loggerLevel: [
148
156
  { value: 'debug', label: 'debug' },
149
157
  { value: 'info', label: 'info' },
@@ -160,7 +168,7 @@ const SELECT_OPTIONS: Partial<Record<EditableKey, ReadonlyArray<{ value: string;
160
168
  const FIELD_META: Record<EditableKey, { label: string; help: string; min?: number; max?: number; step?: number; placeholder?: string; wide?: boolean }> = {
161
169
  baseDir: {
162
170
  label: '工作区根目录(文件落点)',
163
- help: '每个用户/群成员一个工作区:私聊 <baseDir>/p2p/<open_id>/,群聊 <baseDir>/groups/<chat_id>/<open_id>/;每个话题在其中拥有独立工作目录 <工作区>/topics/<话题>/(Git 仓库工作区可自动改用 worktree)。必须是绝对路径(~ 开头按家目录展开),保存时会创建该目录;只影响之后新建立的会话 —— 已有话题即使 /renew 也沿用原目录,旧文件不会迁移。',
171
+ help: '每个用户/群成员一个工作区:私聊 <baseDir>/p2p/<user_id>/,群聊 <baseDir>/groups/<chat_id>/<user_id>/;每个话题在其中拥有独立工作目录 <工作区>/topics/<话题>/(Git 仓库工作区可自动改用 worktree)。必须是绝对路径(~ 开头按家目录展开),保存时会创建该目录;只影响之后新建立的会话 —— 已有话题即使 /renew 也沿用原目录,旧文件不会迁移。',
164
172
  placeholder: '/Users/you/.dsh/dsh-channels/workspaces',
165
173
  wide: true,
166
174
  },
@@ -168,11 +176,12 @@ const FIELD_META: Record<EditableKey, { label: string; help: string; min?: numbe
168
176
  requireMention: { label: '普通群需 @ 机器人', help: '已绑定的主题内始终不需要 @;关闭后普通群的每条消息都会触发。' },
169
177
  threadFallback: { label: '话题被拒时的兜底', help: 'degrade 降级为会话级路由;strict 直接让回复失败。' },
170
178
  welcomeOnboarding: { label: '首次私聊引导卡', help: '每个私聊会话第一次触达时发送一张命令与用法引导卡,只发一次。' },
179
+ slashCommandPanel: { label: '同步飞书原生斜杠指令面板', help: '连接时把插件命令同步到飞书输入 / 时弹出的面板;需开通 application:app_slash_command:read 与 write 权限,部分企业不允许应用添加斜杠指令。关闭时不读写面板,直接输入命令不受影响。' },
171
180
  allowPermissionSwitch: { label: '允许飞书侧切换权限预设', help: '安全敏感:开启后话题所有者可用 /permissions 切换沙箱与审批预设。' },
172
- adminOpenId: {
173
- label: '管理员 open_id',
174
- help: '唯一管理员。私聊机器人发送 /userid 获取(ou_ 开头,不是 user_id/邮箱);留空 = 清除 Web 覆盖。guarded 模式下未配置会拒绝所有对话。',
175
- placeholder: 'ou_xxx',
181
+ adminUserId: {
182
+ label: '管理员 user_id',
183
+ help: '唯一管理员。私聊机器人发送 /userid 获取(不是 ou_ 开头的 open_id,也不是邮箱);留空 = 清除 Web 覆盖。未配置时自动使用飞书应用所有者(需可选的「获取应用信息」权限,开不了就在这里手动填写);两者都没有时 guarded 模式会拒绝所有对话。',
184
+ placeholder: 'user_id',
176
185
  },
177
186
  accessMode: {
178
187
  label: '准入模式',
@@ -185,9 +194,93 @@ const FIELD_META: Record<EditableKey, { label: string; help: string; min?: numbe
185
194
  max: 30,
186
195
  step: 1,
187
196
  },
188
- acceptAttachments: { label: '接收并保存附件', help: '接收飞书图片、文件与合并转发记录,保存到工作区的 attachments/ 目录;模型不会自动读取,需要时用文件工具自行查看。关闭后附件消息不保存、不触发回复。' },
189
- workspaceRetention: { label: '过期目录保留期', help: '超过所选天数没有活动的飞书话题目录(以及旧版本按话题建立的工作区目录)会进入下方清理列表;用户工作区本身永不删除。DSH 会话日志不受影响。' },
190
- workspaceRetentionAuto: { label: '自动执行目录清理', help: '开启后插件在启动时及每天执行一次过期目录清理;关闭时只能在下方卡片手动清理。开启前请确认保留期已设置。' },
197
+ acceptAttachments: {
198
+ label: '接收入站附件',
199
+ help: '接收飞书图片、文件与合并转发记录。native(默认)下入站附件存入 DSH 原生附件库:触发消息的图片预取进首轮、文件保持懒取,纯附件消息不回复、不落地,需要时请引用该消息(多个文件可合并转发后引用)再提问;archive 下按旧版行为下载到工作区 attachments/。关闭后附件消息不保存、不触发模型、也不列入清单。',
200
+ },
201
+ attachmentStore: {
202
+ label: '入站附件存储后端',
203
+ help: 'native(默认):图片与文件存入 DSH 原生附件库(不可变、内容寻址去重、读取校验、随 Session 回放;不自动清理)。archive:旧版下载到话题工作区 attachments/(可清理但会阻塞会话)。宿主未挂载附件服务时 native 自动降级 archive。',
204
+ },
205
+ prefetchImages: {
206
+ label: '预取触发消息的图片',
207
+ help: 'native 下把触发消息的图片预取为首轮 ImageBlock,会话/话题/卡片不等下载;失败、超限或超时的图片降级为清单中的懒取项。关闭后图片也需用 feishu_attachment_fetch 获取。',
208
+ },
209
+ prefetchTimeoutMs: {
210
+ label: '图片预取整段超时(ms)',
211
+ help: '一次触发消息图片预取的总超时(默认 10000 ms,100–120000);到期后已取回的图片保留,其余降级为清单,不阻塞回合。',
212
+ min: 100,
213
+ max: 120_000,
214
+ step: 100,
215
+ },
216
+ prefetchImageMaxBytes: {
217
+ label: '单张预取图片上限(字节)',
218
+ help: '单张图片超过该字节数即不入首轮图片、留在清单懒取(默认 20971520 = 20 MiB)。',
219
+ min: 1,
220
+ max: 200 * 1024 * 1024,
221
+ step: 1,
222
+ },
223
+ prefetchTotalMaxBytes: {
224
+ label: '单次预取总量上限(字节)',
225
+ help: '一次触发消息图片预取的总字节预算(默认 52428800 = 50 MiB);用尽后剩余图片降级为清单懒取。',
226
+ min: 1,
227
+ max: 1024 * 1024 * 1024,
228
+ step: 1,
229
+ },
230
+ historyBootstrapLimit: {
231
+ label: '首轮话题历史条数',
232
+ help: '新 Session 首轮自动附带本话题最近 N 条消息摘要(0 = 关闭,上限 20);被动读取使用短预算,缺权限/失败时静默省略。更早的消息用 feishu_topic_messages 工具翻页。',
233
+ min: 0,
234
+ max: 20,
235
+ step: 1,
236
+ },
237
+ promptContextTimeoutMs: {
238
+ label: '被动读取预算(ms)',
239
+ help: '引用摘录与首轮话题历史这类被动 prompt 增强的整段预算(默认 3000 ms,100–10000);超时即省略对应块、回合照跑。模型主动调用工具的读取超时另由 feishu_tools 超时控制。',
240
+ min: 100,
241
+ max: 10_000,
242
+ step: 100,
243
+ },
244
+ 'feishuTools.attachmentFetch': {
245
+ label: '启用 feishu_attachment_fetch 工具',
246
+ help: '让模型按 message_id/file_key 懒取已授权消息的图片/文件。关闭后文件无法懒取,插件整体回退 archive 后端(入站文件直接下载到工作区)。对新会话生效。',
247
+ },
248
+ 'feishuTools.attachmentFetchMaxResources': {
249
+ label: '单次取回资源数上限',
250
+ help: '一次工具调用最多取回的附件数量(默认 10,1–50);超出会返回可读原因,模型可按 file_key 拆分重试。',
251
+ min: 1,
252
+ max: 50,
253
+ step: 1,
254
+ },
255
+ 'feishuTools.attachmentFetchMaxBytes': {
256
+ label: '单次取回字节预算(字节)',
257
+ help: '一次工具调用的总下载字节预算(默认 104857600 = 100 MiB);文件流式写入原生库,不整文件缓冲。',
258
+ min: 1,
259
+ max: 1024 * 1024 * 1024,
260
+ step: 1,
261
+ },
262
+ 'feishuTools.attachmentFetchTimeoutMs': {
263
+ label: '单次取回超时(ms)',
264
+ help: '一次 feishu_attachment_fetch 的整段超时(默认 120000 ms,1000–600000);到期取消下载并逐项报告可读原因。',
265
+ min: 1_000,
266
+ max: 600_000,
267
+ step: 1_000,
268
+ },
269
+ 'feishuTools.topicMessages': {
270
+ label: '启用 feishu_topic_messages 工具',
271
+ help: '让模型按需读取引用消息与话题内更早的消息/附件。关闭只影响模型工具;被动的引用摘录与首轮历史仍由 historyBootstrapLimit 控制。',
272
+ },
273
+ 'feishuTools.topicMessagesTimeoutMs': {
274
+ label: '单次话题读取超时(ms)',
275
+ help: '一次 feishu_topic_messages 的整段超时(默认 20000 ms,1000–120000);被动摘录使用独立的短预算。',
276
+ min: 1_000,
277
+ max: 120_000,
278
+ step: 1_000,
279
+ },
280
+ 'feishuTools.sendFile': {
281
+ label: '启用 feishu_send_file 工具',
282
+ help: '让模型把当前话题工作目录里生成的图片/文件发回当前会话(图片 ≤10 MB,文件 ≤30 MB)。需开通 im:resource 权限。对新会话生效。',
283
+ },
191
284
  provider: { label: '模型服务商', help: '留空 = 使用部署默认;例如 deepseek-official。', placeholder: '留空 = 部署默认' },
192
285
  model: { label: '模型 id', help: '留空 = 使用部署默认;例如 deepseek-v4-flash。', placeholder: '留空 = 部署默认' },
193
286
  reasoningEffort: { label: '推理档位', help: '模型声明的精确档位 id(如 high / max);留空 = 适配器默认。', placeholder: '留空 = 适配器默认' },
@@ -199,17 +292,30 @@ const FIELD_META: Record<EditableKey, { label: string; help: string; min?: numbe
199
292
 
200
293
  const GROUP_OF: Record<EditableKey, EditableFieldWire['group']> = {
201
294
  baseDir: 'workspace',
202
- workspaceRetention: 'workspace',
203
- workspaceRetentionAuto: 'workspace',
204
295
  dmMode: 'behavior',
205
296
  requireMention: 'behavior',
206
297
  threadFallback: 'behavior',
207
298
  welcomeOnboarding: 'behavior',
299
+ slashCommandPanel: 'behavior',
208
300
  allowPermissionSwitch: 'behavior',
209
- adminOpenId: 'access',
301
+ adminUserId: 'access',
210
302
  accessMode: 'access',
211
303
  authRequestTtlDays: 'access',
212
304
  acceptAttachments: 'content',
305
+ attachmentStore: 'content',
306
+ prefetchImages: 'content',
307
+ prefetchTimeoutMs: 'content',
308
+ prefetchImageMaxBytes: 'content',
309
+ prefetchTotalMaxBytes: 'content',
310
+ historyBootstrapLimit: 'content',
311
+ promptContextTimeoutMs: 'tools',
312
+ 'feishuTools.attachmentFetch': 'tools',
313
+ 'feishuTools.attachmentFetchMaxResources': 'tools',
314
+ 'feishuTools.attachmentFetchMaxBytes': 'tools',
315
+ 'feishuTools.attachmentFetchTimeoutMs': 'tools',
316
+ 'feishuTools.topicMessages': 'tools',
317
+ 'feishuTools.topicMessagesTimeoutMs': 'tools',
318
+ 'feishuTools.sendFile': 'tools',
213
319
  provider: 'model',
214
320
  model: 'model',
215
321
  reasoningEffort: 'model',
@@ -361,8 +467,6 @@ export function normalizeCredentialValue(raw: unknown): { ok: true; value: strin
361
467
  /** Longest accepted whitelist identifier / display name. */
362
468
  const MAX_ACCESS_VALUE_LENGTH = 128
363
469
 
364
- /** Feishu user open_ids (`ou_` + URL-safe id). Identity is open_id only. */
365
- const OPEN_ID_PATTERN = /^ou_[A-Za-z0-9_-]+$/
366
470
 
367
471
  /** Trim one whitelist identifier, rejecting empty, oversized, or hostile input. */
368
472
  export function normalizeAccessId(
@@ -378,7 +482,7 @@ export function normalizeAccessId(
378
482
  }
379
483
  // eslint-disable-next-line no-control-regex
380
484
  if (/[\u0000-\u001f\u007f]/.test(value)) return { ok: false, message: `${label}包含非法控制字符` }
381
- if (invalidFormat !== undefined && !OPEN_ID_PATTERN.test(value)) {
485
+ if (invalidFormat !== undefined && !FEISHU_USER_ID_PATTERN.test(value)) {
382
486
  return { ok: false, message: invalidFormat }
383
487
  }
384
488
  return { ok: true, value }
@@ -441,6 +545,8 @@ function phaseOf(target: SettingsTarget): ChannelPhaseWire {
441
545
  function buildStatus(target: SettingsTarget, overlayPath: string): ChannelStatusWire {
442
546
  const health = target.health()
443
547
  const connection = health.connection
548
+ const scopeCheck = target.isRunning() ? target.scopeCheck() : undefined
549
+ const slashCommandSync = target.isRunning() ? target.slashCommandSync() : undefined
444
550
  return {
445
551
  running: target.isRunning(),
446
552
  configured: target.isConfigured(),
@@ -466,6 +572,8 @@ function buildStatus(target: SettingsTarget, overlayPath: string): ChannelStatus
466
572
  version: DSH_CHANNELS_VERSION,
467
573
  overlayPath,
468
574
  busy: target.isRestarting() || target.isConnecting(),
575
+ ...(scopeCheck === undefined ? {} : { scopeCheck }),
576
+ ...(slashCommandSync === undefined ? {} : { slashCommandSync }),
469
577
  }
470
578
  }
471
579
 
@@ -536,9 +644,11 @@ export class SettingsSurface {
536
644
  const effective = this.target.configSnapshot()
537
645
  const overlay = await this.store.load()
538
646
  const running = this.target.runningConfigSnapshot()
539
- const overriddenKeys = EDITABLE_KEYS.filter(key => overlay[key] !== undefined)
647
+ const overriddenKeys = EDITABLE_KEYS.filter(
648
+ key => overlayValueOf(overlay as Record<string, unknown>, key) !== undefined,
649
+ )
540
650
  const restartPendingKeys = this.target.isRunning() && running !== undefined
541
- ? RESTART_KEYS.filter(key => effective[key] !== running[key])
651
+ ? RESTART_KEYS.filter(key => configValueOf(effective, key) !== configValueOf(running, key))
542
652
  : []
543
653
  const catalog = await this.catalogFor(effective.provider, effective.model)
544
654
  const fields = withDirectoryBrowse(
@@ -571,23 +681,6 @@ export class SettingsSurface {
571
681
  appSecret: toWire(effective.appSecret, credentialStates.appSecret),
572
682
  }
573
683
 
574
- // The cleanup preview is derived from live workspace/route state; a broken
575
- // seam must not break the whole panel, so it degrades to zero counts + error.
576
- let cleanup: CleanupPreviewWire
577
- try {
578
- cleanup = await this.target.cleanupPreview()
579
- } catch (error) {
580
- this.logger?.warn?.('dsh-channels: 目录清理预览失败:%s', messageOf(error))
581
- cleanup = {
582
- retention: effective.workspaceRetention,
583
- auto: effective.workspaceRetentionAuto,
584
- workspaces: 0,
585
- candidates: 0,
586
- candidateBytes: 0,
587
- error: messageOf(error),
588
- }
589
- }
590
-
591
684
  // The access snapshot comes from in-memory maps, but a broken seam must
592
685
  // still not take the whole panel down: degrade to an empty, fail-closed
593
686
  // view (the whitelist mutations will report their own errors).
@@ -611,14 +704,13 @@ export class SettingsSurface {
611
704
  config: {
612
705
  base: pickEditable(base) as Record<string, ConfigValue | undefined>,
613
706
  effective: pickEditable(effective) as Record<string, ConfigValue | undefined>,
614
- overlay: overlay as EditableValues,
707
+ overlay: overlayValues(overlay),
615
708
  overriddenKeys,
616
709
  restartPendingKeys,
617
710
  fields,
618
711
  readOnly: readOnlyFields(effective),
619
712
  credentials,
620
713
  },
621
- cleanup,
622
714
  access,
623
715
  }
624
716
  }
@@ -641,7 +733,7 @@ export class SettingsSurface {
641
733
  clearedKeys.push(key)
642
734
  continue
643
735
  }
644
- raw[key] = value
736
+ setOverlayValue(raw, key, value)
645
737
  }
646
738
 
647
739
  const parsed = parseOverlay(raw)
@@ -670,9 +762,8 @@ export class SettingsSurface {
670
762
 
671
763
  let merged: OverlayPatch
672
764
  try {
673
- merged = { ...await this.store.load() }
674
- for (const key of clearedKeys) delete merged[key]
675
- Object.assign(merged, parsed.patch)
765
+ merged = mergeOverlayPatch(await this.store.load(), parsed.patch)
766
+ for (const key of clearedKeys) deleteOverlayKey(merged, key)
676
767
  } catch (error) {
677
768
  return { ok: false, error: `写入覆盖配置失败:${messageOf(error)}` }
678
769
  }
@@ -681,7 +772,7 @@ export class SettingsSurface {
681
772
  // provider (or lone model) leaves the session without a complete route,
682
773
  // so an edit to either side must end with both set or both cleared.
683
774
  if (values.provider !== undefined || values.model !== undefined) {
684
- const prospective = { ...this.target.baseConfigSnapshot(), ...merged }
775
+ const prospective = mergeOverlay(this.target.baseConfigSnapshot(), merged)
685
776
  const hasProvider = (prospective.provider ?? '') !== ''
686
777
  const hasModel = (prospective.model ?? '') !== ''
687
778
  if (hasProvider !== hasModel) {
@@ -846,9 +937,9 @@ export class SettingsSurface {
846
937
  return this.mutateAccess(() => this.target.removeAccessGroup(id.value))
847
938
  }
848
939
 
849
- /** Authorize one user `open_id`; `ou_` prefix is required (not user_id/email). */
850
- async saveAccessUser(openId: unknown, name?: unknown): Promise<AccessMutationResponse> {
851
- const id = normalizeAccessId(openId, '用户 open_id', '用户 open_id 必须形如 ou_xxx(不是 user_id/邮箱)')
940
+ /** Authorize one user `user_id` (not an open_id or email). */
941
+ async saveAccessUser(userId: unknown, name?: unknown): Promise<AccessMutationResponse> {
942
+ const id = normalizeAccessId(userId, '用户 user_id', '用户 user_id 只能包含字母、数字、_ 和 -(不是 ou_ 开头的 open_id,也不是邮箱)')
852
943
  if (!id.ok) return { ok: false, error: id.message }
853
944
  const label = normalizeAccessName(name)
854
945
  if (!label.ok) return { ok: false, error: label.message }
@@ -856,8 +947,8 @@ export class SettingsSurface {
856
947
  }
857
948
 
858
949
  /** Revoke one user authorization (immediate: the next message re-evaluates). */
859
- async removeAccessUser(openId: unknown): Promise<AccessMutationResponse> {
860
- const id = normalizeAccessId(openId, '用户 open_id', '用户 open_id 必须形如 ou_xxx(不是 user_id/邮箱)')
950
+ async removeAccessUser(userId: unknown): Promise<AccessMutationResponse> {
951
+ const id = normalizeAccessId(userId, '用户 user_id', '用户 user_id 只能包含字母、数字、_ 和 -(不是 ou_ 开头的 open_id,也不是邮箱)')
861
952
  if (!id.ok) return { ok: false, error: id.message }
862
953
  return this.mutateAccess(() => this.target.removeAccessUser(id.value))
863
954
  }
@@ -897,23 +988,6 @@ export class SettingsSurface {
897
988
  if (this.directoryPicker === undefined) throw new Error(PANEL_COPY.directoryPickFailed)
898
989
  return await this.directoryPicker.pick(signal)
899
990
  }
900
-
901
- /**
902
- * Run the workspace-retention cleanup once. Irreversible deletions are the
903
- * target's responsibility; the surface only reports the outcome and returns
904
- * a refreshed panel (its cleanup preview then reflects the remaining workspaces).
905
- */
906
- async runCleanup(): Promise<CleanupResponse> {
907
- const busy = this.writeBusy()
908
- if (busy !== undefined) return busy
909
- let result: CleanupRunWire
910
- try {
911
- result = await this.target.runWorkspaceCleanup()
912
- } catch (error) {
913
- return { ok: false, error: `清理失败:${messageOf(error)}` }
914
- }
915
- return { ok: true, result, panel: await this.panel() }
916
- }
917
991
  }
918
992
 
919
993
  export { PANEL_COPY }
package/src/streamer.ts CHANGED
@@ -24,8 +24,16 @@
24
24
  * card. Reasoning is never segmented — it stays on card 1 inside the
25
25
  * fold panel's own fixed window, independent of the body length.
26
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.
27
+ * (`回复结束` / `已停止` / `回复失败`), render the settled footer on the main
28
+ * card and request the Typing → DONE/No reaction swap on the triggering
29
+ * message.
30
+ *
31
+ * Stop button: the card that is currently streaming in the session's active
32
+ * (front) turn carries a stop button until the turn settles; queued turns
33
+ * gain it when they become the front. `stopTargetOf()` resolves a clicked card to its
34
+ * session; `markStopping()` / `markStopDenied()` record the click's outcome
35
+ * on the card. Ownership checks and the cancellation itself belong to the
36
+ * caller.
29
37
  *
30
38
  * The first visible text delta flips the panel title 推理中 → 回复中; the
31
39
  * final patch flips it to 回复结束 or 回复失败. Patching is throttled to `refreshMs` and
@@ -42,7 +50,7 @@
42
50
 
43
51
  import type { AssistantStreamFrame, Session, SessionEvent, TurnEndReason } from './dsh-types.ts'
44
52
  import { buildCard, segmentCardContent, segmentCardOptions } from './cards.ts'
45
- import type { CardState, PanelPhase } from './cards.ts'
53
+ import type { CardState, PanelPhase, StopControl } from './cards.ts'
46
54
  import type { FeishuChannel } from './feishu-channel.ts'
47
55
  import { cacheHitPct, foldAll, foldEvent } from './stats.ts'
48
56
  import type { HeaderSample, StatsFold, UsageSample } from './stats.ts'
@@ -170,6 +178,8 @@ interface TurnState {
170
178
  reasoningMs: number
171
179
  /** The in-flight tool call, if any (cleared by its `tool/result`). */
172
180
  toolActivity: { name: string; label: string; since: number } | undefined
181
+ /** Tool names whose approval card is still undecided (shown over the tool line). */
182
+ awaitingApproval: readonly string[]
173
183
  /** Live workflow-run folds projected by the workflow tool into this session. */
174
184
  workflowRuns: Map<string, WorkflowRun>
175
185
  /** The last settled workflow run (kept visible until the next activity). */
@@ -202,6 +212,10 @@ interface TurnState {
202
212
  * durable model history, so the card must drop them again.
203
213
  */
204
214
  attempts: Map<string, AttemptMark>
215
+ /** The owner clicked stop; the button renders disabled until the turn settles. */
216
+ stopRequested: boolean
217
+ /** A non-owner clicked stop; the owner-only notice renders under the button. */
218
+ stopDenied: boolean
205
219
  }
206
220
 
207
221
  /** Card/body lengths captured at one stream attempt's `start` frame. */
@@ -350,6 +364,7 @@ export class TurnStreamer {
350
364
  reasoningSince: undefined,
351
365
  reasoningMs: 0,
352
366
  toolActivity: undefined,
367
+ awaitingApproval: [],
353
368
  workflowRuns: new Map(),
354
369
  workflowEndStatus: undefined,
355
370
  children: new Map(),
@@ -360,10 +375,15 @@ export class TurnStreamer {
360
375
  toolKind: '',
361
376
  heartbeatTimer: undefined,
362
377
  attempts: new Map(),
378
+ stopRequested: false,
379
+ stopDenied: false,
363
380
  })
364
381
  this.bySession.set(session.id, queue)
365
382
  const state = queue[queue.length - 1]!
366
383
  this.scheduleHeartbeat(session.id, state)
384
+ // The first frame is sent without a stop button; the front turn gains it
385
+ // on its first patch.
386
+ if (this.front(session.id) === state) this.markDirty(session.id, state)
367
387
  }
368
388
 
369
389
  /** Drop all state for a session (agent disposed / route removed). */
@@ -381,10 +401,61 @@ export class TurnStreamer {
381
401
  this.segmentChains.delete(sessionId)
382
402
  }
383
403
 
404
+ /** Drop every session's state and timers (service shutdown). */
405
+ detachAll(): void {
406
+ for (const sessionId of [...this.bySession.keys()]) this.detach(sessionId)
407
+ }
408
+
384
409
  hasSession(sessionId: string): boolean {
385
410
  return this.bySession.has(sessionId)
386
411
  }
387
412
 
413
+ /**
414
+ * The session whose active (front) turn streams into `messageId`, or
415
+ * undefined when the card belongs to no such turn (queued, settled,
416
+ * detached, or unknown). Only the front turn is the one `cancel` stops.
417
+ * @param messageId Feishu message id of a clicked card.
418
+ * @returns The owning session id.
419
+ */
420
+ stopTargetOf(messageId: string): string | undefined {
421
+ for (const sessionId of this.bySession.keys()) {
422
+ if (this.front(sessionId)?.cards.some(card => card.messageId === messageId) === true) return sessionId
423
+ }
424
+ return undefined
425
+ }
426
+
427
+ /**
428
+ * Show (or clear) the approval wait on the session's live turn: the tool
429
+ * line reads `⏳ 等待你审批:bash` while any approval card is undecided.
430
+ * @param sessionId - the session waiting on approval.
431
+ * @param toolNames - the tools still waiting; empty clears the line.
432
+ */
433
+ setAwaitingApproval(sessionId: string, toolNames: readonly string[]): void {
434
+ const state = this.front(sessionId)
435
+ if (state === undefined) return
436
+ if (state.awaitingApproval.join('\u0000') === toolNames.join('\u0000')) return
437
+ state.awaitingApproval = [...toolNames]
438
+ this.markDirty(sessionId, state)
439
+ }
440
+
441
+ /** Render the stop button disabled on the session's live turn. */
442
+ markStopping(sessionId: string): void {
443
+ this.markStop(sessionId, state => { state.stopRequested = true })
444
+ }
445
+
446
+ /** Add the owner-only notice under the session's stop button (once). */
447
+ markStopDenied(sessionId: string): void {
448
+ this.markStop(sessionId, state => { state.stopDenied = true })
449
+ }
450
+
451
+ private markStop(sessionId: string, apply: (state: TurnState) => void): void {
452
+ const state = this.front(sessionId)
453
+ if (state === undefined) return
454
+ const before = `${state.stopRequested}/${state.stopDenied}`
455
+ apply(state)
456
+ if (`${state.stopRequested}/${state.stopDenied}` !== before) this.markDirty(sessionId, state)
457
+ }
458
+
388
459
  /** The front (oldest unfinished) turn state of a session. */
389
460
  private front(sessionId: string): TurnState | undefined {
390
461
  const queue = this.bySession.get(sessionId)
@@ -518,7 +589,7 @@ export class TurnStreamer {
518
589
  // The ended turn belongs to the front state: assign it if a turn was
519
590
  // rejected/emptied before any step started.
520
591
  if (state.turn === undefined) state.turn = event.data.turn
521
- this.finalize(session.id, state, turnFailure(event.data.reason))
592
+ this.finalize(session.id, state, turnFailure(event.data.reason), turnAborted(event.data.reason))
522
593
  break
523
594
  }
524
595
  default:
@@ -834,6 +905,9 @@ export class TurnStreamer {
834
905
  */
835
906
  private toolLine(state: TurnState, now: number): string {
836
907
  if (state.finalized) return ''
908
+ if (state.awaitingApproval.length > 0) {
909
+ return clipActivity(`⏳ 等待你审批:${[...new Set(state.awaitingApproval)].join('、')}`)
910
+ }
837
911
  const tool = state.toolActivity
838
912
  if (tool === undefined) return ''
839
913
  const prefix = tool.name === 'subagent'
@@ -884,12 +958,16 @@ export class TurnStreamer {
884
958
  const kind = this.noteKind('tool', tool, state.toolKind)
885
959
  if (kind !== undefined) state.toolKind = kind
886
960
  }
961
+ const stop: StopControl | undefined = !active || state.finalized || this.front(sessionId) !== state
962
+ ? undefined
963
+ : state.stopRequested ? 'stopping' : 'ready'
887
964
  return {
888
965
  phase: state.phase,
889
966
  reasoning: state.reasoning,
890
967
  body,
891
968
  status: active ? state.status : '',
892
969
  tool: active ? state.tool : '',
970
+ ...(stop === undefined ? {} : { stop, stopDenied: state.stopDenied }),
893
971
  stats: this.statsFor(sessionId, state),
894
972
  }
895
973
  }
@@ -1062,10 +1140,13 @@ export class TurnStreamer {
1062
1140
  sessionId: string,
1063
1141
  state: TurnState,
1064
1142
  failure: { message: string; code?: string } | undefined,
1143
+ aborted = false,
1065
1144
  ): void {
1066
1145
  if (state.finalized) return
1067
1146
  state.finalized = true
1068
- state.phase = failure === undefined ? 'done' : 'error'
1147
+ // Only a stop this channel requested reads as 已停止; other cancellation
1148
+ // causes (parent, hook, disposal) settle like a normal end.
1149
+ state.phase = failure !== undefined ? 'error' : aborted && state.stopRequested ? 'stopped' : 'done'
1069
1150
  // A turn that ends inside a reasoning phase (e.g. no answer text) still
1070
1151
  // reports the time it spent reasoning.
1071
1152
  this.closeReasoning(state)
@@ -1118,6 +1199,9 @@ export class TurnStreamer {
1118
1199
  if (queue === undefined) return
1119
1200
  const index = queue.indexOf(state)
1120
1201
  if (index >= 0) queue.splice(index, 1)
1202
+ // The next queued turn becomes the active one and gains the stop button.
1203
+ const next = this.front(sessionId)
1204
+ if (next !== undefined) this.markDirty(sessionId, next)
1121
1205
  })
1122
1206
 
1123
1207
  this.hooks.onTurnFinalized(sessionId, state.turn ?? 0, state.cards[0]?.messageId ?? '')
@@ -1136,6 +1220,11 @@ export class TurnStreamer {
1136
1220
  }
1137
1221
  }
1138
1222
 
1223
+ /** Whether a turn ended through a cancellation request (`/stop`, the stop button). */
1224
+ function turnAborted(reason: TurnEndReason): boolean {
1225
+ return typeof reason === 'object' && reason !== null && reason.kind === 'aborted'
1226
+ }
1227
+
1139
1228
  /** Extract the user-visible failure from the current Harness turn-end union. */
1140
1229
  function turnFailure(reason: TurnEndReason): { message: string; code?: string } | undefined {
1141
1230
  if (typeof reason !== 'object' || reason === null || reason.kind !== 'error') return undefined