@modesto_ma/dsh-channels 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/README.md +302 -2
  2. package/cordis.patch.yml +3 -0
  3. package/docs/access-control-design.md +550 -0
  4. package/docs/assets/web-settings-panel.png +0 -0
  5. package/docs/assets/web-settings-restart-pending.png +0 -0
  6. package/docs/feasibility-and-design.md +213 -0
  7. package/docs/feishu-onboarding.md +268 -0
  8. package/docs/persistence.md +199 -0
  9. package/docs/refactor-plan.md +56 -0
  10. package/docs/review-dsh-0.2.0.md +389 -0
  11. package/docs/slash-commands-implementation-plan.md +410 -0
  12. package/docs/web-display-only-surface-roadmap.md +363 -0
  13. package/lib/client.js +2226 -0
  14. package/lib/client.js.map +1 -0
  15. package/lib/index.js +10217 -0
  16. package/lib/index.js.map +1 -0
  17. package/package.json +98 -4
  18. package/src/access/cards.ts +188 -0
  19. package/src/access/controller.ts +201 -0
  20. package/src/access/index.ts +13 -0
  21. package/src/access/policy.ts +103 -0
  22. package/src/access/requests.ts +512 -0
  23. package/src/access/store.ts +220 -0
  24. package/src/approvals.ts +8 -0
  25. package/src/cards/approval.ts +114 -0
  26. package/src/cards/index.ts +11 -0
  27. package/src/cards/primitives.ts +82 -0
  28. package/src/cards/question.ts +141 -0
  29. package/src/cards/streaming.ts +563 -0
  30. package/src/cards.ts +8 -0
  31. package/src/cleanup.ts +398 -0
  32. package/src/client/api.ts +262 -0
  33. package/src/client/catalog.ts +92 -0
  34. package/src/client/index.ts +61 -0
  35. package/src/client/panel.tsx +1475 -0
  36. package/src/client/styles.ts +98 -0
  37. package/src/commands/registry.ts +155 -0
  38. package/src/commands.ts +335 -0
  39. package/src/config.ts +206 -0
  40. package/src/domain.ts +428 -0
  41. package/src/dsh-types.ts +11 -0
  42. package/src/feishu/agent-context.ts +62 -0
  43. package/src/feishu/delivery.ts +188 -0
  44. package/src/feishu/inbound-attachments.ts +436 -0
  45. package/src/feishu-channel.ts +339 -0
  46. package/src/health.ts +233 -0
  47. package/src/host/agent.ts +181 -0
  48. package/src/host/commands.ts +63 -0
  49. package/src/host/core.ts +345 -0
  50. package/src/host/index.ts +12 -0
  51. package/src/host/interaction.ts +101 -0
  52. package/src/host/services.ts +320 -0
  53. package/src/index.ts +122 -0
  54. package/src/interactions/approvals.ts +154 -0
  55. package/src/interactions/pending.ts +162 -0
  56. package/src/interactions/questions.ts +274 -0
  57. package/src/questions.ts +8 -0
  58. package/src/routing/inbound.ts +524 -0
  59. package/src/service.ts +2138 -0
  60. package/src/session/manager.ts +352 -0
  61. package/src/session/repair.ts +157 -0
  62. package/src/session/request-policy.ts +192 -0
  63. package/src/session/routes.ts +165 -0
  64. package/src/session-commands.ts +310 -0
  65. package/src/session-export.ts +112 -0
  66. package/src/settings/catalog.ts +309 -0
  67. package/src/settings/contract.ts +508 -0
  68. package/src/settings/overlay.ts +164 -0
  69. package/src/settings/routes.ts +409 -0
  70. package/src/settings/surface.ts +773 -0
  71. package/src/stats.ts +485 -0
  72. package/src/streamer.ts +1108 -0
  73. package/src/workspaces.ts +199 -0
