@modesto_ma/dsh-channels 0.4.0 → 0.5.0-beta.2

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 (66) hide show
  1. package/README.md +83 -57
  2. package/docs/access-control-design.md +8 -5
  3. package/docs/attachment-pipeline-v2.md +33 -41
  4. package/docs/feishu-onboarding.md +36 -33
  5. package/docs/persistence.md +6 -4
  6. package/docs/refactor-plan.md +1 -1
  7. package/docs/workspace-isolation-design.md +7 -8
  8. package/lib/client.js +444 -288
  9. package/lib/client.js.map +1 -1
  10. package/lib/index.js +9017 -6794
  11. package/lib/index.js.map +1 -1
  12. package/package.json +1 -1
  13. package/src/access/cards.ts +9 -9
  14. package/src/access/controller.ts +10 -10
  15. package/src/access/index.ts +1 -0
  16. package/src/access/policy.ts +10 -10
  17. package/src/access/requests.ts +32 -31
  18. package/src/access/store.ts +43 -13
  19. package/src/access/targets.ts +56 -0
  20. package/src/cards/approval.ts +96 -26
  21. package/src/cards/index.ts +1 -0
  22. package/src/cards/onboarding.ts +1 -0
  23. package/src/cards/picker.ts +108 -0
  24. package/src/cards/streaming.ts +56 -7
  25. package/src/cards/wiki.ts +124 -0
  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 +96 -24
  32. package/src/client/panel/workspace-tab.tsx +2 -18
  33. package/src/client/string-list.ts +139 -0
  34. package/src/client/styles.ts +5 -0
  35. package/src/commands/registry.ts +8 -5
  36. package/src/config.ts +55 -54
  37. package/src/domain.ts +36 -123
  38. package/src/feishu/delivery.ts +34 -1
  39. package/src/feishu/inbound-attachments.ts +1 -1
  40. package/src/feishu/manifest.ts +8 -31
  41. package/src/feishu/message-read.ts +97 -24
  42. package/src/feishu/scopes.ts +194 -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 +72 -112
  46. package/src/feishu/wiki/api.ts +346 -0
  47. package/src/feishu/wiki/consent.ts +183 -0
  48. package/src/feishu/wiki/links.ts +122 -0
  49. package/src/feishu/wiki/scope.ts +66 -0
  50. package/src/feishu/wiki/tools.ts +984 -0
  51. package/src/feishu-channel.ts +280 -9
  52. package/src/index.ts +5 -4
  53. package/src/interactions/approvals.ts +187 -7
  54. package/src/interactions/pending.ts +3 -3
  55. package/src/routing/inbound.ts +73 -106
  56. package/src/service.ts +545 -440
  57. package/src/session/manager.ts +1 -1
  58. package/src/session/routes.ts +2 -3
  59. package/src/settings/contract.ts +110 -89
  60. package/src/settings/routes.ts +3 -17
  61. package/src/settings/surface.ts +144 -94
  62. package/src/streamer.ts +94 -5
  63. package/src/workspaces.ts +21 -29
  64. package/src/cleanup.ts +0 -546
  65. package/src/client/panel/cleanup-card.tsx +0 -106
  66. package/src/feishu/pending-attachments.ts +0 -562
@@ -12,6 +12,8 @@
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'
16
+ import { parseWikiRootLink, WIKI_ROOT_LINK_ERRORS } from '../feishu/wiki/links.ts'
15
17
  import type { HealthSnapshot } from '../health.ts'
16
18
  import { prepareWorkspaceBaseDir } from '../workspaces.ts'
17
19
  import {
@@ -25,9 +27,6 @@ import type {
25
27
  CatalogWire,
26
28
  ChannelPhaseWire,
27
29
  ChannelStatusWire,
28
- CleanupPreviewWire,
29
- CleanupResponse,
30
- CleanupRunWire,
31
30
  ClearConfigResponse,
32
31
  ClearCredentialResponse,
33
32
  ConfigValue,
@@ -45,6 +44,9 @@ import type {
45
44
  SaveConfigResponse,
46
45
  SaveCredentialsRequest,
47
46
  SaveCredentialsResponse,
47
+ ScopeCheckWire,
48
+ SlashCommandSyncWire,
49
+ StringListItemFormat,
48
50
  } from './contract.ts'
49
51
  import {
50
52
  configValueOf,
@@ -71,6 +73,10 @@ export interface SettingsTarget {
71
73
  startedAtMs(): number | undefined
72
74
  uptimeMs(): number | undefined
73
75
  startError(): string | undefined
76
+ /** Post-connect scope self-check for the open connection, once it finished. */
77
+ scopeCheck(): ScopeCheckWire | undefined
78
+ /** Post-connect native slash-command panel sync for the open connection, once it finished. */
79
+ slashCommandSync(): SlashCommandSyncWire | undefined
74
80
  /**
75
81
  * Whether the Feishu application credentials resolve. A false value means the
76
82
  * plugin is loaded but deliberately holds no connection: the panel guides the
@@ -91,20 +97,16 @@ export interface SettingsTarget {
91
97
  credentialStates(): Promise<{ appId: CredentialInfo; appSecret: CredentialInfo }>
92
98
  setCredential(field: CredentialField, value: string): Promise<void>
93
99
  clearCredential(field: CredentialField): Promise<void>
94
- /** What the workspace-retention cleanup would delete right now (read-only scan). */
95
- cleanupPreview(): Promise<CleanupPreviewWire>
96
- /** Run the workspace cleanup once; partial failures are reported per workspace. */
97
- runWorkspaceCleanup(): Promise<CleanupRunWire>
98
100
  /** Current admission snapshot: mode, administrator, whitelists, pending requests. */
99
101
  accessSnapshot(): AccessWire
100
102
  /** Authorize one group (panel entry); resolves with the refreshed snapshot. */
101
103
  addAccessGroup(chatId: string, name?: string): Promise<AccessMutationResponse>
102
104
  /** Revoke one group authorization (takes effect on the next message). */
103
105
  removeAccessGroup(chatId: string): Promise<AccessMutationResponse>
104
- /** Authorize one user `open_id` (global across authorized groups). */
105
- addAccessUser(openId: string, name?: string): Promise<AccessMutationResponse>
106
+ /** Authorize one user `user_id` (global across authorized groups). */
107
+ addAccessUser(userId: string, name?: string): Promise<AccessMutationResponse>
106
108
  /** Revoke one user authorization (takes effect on the next message). */
107
- removeAccessUser(openId: string): Promise<AccessMutationResponse>
109
+ removeAccessUser(userId: string): Promise<AccessMutationResponse>
108
110
  /** Settle one pending request (panel shares this path with the Feishu DM card). */
109
111
  resolveAccessRequest(token: string, decision: 'allow' | 'deny'): Promise<AccessMutationResponse>
110
112
  }
@@ -124,20 +126,90 @@ const OPTIONAL_TEXT_KEYS = new Set<EditableKey>([
124
126
  'model',
125
127
  'reasoningEffort',
126
128
  'agentPreset',
127
- 'adminOpenId',
129
+ 'adminUserId',
128
130
  ])
129
131
 
130
132
  const BOOLEAN_KEYS = new Set<EditableKey>([
131
133
  'requireMention',
132
134
  'welcomeOnboarding',
135
+ 'slashCommandPanel',
133
136
  'allowPermissionSwitch',
134
137
  'acceptAttachments',
135
138
  'prefetchImages',
136
- 'workspaceRetentionAuto',
137
139
  'feishuTools.attachmentFetch',
138
140
  'feishuTools.topicMessages',
141
+ 'feishuTools.sendFile',
142
+ 'feishuTools.wikiRead',
143
+ 'feishuTools.wikiWrite',
139
144
  ])
140
145
 
146
+ /** Bounds and entry format of one `string-list` field. */
147
+ interface StringListSpec {
148
+ /** Most non-empty entries accepted (mirrors the config schema bound). */
149
+ maxItems: number
150
+ itemFormat?: StringListItemFormat
151
+ }
152
+
153
+ /** Editable keys rendered as `string-list` (one input per entry). */
154
+ const STRING_LIST_FIELDS: Partial<Record<EditableKey, StringListSpec>> = {
155
+ wikiRoots: { maxItems: 20, itemFormat: 'wiki-root-link' },
156
+ }
157
+
158
+ /** Entry checks per item format: the readable reason, or `undefined` when valid. */
159
+ const ITEM_FORMAT_CHECKS: Readonly<Record<StringListItemFormat, (entry: string) => string | undefined>> = {
160
+ 'wiki-root-link': (entry) => {
161
+ const parsed = parseWikiRootLink(entry)
162
+ return parsed.ok ? undefined : WIKI_ROOT_LINK_ERRORS[parsed.error]
163
+ },
164
+ }
165
+
166
+ /** `第 N 项「entry」:reason`, shared wording with the Client check. */
167
+ function listEntryMessage(index: number, entry: string | undefined, reason: string): string {
168
+ const position = PANEL_COPY.listEntry.replace('{n}', String(index + 1))
169
+ return entry === undefined ? `${position}${reason}` : `${position}「${entry}」:${reason}`
170
+ }
171
+
172
+ /**
173
+ * Normalize one submitted `string-list` value: entries are trimmed, empty
174
+ * entries dropped, the count bounded by `maxItems`, and every remaining entry
175
+ * checked against `itemFormat`. Issue indexes refer to the submitted array
176
+ * (empty inputs included), so the panel can mark the exact input.
177
+ *
178
+ * @param key - The editable key the issues are reported under.
179
+ * @param raw - The untrusted submitted value.
180
+ * @param spec - The field's bounds and entry format.
181
+ * @returns The cleaned entries, or every per-entry / whole-field issue.
182
+ */
183
+ export function normalizeStringList(
184
+ key: EditableKey,
185
+ raw: unknown,
186
+ spec: StringListSpec,
187
+ ): { ok: true; value: string[] } | { ok: false; issues: FieldIssueWire[] } {
188
+ if (!Array.isArray(raw)) return { ok: false, issues: [{ key, message: PANEL_COPY.listNotArray }] }
189
+ const issues: FieldIssueWire[] = []
190
+ const value: string[] = []
191
+ const check = spec.itemFormat === undefined ? undefined : ITEM_FORMAT_CHECKS[spec.itemFormat]
192
+ raw.forEach((item: unknown, index) => {
193
+ if (typeof item !== 'string') {
194
+ issues.push({ key, index, message: listEntryMessage(index, undefined, PANEL_COPY.listEntryNotString) })
195
+ return
196
+ }
197
+ const entry = item.trim()
198
+ if (entry === '') return
199
+ const reason = check?.(entry)
200
+ if (reason !== undefined) {
201
+ issues.push({ key, index, message: listEntryMessage(index, entry, reason) })
202
+ return
203
+ }
204
+ value.push(entry)
205
+ })
206
+ const count = raw.filter(item => typeof item !== 'string' || item.trim() !== '').length
207
+ if (count > spec.maxItems) {
208
+ issues.push({ key, message: PANEL_COPY.listTooMany.replace('{max}', String(spec.maxItems)) })
209
+ }
210
+ return issues.length > 0 ? { ok: false, issues } : { ok: true, value }
211
+ }
212
+
141
213
  const SELECT_OPTIONS: Partial<Record<EditableKey, ReadonlyArray<{ value: string; label: string }>>> = {
142
214
  attachmentStore: [
143
215
  { value: 'native', label: 'DSH 原生附件库(默认)' },
@@ -151,13 +223,6 @@ const SELECT_OPTIONS: Partial<Record<EditableKey, ReadonlyArray<{ value: string;
151
223
  { value: 'degrade', label: '话题被拒时降级为会话级' },
152
224
  { value: 'strict', label: '话题被拒时直接失败' },
153
225
  ],
154
- workspaceRetention: [
155
- { value: 'off', label: '关闭(不删除)' },
156
- { value: '7d', label: '7 天' },
157
- { value: '30d', label: '30 天' },
158
- { value: '90d', label: '90 天' },
159
- { value: '180d', label: '180 天' },
160
- ],
161
226
  loggerLevel: [
162
227
  { value: 'debug', label: 'debug' },
163
228
  { value: 'info', label: 'info' },
@@ -174,7 +239,7 @@ const SELECT_OPTIONS: Partial<Record<EditableKey, ReadonlyArray<{ value: string;
174
239
  const FIELD_META: Record<EditableKey, { label: string; help: string; min?: number; max?: number; step?: number; placeholder?: string; wide?: boolean }> = {
175
240
  baseDir: {
176
241
  label: '工作区根目录(文件落点)',
177
- help: '每个用户/群成员一个工作区:私聊 <baseDir>/p2p/<open_id>/,群聊 <baseDir>/groups/<chat_id>/<open_id>/;每个话题在其中拥有独立工作目录 <工作区>/topics/<话题>/(Git 仓库工作区可自动改用 worktree)。必须是绝对路径(~ 开头按家目录展开),保存时会创建该目录;只影响之后新建立的会话 —— 已有话题即使 /renew 也沿用原目录,旧文件不会迁移。',
242
+ help: '每个用户/群成员一个工作区:私聊 <baseDir>/p2p/<user_id>/,群聊 <baseDir>/groups/<chat_id>/<user_id>/;每个话题在其中拥有独立工作目录 <工作区>/topics/<话题>/(Git 仓库工作区可自动改用 worktree)。必须是绝对路径(~ 开头按家目录展开),保存时会创建该目录;只影响之后新建立的会话 —— 已有话题即使 /renew 也沿用原目录,旧文件不会迁移。',
178
243
  placeholder: '/Users/you/.dsh/dsh-channels/workspaces',
179
244
  wide: true,
180
245
  },
@@ -182,11 +247,12 @@ const FIELD_META: Record<EditableKey, { label: string; help: string; min?: numbe
182
247
  requireMention: { label: '普通群需 @ 机器人', help: '已绑定的主题内始终不需要 @;关闭后普通群的每条消息都会触发。' },
183
248
  threadFallback: { label: '话题被拒时的兜底', help: 'degrade 降级为会话级路由;strict 直接让回复失败。' },
184
249
  welcomeOnboarding: { label: '首次私聊引导卡', help: '每个私聊会话第一次触达时发送一张命令与用法引导卡,只发一次。' },
250
+ slashCommandPanel: { label: '同步飞书原生斜杠指令面板', help: '连接时把插件命令同步到飞书输入 / 时弹出的面板;需开通 application:app_slash_command:read 与 write 权限,部分企业不允许应用添加斜杠指令。关闭时不读写面板,直接输入命令不受影响。' },
185
251
  allowPermissionSwitch: { label: '允许飞书侧切换权限预设', help: '安全敏感:开启后话题所有者可用 /permissions 切换沙箱与审批预设。' },
186
- adminOpenId: {
187
- label: '管理员 open_id',
188
- help: '唯一管理员。私聊机器人发送 /userid 获取(ou_ 开头,不是 user_id/邮箱);留空 = 清除 Web 覆盖。guarded 模式下未配置会拒绝所有对话。',
189
- placeholder: 'ou_xxx',
252
+ adminUserId: {
253
+ label: '管理员 user_id',
254
+ help: '唯一管理员。私聊机器人发送 /userid 获取(不是 ou_ 开头的 open_id,也不是邮箱);留空 = 清除 Web 覆盖。未配置时自动使用飞书应用所有者(需可选的「获取应用信息」权限,开不了就在这里手动填写);两者都没有时 guarded 模式会拒绝所有对话。',
255
+ placeholder: 'user_id',
190
256
  },
191
257
  accessMode: {
192
258
  label: '准入模式',
@@ -201,7 +267,7 @@ const FIELD_META: Record<EditableKey, { label: string; help: string; min?: numbe
201
267
  },
202
268
  acceptAttachments: {
203
269
  label: '接收入站附件',
204
- help: '接收飞书图片、文件与合并转发记录。native(默认)下入站附件存入 DSH 原生附件库:触发消息的图片预取进首轮、文件保持懒取,纯附件消息静默登记元数据;archive 下按旧版行为下载到工作区 attachments/。关闭后附件消息不保存、不触发模型、也不登记。',
270
+ help: '接收飞书图片、文件与合并转发记录。native(默认)下入站附件存入 DSH 原生附件库:触发消息的图片预取进首轮、文件保持懒取,纯附件消息不回复、不落地,需要时请引用该消息(多个文件可合并转发后引用)再提问;archive 下按旧版行为下载到工作区 attachments/。关闭后附件消息不保存、不触发模型、也不列入清单。',
205
271
  },
206
272
  attachmentStore: {
207
273
  label: '入站附件存储后端',
@@ -239,19 +305,6 @@ const FIELD_META: Record<EditableKey, { label: string; help: string; min?: numbe
239
305
  max: 20,
240
306
  step: 1,
241
307
  },
242
- pendingAttachmentTtlMs: {
243
- label: '静默附件登记有效期(ms)',
244
- help: '纯附件/未触发消息只登记元数据(不下载)。超过该时长的登记由周期 GC 清除;0 = 不按时间清理(每个 scope 仍有容量上限)。默认 604800000 = 7 天。',
245
- min: 0,
246
- step: 1,
247
- },
248
- pendingAttachmentLimit: {
249
- label: '静默附件登记容量(每个 scope)',
250
- help: '每个 (会话, 话题, 发送者) scope 最多保留的登记条数(1–100);超出时淘汰该 scope 最旧的一条。',
251
- min: 1,
252
- max: 100,
253
- step: 1,
254
- },
255
308
  promptContextTimeoutMs: {
256
309
  label: '被动读取预算(ms)',
257
310
  help: '引用摘录与首轮话题历史这类被动 prompt 增强的整段预算(默认 3000 ms,100–10000);超时即省略对应块、回合照跑。模型主动调用工具的读取超时另由 feishu_tools 超时控制。',
@@ -295,8 +348,24 @@ const FIELD_META: Record<EditableKey, { label: string; help: string; min?: numbe
295
348
  max: 120_000,
296
349
  step: 1_000,
297
350
  },
298
- workspaceRetention: { label: '过期目录保留期', help: '只清理工作区与 Agent 产物目录,不影响 Session 附件:默认 native 存储下附件在 DSH 原生附件库,不参与目录清理;仅当显式使用 archive 后端时,附件副本位于话题目录、会随目录清理。用户工作区本身永不删除,DSH 会话日志不受影响。' },
299
- workspaceRetentionAuto: { label: '自动执行目录清理', help: '开启后插件在启动时及每天执行一次过期目录清理;关闭时只能在下方卡片手动清理。开启前请确认保留期已设置;清理只清理工作区与 Agent 产物目录,不影响 Session 附件。' },
351
+ 'feishuTools.sendFile': {
352
+ label: '启用 feishu_send_file 工具',
353
+ help: '让模型把当前话题工作目录里生成的图片/文件发回当前会话(图片 ≤10 MB,文件 ≤30 MB)。需开通 im:resource 权限。对新会话生效。',
354
+ },
355
+ 'feishuTools.wikiRead': {
356
+ label: '启用知识库读取工具',
357
+ help: '让模型浏览「知识库范围」内的知识库节点并读取 docx 文档(feishu_wiki_list / feishu_wiki_read),以应用身份访问。需开通 wiki:wiki(或 wiki:wiki:readonly)与 docx:document(或 docx:document:readonly)权限。只有话题所有者是管理员或在知识库白名单(/auth wiki add @成员)中才能使用;打开文档前会在授权卡上征得话题所有者同意,每个话题一次。知识库范围为空时不挂载。对新会话生效。',
358
+ },
359
+ 'feishuTools.wikiWrite': {
360
+ label: '启用知识库写入工具',
361
+ help: '让模型在「知识库范围」内新建 docx 文档,并编辑本应用拥有的文档(feishu_wiki_create / feishu_wiki_edit),以应用身份访问;每次新建或编辑都需话题所有者在审批卡上同意。需开通 wiki:wiki 与 docx:document 权限;使用条件同读取工具(管理员或知识库白名单)。知识库范围为空时不挂载。对新会话生效。',
362
+ },
363
+ wikiRoots: {
364
+ label: '知识库范围(wikiRoots)',
365
+ help: '知识库工具只能访问这些位置,每个输入框填一个链接:整个知识空间 https://<租户>.feishu.cn/wiki/space/<space_id>,或节点 https://<租户>.feishu.cn/wiki/<node_token>(该节点及其下所有节点);最多 20 项,空输入框保存时忽略。留空 = 不挂载任何知识库工具(即使上面的开关已开启)。对新会话生效。',
366
+ placeholder: 'https://<租户>.feishu.cn/wiki/space/<space_id>',
367
+ wide: true,
368
+ },
300
369
  provider: { label: '模型服务商', help: '留空 = 使用部署默认;例如 deepseek-official。', placeholder: '留空 = 部署默认' },
301
370
  model: { label: '模型 id', help: '留空 = 使用部署默认;例如 deepseek-v4-flash。', placeholder: '留空 = 部署默认' },
302
371
  reasoningEffort: { label: '推理档位', help: '模型声明的精确档位 id(如 high / max);留空 = 适配器默认。', placeholder: '留空 = 适配器默认' },
@@ -308,14 +377,13 @@ const FIELD_META: Record<EditableKey, { label: string; help: string; min?: numbe
308
377
 
309
378
  const GROUP_OF: Record<EditableKey, EditableFieldWire['group']> = {
310
379
  baseDir: 'workspace',
311
- workspaceRetention: 'workspace',
312
- workspaceRetentionAuto: 'workspace',
313
380
  dmMode: 'behavior',
314
381
  requireMention: 'behavior',
315
382
  threadFallback: 'behavior',
316
383
  welcomeOnboarding: 'behavior',
384
+ slashCommandPanel: 'behavior',
317
385
  allowPermissionSwitch: 'behavior',
318
- adminOpenId: 'access',
386
+ adminUserId: 'access',
319
387
  accessMode: 'access',
320
388
  authRequestTtlDays: 'access',
321
389
  acceptAttachments: 'content',
@@ -325,8 +393,6 @@ const GROUP_OF: Record<EditableKey, EditableFieldWire['group']> = {
325
393
  prefetchImageMaxBytes: 'content',
326
394
  prefetchTotalMaxBytes: 'content',
327
395
  historyBootstrapLimit: 'content',
328
- pendingAttachmentTtlMs: 'content',
329
- pendingAttachmentLimit: 'content',
330
396
  promptContextTimeoutMs: 'tools',
331
397
  'feishuTools.attachmentFetch': 'tools',
332
398
  'feishuTools.attachmentFetchMaxResources': 'tools',
@@ -334,6 +400,10 @@ const GROUP_OF: Record<EditableKey, EditableFieldWire['group']> = {
334
400
  'feishuTools.attachmentFetchTimeoutMs': 'tools',
335
401
  'feishuTools.topicMessages': 'tools',
336
402
  'feishuTools.topicMessagesTimeoutMs': 'tools',
403
+ 'feishuTools.sendFile': 'tools',
404
+ 'feishuTools.wikiRead': 'tools',
405
+ 'feishuTools.wikiWrite': 'tools',
406
+ wikiRoots: 'tools',
337
407
  provider: 'model',
338
408
  model: 'model',
339
409
  reasoningEffort: 'model',
@@ -347,10 +417,13 @@ const GROUP_OF: Record<EditableKey, EditableFieldWire['group']> = {
347
417
  export const FIELD_DESCRIPTORS: readonly EditableFieldWire[] = EDITABLE_KEYS.map(key => {
348
418
  const meta = FIELD_META[key]
349
419
  const options = SELECT_OPTIONS[key]
420
+ const list = STRING_LIST_FIELDS[key]
350
421
  return {
351
422
  key,
352
423
  label: meta.label,
353
- kind: BOOLEAN_KEYS.has(key) ? 'boolean' : options !== undefined ? 'select' : meta.min !== undefined ? 'number' : 'text',
424
+ kind: list !== undefined
425
+ ? 'string-list'
426
+ : BOOLEAN_KEYS.has(key) ? 'boolean' : options !== undefined ? 'select' : meta.min !== undefined ? 'number' : 'text',
354
427
  group: GROUP_OF[key],
355
428
  help: meta.help,
356
429
  ...(options === undefined ? {} : { options }),
@@ -358,6 +431,8 @@ export const FIELD_DESCRIPTORS: readonly EditableFieldWire[] = EDITABLE_KEYS.map
358
431
  ...(meta.max === undefined ? {} : { max: meta.max }),
359
432
  ...(meta.step === undefined ? {} : { step: meta.step }),
360
433
  ...(meta.placeholder === undefined ? {} : { placeholder: meta.placeholder }),
434
+ ...(list === undefined ? {} : { maxItems: list.maxItems }),
435
+ ...(list?.itemFormat === undefined ? {} : { itemFormat: list.itemFormat }),
361
436
  ...(meta.wide === undefined ? {} : { wide: meta.wide }),
362
437
  restartRequired: RESTART_SET.has(key),
363
438
  }
@@ -485,8 +560,6 @@ export function normalizeCredentialValue(raw: unknown): { ok: true; value: strin
485
560
  /** Longest accepted whitelist identifier / display name. */
486
561
  const MAX_ACCESS_VALUE_LENGTH = 128
487
562
 
488
- /** Feishu user open_ids (`ou_` + URL-safe id). Identity is open_id only. */
489
- const OPEN_ID_PATTERN = /^ou_[A-Za-z0-9_-]+$/
490
563
 
491
564
  /** Trim one whitelist identifier, rejecting empty, oversized, or hostile input. */
492
565
  export function normalizeAccessId(
@@ -502,7 +575,7 @@ export function normalizeAccessId(
502
575
  }
503
576
  // eslint-disable-next-line no-control-regex
504
577
  if (/[\u0000-\u001f\u007f]/.test(value)) return { ok: false, message: `${label}包含非法控制字符` }
505
- if (invalidFormat !== undefined && !OPEN_ID_PATTERN.test(value)) {
578
+ if (invalidFormat !== undefined && !FEISHU_USER_ID_PATTERN.test(value)) {
506
579
  return { ok: false, message: invalidFormat }
507
580
  }
508
581
  return { ok: true, value }
@@ -565,6 +638,8 @@ function phaseOf(target: SettingsTarget): ChannelPhaseWire {
565
638
  function buildStatus(target: SettingsTarget, overlayPath: string): ChannelStatusWire {
566
639
  const health = target.health()
567
640
  const connection = health.connection
641
+ const scopeCheck = target.isRunning() ? target.scopeCheck() : undefined
642
+ const slashCommandSync = target.isRunning() ? target.slashCommandSync() : undefined
568
643
  return {
569
644
  running: target.isRunning(),
570
645
  configured: target.isConfigured(),
@@ -590,6 +665,8 @@ function buildStatus(target: SettingsTarget, overlayPath: string): ChannelStatus
590
665
  version: DSH_CHANNELS_VERSION,
591
666
  overlayPath,
592
667
  busy: target.isRestarting() || target.isConnecting(),
668
+ ...(scopeCheck === undefined ? {} : { scopeCheck }),
669
+ ...(slashCommandSync === undefined ? {} : { slashCommandSync }),
593
670
  }
594
671
  }
595
672
 
@@ -697,23 +774,6 @@ export class SettingsSurface {
697
774
  appSecret: toWire(effective.appSecret, credentialStates.appSecret),
698
775
  }
699
776
 
700
- // The cleanup preview is derived from live workspace/route state; a broken
701
- // seam must not break the whole panel, so it degrades to zero counts + error.
702
- let cleanup: CleanupPreviewWire
703
- try {
704
- cleanup = await this.target.cleanupPreview()
705
- } catch (error) {
706
- this.logger?.warn?.('dsh-channels: 目录清理预览失败:%s', messageOf(error))
707
- cleanup = {
708
- retention: effective.workspaceRetention,
709
- auto: effective.workspaceRetentionAuto,
710
- workspaces: 0,
711
- candidates: 0,
712
- candidateBytes: 0,
713
- error: messageOf(error),
714
- }
715
- }
716
-
717
777
  // The access snapshot comes from in-memory maps, but a broken seam must
718
778
  // still not take the whole panel down: degrade to an empty, fail-closed
719
779
  // view (the whitelist mutations will report their own errors).
@@ -744,7 +804,6 @@ export class SettingsSurface {
744
804
  readOnly: readOnlyFields(effective),
745
805
  credentials,
746
806
  },
747
- cleanup,
748
807
  access,
749
808
  }
750
809
  }
@@ -760,6 +819,7 @@ export class SettingsSurface {
760
819
 
761
820
  const raw: Record<string, unknown> = {}
762
821
  const clearedKeys: EditableKey[] = []
822
+ const listIssues: FieldIssueWire[] = []
763
823
  for (const key of EDITABLE_KEYS) {
764
824
  const value = values[key]
765
825
  if (value === undefined) continue
@@ -767,15 +827,22 @@ export class SettingsSurface {
767
827
  clearedKeys.push(key)
768
828
  continue
769
829
  }
830
+ const list = STRING_LIST_FIELDS[key]
831
+ if (list !== undefined) {
832
+ const normalized = normalizeStringList(key, value, list)
833
+ if (normalized.ok) setOverlayValue(raw, key, normalized.value)
834
+ else listIssues.push(...normalized.issues)
835
+ continue
836
+ }
770
837
  setOverlayValue(raw, key, value)
771
838
  }
772
839
 
773
840
  const parsed = parseOverlay(raw)
774
- if (!parsed.ok) {
775
- const issues: FieldIssueWire[] = parsed.issues.map(issue => ({
776
- key: issue.path,
777
- message: issue.message,
778
- }))
841
+ if (!parsed.ok || listIssues.length > 0) {
842
+ const issues: FieldIssueWire[] = [
843
+ ...listIssues,
844
+ ...(parsed.ok ? [] : parsed.issues.map(issue => ({ key: issue.path, message: issue.message }))),
845
+ ]
779
846
  return { ok: false, error: '配置校验失败', issues }
780
847
  }
781
848
 
@@ -971,9 +1038,9 @@ export class SettingsSurface {
971
1038
  return this.mutateAccess(() => this.target.removeAccessGroup(id.value))
972
1039
  }
973
1040
 
974
- /** Authorize one user `open_id`; `ou_` prefix is required (not user_id/email). */
975
- async saveAccessUser(openId: unknown, name?: unknown): Promise<AccessMutationResponse> {
976
- const id = normalizeAccessId(openId, '用户 open_id', '用户 open_id 必须形如 ou_xxx(不是 user_id/邮箱)')
1041
+ /** Authorize one user `user_id` (not an open_id or email). */
1042
+ async saveAccessUser(userId: unknown, name?: unknown): Promise<AccessMutationResponse> {
1043
+ const id = normalizeAccessId(userId, '用户 user_id', '用户 user_id 只能包含字母、数字、_ 和 -(不是 ou_ 开头的 open_id,也不是邮箱)')
977
1044
  if (!id.ok) return { ok: false, error: id.message }
978
1045
  const label = normalizeAccessName(name)
979
1046
  if (!label.ok) return { ok: false, error: label.message }
@@ -981,8 +1048,8 @@ export class SettingsSurface {
981
1048
  }
982
1049
 
983
1050
  /** Revoke one user authorization (immediate: the next message re-evaluates). */
984
- async removeAccessUser(openId: unknown): Promise<AccessMutationResponse> {
985
- const id = normalizeAccessId(openId, '用户 open_id', '用户 open_id 必须形如 ou_xxx(不是 user_id/邮箱)')
1051
+ async removeAccessUser(userId: unknown): Promise<AccessMutationResponse> {
1052
+ const id = normalizeAccessId(userId, '用户 user_id', '用户 user_id 只能包含字母、数字、_ 和 -(不是 ou_ 开头的 open_id,也不是邮箱)')
986
1053
  if (!id.ok) return { ok: false, error: id.message }
987
1054
  return this.mutateAccess(() => this.target.removeAccessUser(id.value))
988
1055
  }
@@ -1022,23 +1089,6 @@ export class SettingsSurface {
1022
1089
  if (this.directoryPicker === undefined) throw new Error(PANEL_COPY.directoryPickFailed)
1023
1090
  return await this.directoryPicker.pick(signal)
1024
1091
  }
1025
-
1026
- /**
1027
- * Run the workspace-retention cleanup once. Irreversible deletions are the
1028
- * target's responsibility; the surface only reports the outcome and returns
1029
- * a refreshed panel (its cleanup preview then reflects the remaining workspaces).
1030
- */
1031
- async runCleanup(): Promise<CleanupResponse> {
1032
- const busy = this.writeBusy()
1033
- if (busy !== undefined) return busy
1034
- let result: CleanupRunWire
1035
- try {
1036
- result = await this.target.runWorkspaceCleanup()
1037
- } catch (error) {
1038
- return { ok: false, error: `清理失败:${messageOf(error)}` }
1039
- }
1040
- return { ok: true, result, panel: await this.panel() }
1041
- }
1042
1092
  }
1043
1093
 
1044
1094
  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