@@ -0,0 +1,320 @@
1
+ /**
2
+ * Host bridge, service surface: workspaces, storage domains, credentials,
3
+ * token meter, LLM catalog, session projections, Cordis diagnostics, and the
4
+ * `HostContext` slice this plugin consumes.
5
+ *
6
+ * @module dsh-channels/host/services
7
+ */
8
+
9
+ import type { AgentRegistry } from './agent.ts'
10
+ import type { CommandRuntime } from './commands.ts'
11
+ import type { AssistantStreamPayload, Session, SessionEvent, SessionId } from './core.ts'
12
+ import type { ApprovalAnswererListener, UserQuestionAnswererListener } from './interaction.ts'
13
+
14
+ // ---- Workspace surface --------------------------------------------------
15
+
16
+ export interface Workspace {
17
+ readonly id: string
18
+ readonly path: string
19
+ readonly title: string
20
+ attachSession(id: SessionId): Promise<void> | void
21
+ detachSession(id: SessionId): Promise<void> | void
22
+ }
23
+
24
+ export interface WorkspaceRegistry {
25
+ create(path: string, title?: string): Promise<Workspace>
26
+ /** Every workspace currently registered with the host. */
27
+ list(): Workspace[]
28
+ /** Unregister one workspace record (the directory itself is the caller's concern). */
29
+ delete(id: string): Promise<boolean>
30
+ }
31
+
32
+ // ---- Storage / token-meter / llm / credentials surface ------------------
33
+
34
+ /** The host's storage-domain facility (narrowly typed at the call site). */
35
+ export interface StorageDomainFacility {
36
+ open(spec: unknown): Promise<unknown>
37
+ }
38
+
39
+ /** One resolved credential value and the source layer that supplied it. */
40
+ export interface ResolvedCredential {
41
+ value: string
42
+ source: string
43
+ }
44
+
45
+ /** Presence/writability facts for one credential reference (never the value). */
46
+ export interface CredentialInfo {
47
+ configured: boolean
48
+ writable?: boolean
49
+ source?: string
50
+ }
51
+
52
+ /**
53
+ * The host credentials seam (`@deepseek-ai/dsh-credentials`): resolves a
54
+ * reference per call with the provider's layer precedence
55
+ * (env > $DSH_HOME/.credentials.yaml > .env fallbacks), and can store a value
56
+ * for a reference through the same seam (the Web panel's credential editor).
57
+ * Every member beyond the read path is optional so embedded/test hosts can
58
+ * supply only what they support.
59
+ */
60
+ export interface CredentialProvider {
61
+ resolve(ref: string): Promise<ResolvedCredential | undefined>
62
+ /** Presence check without revealing the value (the host's `credentials.describe`). */
63
+ describe?(ref: string): Promise<CredentialInfo | undefined>
64
+ /** Store a value for the reference; stored values outrank environment fallbacks. */
65
+ set?(ref: string, value: string): Promise<void>
66
+ /** Remove the stored value (environment fallbacks then apply again). */
67
+ unset?(ref: string): Promise<void>
68
+ }
69
+
70
+ export interface TokenMeasurement {
71
+ readonly totalTokens: number
72
+ }
73
+
74
+ export interface TokenMeter {
75
+ measure(session: Session, requestHeader?: unknown): TokenMeasurement
76
+ }
77
+
78
+ /** One registered provider route, as advertised by the LLM service. */
79
+ export interface LlmProviderInfo {
80
+ id: string
81
+ name: string
82
+ }
83
+
84
+ /** One adapter-owned reasoning effort advertised for an exact model route. */
85
+ export interface LlmReasoningEffortInfo {
86
+ /** Opaque stable id accepted by model requests. */
87
+ id: string
88
+ /** Human-readable effort name for selectors. */
89
+ name: string
90
+ description?: string
91
+ }
92
+
93
+ /** Selectable reasoning efforts for one exact provider/model route. */
94
+ export interface LlmModelReasoningInfo {
95
+ /** Supported efforts from least to greatest. */
96
+ efforts: readonly LlmReasoningEffortInfo[]
97
+ /** Adapter-configured default effort; absent means the provider default. */
98
+ defaultEffort?: string
99
+ }
100
+
101
+ export interface LlmModelInfo {
102
+ /** Provider route that owns this entry. */
103
+ provider?: string
104
+ /** Model id passed to request generation. */
105
+ id?: string
106
+ /** Human-readable model name for selectors. */
107
+ name?: string
108
+ description?: string
109
+ context?: { contextWindow: number }
110
+ /** Adapter-owned reasoning levels, when `resolveModelInfo` reports them. */
111
+ reasoning?: LlmModelReasoningInfo
112
+ }
113
+
114
+ export interface LlmService {
115
+ resolveModelInfo(provider: string, model: string): Promise<LlmModelInfo>
116
+ /** Registered provider routes, when the deployment exposes them. */
117
+ listProviders?(): readonly LlmProviderInfo[]
118
+ /** Models advertised for one provider route. */
119
+ listModels?(provider: string): Promise<readonly LlmModelInfo[]>
120
+ }
121
+
122
+ /**
123
+ * Deployment default model selection (`@deepseek-ai/dsh-agent-default-model`).
124
+ * Web UI and headless entry points read it before creating an Agent; the
125
+ * settings panel only reads it to label the "inherit" choice.
126
+ */
127
+ export interface AgentDefaultModel {
128
+ currentSelection(): { provider: string; model: string; reasoningEffort?: string }
129
+ }
130
+
131
+ // ---- Session projections -------------------------------------------------
132
+
133
+ /**
134
+ * Whole-log figures maintained by the host's `sessionStats` projection
135
+ * (`@deepseek-ai/dsh-session-stats`, mounted by the web-app bundle). Reading
136
+ * these replaces re-folding the event log for wall times and TTFT.
137
+ */
138
+ export interface SessionStatsView {
139
+ turns: number
140
+ steps: number
141
+ llmMs: number
142
+ toolMs: number
143
+ ttftMs: number
144
+ ttftSteps: number
145
+ decodeMs: number
146
+ decodeTokens: number
147
+ }
148
+
149
+ /** Provider-reported token buckets maintained by the `tokenUsage` projection. */
150
+ export interface TokenUsageBuckets {
151
+ uncachedInputTokens: number
152
+ outputTokens: number
153
+ cacheReadTokens: number
154
+ cacheWriteTokens: number
155
+ }
156
+
157
+ export interface TokenUsageView {
158
+ totals: TokenUsageBuckets
159
+ last: { turn: number; step: number; buckets: TokenUsageBuckets } | null
160
+ }
161
+
162
+ /** Route capacity and prompt pressure maintained by the `contextPressure` projection. */
163
+ export interface ContextPressureView {
164
+ pressureTokens?: number
165
+ projectedTokens?: number
166
+ contextWindow?: number
167
+ }
168
+
169
+ /**
170
+ * The host projection registry (`ctx.sessionProjections`). Read through
171
+ * `ctx.get` so deployments without the registry keep working: `stateOf`
172
+ * returns `undefined` for an unregistered key.
173
+ */
174
+ export interface SessionProjections {
175
+ stateOf(session: Session, key: string): unknown
176
+ }
177
+
178
+
179
+ // ---- Session query -------------------------------------------------------
180
+
181
+ /** Latest log-backed title of one session. */
182
+ export interface SessionTitleSnapshot {
183
+ title: string
184
+ updatedAt: number
185
+ }
186
+
187
+ export interface SessionTitleObservation {
188
+ /** Absent when the observed log has no title event. */
189
+ title?: SessionTitleSnapshot
190
+ }
191
+
192
+ /** One ordered result from a batch title observation. */
193
+ export type SessionTitleObservationResult =
194
+ | { sessionId: SessionId; status: 'fulfilled'; value: SessionTitleObservation }
195
+ | { sessionId: SessionId; status: 'rejected'; reason: unknown }
196
+
197
+ /**
198
+ * The narrow read side of `ctx.sessionQuery` this plugin consumes: live-first
199
+ * title folding for the sessions a Feishu topic has bound.
200
+ */
201
+ export interface SessionQuery {
202
+ readTitleSnapshots(sessionIds: readonly SessionId[]): Promise<readonly SessionTitleObservationResult[]>
203
+ }
204
+
205
+ // ---- Permission presets --------------------------------------------------
206
+
207
+ /** One selectable sandbox+approval preset offered to clients. */
208
+ export interface PermissionPresetOption {
209
+ value: string
210
+ name: string
211
+ description?: string
212
+ }
213
+
214
+ /** The process-level preset catalog exposed to current-session controls. */
215
+ export interface PermissionCatalog {
216
+ options: PermissionPresetOption[]
217
+ defaultOptions: PermissionPresetOption[]
218
+ defaultPreset: string
219
+ }
220
+
221
+ /**
222
+ * The host permission-preset service (`ctx.permissionPresets`): reads the
223
+ * current Session's effective preset and records a switched one.
224
+ */
225
+ export interface PermissionPresets {
226
+ catalog(): PermissionCatalog
227
+ current(session: Session): string
228
+ optionOf(name: string): PermissionPresetOption
229
+ set(session: Session, name: string): void
230
+ }
231
+
232
+ // ---- Cordis diagnostic surface -----------------------------------------
233
+
234
+ /** Numeric values mirror Cordis 4's public `FiberState` enum. */
235
+ export const CordisFiberState = {
236
+ PENDING: 0,
237
+ LOADING: 1,
238
+ ACTIVE: 2,
239
+ FAILED: 3,
240
+ DISPOSED: 4,
241
+ UNLOADING: 5,
242
+ } as const
243
+
244
+ export type CordisFiberState = typeof CordisFiberState[keyof typeof CordisFiberState]
245
+
246
+ export interface CordisFiber {
247
+ readonly state: CordisFiberState
248
+ /** Best-effort plugin display name (`fiber.name`), used only for health diagnostics. */
249
+ readonly name?: string
250
+ }
251
+
252
+ export interface CordisPluginRuntime {
253
+ readonly fibers: Iterable<CordisFiber>
254
+ }
255
+
256
+ export interface CordisRegistry {
257
+ readonly size: number
258
+ values(): IterableIterator<CordisPluginRuntime>
259
+ }
260
+
261
+ // ---- Host context surface ----------------------------------------------
262
+
263
+ /**
264
+ * The host (Cordis) context slice the plugin consumes. Declared locally and
265
+ * structurally: the host injects every capability through `ctx`, and the
266
+ * plugin never imports cordis at runtime, so there is no dependency on the
267
+ * npm-published cordis typings (whose `.ts`-specifier augmentations do not
268
+ * resolve under bundler module resolution).
269
+ */
270
+ export interface HostLogger {
271
+ debug?(...args: unknown[]): void
272
+ info?(...args: unknown[]): void
273
+ warn?(...args: unknown[]): void
274
+ error?(...args: unknown[]): void
275
+ }
276
+
277
+ export interface HostContext {
278
+ agents: AgentRegistry
279
+ commands: CommandRuntime
280
+ workspaceRegistry: WorkspaceRegistry
281
+ storageDomain: StorageDomainFacility
282
+ /** Resolved through the injected `credentials` seam (env > .credentials.yaml > .env). */
283
+ credentials: CredentialProvider
284
+ tokenMeter: TokenMeter
285
+ llm: LlmService
286
+ /** Built-in Cordis plugin registry used for aggregate health diagnostics. */
287
+ registry: CordisRegistry
288
+ logger?: HostLogger
289
+ /** Safe service lookup (returns undefined for absent services). */
290
+ get?(name: string): unknown
291
+ /** Host events this plugin subscribes to (overloads per event). */
292
+ on(name: 'session/event', listener: (session: Session, event: SessionEvent) => void): () => boolean
293
+ /** Live (transient) model stream for every agent; replaces the removed `assistant/chunk` events. */
294
+ on(name: 'agent/assistant-stream', listener: (payload: AssistantStreamPayload) => void): () => boolean
295
+ /**
296
+ * The host's structured-question waterfall (`ask_user_question`). Same
297
+ * shape as `approval/request`: return an answer to claim, or delegate with
298
+ * `next()`. Untagged plugin contexts receive requests for every agent, so
299
+ * ownership filtering is mandatory.
300
+ */
301
+ on(
302
+ name: 'user-questions/request',
303
+ listener: UserQuestionAnswererListener,
304
+ options?: { prepend?: boolean },
305
+ ): () => boolean
306
+ /**
307
+ * The host's HITL answerer waterfall. Registered with `prepend: true` so
308
+ * this channel claims its own sessions before generic answerers (the web
309
+ * gateway); non-owned requests delegate through `next()`. Untagged plugin
310
+ * contexts receive requests for EVERY agent, so ownership filtering is
311
+ * mandatory. Safe to register without the approval service mounted — the
312
+ * event simply never fires.
313
+ */
314
+ on(
315
+ name: 'approval/request',
316
+ listener: ApprovalAnswererListener,
317
+ options?: { prepend?: boolean },
318
+ ): () => boolean
319
+ effect(callback: () => (() => void | Promise<void>) | void, label?: string): void
320
+ }
package/src/index.ts ADDED
@@ -0,0 +1,122 @@
1
+ /**
2
+ * dsh-channels — DeepSeek Harness 飞书渠道插件。
3
+ *
4
+ * 一个 dsh 实例对应一个飞书机器人;用户通过私聊、群聊 @、话题群 @ 三种方式
5
+ * 发起会话。每条触发消息:立即加 “Typing” 表情 → 以话题形式回复(创建话题)
6
+ * → 绑定 DSH session → 卡片 2.0 流式回复(推理折叠面板 + 富文本正文 + 两行
7
+ * session 信息,默认 500ms 刷新)→ 流式结束移除 Typing,成功更新为
8
+ * “DONE”,失败显示结构化错误并更新为 “No”。
9
+ *
10
+ * 工作区归属:私聊一人一工作区;群聊中每个用户在该群拥有独立工作区
11
+ * (目录即工作区,会话以 cwd 落盘)。
12
+ *
13
+ * @module dsh-channels
14
+ */
15
+
16
+ import type { HostContext } from './dsh-types.ts'
17
+ import { FeishuService } from './service.ts'
18
+ import { configSchema, resolveConfig } from './config.ts'
19
+ import type { FeishuConfig } from './config.ts'
20
+ import { defaultChannelFactory } from './feishu-channel.ts'
21
+ import type { ChannelFactory } from './feishu-channel.ts'
22
+ import { OverlayStore, defaultOverlayPath } from './settings/overlay.ts'
23
+ import { mountSettingsRoutes } from './settings/routes.ts'
24
+ import { SettingsSurface } from './settings/surface.ts'
25
+ import { createHostCatalog } from './settings/catalog.ts'
26
+
27
+ export type * from './config.ts'
28
+ export type * from './cards.ts'
29
+ export type * from './domain.ts'
30
+ export type * from './feishu-channel.ts'
31
+ export type * from './approvals.ts'
32
+ export type * from './commands.ts'
33
+ export type * from './health.ts'
34
+ export type * from './session-commands.ts'
35
+ export type * from './session-export.ts'
36
+ export type * from './stats.ts'
37
+ export type * from './questions.ts'
38
+ export type * from './settings/contract.ts'
39
+ export * from './access/index.ts'
40
+ export type { AccessGroupRecord, AccessRequestRecord, AccessUserRecord } from './domain.ts'
41
+ export { CATALOG_ROUTE, CLEANUP_ROUTE, CONFIG_ROUTE, CREDENTIALS_ROUTE, EDITABLE_KEYS, PANEL_COPY, PANEL_ROUTE, RECONNECT_ROUTE, RESTART_KEYS, STATUS_ROUTE } from './settings/contract.ts'
42
+ export { defaultOverlayPath, mergeOverlay, normalizeOverlay, OverlayStore, parseOverlay, pickEditable, resolveDshHome } from './settings/overlay.ts'
43
+ export { applyCatalog, emptyCatalog, FIELD_DESCRIPTORS, normalizeCredentialValue, readOnlyFields, SettingsSurface } from './settings/surface.ts'
44
+ export type { SettingsTarget } from './settings/surface.ts'
45
+ export { createHostCatalog } from './settings/catalog.ts'
46
+ export type { CatalogWarn, SettingsCatalog } from './settings/catalog.ts'
47
+ export { isSameOriginRequest, mountSettingsRoutes, registerSettingsRoutes } from './settings/routes.ts'
48
+ export type { HostWebServer } from './settings/routes.ts'
49
+ export type { ApprovalOutcome, ApprovalRequest, ApprovalAnswererListener, AssistantStreamFrame, AskUserQuestionAnswer, AskUserQuestionAnswerItem, AskUserQuestionItem, AskUserQuestionRequestEvent, UserQuestionAnswererListener, AgentCancelCause, SessionStatsView, TokenUsageView, ContextPressureView, SessionProjections } from './dsh-types.ts'
50
+ export { buildCard, buildInterruptionCard, cardJsonBytes, CARD_MAX_BYTES, CARD_TARGET_BYTES, CARD_PANEL_RESERVE_BYTES, REASONING_MAX_BYTES, INTERRUPTION_NOTICE, MAX_TABLES_PER_CARD, PANEL_TITLES, PANEL_CHEVRON_TOKEN, segmentCardContent, splitBodyForCard, stripTables, tableBlocks, TRUNCATION_SUFFIX, buildApprovalCard, buildApprovalSettledCard, buildQuestionCard, buildQuestionSettledCard, MAX_QUESTION_OPTIONS } from './cards.ts'
51
+ export { ApprovalAnswerer } from './approvals.ts'
52
+ export { QuestionAnswerer } from './questions.ts'
53
+ export { armModelCommand, buildCommandResultCard, CHANNEL_COMMAND_NAMES, commandResultCardKind, isSupportedCommand, parseSlashCommand, registerChannelCommands, registerModelCommand, RouteOperationQueue, SUPPORTED_COMMAND_NAMES, supportedCommandHelp, supportedCommandList } from './commands.ts'
54
+ export { buildHealthCard, countFiberStates, foldHealthSnapshot, HEALTH_DEPENDENCY_NAMES, maskOpenIdTail } from './health.ts'
55
+ export { buildCompactComparisonCard, buildRenewCard, buildSessionHistoryCard, buildSessionStatusCard, buildWorkDirCard, compactFailureContext, querySessionHistory, resolveWorkDir, SESSION_HISTORY_DISPLAY_LIMIT } from './session-commands.ts'
56
+ export { createLogicalSessionExport, DSH_CHANNELS_VERSION, LOGICAL_EXPORT_FORMAT, LOGICAL_EXPORT_FORMAT_VERSION, serializeLogicalSession } from './session-export.ts'
57
+ export { addUsage, applyProjectionViews, averageDecodeTokensPerSecond, averageTtftMs, cacheHitPct, EMPTY_TOTALS, foldAll, foldEvent, foldSessionStats, measureSessionContext, readProjectionViews, usageFromBuckets } from './stats.ts'
58
+ export { FeishuService } from './service.ts'
59
+ export { TurnStreamer } from './streamer.ts'
60
+ export { ensureWorkspace, stagingPathFor, topicTokenFor, workspacePathFor, workspaceTitleFor } from './workspaces.ts'
61
+ export { accessGroupRecordSchema, accessRequestRecordSchema, accessUserRecordSchema, appendSessionHistory, chatRouteKey, expiredRouteRecordSchema, feishuDomain, normalizeRouteRecord, normalizeSessionHistory, routeRecordSchema, sessionHistoryEntrySchema, inflightSnapshotSchema, processedRecordSchema } from './domain.ts'
62
+
63
+ /** Cordis function-plugin name. */
64
+ export const name = 'channels'
65
+
66
+ /** Host services this plugin consumes (all resolved through ctx).
67
+ *
68
+ * `credentials` is listed even though the plugin could fall back to the
69
+ * process environment: at boot the credentials provider may not be visible
70
+ * yet (a race observed in real profiles), and inject makes the loader wait
71
+ * for it. `llm` / `tokenMeter` feed the card's context line and ship with
72
+ * every dsh-base profile.
73
+ */
74
+ export const inject = ['agents', 'commands', 'workspaceRegistry', 'storageDomain', 'credentials', 'llm', 'tokenMeter'] as const
75
+
76
+ /**
77
+ * Cordis Standard Schema declaration for the plugin config.
78
+ *
79
+ * The loader validates (and default-fills) the raw composition config through
80
+ * `Config['~standard'].validate()` *before* `apply()` runs, so a malformed
81
+ * `cordis.patch.yml` entry fails at load time with a cordis `ValidationError`
82
+ * instead of surfacing from inside the async service start. `apply()` keeps
83
+ * its `resolveConfig()` fallback for direct invocation (unit tests, embedded
84
+ * hosts) where config has not been pre-validated.
85
+ */
86
+ export const Config = configSchema
87
+
88
+ export interface FeishuPluginOptions extends FeishuConfig {
89
+ /** Test seam: substitute a channel implementation. */
90
+ channelFactory?: ChannelFactory
91
+ }
92
+
93
+ /**
94
+ * Install the bridge. Credentials stay references here — the actual secrets
95
+ * resolve through `ctx.credentials` (env > `$DSH_HOME/.credentials.yaml`
96
+ * > `.env`) when `FeishuService.start()` builds the channel.
97
+ */
98
+ export function apply(ctx: HostContext, config?: unknown): void {
99
+ const resolved = resolveConfig(config)
100
+ const factory: ChannelFactory = (config as FeishuPluginOptions | undefined)?.channelFactory
101
+ ?? defaultChannelFactory
102
+ // Web settings overlay (optional file; degrades to "no overrides").
103
+ const overlay = new OverlayStore(
104
+ resolved.settingsFile ?? defaultOverlayPath(),
105
+ { warn: (message, ...args) => ctx.logger?.warn?.(message, ...args) },
106
+ )
107
+ const service = new FeishuService(ctx, resolved, factory, undefined, overlay)
108
+ // Settings routes ride the host's web server when this deployment has one.
109
+ // The catalog resolves the panel's model selectors from live host services.
110
+ const warn = (message: string, ...args: unknown[]): void => { ctx.logger?.warn?.(message, ...args) }
111
+ mountSettingsRoutes(ctx, new SettingsSurface(service, overlay, { warn }, createHostCatalog(ctx, warn)))
112
+ ctx.effect(() => {
113
+ void service.boot().catch((error: unknown) => {
114
+ ctx.logger?.error?.('dsh-channels failed to start: %s', String(error))
115
+ // Critical lifecycle line duplicated to the console: cordis logger
116
+ // messages emitted before a web exporter attaches are buffered and may
117
+ // never reach stdout, which makes real-device debugging guesswork.
118
+ console.error('[dsh-channels] failed to start:', error)
119
+ })
120
+ return () => service.shutdown()
121
+ }, 'dsh-channels')
122
+ }
@@ -0,0 +1,154 @@
1
+ /**
2
+ * HITL approval answerer: turns the host's `approval/request` waterfall
3
+ * events into standalone Feishu decision cards.
4
+ *
5
+ * The answerer is registered with `prepend: true` on the plugin context so it
6
+ * runs BEFORE generic answerers: a request for a session this channel routes
7
+ * is claimed here, and the decision card becomes the only way to answer.
8
+ * Requests for sessions the channel does NOT route delegate through `next()`.
9
+ *
10
+ * Rendering lives in `cards/approval.ts`; the shared pending-card lifecycle
11
+ * (delivery, owner gating, timeout, settle-once, withdrawal) lives in
12
+ * `interactions/pending.ts`.
13
+ *
14
+ * @module dsh-channels/interactions/approvals
15
+ */
16
+
17
+ import { randomUUID } from 'node:crypto'
18
+ import { buildApprovalCard, buildApprovalSettledCard } from '../cards/approval.ts'
19
+ import type { ApprovalCardState, ApprovalQuestion } from '../cards/approval.ts'
20
+ import type { ApprovalOutcome, ApprovalRequest } from '../dsh-types.ts'
21
+ import type { CardActionEvent, FeishuChannel } from '../feishu-channel.ts'
22
+ import { PendingCardTable } from './pending.ts'
23
+ import type { InteractionRoute, PendingCardEntry } from './pending.ts'
24
+
25
+ /** Feishu placement of one session's approval cards. */
26
+ export type ApprovalRoute = InteractionRoute
27
+
28
+ export interface ApprovalHooks {
29
+ /**
30
+ * Resolve the Feishu placement for a session. `undefined` means the session
31
+ * is not owned by this channel — the answerer delegates through `next()`.
32
+ */
33
+ routeFor(sessionId: string): ApprovalRoute | undefined
34
+ /** Log a non-fatal error (delivery/update failures). */
35
+ onError(sessionId: string, error: unknown): void
36
+ }
37
+
38
+ /** One in-flight approval question: its card content and its resolver. */
39
+ interface Pending extends PendingCardEntry {
40
+ question: ApprovalQuestion
41
+ resolve(outcome: ApprovalOutcome): void
42
+ }
43
+
44
+ export class ApprovalAnswerer {
45
+ private readonly table: PendingCardTable<Pending>
46
+
47
+ constructor(
48
+ channel: FeishuChannel,
49
+ private readonly hooks: ApprovalHooks,
50
+ private readonly timeoutMs: number,
51
+ ) {
52
+ this.table = new PendingCardTable(channel, hooks.onError)
53
+ }
54
+
55
+ /** In-flight question count (diagnostics/tests). */
56
+ pendingCount(): number {
57
+ return this.table.count()
58
+ }
59
+
60
+ /**
61
+ * The `approval/request` waterfall listener. Never rejects: the host service
62
+ * already contains answerer failures to the fail-closed `'unavailable'`.
63
+ */
64
+ onRequest(req: ApprovalRequest, next: () => Promise<ApprovalOutcome>): Promise<ApprovalOutcome> {
65
+ const route = this.hooks.routeFor(req.agent.session.id)
66
+ if (route === undefined) return next()
67
+ // The abort window between the service's own signal check and this
68
+ // listener is closed by settling synchronously: an aborted ask never
69
+ // opens a question.
70
+ if (req.signal?.aborted === true) return Promise.resolve('cancelled')
71
+
72
+ const token = randomUUID()
73
+ // `displayReason` (DSH 0.2.0) is presentation-only and locale-aware;
74
+ // `reason` remains the audit-trail fallback.
75
+ const displayReason = req.displayReason?.zh ?? req.displayReason?.en ?? req.reason
76
+ const question: ApprovalQuestion = {
77
+ token,
78
+ toolName: req.toolName,
79
+ ...(displayReason === undefined ? {} : { reason: displayReason }),
80
+ timeoutMs: this.timeoutMs,
81
+ sessionId: req.agent.session.id,
82
+ }
83
+ return new Promise<ApprovalOutcome>((resolve) => {
84
+ const entry: Pending = {
85
+ token,
86
+ sessionId: req.agent.session.id,
87
+ route,
88
+ messageId: undefined,
89
+ settled: false,
90
+ timer: undefined,
91
+ signal: req.signal,
92
+ onAbort: undefined,
93
+ unauthorizedNoted: false,
94
+ question,
95
+ resolve,
96
+ }
97
+ const onAbort = (): void => {
98
+ this.settle(entry, 'cancelled', 'cancelled')
99
+ }
100
+ entry.onAbort = onAbort
101
+ // Registered synchronously at promise creation so no abort can slip
102
+ // between the ownership check and the card delivery; a residual abort
103
+ // in the tiny window before addEventListener is still covered because
104
+ // the host's decide() races the signal against this promise and
105
+ // discards a late resolution (worst case: the card times out).
106
+ req.signal?.addEventListener('abort', onAbort, { once: true })
107
+ this.table.add(entry)
108
+ void this.table.deliver(entry, {
109
+ card: buildApprovalCard(question),
110
+ onWithdrawn: e => this.table.patch(e, buildApprovalSettledCard(e.question, 'cancelled')),
111
+ onDelivered: e => this.table.armTimeout(e, this.timeoutMs, () => {
112
+ this.settle(e, 'rejected', 'expired')
113
+ }),
114
+ // No card → no human answerer → fail closed.
115
+ onFailure: e => {
116
+ this.table.close(e, () => {})
117
+ e.resolve('unavailable')
118
+ },
119
+ })
120
+ })
121
+ }
122
+
123
+ /**
124
+ * Route one card button-callback event to its pending question. Unknown
125
+ * cards and non-owner operators are ignored (a non-owner click adds the
126
+ * owner-only notice to the card once); owner decisions settle the question.
127
+ */
128
+ onCardAction(event: CardActionEvent): void {
129
+ const entry = this.table.resolveAction(event, candidate => {
130
+ if (candidate.unauthorizedNoted) return
131
+ candidate.unauthorizedNoted = true
132
+ this.table.patch(candidate, buildApprovalCard(candidate.question, { unauthorizedNote: true }))
133
+ })
134
+ if (entry === undefined) return
135
+ const decision = (event.action.value as { decision?: unknown } | undefined)?.decision
136
+ if (decision === 'allow') this.settle(entry, 'allowed-once', 'allowed')
137
+ else if (decision === 'deny') this.settle(entry, 'rejected', 'denied')
138
+ }
139
+
140
+ /** Withdraw every in-flight question (plugin shutdown). */
141
+ dispose(): void {
142
+ this.table.withdrawAll(entry => this.settle(entry, 'cancelled'))
143
+ }
144
+
145
+ /** Settle once, then patch the card to its terminal state. */
146
+ private settle(entry: Pending, outcome: ApprovalOutcome, cardState?: ApprovalCardState): void {
147
+ const closed = this.table.close(entry, () => {
148
+ if (cardState !== undefined) {
149
+ this.table.patch(entry, buildApprovalSettledCard(entry.question, cardState))
150
+ }
151
+ })
152
+ if (closed) entry.resolve(outcome)
153
+ }
154
+ }