@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
package/src/service.ts ADDED
@@ -0,0 +1,2138 @@
1
+ /**
2
+ * The Feishu bridge service: inbound routing, workspace/session ownership,
3
+ * topic creation, and streaming-card orchestration.
4
+ *
5
+ * Ownership model (one workspace per user × chat):
6
+ * - p2p: one workspace per (bot, user), all of that user's sessions inside;
7
+ * - group: one workspace per (chat, user), sessions grouped per user.
8
+ *
9
+ * Routing model:
10
+ * - `thread_id` → bound session (no @ needed inside an owned topic);
11
+ * - degraded chat-scoped keys when Feishu refuses topic replies;
12
+ * - new topic + new session for an @-triggered message without a route.
13
+ *
14
+ * @module dsh-channels/service
15
+ */
16
+
17
+ import { lstat, readdir, realpath, rm } from 'node:fs/promises'
18
+ import path from 'node:path'
19
+ import { AccessController } from './access/controller.ts'
20
+ import { AccessRequests } from './access/requests.ts'
21
+ import { AccessStore } from './access/store.ts'
22
+ import { ApprovalAnswerer } from './approvals.ts'
23
+ import type { ApprovalRoute } from './approvals.ts'
24
+ import { QuestionAnswerer } from './questions.ts'
25
+ import type {
26
+ CommandInvocation,
27
+ CommandResult,
28
+ CredentialInfo,
29
+ CredentialProvider,
30
+ CordisFiber,
31
+ HostContext,
32
+ LlmService,
33
+ Session,
34
+ SessionId,
35
+ SessionProjections,
36
+ TokenMeter,
37
+ Workspace,
38
+ } from './dsh-types.ts'
39
+ import { standaloneInfoCard } from './cards.ts'
40
+ import {
41
+ armEffortCommand, armModelCommand, armRenewCommand, registerModelCommand, commandResultCardKind, parseSlashCommand,
42
+ registerEffortCommand, registerRenewCommand, RouteOperationQueue, supportedCommandHelp, supportedCommandList,
43
+ } from './commands.ts'
44
+ import type { EffortCommandAction, ParsedSlashCommand } from './commands.ts'
45
+ import { commandSpec } from './commands/registry.ts'
46
+ import type { ChannelCommandSpec } from './commands/registry.ts'
47
+ import type { PermissionPresets, SessionQuery } from './dsh-types.ts'
48
+ import { feishuDomain } from './domain.ts'
49
+ import {
50
+ applyWorkspaceCleanup, isPathInside, planWorkspaceCleanup, retentionMs,
51
+ } from './cleanup.ts'
52
+ import type { CleanupDeps, WorkspaceStats } from './cleanup.ts'
53
+ import type {
54
+ AccessGroupRecord, AccessRequestRecord, AccessUserRecord,
55
+ ExpiredRouteRecord, FeishuDomainGlobal, FeishuDomainHandle, InflightSnapshot, ProcessedRecord, RawDomainHandle,
56
+ RouteRecord,
57
+ } from './domain.ts'
58
+ import { RouteStore } from './session/routes.ts'
59
+ import { InflightRepair } from './session/repair.ts'
60
+ import { SessionManager } from './session/manager.ts'
61
+ import type { LiveAgent, RenewCommit } from './session/manager.ts'
62
+ import { CardDelivery } from './feishu/delivery.ts'
63
+ import { AttachmentIntake } from './feishu/inbound-attachments.ts'
64
+ import { InboundRouter } from './routing/inbound.ts'
65
+ import type { RequestPolicyController } from './session/request-policy.ts'
66
+ import type { ChannelFactory, FeishuChannel, NormalizedMessage } from './feishu-channel.ts'
67
+ import { buildHealthCard, foldHealthSnapshot, HEALTH_DEPENDENCY_NAMES } from './health.ts'
68
+ import type { HealthDependencyName, HealthSnapshot } from './health.ts'
69
+ import {
70
+ buildCompactComparisonCard, buildRenewCard, buildSessionHistoryCard, buildSessionStatusCard, buildWorkDirCard,
71
+ compactFailureContext, resolveWorkDir, SESSION_HISTORY_DISPLAY_LIMIT,
72
+ } from './session-commands.ts'
73
+ import type { SessionHistoryAnnotation } from './session-commands.ts'
74
+ import { normalizeSessionHistory } from './domain.ts'
75
+ import { foldSessionStats, measureSessionContext, readProjectionViews } from './stats.ts'
76
+ import type { ContextMeasurement, ContextServices } from './stats.ts'
77
+ import { createLogicalSessionExport } from './session-export.ts'
78
+ import type { SessionExportBuilder } from './session-export.ts'
79
+ import { ACTIVITY_BUILD_MARKER, TurnStreamer } from './streamer.ts'
80
+ import type { ProjectedStats, StreamerHooks } from './streamer.ts'
81
+ import type { FeishuConfig } from './config.ts'
82
+ import type {
83
+ AccessMutationResponse, AccessRequestWire, AccessWire,
84
+ } from './settings/contract.ts'
85
+ import type { OverlayStore } from './settings/overlay.ts'
86
+
87
+ /** Periodic route-GC sweep cadence (a long-running instance also accumulates
88
+ * dead records; TTL semantics make 6 h granularity more than sufficient). */
89
+ const GC_SWEEP_INTERVAL_MS = 6 * 60 * 60 * 1000
90
+
91
+ /** Dedup-record retention: Feishu redelivery windows are minutes-scale, so a
92
+ * day is 100×+ headroom while keeping the table bounded. */
93
+ const PROCESSED_RETENTION_MS = 24 * 60 * 60 * 1000
94
+
95
+ /** Workspace-retention sweep cadence when automatic cleanup is enabled. */
96
+ const RETENTION_SWEEP_INTERVAL_MS = 24 * 60 * 60 * 1000
97
+
98
+
99
+ interface RenewAdmission {
100
+ route: RouteRecord
101
+ live: LiveAgent
102
+ commit?: RenewCommit
103
+ }
104
+
105
+ interface EffortAdmission {
106
+ live: LiveAgent
107
+ controller: RequestPolicyController
108
+ }
109
+
110
+ interface ModelAdmission {
111
+ live: LiveAgent
112
+ controller: RequestPolicyController
113
+ }
114
+
115
+ /** Wrap the host's storage facility open behind a stable local type. */
116
+ async function openFeishuDomain(ctx: HostContext): Promise<FeishuDomainHandle> {
117
+ const domain = await ctx.storageDomain.open(feishuDomain) as unknown as RawDomainHandle
118
+ const table = domain.table('routes')
119
+ const inflightRaw = domain.table('inflight')
120
+ const processedRaw = domain.table('processed')
121
+ const expiredRoutesRaw = domain.table('expired_routes')
122
+ const accessGroupsRaw = domain.table('access_groups')
123
+ const accessUsersRaw = domain.table('access_users')
124
+ const accessRequestsRaw = domain.table('access_requests')
125
+ return {
126
+ routes: {
127
+ get: key => table.get(key) as RouteRecord | undefined,
128
+ put: (key, value) => table.put(key, value),
129
+ delete: key => table.delete(key),
130
+ entries: () => table.entries() as IterableIterator<[string, RouteRecord]>,
131
+ },
132
+ inflight: {
133
+ get: key => inflightRaw.get(key) as InflightSnapshot | undefined,
134
+ put: (key, value) => inflightRaw.put(key, value),
135
+ update: (key, fn) => inflightRaw.update(key, current => fn(current as InflightSnapshot)),
136
+ delete: key => inflightRaw.delete(key),
137
+ entries: () => inflightRaw.entries() as IterableIterator<[string, InflightSnapshot]>,
138
+ },
139
+ processed: {
140
+ get: key => processedRaw.get(key) as ProcessedRecord | undefined,
141
+ put: (key, value) => processedRaw.put(key, value),
142
+ delete: key => processedRaw.delete(key),
143
+ entries: () => processedRaw.entries() as IterableIterator<[string, ProcessedRecord]>,
144
+ },
145
+ expiredRoutes: {
146
+ get: key => expiredRoutesRaw.get(key) as ExpiredRouteRecord | undefined,
147
+ put: (key, value) => expiredRoutesRaw.put(key, value),
148
+ entries: () => expiredRoutesRaw.entries() as IterableIterator<[string, ExpiredRouteRecord]>,
149
+ },
150
+ accessGroups: {
151
+ get: key => accessGroupsRaw.get(key) as AccessGroupRecord | undefined,
152
+ put: (key, value) => accessGroupsRaw.put(key, value),
153
+ delete: key => accessGroupsRaw.delete(key),
154
+ entries: () => accessGroupsRaw.entries() as IterableIterator<[string, AccessGroupRecord]>,
155
+ },
156
+ accessUsers: {
157
+ get: key => accessUsersRaw.get(key) as AccessUserRecord | undefined,
158
+ put: (key, value) => accessUsersRaw.put(key, value),
159
+ delete: key => accessUsersRaw.delete(key),
160
+ entries: () => accessUsersRaw.entries() as IterableIterator<[string, AccessUserRecord]>,
161
+ },
162
+ accessRequests: {
163
+ get: key => accessRequestsRaw.get(key) as AccessRequestRecord | undefined,
164
+ put: (key, value) => accessRequestsRaw.put(key, value),
165
+ delete: key => accessRequestsRaw.delete(key),
166
+ entries: () => accessRequestsRaw.entries() as IterableIterator<[string, AccessRequestRecord]>,
167
+ },
168
+ getGlobal: () => (domain.global.get() ?? {}) as FeishuDomainGlobal,
169
+ setGlobal: value => domain.global.set(value),
170
+ close: () => domain.close(),
171
+ }
172
+ }
173
+
174
+ /**
175
+ * Resolve one credential reference through the host credentials seam.
176
+ *
177
+ * Precedence is owned by the provider (env > `$DSH_HOME/.credentials.yaml`
178
+ * > `.env` fallbacks). Without a mounted seam the process environment is the
179
+ * whole credential plane.
180
+ */
181
+ async function resolveCredentialRef(ctx: HostContext, ref: string): Promise<string> {
182
+ const provider = ctx.get?.('credentials') as CredentialProvider | undefined
183
+ if (provider !== undefined) {
184
+ const hit = await provider.resolve(ref)
185
+ if (hit !== undefined) return hit.value
186
+ } else {
187
+ const ambient = process.env[ref]
188
+ if (ambient !== undefined && ambient !== '') return ambient
189
+ }
190
+ throw new Error(
191
+ `dsh-channels: credential "${ref}" is not configured; add it to $DSH_HOME/.credentials.yaml `
192
+ + `(e.g. "${ref}: <value>") or export it in the launching environment`,
193
+ )
194
+ }
195
+
196
+ /** Best-effort human-readable error text for the settings panel. */
197
+ function messageOf(error: unknown): string {
198
+ return error instanceof Error ? error.message : String(error)
199
+ }
200
+
201
+ /**
202
+ * Measure one workspace directory for the cleanup preview: total regular-file
203
+ * bytes and file count. Symlinks are never followed; unreadable entries are
204
+ * skipped so a permission problem cannot fail the whole pass.
205
+ */
206
+ async function measureWorkspace(dir: string): Promise<WorkspaceStats | undefined> {
207
+ let info
208
+ try {
209
+ info = await lstat(dir)
210
+ } catch {
211
+ return undefined
212
+ }
213
+ if (!info.isDirectory()) return undefined
214
+ let bytes = 0
215
+ let files = 0
216
+ const pending = [dir]
217
+ while (pending.length > 0) {
218
+ const current = pending.pop()!
219
+ let entries
220
+ try {
221
+ entries = await readdir(current, { withFileTypes: true })
222
+ } catch {
223
+ continue
224
+ }
225
+ for (const entry of entries) {
226
+ const child = path.join(current, entry.name)
227
+ if (entry.isSymbolicLink()) continue
228
+ if (entry.isDirectory()) {
229
+ pending.push(child)
230
+ continue
231
+ }
232
+ if (!entry.isFile()) continue
233
+ try {
234
+ const stat = await lstat(child)
235
+ if (stat.isFile()) {
236
+ bytes += stat.size
237
+ files += 1
238
+ }
239
+ } catch {
240
+ // The directory changed while walking it; the byte total is advisory.
241
+ }
242
+ }
243
+ }
244
+ return { mtimeMs: info.mtimeMs, bytes, files }
245
+ }
246
+
247
+ export class FeishuService {
248
+ private readonly ctx: HostContext
249
+ /** Live config object. Mutated in place — collaborators captured this reference. */
250
+ private readonly config: FeishuConfig
251
+ /** Loader/profile-provided config, the layer below the Web overlay. */
252
+ private readonly baseConfig: FeishuConfig
253
+ /** Web overlay store (`$DSH_HOME/dsh-channels/settings.json`), when mounted. */
254
+ private readonly overlay: OverlayStore | undefined
255
+ private readonly channelFactory: ChannelFactory
256
+ private readonly sessionExportBuilder: SessionExportBuilder
257
+ private channel: FeishuChannel | undefined
258
+ private streamer: TurnStreamer | undefined
259
+ private domain: FeishuDomainHandle | undefined
260
+ /** Durable route store (cache + TTL retirement). */
261
+ private readonly routes: RouteStore
262
+ /** In-flight turn snapshots + startup repair. */
263
+ private readonly repair: InflightRepair
264
+ /** Outbound card delivery + reaction lifecycle. */
265
+ private readonly delivery: CardDelivery
266
+ /** Inbound attachment archival (Feishu resources → workspace files). */
267
+ private readonly attachments: AttachmentIntake
268
+ /** Inbound routing: dedup, admission, decision tree, session establishment. */
269
+ private readonly inbound: InboundRouter
270
+ /** Agent/session lifecycle: live registry, resume, presets, renew. */
271
+ private readonly sessions: SessionManager
272
+ /** FIFO admission for ordinary turns and commands targeting one route. */
273
+ private readonly routeOperations = new RouteOperationQueue()
274
+ /** Active Host command executions, aborted before shutdown tears peers down. */
275
+ private readonly commandControllers = new Set<AbortController>()
276
+ /** Whole command admissions, drained before channel/storage teardown. */
277
+ private readonly commandOperations = new Set<Promise<void>>()
278
+ /** `<provider>/<model>` → context window (lazy async fill; sync reads). */
279
+ private readonly contextWindows = new Map<string, number>()
280
+ private readonly windowRequests = new Set<string>()
281
+ private started = false
282
+ /** Epoch ms when `start()` completed (uptime for `/status`). */
283
+ private startedAt: number | undefined
284
+ /** Last failed `boot()` message, surfaced in the Web settings panel. */
285
+ private lastStartError: string | undefined
286
+ /** Effective config captured at the last successful connect (restart-pending diff). */
287
+ private runningConfig: FeishuConfig | undefined
288
+ /** Guards overlapping panel-triggered reconnects. */
289
+ private restarting = false
290
+ private unlisten: (() => void) | undefined
291
+ /** Periodic route-GC timer (unref'd; only armed when `routeTtlMs > 0`). */
292
+ private gcTimer: ReturnType<typeof setInterval> | undefined
293
+ /** Periodic workspace-retention timer (only armed when auto cleanup is on). */
294
+ private retentionTimer: ReturnType<typeof setInterval> | undefined
295
+ /** Guards overlapping cleanup passes (panel button / daily timer). */
296
+ private cleanupRunning = false
297
+ /** HITL approval answerer (created in `start()`). */
298
+ private approvalAnswerer: ApprovalAnswerer | undefined
299
+ /** HITL structured-question answerer (`ask_user_question`). */
300
+ private questionAnswerer: QuestionAnswerer | undefined
301
+ /** Disposers for the host approval waterfall listener + card actions. */
302
+ private unlistenApproval: (() => boolean) | undefined
303
+ /** Disposer for the host user-questions waterfall listener. */
304
+ private unlistenQuestions: (() => boolean) | undefined
305
+ private unlistenCardAction: (() => void) | undefined
306
+ /** Durable access-control whitelists (groups + users + authorization requests). */
307
+ private readonly accessStore: AccessStore
308
+ /** Channel-plane admission gate (administrator / group / user). */
309
+ private readonly accessController: AccessController
310
+ /** Administrator DM authorization cards + bot-added group info cards. */
311
+ private accessRequests: AccessRequests | undefined
312
+ private unlistenBotAdded: (() => void) | undefined
313
+
314
+ constructor(
315
+ ctx: HostContext,
316
+ config: FeishuConfig,
317
+ channelFactory: ChannelFactory,
318
+ sessionExportBuilder: SessionExportBuilder = createLogicalSessionExport,
319
+ overlay?: OverlayStore,
320
+ ) {
321
+ this.ctx = ctx
322
+ this.config = config
323
+ this.baseConfig = { ...config }
324
+ this.overlay = overlay
325
+ this.channelFactory = channelFactory
326
+ this.sessionExportBuilder = sessionExportBuilder
327
+ this.routes = new RouteStore({
328
+ domain: () => this.domain,
329
+ ttlMs: () => this.config.routeTtlMs,
330
+ warn: (message, ...args) => this.ctx.logger?.warn?.(message, ...args),
331
+ })
332
+ this.repair = new InflightRepair({
333
+ domain: () => this.domain,
334
+ channel: () => this.channel,
335
+ ttlMs: () => this.config.routeTtlMs,
336
+ info: (message, ...args) => this.ctx.logger?.info?.(message, ...args),
337
+ warn: (message, ...args) => this.ctx.logger?.warn?.(message, ...args),
338
+ })
339
+ this.sessions = new SessionManager({
340
+ ctx: this.ctx,
341
+ config: this.config,
342
+ persistRoute: (key, record) => this.routes.put(key, record),
343
+ started: () => this.started,
344
+ detachStream: sessionId => this.streamer?.detach(sessionId),
345
+ warn: (message, ...args) => this.ctx.logger?.warn?.(message, ...args),
346
+ })
347
+ this.attachments = new AttachmentIntake({
348
+ enabled: () => this.config.acceptAttachments,
349
+ channel: () => this.channel,
350
+ warn: (message, ...args) => this.ctx.logger?.warn?.(message, ...args),
351
+ })
352
+ this.delivery = new CardDelivery({
353
+ channel: () => this.channel,
354
+ started: () => this.started,
355
+ routeForSession: sessionId => this.routes.findBySession(sessionId),
356
+ warn: (message, ...args) => this.ctx.logger?.warn?.(message, ...args),
357
+ })
358
+ this.accessStore = new AccessStore({
359
+ domain: () => this.domain,
360
+ warn: (message, ...args) => this.ctx.logger?.warn?.(message, ...args),
361
+ })
362
+ this.accessController = new AccessController({
363
+ config: () => this.config,
364
+ store: this.accessStore,
365
+ channel: () => this.channel,
366
+ warn: (message, ...args) => this.ctx.logger?.warn?.(message, ...args),
367
+ onDeniedGroup: msg => {
368
+ void this.accessRequests?.ensureGroupRequest(msg).catch((error: unknown) => {
369
+ this.ctx.logger?.warn?.('dsh-channels: group authorization request failed: %s', String(error))
370
+ })
371
+ },
372
+ onDeniedUser: msg => {
373
+ void this.accessRequests?.ensureUserRequest(msg).catch((error: unknown) => {
374
+ this.ctx.logger?.warn?.('dsh-channels: user authorization request failed: %s', String(error))
375
+ })
376
+ },
377
+ })
378
+ this.inbound = new InboundRouter({
379
+ ctx: this.ctx,
380
+ config: this.config,
381
+ started: () => this.started,
382
+ domain: () => this.domain,
383
+ routes: this.routes,
384
+ repair: this.repair,
385
+ delivery: this.delivery,
386
+ sessions: this.sessions,
387
+ attachments: this.attachments,
388
+ access: this.accessController,
389
+ queue: this.routeOperations,
390
+ streamer: () => this.streamer,
391
+ questionAnswerer: () => this.questionAnswerer,
392
+ commands: { handleSlash: (msg, line) => this.handleSlashMessage(msg, line) },
393
+ })
394
+ }
395
+
396
+ // ---- lifecycle / Web settings panel ----------------------------------
397
+
398
+ /**
399
+ * Load the Web overlay, then open the channel. Startup errors are recorded
400
+ * for the settings panel and the partial service is torn down before the
401
+ * error is rethrown (the plugin fiber still fails the same way).
402
+ */
403
+ async boot(): Promise<void> {
404
+ try {
405
+ await this.loadOverlay()
406
+ await this.start()
407
+ this.lastStartError = undefined
408
+ this.runningConfig = { ...this.config }
409
+ } catch (error) {
410
+ this.lastStartError = messageOf(error)
411
+ try {
412
+ await this.shutdown()
413
+ } catch {
414
+ // The original startup error is the useful one.
415
+ }
416
+ throw error
417
+ }
418
+ }
419
+
420
+ /** Re-read the Web overlay over the profile config (no channel touch). */
421
+ private async loadOverlay(): Promise<void> {
422
+ const overlay = this.overlay === undefined ? {} : await this.overlay.load()
423
+ this.replaceConfig({ ...this.baseConfig, ...overlay })
424
+ }
425
+
426
+ /**
427
+ * Replace every own key of the live config object. `Object.assign` alone
428
+ * would keep stale overlay-only keys (e.g. `provider`) when the next layer
429
+ * no longer defines them; collaborators hold this object identity, so the
430
+ * mutation must happen in place.
431
+ */
432
+ private replaceConfig(next: FeishuConfig): void {
433
+ const live = this.config as unknown as Record<string, unknown>
434
+ for (const key of Object.keys(live)) delete live[key]
435
+ Object.assign(live, next)
436
+ }
437
+
438
+ /**
439
+ * Apply an already-validated overlay over the profile config. Fields read
440
+ * per event/turn take effect immediately; `RESTART_KEYS` need `restart()`.
441
+ */
442
+ applyOverlay(overlay: Partial<FeishuConfig>): void {
443
+ this.replaceConfig({ ...this.baseConfig, ...overlay })
444
+ this.refreshRetentionTimer()
445
+ }
446
+
447
+ /** Tear the connection down and bring it back with the latest config. */
448
+ async restart(): Promise<void> {
449
+ if (this.restarting) throw new Error('飞书连接正在重启,请稍候再试')
450
+ this.restarting = true
451
+ try {
452
+ await this.shutdown()
453
+ await this.loadOverlay()
454
+ await this.start()
455
+ this.lastStartError = undefined
456
+ this.runningConfig = { ...this.config }
457
+ } catch (error) {
458
+ this.lastStartError = messageOf(error)
459
+ // `start()` flips `started` before wiring; a failed reconnect must not
460
+ // leave the panel claiming a live connection.
461
+ try {
462
+ await this.shutdown()
463
+ } catch {
464
+ // The restart error is the useful one.
465
+ }
466
+ throw error
467
+ } finally {
468
+ this.restarting = false
469
+ }
470
+ }
471
+
472
+ isRunning(): boolean { return this.started }
473
+ isRestarting(): boolean { return this.restarting }
474
+ startedAtMs(): number | undefined { return this.startedAt }
475
+ uptimeMs(): number | undefined {
476
+ return this.startedAt === undefined || !this.started ? undefined : Math.max(0, Date.now() - this.startedAt)
477
+ }
478
+ startError(): string | undefined { return this.lastStartError }
479
+ configSnapshot(): FeishuConfig { return { ...this.config } }
480
+ baseConfigSnapshot(): FeishuConfig { return { ...this.baseConfig } }
481
+ runningConfigSnapshot(): FeishuConfig | undefined {
482
+ return this.runningConfig === undefined ? undefined : { ...this.runningConfig }
483
+ }
484
+
485
+ // ---- access-control panel surface ------------------------------------
486
+
487
+ /** Whitelists + pending authorization requests (read-only Web projection). */
488
+ accessSnapshot(): AccessWire {
489
+ const admin = this.config.adminOpenId
490
+ return {
491
+ mode: this.config.accessMode,
492
+ admin: {
493
+ ...(admin === undefined ? {} : { openId: admin }),
494
+ configured: admin !== undefined,
495
+ },
496
+ groups: this.accessStore.groupRecords().map(record => ({
497
+ chatId: record.chatId,
498
+ ...(record.name === undefined ? {} : { name: record.name }),
499
+ source: record.source,
500
+ addedAt: record.addedAt,
501
+ })),
502
+ users: this.accessStore.userRecords().map(record => ({
503
+ openId: record.openId,
504
+ ...(record.name === undefined ? {} : { name: record.name }),
505
+ source: record.source,
506
+ addedAt: record.addedAt,
507
+ })),
508
+ requests: this.accessStore.pendingRequests().map(record => {
509
+ const wire: AccessRequestWire = {
510
+ token: record.token,
511
+ kind: record.kind,
512
+ targetId: record.targetId,
513
+ createdAt: record.createdAt,
514
+ expiresAt: record.expiresAt,
515
+ }
516
+ const name = record.kind === 'group' ? record.groupName : record.requesterName
517
+ if (name !== undefined) wire.name = name
518
+ if (record.chatId !== undefined) wire.chatId = record.chatId
519
+ if (record.groupName !== undefined) wire.groupName = record.groupName
520
+ const requester = record.requesterName ?? record.requesterOpenId
521
+ if (requester !== undefined) wire.requester = requester
522
+ return wire
523
+ }),
524
+ }
525
+ }
526
+
527
+ /** Panel: authorize one group by id. */
528
+ async addAccessGroup(chatId: string, name?: string): Promise<AccessMutationResponse> {
529
+ const id = this.cleanAccessTargetId(chatId)
530
+ if (id === undefined) return { ok: false, error: 'chat_id 不合法(应为 oc_ 开头的群 ID)' }
531
+ const admin = this.config.adminOpenId
532
+ if (admin === undefined) return { ok: false, error: '请先在常用配置中设置管理员 open_id' }
533
+ try {
534
+ if (!this.accessStore.hasGroup(id)) {
535
+ await this.accessStore.addGroup({
536
+ chatId: id,
537
+ ...(name === undefined || name.trim() === '' ? {} : { name: name.trim() }),
538
+ source: 'panel',
539
+ addedBy: admin,
540
+ addedAt: Date.now(),
541
+ })
542
+ }
543
+ return { ok: true, access: this.accessSnapshot() }
544
+ } catch (error) {
545
+ return { ok: false, error: `写入授权失败:${messageOf(error)}` }
546
+ }
547
+ }
548
+
549
+ /** Panel: revoke one group. */
550
+ async removeAccessGroup(chatId: string): Promise<AccessMutationResponse> {
551
+ const id = this.cleanAccessTargetId(chatId)
552
+ if (id === undefined) return { ok: false, error: 'chat_id 不合法' }
553
+ try {
554
+ await this.accessStore.removeGroup(id)
555
+ return { ok: true, access: this.accessSnapshot() }
556
+ } catch (error) {
557
+ return { ok: false, error: `撤销授权失败:${messageOf(error)}` }
558
+ }
559
+ }
560
+
561
+ /** Panel: authorize one user (global across authorized groups). */
562
+ async addAccessUser(openId: string, name?: string): Promise<AccessMutationResponse> {
563
+ const id = this.cleanAccessUserId(openId)
564
+ if (id === undefined) return { ok: false, error: 'open_id 不合法(应以 ou_ 开头)' }
565
+ const admin = this.config.adminOpenId
566
+ if (admin === undefined) return { ok: false, error: '请先在常用配置中设置管理员 open_id' }
567
+ try {
568
+ if (!this.accessStore.hasUser(id)) {
569
+ await this.accessStore.addUser({
570
+ openId: id,
571
+ ...(name === undefined || name.trim() === '' ? {} : { name: name.trim() }),
572
+ source: 'panel',
573
+ addedBy: admin,
574
+ addedAt: Date.now(),
575
+ })
576
+ }
577
+ return { ok: true, access: this.accessSnapshot() }
578
+ } catch (error) {
579
+ return { ok: false, error: `写入授权失败:${messageOf(error)}` }
580
+ }
581
+ }
582
+
583
+ /** Panel: revoke one user. */
584
+ async removeAccessUser(openId: string): Promise<AccessMutationResponse> {
585
+ const id = this.cleanAccessUserId(openId)
586
+ if (id === undefined) return { ok: false, error: 'open_id 不合法' }
587
+ try {
588
+ await this.accessStore.removeUser(id)
589
+ return { ok: true, access: this.accessSnapshot() }
590
+ } catch (error) {
591
+ return { ok: false, error: `撤销授权失败:${messageOf(error)}` }
592
+ }
593
+ }
594
+
595
+ /** Panel: decide one pending authorization request (same path as the card). */
596
+ async resolveAccessRequest(token: string, decision: 'allow' | 'deny'): Promise<AccessMutationResponse> {
597
+ const requests = this.accessRequests
598
+ if (requests === undefined) return { ok: false, error: '授权服务尚未启动' }
599
+ const actor = this.config.adminOpenId
600
+ if (actor === undefined) return { ok: false, error: '请先配置管理员 open_id' }
601
+ try {
602
+ const result = await requests.resolve(token, decision, actor)
603
+ if (!result.ok) return { ok: false, error: result.error }
604
+ return { ok: true, access: this.accessSnapshot() }
605
+ } catch (error) {
606
+ return { ok: false, error: `处理授权申请失败:${messageOf(error)}` }
607
+ }
608
+ }
609
+
610
+ /** Trim + bound one panel-supplied identifier; rejects control characters. */
611
+ private cleanAccessTargetId(raw: string): string | undefined {
612
+ const value = raw.trim()
613
+ if (value === '' || value.length > 128) return undefined
614
+ // eslint-disable-next-line no-control-regex
615
+ if (/[\u0000-\u001f\u007f]/u.test(value)) return undefined
616
+ return value
617
+ }
618
+
619
+ /** Same normalization for user open_ids, which must look like ou_xxx. */
620
+ private cleanAccessUserId(raw: string): string | undefined {
621
+ const value = this.cleanAccessTargetId(raw)
622
+ if (value === undefined || !/^ou_[A-Za-z0-9_-]+$/u.test(value)) return undefined
623
+ return value
624
+ }
625
+
626
+ // ---- workspace retention cleanup -------------------------------------
627
+
628
+ /**
629
+ * Workspace-retention preview for the Web panel: how many plugin-created
630
+ * workspaces exist, how many are past the configured window, and their
631
+ * measured footprint. Never deletes anything.
632
+ */
633
+ async cleanupPreview(): Promise<{
634
+ retention: string
635
+ thresholdMs?: number
636
+ auto: boolean
637
+ workspaces: number
638
+ candidates: number
639
+ candidateBytes: number
640
+ error?: string
641
+ }> {
642
+ const retention = this.config.workspaceRetention
643
+ const window = retentionMs(retention)
644
+ const base = {
645
+ retention,
646
+ ...(window === undefined ? {} : { thresholdMs: window }),
647
+ auto: this.config.workspaceRetentionAuto,
648
+ }
649
+ try {
650
+ const workspaces = this.ctx.workspaceRegistry.list().filter(workspace => this.isPluginWorkspace(workspace))
651
+ const plan = await planWorkspaceCleanup(this.cleanupDeps())
652
+ return {
653
+ ...base,
654
+ workspaces: workspaces.length,
655
+ candidates: plan?.candidates.length ?? 0,
656
+ candidateBytes: plan?.candidates.reduce((total, candidate) => total + candidate.bytes, 0) ?? 0,
657
+ }
658
+ } catch (error) {
659
+ return { ...base, workspaces: 0, candidates: 0, candidateBytes: 0, error: messageOf(error) }
660
+ }
661
+ }
662
+
663
+ /**
664
+ * Delete every stale plugin workspace the configured retention allows.
665
+ * Routes bound to a removed workspace are retired first (thread routes leave
666
+ * a tombstone), so an old topic fails with the explicit expired card instead
667
+ * of resuming into a missing directory. DSH Session logs are not touched.
668
+ */
669
+ async runWorkspaceCleanup(): Promise<{
670
+ ok: boolean
671
+ deletedWorkspaces: number
672
+ retiredRoutes: number
673
+ freedBytes: number
674
+ failures: string[]
675
+ error?: string
676
+ }> {
677
+ const empty = { ok: false, deletedWorkspaces: 0, retiredRoutes: 0, freedBytes: 0, failures: [] as string[] }
678
+ if (retentionMs(this.config.workspaceRetention) === undefined) {
679
+ return { ...empty, error: '未启用工作区清理阈值(workspaceRetention 为 off)' }
680
+ }
681
+ if (this.cleanupRunning) return { ...empty, error: '工作区清理正在进行中,请稍候' }
682
+ this.cleanupRunning = true
683
+ try {
684
+ const deps = this.cleanupDeps()
685
+ const plan = await planWorkspaceCleanup(deps)
686
+ if (plan === undefined) return { ...empty, error: '工作区清理不可用:baseDir 配置无效' }
687
+ const outcome = await applyWorkspaceCleanup(deps, plan)
688
+ this.ctx.logger?.info?.(
689
+ 'dsh-channels: workspace cleanup removed %d workspace(s), retired %d route(s), freed %d bytes',
690
+ outcome.deletedWorkspaces, outcome.retiredRoutes, outcome.freedBytes,
691
+ )
692
+ return {
693
+ ok: true,
694
+ deletedWorkspaces: outcome.deletedWorkspaces,
695
+ retiredRoutes: outcome.retiredRoutes,
696
+ freedBytes: outcome.freedBytes,
697
+ failures: outcome.failures.map(failure => `${failure.path}: ${failure.message}`),
698
+ }
699
+ } catch (error) {
700
+ return { ...empty, error: messageOf(error) }
701
+ } finally {
702
+ this.cleanupRunning = false
703
+ }
704
+ }
705
+
706
+ /** Whether one registry workspace was created by this plugin inside `baseDir`. */
707
+ private isPluginWorkspace(workspace: Workspace): boolean {
708
+ const pluginTitle = workspace.title.startsWith('飞书私聊 ·') || workspace.title.startsWith('飞书群聊 ·')
709
+ return pluginTitle && isPathInside(path.resolve(this.config.baseDir), workspace.path)
710
+ }
711
+
712
+ /** Effect seams for the cleanup policy module (all I/O stays here). */
713
+ private cleanupDeps(): CleanupDeps {
714
+ const baseDir = path.resolve(this.config.baseDir)
715
+ return {
716
+ baseDir,
717
+ retention: this.config.workspaceRetention,
718
+ listWorkspaces: () => this.ctx.workspaceRegistry.list(),
719
+ activeRoutes: () => [...(this.domain?.routes.entries() ?? [])].map(([key, record]) => ({
720
+ key,
721
+ kind: record.kind,
722
+ workspacePath: record.workspacePath,
723
+ sessionId: record.sessionId,
724
+ lastActivityAt: record.lastActivityAt,
725
+ })),
726
+ tombstones: () => [...(this.domain?.expiredRoutes.entries() ?? [])].map(([key, record]) => ({
727
+ key,
728
+ ...(record.workspacePath === undefined ? {} : { workspacePath: record.workspacePath }),
729
+ expiredAt: record.expiredAt,
730
+ ...(record.lastActivityAt === undefined ? {} : { lastActivityAt: record.lastActivityAt }),
731
+ })),
732
+ isLive: sessionId => this.sessions.get(sessionId) !== undefined
733
+ || this.ctx.agents.get(sessionId as SessionId) !== undefined,
734
+ statWorkspace: workspacePath => measureWorkspace(workspacePath),
735
+ removeWorkspace: async workspace => {
736
+ const target = path.resolve(workspace.path)
737
+ if (!isPathInside(baseDir, target)) {
738
+ throw new Error(`refusing to delete a path outside baseDir (${target})`)
739
+ }
740
+ // Re-verify the physical path: a symlinked ancestor inside baseDir
741
+ // must not let the recursive delete escape it.
742
+ try {
743
+ const [physicalBase, physicalTarget] = await Promise.all([realpath(baseDir), realpath(target)])
744
+ if (!isPathInside(physicalBase, physicalTarget)) {
745
+ throw new Error(`refusing to delete a symlink escape outside baseDir (${physicalTarget})`)
746
+ }
747
+ } catch (error) {
748
+ if (error instanceof Error && error.message.startsWith('refusing to delete a symlink escape')) throw error
749
+ // A missing workspace is already effectively removed (rm is idempotent).
750
+ }
751
+ await rm(target, { recursive: true, force: true })
752
+ try {
753
+ await this.ctx.workspaceRegistry.delete(workspace.id)
754
+ } catch (error) {
755
+ this.ctx.logger?.warn?.(
756
+ 'dsh-channels: unregistering cleaned workspace %s failed: %s', workspace.path, String(error),
757
+ )
758
+ }
759
+ },
760
+ retireRoute: async route => {
761
+ if (route.kind === 'thread') await this.routes.retire(route.key)
762
+ else await this.routes.remove(route.key)
763
+ },
764
+ warn: message => this.ctx.logger?.warn?.(message),
765
+ }
766
+ }
767
+
768
+ /**
769
+ * (Re)arm the daily retention timer to match the live config. `runNow`
770
+ * performs one pass immediately; it is only used at startup so saving any
771
+ * unrelated setting can never trigger a surprise deletion pass.
772
+ */
773
+ private refreshRetentionTimer(runNow = false): void {
774
+ if (this.retentionTimer !== undefined) {
775
+ clearInterval(this.retentionTimer)
776
+ this.retentionTimer = undefined
777
+ }
778
+ if (!this.started
779
+ || !this.config.workspaceRetentionAuto
780
+ || retentionMs(this.config.workspaceRetention) === undefined) return
781
+ const sweep = (): void => {
782
+ void this.runWorkspaceCleanup().then(result => {
783
+ if (!result.ok && result.error !== '工作区清理正在进行中,请稍候') {
784
+ this.ctx.logger?.warn?.('dsh-channels: automatic workspace cleanup failed: %s', result.error)
785
+ }
786
+ })
787
+ }
788
+ if (runNow) sweep()
789
+ const timer = setInterval(sweep, RETENTION_SWEEP_INTERVAL_MS)
790
+ timer.unref?.()
791
+ this.retentionTimer = timer
792
+ }
793
+
794
+ /** Aggregate health used by `/health` and the Web settings panel. */
795
+ health(): HealthSnapshot { return this.healthSnapshot() }
796
+
797
+ /**
798
+ * Credential facts for the two Feishu refs. Values are never returned:
799
+ * `describe()` reports presence/source/writability only.
800
+ */
801
+ async credentialStates(): Promise<{ appId: CredentialInfo; appSecret: CredentialInfo }> {
802
+ const provider = this.credentialProvider()
803
+ const read = async (ref: string): Promise<CredentialInfo> => {
804
+ try {
805
+ if (provider?.describe !== undefined) {
806
+ const info = await provider.describe(ref)
807
+ return {
808
+ configured: info?.configured === true,
809
+ writable: info?.writable !== false && provider.set !== undefined,
810
+ ...(info?.source === undefined ? {} : { source: info.source }),
811
+ }
812
+ }
813
+ if (provider === undefined) {
814
+ const ambient = process.env[ref]
815
+ return { configured: ambient !== undefined && ambient !== '', writable: false }
816
+ }
817
+ return { configured: (await provider.resolve(ref)) !== undefined, writable: provider.set !== undefined }
818
+ } catch {
819
+ return { configured: false, writable: false }
820
+ }
821
+ }
822
+ const [appId, appSecret] = await Promise.all([read(this.config.appId), read(this.config.appSecret)])
823
+ return { appId, appSecret }
824
+ }
825
+
826
+ /** The host credentials seam, when mounted. */
827
+ private credentialProvider(): CredentialProvider | undefined {
828
+ return this.ctx.get?.('credentials') as CredentialProvider | undefined
829
+ }
830
+
831
+ /** Store one Feishu credential value through the host seam; the value is never logged. */
832
+ async setCredential(field: 'appId' | 'appSecret', value: string): Promise<void> {
833
+ const provider = this.credentialProvider()
834
+ if (provider?.set === undefined) {
835
+ throw new Error('主机的凭据服务不可写(未挂载 credentials.set,或当前提供方只读)')
836
+ }
837
+ const ref = this.credentialRef(field)
838
+ try {
839
+ await provider.set(ref, value)
840
+ } catch (error) {
841
+ throw new Error(`写入凭据引用 ${ref} 失败:${messageOf(error)}`)
842
+ }
843
+ }
844
+
845
+ /** Remove the stored value of one Feishu credential reference. */
846
+ async clearCredential(field: 'appId' | 'appSecret'): Promise<void> {
847
+ const provider = this.credentialProvider()
848
+ if (provider?.unset === undefined) {
849
+ throw new Error('主机的凭据服务不支持移除凭据')
850
+ }
851
+ const ref = this.credentialRef(field)
852
+ try {
853
+ await provider.unset(ref)
854
+ } catch (error) {
855
+ throw new Error(`移除凭据引用 ${ref} 失败:${messageOf(error)}`)
856
+ }
857
+ }
858
+
859
+ /** The overlay-independent ref name backing one editable credential slot. */
860
+ private credentialRef(field: 'appId' | 'appSecret'): string {
861
+ return field === 'appId' ? this.config.appId : this.config.appSecret
862
+ }
863
+
864
+ /** Wire events and open the long connection. */
865
+ async start(): Promise<void> {
866
+ if (this.started) return
867
+ this.started = true
868
+
869
+ // 1. Resolve credentials through the host seam (env > .credentials.yaml > .env).
870
+ const appId = await resolveCredentialRef(this.ctx, this.config.appId)
871
+ const appSecret = await resolveCredentialRef(this.ctx, this.config.appSecret)
872
+
873
+ // 2. Build the channel with the resolved secrets.
874
+ const channel = this.channelFactory({
875
+ appId,
876
+ appSecret,
877
+ loggerLevel: this.config.loggerLevel,
878
+ requireMention: this.config.requireMention,
879
+ dmMode: this.config.dmMode,
880
+ })
881
+ this.channel = channel
882
+ const hooks: StreamerHooks = {
883
+ measureContext: session => this.measureContext(session),
884
+ projectStats: session => this.projectCardStats(session),
885
+ sessionLabel: sessionId => sessionId,
886
+ onError: (_key, error) => {
887
+ this.ctx.logger?.warn?.('dsh-channels stream error: %s', String(error))
888
+ },
889
+ onTurnFinalized: (_sessionId, _turn, mainCardMessageId) => {
890
+ // The turn settled (final patches fired, reaction swap requested):
891
+ // retire its repair snapshot. Kept deliberately non-awaited — repair
892
+ // state is a safety net, never a dependency of the turn lifecycle.
893
+ void this.repair.clear(mainCardMessageId)
894
+ },
895
+ onSegmentDelivered: (mainCardMessageId, segmentIndex, messageId, mode) => {
896
+ void this.repair.recordSegment(mainCardMessageId, segmentIndex, messageId, mode)
897
+ },
898
+ sendSegmentCard: (sessionId, segment, card, fallbackText) =>
899
+ this.delivery.segmentCard(sessionId, segment, card, fallbackText),
900
+ }
901
+ this.streamer = new TurnStreamer(
902
+ channel,
903
+ this.config.refreshMs,
904
+ hooks,
905
+ this.config.statusRefreshMs,
906
+ )
907
+
908
+ this.domain = await openFeishuDomain(this.ctx)
909
+ this.routes.load()
910
+ this.accessStore.load()
911
+
912
+ // HITL: answer the host's approval/request waterfall for sessions this
913
+ // channel routes. `prepend` claims such requests BEFORE the web gateway's
914
+ // generic answerer (its panel would be invisible to a Feishu user), while
915
+ // non-owned sessions delegate through `next()`.
916
+ const answerer = new ApprovalAnswerer(channel, {
917
+ routeFor: sessionId => this.approvalRouteFor(sessionId),
918
+ onError: (sessionId, error) => {
919
+ this.ctx.logger?.warn?.('dsh-channels approval error (session %s): %s', sessionId, String(error))
920
+ },
921
+ }, this.config.approvalTimeoutMs)
922
+ this.approvalAnswerer = answerer
923
+ this.unlistenApproval = this.ctx.on('approval/request', (req, next) => answerer.onRequest(req, next), { prepend: true })
924
+ // Structured questions (`ask_user_question`): the host's only shipped
925
+ // answerer is the Web client, so a Feishu-only session would otherwise
926
+ // fail every ask with NO_PROVIDER.
927
+ const questions = new QuestionAnswerer(channel, {
928
+ routeFor: sessionId => this.approvalRouteFor(sessionId),
929
+ onError: (sessionId, error) => {
930
+ this.ctx.logger?.warn?.('dsh-channels question error (session %s): %s', sessionId, String(error))
931
+ },
932
+ }, this.config.questionTimeoutMs)
933
+ this.questionAnswerer = questions
934
+ this.unlistenQuestions = this.ctx.on(
935
+ 'user-questions/request',
936
+ (request, next) => questions.onRequest(request, next),
937
+ { prepend: true },
938
+ )
939
+ const accessRequests = new AccessRequests({
940
+ config: () => this.config,
941
+ store: this.accessStore,
942
+ channel: () => this.channel,
943
+ warn: (message, ...args) => this.ctx.logger?.warn?.(message, ...args),
944
+ })
945
+ this.accessRequests = accessRequests
946
+ this.unlistenBotAdded = channel.onBotAdded(event => {
947
+ void accessRequests.handleBotAdded(event).catch((error: unknown) => {
948
+ this.ctx.logger?.warn?.('dsh-channels: bot-added group info card failed: %s', String(error))
949
+ })
950
+ })
951
+ void accessRequests.expirePending().catch((error: unknown) => {
952
+ this.ctx.logger?.warn?.('dsh-channels: access request expiry sweep failed: %s', String(error))
953
+ })
954
+ this.unlistenCardAction = channel.onCardAction(event => {
955
+ void accessRequests.onCardAction(event)
956
+ answerer.onCardAction(event)
957
+ questions.onCardAction(event)
958
+ })
959
+
960
+ this.ctx.on('session/event', (session, event) => {
961
+ this.streamer?.onSessionEvent(session, event)
962
+ })
963
+ // DSH removed the durable `assistant/chunk` event (format v2 embeds the
964
+ // stream in the settlement); the live deltas arrive here instead.
965
+ this.ctx.on('agent/assistant-stream', ({ agent, frame }) => {
966
+ this.streamer?.onAssistantFrame(agent.session, frame)
967
+ })
968
+ this.unlisten = channel.onMessage(message => {
969
+ void this.inbound.handle(message).catch((error: unknown) => {
970
+ this.ctx.logger?.warn?.('dsh-channels inbound error: %s', String(error))
971
+ // User-facing safety net: never leave the user without a reply.
972
+ void channel
973
+ .send(message.chatId, { text: `dsh-channels 处理失败:${String(error)}` }, { replyTo: message.messageId })
974
+ .catch(() => {})
975
+ })
976
+ })
977
+ await channel.connect()
978
+ // Repair cards a previous instance left mid-flight (best-effort, background).
979
+ void this.repair.repairAll().catch((error: unknown) => {
980
+ this.ctx.logger?.warn?.('dsh-channels: inflight repair failed: %s', String(error))
981
+ })
982
+ // Record GC: one sweep right after boot, then a slow periodic sweep.
983
+ // Dedup records are pruned unconditionally (fixed retention); route
984
+ // records of deleted topics (their thread id never reappears) are pruned
985
+ // when `routeTtlMs` is enabled.
986
+ void this.gcExpiredEntries().catch((error: unknown) => {
987
+ this.ctx.logger?.warn?.('dsh-channels: record GC failed: %s', String(error))
988
+ })
989
+ const timer = setInterval(() => {
990
+ void this.gcExpiredEntries().catch(() => {})
991
+ }, GC_SWEEP_INTERVAL_MS)
992
+ timer.unref?.()
993
+ this.gcTimer = timer
994
+ // Workspace retention: opt-in automatic cleanup (default off).
995
+ this.refreshRetentionTimer(true)
996
+ this.ctx.logger?.info?.(
997
+ 'dsh-channels: feishu long connection established (bot open_id %s, %d route(s) loaded)',
998
+ channel.botOpenId() ?? 'unknown',
999
+ this.routes.size(),
1000
+ )
1001
+ console.info('[dsh-channels] feishu long connection established, bot open_id:', channel.botOpenId())
1002
+ console.info('[dsh-channels] activity build:', ACTIVITY_BUILD_MARKER)
1003
+ this.startedAt = Date.now()
1004
+ console.info('[dsh-channels] model request overrides:', {
1005
+ provider: this.config.provider ?? 'deployment-default',
1006
+ model: this.config.model ?? 'deployment-default',
1007
+ reasoningEffort: this.config.reasoningEffort ?? 'adapter-default',
1008
+ })
1009
+ if (this.config.accessMode === 'open') {
1010
+ console.warn('[dsh-channels] 准入=open:任何应用可用范围内的用户都可以对话(不安全,仅调试)')
1011
+ } else if (this.config.adminOpenId === undefined) {
1012
+ console.warn(
1013
+ '[dsh-channels] 准入=guarded 但未配置管理员 open_id:所有对话都会被拒绝;'
1014
+ + '私聊机器人发送 /userid 取得 open_id 后填入 Web 设置面板',
1015
+ )
1016
+ } else {
1017
+ console.info('[dsh-channels] 准入=guarded,管理员已配置')
1018
+ }
1019
+ const approvalMounted = this.ctx.get?.('approval') !== undefined
1020
+ console.info(
1021
+ approvalMounted
1022
+ ? `[dsh-channels] HITL 审批应答已就绪(决策卡片超时 ${this.config.approvalTimeoutMs} ms)`
1023
+ : '[dsh-channels] 主机未挂载 approval 服务,HITL 审批应答关闭',
1024
+ )
1025
+ const questionsMounted = this.ctx.get?.('userQuestions') !== undefined
1026
+ console.info(
1027
+ questionsMounted
1028
+ ? `[dsh-channels] ask_user_question 应答已就绪(提问卡片超时 ${this.config.questionTimeoutMs} ms)`
1029
+ : '[dsh-channels] 主机未挂载 userQuestions 服务,结构化提问应答关闭',
1030
+ )
1031
+ }
1032
+
1033
+ /** Tear down: close the connection and dispose all live agents. */
1034
+ async shutdown(): Promise<void> {
1035
+ // Close admission first. In-flight Host commands receive cancellation
1036
+ // before their Agent/channel/storage dependencies are disposed, while
1037
+ // already queued route operations observe `started === false` and retire.
1038
+ this.started = false
1039
+ for (const controller of this.commandControllers) controller.abort()
1040
+ await Promise.allSettled([...this.commandOperations])
1041
+ this.commandControllers.clear()
1042
+ this.commandOperations.clear()
1043
+ this.unlisten?.()
1044
+ this.unlisten = undefined
1045
+ this.unlistenCardAction?.()
1046
+ this.unlistenCardAction = undefined
1047
+ this.unlistenBotAdded?.()
1048
+ this.unlistenBotAdded = undefined
1049
+ this.accessRequests?.clear()
1050
+ this.accessRequests = undefined
1051
+ this.accessController.clear()
1052
+ this.unlistenApproval?.()
1053
+ this.unlistenApproval = undefined
1054
+ this.unlistenQuestions?.()
1055
+ this.unlistenQuestions = undefined
1056
+ // Withdraw in-flight approval questions (the host settles them cancelled).
1057
+ this.approvalAnswerer?.dispose()
1058
+ this.approvalAnswerer = undefined
1059
+ this.questionAnswerer?.dispose()
1060
+ this.questionAnswerer = undefined
1061
+ if (this.gcTimer !== undefined) {
1062
+ clearInterval(this.gcTimer)
1063
+ this.gcTimer = undefined
1064
+ }
1065
+ if (this.retentionTimer !== undefined) {
1066
+ clearInterval(this.retentionTimer)
1067
+ this.retentionTimer = undefined
1068
+ }
1069
+ await this.sessions.disposeAll()
1070
+ this.routeOperations.clear()
1071
+ this.inbound.clear()
1072
+ this.routes.clear()
1073
+ this.accessStore.clear()
1074
+ try {
1075
+ await this.channel?.disconnect()
1076
+ } catch {
1077
+ // Already disconnected is fine.
1078
+ }
1079
+ this.channel = undefined
1080
+ this.streamer = undefined
1081
+ await this.domain?.close()
1082
+ this.domain = undefined
1083
+ this.runningConfig = undefined
1084
+ }
1085
+
1086
+ // ---- inbound routing -------------------------------------------------
1087
+
1088
+
1089
+ /** Slash-command plane. Nothing in this method calls `agent.followup()`. */
1090
+ /**
1091
+ * Slash-command admission. The command vocabulary and its admission rules
1092
+ * come from the command registry; nothing here calls `agent.followup()`.
1093
+ */
1094
+ private async handleSlashMessage(msg: NormalizedMessage, line: string): Promise<void> {
1095
+ if (!this.started) return
1096
+ const parsed = parseSlashCommand(line)
1097
+ // Channel-plane command admission runs before ANY card/topic/side effect:
1098
+ // denied senders get at most a fixed private-chat notice (or silence in
1099
+ // groups), never an unknown-command card or a command thread.
1100
+ if (!(await this.accessController.admitCommand(msg, parsed?.name))) return
1101
+ if (parsed === undefined) {
1102
+ await this.delivery.commandCard(msg, {
1103
+ kind: 'input-error',
1104
+ text: '命令名必须以小写字母开头,且只能包含小写字母、数字、下划线或连字符。',
1105
+ supported: supportedCommandList(),
1106
+ })
1107
+ return
1108
+ }
1109
+ const spec = commandSpec(parsed.name)
1110
+ if (spec === undefined) {
1111
+ await this.delivery.commandCard(msg, {
1112
+ kind: 'unknown', command: parsed.name,
1113
+ text: `未找到命令 /${parsed.name}。`, supported: supportedCommandList(),
1114
+ })
1115
+ return
1116
+ }
1117
+
1118
+ // `/auth` is administrator-only, route-free, and handled right here so it
1119
+ // works even inside an unauthorized group (that is the "authorize from
1120
+ // the conversation" entry point).
1121
+ if (spec.name === 'auth') {
1122
+ await this.runAuthCommand(msg, parsed)
1123
+ return
1124
+ }
1125
+
1126
+ // Route-free commands answer without any Session work.
1127
+ if (spec.routeFree === true) {
1128
+ await this.runRouteFreeCommand(spec, msg, parsed)
1129
+ return
1130
+ }
1131
+
1132
+ // A TTL-retired topic is permanently closed except for session-optional
1133
+ // diagnostics (`/health`).
1134
+ const expiredRoute = msg.threadId === undefined ? undefined : this.routes.expiredRoute(msg.threadId)
1135
+ if (expiredRoute !== undefined) {
1136
+ if (spec.sessionOptional === true) {
1137
+ await this.runRouteFreeCommand(spec, msg, parsed)
1138
+ return
1139
+ }
1140
+ await this.delivery.expiredRouteCard(msg, parsed.name)
1141
+ return
1142
+ }
1143
+
1144
+ const route = await this.inbound.resolveRoute(msg)
1145
+ if (route === undefined) {
1146
+ if (spec.sessionOptional === true) {
1147
+ await this.runRouteFreeCommand(spec, msg, parsed)
1148
+ return
1149
+ }
1150
+ await this.delivery.commandCard(msg, {
1151
+ kind: 'unbound', command: parsed.name,
1152
+ text: '当前话题尚未绑定 Session。请先发送普通消息创建会话,或使用 /health 检查通道。',
1153
+ })
1154
+ return
1155
+ }
1156
+
1157
+ if (msg.senderId !== route.senderOpenId) {
1158
+ await this.delivery.commandCard(msg, {
1159
+ kind: 'forbidden', command: parsed.name, text: '无权操作此会话。',
1160
+ })
1161
+ return
1162
+ }
1163
+
1164
+ const operation = this.routeOperations.run(route.key, async () => {
1165
+ if (!this.started) return
1166
+ const current = this.routes.get(route.key) ?? route
1167
+ if (msg.senderId !== current.senderOpenId) {
1168
+ await this.delivery.commandCard(msg, {
1169
+ kind: 'forbidden', command: parsed.name, text: '无权操作此会话。',
1170
+ })
1171
+ return
1172
+ }
1173
+ await this.runRouteCommand(spec, msg, parsed, current)
1174
+ })
1175
+ this.commandOperations.add(operation)
1176
+ try {
1177
+ await operation
1178
+ } finally {
1179
+ this.commandOperations.delete(operation)
1180
+ }
1181
+ }
1182
+
1183
+ /**
1184
+ * `/auth`: administrator-only access management on the channel plane. It
1185
+ * never creates a Session or resumes an Agent; every reply is a fixed card.
1186
+ */
1187
+ private async runAuthCommand(msg: NormalizedMessage, parsed: ParsedSlashCommand): Promise<void> {
1188
+ if (!this.accessController.isAdmin(msg.senderId)) return
1189
+ const direct = { createThread: false }
1190
+ const words = parsed.rawInput.trim().split(/\s+/u).filter(word => word !== '')
1191
+ const action = words[0] ?? 'status'
1192
+ const now = Date.now()
1193
+ try {
1194
+ if (action === 'status') {
1195
+ const groups = this.accessStore.groupRecords()
1196
+ const users = this.accessStore.userRecords()
1197
+ const pending = this.accessStore.pendingRequests()
1198
+ await this.delivery.standaloneReply(msg, standaloneInfoCard('**🔐 访问授权状态**', [
1199
+ `**准入模式**:\`${this.config.accessMode}\``,
1200
+ `**管理员**:${this.config.adminOpenId === undefined ? '未配置' : `\`${this.config.adminOpenId}\``}`,
1201
+ ...(msg.chatType === 'group'
1202
+ ? [`**本群**:${this.accessStore.hasGroup(msg.chatId) ? '已授权' : '未授权'}(\`${msg.chatId}\`)`]
1203
+ : []),
1204
+ `**白名单**:群 ${groups.length} 个 · 用户 ${users.length} 个 · 待处理申请 ${pending.length} 个`,
1205
+ '群授权:`/auth allow` 或 `/auth revoke`(在目标群内发);用户授权:`/auth user add <open_id>`。',
1206
+ ]), direct)
1207
+ return
1208
+ }
1209
+ if (action === 'allow' || action === 'revoke') {
1210
+ if (msg.chatType !== 'group') {
1211
+ await this.delivery.commandCard(msg, {
1212
+ kind: 'input-error', command: 'auth',
1213
+ text: '群授权命令只能在群聊中使用:在目标群里发送 /auth allow 或 /auth revoke。',
1214
+ }, direct)
1215
+ return
1216
+ }
1217
+ if (action === 'allow') {
1218
+ if (this.accessStore.hasGroup(msg.chatId)) {
1219
+ await this.delivery.commandCard(msg, {
1220
+ kind: 'success', command: 'auth', text: `本群已经授权(\`${msg.chatId}\`)。`,
1221
+ }, direct)
1222
+ return
1223
+ }
1224
+ let name: string | undefined
1225
+ try {
1226
+ name = (await this.channel?.getChatInfo(msg.chatId))?.name
1227
+ } catch {
1228
+ name = undefined
1229
+ }
1230
+ await this.accessStore.addGroup({
1231
+ chatId: msg.chatId,
1232
+ ...(name === undefined || name === '' ? {} : { name }),
1233
+ source: 'command',
1234
+ addedBy: msg.senderId,
1235
+ addedAt: now,
1236
+ })
1237
+ await this.delivery.commandCard(msg, {
1238
+ kind: 'success', command: 'auth',
1239
+ text: '✅ 已授权本群。群内只有管理员与已授权用户能与我对话。',
1240
+ }, direct)
1241
+ return
1242
+ }
1243
+ const removed = await this.accessStore.removeGroup(msg.chatId)
1244
+ await this.delivery.commandCard(msg, {
1245
+ kind: removed ? 'success' : 'unavailable', command: 'auth',
1246
+ text: removed ? '已撤销本群授权,立即生效。' : '本群当前未被授权。',
1247
+ }, direct)
1248
+ return
1249
+ }
1250
+ if (action === 'user') {
1251
+ const sub = words[1]
1252
+ const openId = words[2]
1253
+ if ((sub !== 'add' && sub !== 'remove') || openId === undefined
1254
+ || !/^ou_[A-Za-z0-9_-]+$/u.test(openId)) {
1255
+ await this.delivery.commandCard(msg, {
1256
+ kind: 'input-error', command: 'auth',
1257
+ text: '用法:/auth user add <open_id> 或 /auth user remove <open_id>(open_id 以 ou_ 开头,可用 /userid 查看)。',
1258
+ }, direct)
1259
+ return
1260
+ }
1261
+ if (sub === 'add') {
1262
+ if (this.accessStore.hasUser(openId)) {
1263
+ await this.delivery.commandCard(msg, {
1264
+ kind: 'success', command: 'auth', text: `该用户已在白名单中(\`${openId}\`)。`,
1265
+ }, direct)
1266
+ return
1267
+ }
1268
+ await this.accessStore.addUser({
1269
+ openId, source: 'command', addedBy: msg.senderId, addedAt: now,
1270
+ })
1271
+ await this.delivery.commandCard(msg, {
1272
+ kind: 'success', command: 'auth',
1273
+ text: `✅ 已授权用户 \`${openId}\`(在所有已授权群里生效)。`,
1274
+ }, direct)
1275
+ return
1276
+ }
1277
+ const removed = await this.accessStore.removeUser(openId)
1278
+ await this.delivery.commandCard(msg, {
1279
+ kind: removed ? 'success' : 'unavailable', command: 'auth',
1280
+ text: removed ? `已移除用户 \`${openId}\`,立即生效。` : `用户 \`${openId}\` 不在白名单中。`,
1281
+ }, direct)
1282
+ return
1283
+ }
1284
+ if (action === 'list') {
1285
+ const groups = this.accessStore.groupRecords()
1286
+ const users = this.accessStore.userRecords()
1287
+ const pending = this.accessStore.pendingRequests()
1288
+ const groupLines = groups.length === 0
1289
+ ? ['(暂无)']
1290
+ : groups.slice(0, 30).map(group => `- ${group.name ?? group.chatId} \`${group.chatId}\``)
1291
+ const userLines = users.length === 0
1292
+ ? ['(暂无)']
1293
+ : users.slice(0, 30).map(user => `- ${user.name ?? user.openId} \`${user.openId}\``)
1294
+ await this.delivery.standaloneReply(msg, standaloneInfoCard('**🔐 访问授权清单**', [
1295
+ `**已授权群(${groups.length})**`,
1296
+ ...groupLines,
1297
+ `**已授权用户(${users.length})**`,
1298
+ ...userLines,
1299
+ `**待处理申请**:${pending.length} 个(可在 Web 设置面板处理)`,
1300
+ ]), direct)
1301
+ return
1302
+ }
1303
+ await this.delivery.commandCard(msg, {
1304
+ kind: 'input-error', command: 'auth',
1305
+ text: '用法:/auth [status|allow|revoke|list|user add <open_id>|user remove <open_id>]',
1306
+ }, direct)
1307
+ } catch (error) {
1308
+ this.ctx.logger?.warn?.('dsh-channels: /auth failed: %s', String(error))
1309
+ await this.delivery.commandCard(msg, {
1310
+ kind: 'unavailable', command: 'auth', text: `授权操作失败:${messageOf(error)}`,
1311
+ }, direct).catch(() => {})
1312
+ }
1313
+ }
1314
+
1315
+ /** Answer one command that needs no Session (help / userid / groupid / health). */
1316
+ private async runRouteFreeCommand(
1317
+ spec: ChannelCommandSpec,
1318
+ msg: NormalizedMessage,
1319
+ parsed: ParsedSlashCommand,
1320
+ ): Promise<void> {
1321
+ const createThread = spec.directReply !== true
1322
+ if (parsed.rawInput.trim() !== '') {
1323
+ await this.delivery.commandCard(msg, {
1324
+ kind: 'input-error', command: spec.name, text: `用法错误:/${spec.name} 不接受参数。`,
1325
+ }, { createThread })
1326
+ return
1327
+ }
1328
+ if (spec.mentionGate === true
1329
+ && msg.chatType === 'group'
1330
+ && msg.threadId === undefined
1331
+ && this.config.requireMention
1332
+ && !msg.mentionedBot) {
1333
+ return
1334
+ }
1335
+ if (spec.chatType !== undefined && msg.chatType !== spec.chatType) {
1336
+ await this.delivery.commandCard(msg, {
1337
+ kind: 'context-error', command: spec.name,
1338
+ text: spec.chatType === 'p2p'
1339
+ ? `命令 /${spec.name} 仅支持在私聊中使用。`
1340
+ : `命令 /${spec.name} 仅支持在群聊中使用。`,
1341
+ }, { createThread })
1342
+ return
1343
+ }
1344
+ await this.delivery.withCommandReaction(msg, async () => {
1345
+ switch (spec.name) {
1346
+ case 'help':
1347
+ await this.delivery.commandCard(msg, { kind: 'help', text: supportedCommandHelp() }, { createThread })
1348
+ return
1349
+ case 'userid':
1350
+ await this.delivery.commandCard(msg, {
1351
+ kind: 'success', command: spec.name, text: `当前用户 ID:\`${msg.senderId}\``,
1352
+ }, { createThread })
1353
+ return
1354
+ case 'groupid':
1355
+ await this.delivery.commandCard(msg, {
1356
+ kind: 'success', command: spec.name, text: `当前群组 ID:\`${msg.chatId}\``,
1357
+ }, { createThread })
1358
+ return
1359
+ case 'health':
1360
+ await this.delivery.standaloneReply(msg, buildHealthCard(this.healthSnapshot()), { createThread })
1361
+ return
1362
+ default:
1363
+ await this.delivery.commandCard(msg, {
1364
+ kind: 'unavailable', command: spec.name, text: `命令 /${spec.name} 暂不可用。`,
1365
+ }, { createThread })
1366
+ }
1367
+ })
1368
+ }
1369
+
1370
+ /** Answer one route-bound command inside the per-route admission queue. */
1371
+ private async runRouteCommand(
1372
+ spec: ChannelCommandSpec,
1373
+ msg: NormalizedMessage,
1374
+ parsed: ParsedSlashCommand,
1375
+ current: RouteRecord,
1376
+ ): Promise<void> {
1377
+ await this.delivery.withCommandReaction(msg, async () => {
1378
+ try {
1379
+ if (spec.channelPlane === true) {
1380
+ await this.runChannelCommand(spec, msg, parsed, current)
1381
+ return
1382
+ }
1383
+ await this.runAgentCommand(spec, msg, parsed, current)
1384
+ } catch (error) {
1385
+ this.ctx.logger?.warn?.(
1386
+ 'dsh-channels: command /%s failed for session %s: %s',
1387
+ spec.name, current.sessionId, String(error),
1388
+ )
1389
+ await this.delivery.commandCard(msg, {
1390
+ kind: 'internal-error', command: spec.name,
1391
+ text: '命令执行失败,请稍后重试;若持续发生,请查看 Harness 日志。',
1392
+ sessionId: current.sessionId,
1393
+ })
1394
+ }
1395
+ })
1396
+ }
1397
+
1398
+ /**
1399
+ * Commands the channel answers itself. `/stop` aborts the live turn
1400
+ * directly and deliberately never cold-resumes a Session just to cancel
1401
+ * nothing.
1402
+ */
1403
+ private async runChannelCommand(
1404
+ spec: ChannelCommandSpec,
1405
+ msg: NormalizedMessage,
1406
+ parsed: ParsedSlashCommand,
1407
+ current: RouteRecord,
1408
+ ): Promise<void> {
1409
+ if (spec.name === 'permissions') {
1410
+ await this.runPermissionsCommand(msg, parsed, current)
1411
+ return
1412
+ }
1413
+ if (spec.name === 'stop') {
1414
+ const agent = this.sessions.get(current.sessionId)?.agent
1415
+ ?? this.ctx.agents.get(current.sessionId as SessionId)
1416
+ const running = agent?.status === 'running'
1417
+ agent?.cancel?.({ kind: 'user' })
1418
+ await this.delivery.commandCard(msg, {
1419
+ kind: 'success', command: 'stop', sessionId: current.sessionId,
1420
+ text: running ? '已请求停止当前回合。' : '当前没有正在运行的回合。',
1421
+ })
1422
+ return
1423
+ }
1424
+ await this.delivery.commandCard(msg, {
1425
+ kind: 'unavailable', command: spec.name, text: `命令 /${spec.name} 暂不可用。`,
1426
+ sessionId: current.sessionId,
1427
+ })
1428
+ }
1429
+
1430
+ /**
1431
+ * `/permissions`: show this Session's effective sandbox+approval preset and
1432
+ * the selectable catalog; switch only when the deployment opted in.
1433
+ */
1434
+ private async runPermissionsCommand(
1435
+ msg: NormalizedMessage,
1436
+ parsed: ParsedSlashCommand,
1437
+ current: RouteRecord,
1438
+ ): Promise<void> {
1439
+ const presets = this.ctx.get?.('permissionPresets') as PermissionPresets | undefined
1440
+ if (presets === undefined) {
1441
+ await this.delivery.commandCard(msg, {
1442
+ kind: 'unavailable', command: 'permissions', sessionId: current.sessionId,
1443
+ text: '当前部署未挂载 permissionPresets 服务,无法查看权限预设。',
1444
+ })
1445
+ return
1446
+ }
1447
+ const live = await this.sessions.ensureLive(current)
1448
+ let active: string | undefined
1449
+ let catalogLine = ''
1450
+ try {
1451
+ active = presets.current(live.agent.session)
1452
+ const catalog = presets.catalog()
1453
+ catalogLine = catalog.options.map(option => `\`${option.value}\``).join('、')
1454
+ } catch {
1455
+ active = undefined
1456
+ }
1457
+ const requested = parsed.rawInput.trim()
1458
+ if (requested === '') {
1459
+ const lines = [
1460
+ `**当前权限预设**:${active === undefined ? '暂无数据' : `\`${active}\``}`,
1461
+ ...(catalogLine === '' ? [] : [`**可选预设**:${catalogLine}`]),
1462
+ ...(this.config.allowPermissionSwitch
1463
+ ? ['使用 `/permissions <name>` 切换;切换会同时调整沙箱与审批策略。']
1464
+ : ['当前部署未开启飞书侧切换(`allowPermissionSwitch: false`),仅支持查看。']),
1465
+ ]
1466
+ await this.delivery.standaloneReply(msg, standaloneInfoCard('**🔐 权限预设**', lines))
1467
+ return
1468
+ }
1469
+ if (!this.config.allowPermissionSwitch) {
1470
+ await this.delivery.commandCard(msg, {
1471
+ kind: 'unavailable', command: 'permissions', sessionId: current.sessionId,
1472
+ text: '当前部署未开启飞书侧权限切换,仅支持 /permissions 查看。',
1473
+ })
1474
+ return
1475
+ }
1476
+ try {
1477
+ presets.set(live.agent.session, requested)
1478
+ await this.delivery.standaloneReply(msg, standaloneInfoCard('**🔐 权限预设已切换**', [
1479
+ `**当前权限预设**:\`${requested}\``,
1480
+ '沙箱模式与审批策略已按该预设更新。',
1481
+ ]))
1482
+ } catch (error) {
1483
+ this.ctx.logger?.warn?.(
1484
+ 'dsh-channels: permission switch for session %s failed: %s', current.sessionId, String(error),
1485
+ )
1486
+ await this.delivery.commandCard(msg, {
1487
+ kind: 'input-error', command: 'permissions', sessionId: current.sessionId,
1488
+ text: `未知或不可用的权限预设:\`${requested}\`。可用:${catalogLine === '' ? '暂无数据' : catalogLine}`,
1489
+ })
1490
+ }
1491
+ }
1492
+
1493
+ /**
1494
+ * Execute one command through the agent command plane: resolve the live
1495
+ * agent, mount borrowed channel commands when needed, arm the channel-scoped
1496
+ * `/renew` and `/effort` handlers, then render the settled result.
1497
+ */
1498
+ private async runAgentCommand(
1499
+ spec: ChannelCommandSpec,
1500
+ msg: NormalizedMessage,
1501
+ parsed: ParsedSlashCommand,
1502
+ current: RouteRecord,
1503
+ ): Promise<void> {
1504
+ // An alias (`/new`) resolves to this canonical spec; execute the canonical
1505
+ // line so the agent command plane finds the registered definition.
1506
+ const line = `/${spec.name}${parsed.rawInput}`
1507
+ const live = await this.sessions.ensureLive(current)
1508
+ let definition = this.ctx.commands.find(live.agent, spec.name)
1509
+ let unregisterBorrowedCommand: (() => void) | undefined
1510
+ if (definition === undefined && spec.name === 'renew') {
1511
+ unregisterBorrowedCommand = registerRenewCommand(live.agent.ctx.commands)
1512
+ definition = this.ctx.commands.find(live.agent, spec.name)
1513
+ }
1514
+ if (definition === undefined && spec.name === 'effort') {
1515
+ unregisterBorrowedCommand = registerEffortCommand(live.agent.ctx.commands)
1516
+ definition = this.ctx.commands.find(live.agent, spec.name)
1517
+ }
1518
+ if (definition === undefined && spec.name === 'model') {
1519
+ unregisterBorrowedCommand = registerModelCommand(live.agent.ctx.commands)
1520
+ definition = this.ctx.commands.find(live.agent, spec.name)
1521
+ }
1522
+ if (definition === undefined) {
1523
+ unregisterBorrowedCommand?.()
1524
+ const text = spec.name === 'compact'
1525
+ ? '当前部署未挂载 Harness 原生 /compact 命令。'
1526
+ : `当前 Session 未挂载 /${spec.name} 命令。`
1527
+ await this.delivery.commandCard(msg, {
1528
+ kind: 'unavailable', command: spec.name, text, sessionId: current.sessionId,
1529
+ })
1530
+ return
1531
+ }
1532
+ const compactBefore = spec.name === 'compact'
1533
+ ? await this.measureCommandContext(live.agent.session)
1534
+ : undefined
1535
+ const controller = new AbortController()
1536
+ this.commandControllers.add(controller)
1537
+ const renewAdmission: RenewAdmission | undefined = spec.name === 'renew'
1538
+ ? { route: current, live }
1539
+ : undefined
1540
+ const effortAdmission: EffortAdmission | undefined = spec.name === 'effort'
1541
+ ? { live, controller: this.sessions.requestPolicy(live.agent) }
1542
+ : undefined
1543
+ const modelAdmission: ModelAdmission | undefined = spec.name === 'model'
1544
+ ? { live, controller: this.sessions.requestPolicy(live.agent) }
1545
+ : undefined
1546
+ const disarmRenew = renewAdmission === undefined
1547
+ ? undefined
1548
+ : armRenewCommand(controller.signal, invocation => this.runRenewCommand(invocation, renewAdmission))
1549
+ const disarmEffort = effortAdmission === undefined
1550
+ ? undefined
1551
+ : armEffortCommand(
1552
+ controller.signal,
1553
+ (invocation, action) => this.runEffortCommand(invocation, action, effortAdmission),
1554
+ )
1555
+ const disarmModel = modelAdmission === undefined
1556
+ ? undefined
1557
+ : armModelCommand(
1558
+ controller.signal,
1559
+ (invocation, rawInput) => this.runModelCommand(invocation, rawInput, modelAdmission),
1560
+ )
1561
+ let execution
1562
+ try {
1563
+ execution = await this.ctx.commands.execute(live.agent, line, [], controller.signal)
1564
+ } finally {
1565
+ this.commandControllers.delete(controller)
1566
+ disarmRenew?.()
1567
+ disarmEffort?.()
1568
+ disarmModel?.()
1569
+ unregisterBorrowedCommand?.()
1570
+ if (renewAdmission?.commit !== undefined) {
1571
+ await this.sessions.releaseRenewed(renewAdmission.commit)
1572
+ }
1573
+ }
1574
+ if (controller.signal.aborted || !this.started) return
1575
+ if (execution === undefined) {
1576
+ await this.delivery.commandCard(msg, {
1577
+ kind: 'unavailable', command: spec.name,
1578
+ text: `当前 Session 无法执行 /${spec.name}。`, sessionId: current.sessionId,
1579
+ })
1580
+ return
1581
+ }
1582
+ await this.renderCommandResult({
1583
+ spec, msg, current, live,
1584
+ result: execution.result,
1585
+ compactBefore,
1586
+ renewAdmission,
1587
+ effortAdmission,
1588
+ modelAdmission,
1589
+ })
1590
+ }
1591
+
1592
+ /** Render one settled agent-plane command result. */
1593
+ private async renderCommandResult(input: {
1594
+ spec: ChannelCommandSpec
1595
+ msg: NormalizedMessage
1596
+ current: RouteRecord
1597
+ live: LiveAgent
1598
+ result: CommandResult
1599
+ compactBefore: ContextMeasurement | undefined
1600
+ renewAdmission: RenewAdmission | undefined
1601
+ effortAdmission: EffortAdmission | undefined
1602
+ modelAdmission: ModelAdmission | undefined
1603
+ }): Promise<void> {
1604
+ const { spec, msg, current, live, result, compactBefore, renewAdmission, effortAdmission, modelAdmission } = input
1605
+ if (spec.name === 'effort') effortAdmission?.controller.sync(live.agent.session.snapshotEvents())
1606
+ if (spec.name === 'model') modelAdmission?.controller.sync(live.agent.session.snapshotEvents())
1607
+ if (spec.name === 'renew' && result.kind === 'success' && renewAdmission?.commit !== undefined) {
1608
+ await this.delivery.standaloneReply(msg, buildRenewCard({
1609
+ previousSessionId: renewAdmission.commit.previous.sessionId,
1610
+ currentSessionId: renewAdmission.commit.current.sessionId,
1611
+ workspacePath: renewAdmission.commit.current.workspacePath,
1612
+ }))
1613
+ return
1614
+ }
1615
+ if (spec.name === 'health' && result.kind === 'success') {
1616
+ await this.delivery.standaloneReply(msg, buildHealthCard(this.healthSnapshot()))
1617
+ return
1618
+ }
1619
+ if (spec.name === 'status' && result.kind === 'success') {
1620
+ const session = live.agent.session
1621
+ const stats = foldSessionStats(session, this.projectionRegistry())
1622
+ const context = await measureSessionContext(
1623
+ session, this.commandContextServices(), stats.lastHeader,
1624
+ )
1625
+ await this.delivery.standaloneReply(msg, buildSessionStatusCard({
1626
+ sessionId: current.sessionId,
1627
+ stats,
1628
+ context,
1629
+ extras: {
1630
+ permissionPreset: this.permissionPresetOf(session),
1631
+ workspacePath: current.workspacePath,
1632
+ uptimeMs: this.startedAt === undefined ? undefined : Date.now() - this.startedAt,
1633
+ liveAgents: this.sessions.liveCount(),
1634
+ routeCount: this.routes.size(),
1635
+ },
1636
+ }))
1637
+ return
1638
+ }
1639
+ if (spec.name === 'workspace' && result.kind === 'success') {
1640
+ const resolved = resolveWorkDir(live.agent.session.header.cwd, current.workspacePath)
1641
+ if (resolved.kind === 'success') {
1642
+ await this.delivery.standaloneReply(msg, buildWorkDirCard(current.sessionId, resolved.path))
1643
+ return
1644
+ }
1645
+ if (resolved.kind === 'mismatch') {
1646
+ this.ctx.logger?.warn?.(
1647
+ 'dsh-channels: workspace mismatch for session %s (paths redacted)', current.sessionId,
1648
+ )
1649
+ }
1650
+ await this.delivery.commandCard(msg, {
1651
+ kind: 'unavailable', command: spec.name,
1652
+ text: resolved.text, sessionId: current.sessionId,
1653
+ })
1654
+ return
1655
+ }
1656
+ if (spec.name === 'history' && result.kind === 'success') {
1657
+ await this.delivery.standaloneReply(msg, buildSessionHistoryCard(
1658
+ current, await this.sessionHistoryAnnotations(current),
1659
+ ))
1660
+ return
1661
+ }
1662
+ if (spec.name === 'export' && result.kind === 'success') {
1663
+ try {
1664
+ const file = await this.sessionExportBuilder(live.agent.session)
1665
+ await this.delivery.requireChannel().send(msg.chatId, { file }, {
1666
+ replyTo: msg.messageId,
1667
+ replyInThread: true,
1668
+ })
1669
+ } catch (error) {
1670
+ this.ctx.logger?.warn?.(
1671
+ 'dsh-channels: logical export delivery failed for session %s: %s',
1672
+ current.sessionId, String(error),
1673
+ )
1674
+ await this.delivery.commandCard(msg, {
1675
+ kind: 'internal-error', command: spec.name,
1676
+ text: 'Session 逻辑日志导出失败,未交付文件;请稍后重试并查看 Harness 日志。',
1677
+ sessionId: current.sessionId,
1678
+ })
1679
+ }
1680
+ return
1681
+ }
1682
+ if (spec.name === 'compact' && compactBefore !== undefined) {
1683
+ const compactAfter = await this.measureCommandContext(live.agent.session)
1684
+ if (result.kind === 'success') {
1685
+ await this.delivery.standaloneReply(msg, buildCompactComparisonCard({
1686
+ sessionId: current.sessionId,
1687
+ resultText: result.text ?? '命令已执行。',
1688
+ before: compactBefore,
1689
+ after: compactAfter,
1690
+ }))
1691
+ return
1692
+ }
1693
+ await this.delivery.commandCard(msg, {
1694
+ kind: commandResultCardKind(result), command: spec.name,
1695
+ text: `${result.text}\n\n${compactFailureContext(compactBefore, compactAfter)}`,
1696
+ sessionId: current.sessionId,
1697
+ })
1698
+ return
1699
+ }
1700
+ await this.delivery.commandCard(msg, {
1701
+ kind: commandResultCardKind(result), command: spec.name,
1702
+ text: result.text ?? '命令已执行。', sessionId: current.sessionId,
1703
+ })
1704
+ }
1705
+
1706
+ /**
1707
+ * Annotate a route's session history with host-query facts: the latest
1708
+ * log-backed title per session and whether it is currently live. Both are
1709
+ * best-effort — a missing or failing `sessionQuery` only drops the titles.
1710
+ */
1711
+ private async sessionHistoryAnnotations(record: RouteRecord): Promise<Record<string, SessionHistoryAnnotation>> {
1712
+ const ids = normalizeSessionHistory(record)
1713
+ .slice(-SESSION_HISTORY_DISPLAY_LIMIT)
1714
+ .map(entry => entry.sessionId)
1715
+ const annotations: Record<string, SessionHistoryAnnotation> = {}
1716
+ for (const id of ids) {
1717
+ annotations[id] = { live: this.sessions.get(id) !== undefined || this.ctx.agents.get(id as SessionId) !== undefined }
1718
+ }
1719
+ const query = this.ctx.get?.('sessionQuery') as SessionQuery | undefined
1720
+ if (query === undefined) return annotations
1721
+ try {
1722
+ const results = await query.readTitleSnapshots(ids as SessionId[])
1723
+ for (const item of results) {
1724
+ if (item.status !== 'fulfilled' || item.value.title === undefined) continue
1725
+ annotations[item.sessionId] = { ...annotations[item.sessionId], title: item.value.title.title }
1726
+ }
1727
+ } catch (error) {
1728
+ this.ctx.logger?.warn?.('dsh-channels: session title lookup failed: %s', String(error))
1729
+ }
1730
+ return annotations
1731
+ }
1732
+
1733
+ /** Effective permission preset of one Session, when the service is mounted. */
1734
+ private permissionPresetOf(session: Session): string | undefined {
1735
+ const presets = this.ctx.get?.('permissionPresets') as PermissionPresets | undefined
1736
+ try {
1737
+ return presets?.current(session)
1738
+ } catch {
1739
+ return undefined
1740
+ }
1741
+ }
1742
+
1743
+ /** Optional diagnostic services resolved without topology-sensitive property access. */
1744
+ private commandContextServices(): ContextServices {
1745
+ return {
1746
+ tokenMeter: this.ctx.get?.('tokenMeter') as TokenMeter | undefined,
1747
+ llm: this.ctx.get?.('llm') as LlmService | undefined,
1748
+ }
1749
+ }
1750
+
1751
+ /** Exact command-time measurement shared by `/status` and `/compact`. */
1752
+ private measureCommandContext(session: Session): Promise<ContextMeasurement> {
1753
+ return measureSessionContext(session, this.commandContextServices())
1754
+ }
1755
+
1756
+ /** Capture aggregate health only; raw configuration and errors are excluded. */
1757
+ private healthSnapshot(): HealthSnapshot {
1758
+ const serviceKeys: Record<HealthDependencyName, string> = {
1759
+ agents: 'agents',
1760
+ commands: 'commands',
1761
+ workspace: 'workspaceRegistry',
1762
+ storage: 'storageDomain',
1763
+ credentials: 'credentials',
1764
+ llm: 'llm',
1765
+ 'token-meter': 'tokenMeter',
1766
+ }
1767
+ const directServices: Record<HealthDependencyName, () => unknown> = {
1768
+ agents: () => this.ctx.agents,
1769
+ commands: () => this.ctx.commands,
1770
+ workspace: () => this.ctx.workspaceRegistry,
1771
+ storage: () => this.ctx.storageDomain,
1772
+ credentials: () => this.ctx.credentials,
1773
+ llm: () => this.ctx.llm,
1774
+ 'token-meter': () => this.ctx.tokenMeter,
1775
+ }
1776
+ const dependencies = Object.fromEntries(HEALTH_DEPENDENCY_NAMES.map(name => {
1777
+ try {
1778
+ const service = this.ctx.get === undefined
1779
+ ? directServices[name]()
1780
+ : this.ctx.get(serviceKeys[name])
1781
+ return [name, service !== undefined]
1782
+ } catch {
1783
+ return [name, false]
1784
+ }
1785
+ })) as Record<HealthDependencyName, boolean>
1786
+
1787
+ const fibers: CordisFiber[] = []
1788
+ try {
1789
+ for (const runtime of this.ctx.registry.values()) {
1790
+ for (const fiber of runtime.fibers) fibers.push(fiber)
1791
+ }
1792
+ } catch {
1793
+ // Registry inspection is diagnostic only. An empty fold is safer than
1794
+ // exposing or propagating an internal registry error to the user.
1795
+ }
1796
+
1797
+ const inflightTurnCount = this.repair.count()
1798
+
1799
+ let connection
1800
+ let botOpenId: string | undefined
1801
+ try { connection = this.channel?.getConnectionStatus() } catch {}
1802
+ try { botOpenId = this.channel?.botOpenId() } catch {}
1803
+
1804
+ return foldHealthSnapshot({
1805
+ fibers,
1806
+ dependencies,
1807
+ connection,
1808
+ botOpenId,
1809
+ started: this.started,
1810
+ routeCount: this.routes.size(),
1811
+ liveAgentCount: this.sessions.liveCount(),
1812
+ inflightTurnCount,
1813
+ })
1814
+ }
1815
+
1816
+
1817
+
1818
+
1819
+
1820
+
1821
+
1822
+
1823
+
1824
+
1825
+
1826
+ /**
1827
+ * GC pass over the routing domain. Two independent halves:
1828
+ *
1829
+ * 1. Dedup records (unconditional): `processed` records and in-process
1830
+ * guard entries older than `PROCESSED_RETENTION_MS` are dropped —
1831
+ * redelivery windows are minutes, so a day of retention is ample and
1832
+ * keeps the table bounded regardless of the route TTL setting.
1833
+ * 2. Route/inflight GC (`routeTtlMs > 0`): thread routes idle past the TTL
1834
+ * are retired into minimal persistent tombstones before their active
1835
+ * records are deleted. A later message in that visible topic is then
1836
+ * rejected instead of silently creating a context-free Session. Degraded
1837
+ * chat-scoped routes have no topic boundary and are deleted normally.
1838
+ * Stale in-flight snapshots are deleted without surprising the user with
1839
+ * a repair of an ancient card. Sessions and workspaces remain host-owned.
1840
+ *
1841
+ * Per-key failures are logged and skipped; concurrent writes are safe
1842
+ * because `entries()` snapshots.
1843
+ */
1844
+ private async gcExpiredEntries(): Promise<void> {
1845
+ const now = Date.now()
1846
+ const processedCutoff = now - PROCESSED_RETENTION_MS
1847
+ let removedRoutes = 0
1848
+ let retiredThreadRoutes = 0
1849
+ let removedInflight = 0
1850
+ let removedProcessed = 0
1851
+
1852
+ const processed = this.domain?.processed
1853
+ if (processed !== undefined) {
1854
+ for (const [key, record] of [...processed.entries()]) {
1855
+ if (record.processedAt >= processedCutoff) continue
1856
+ try {
1857
+ await processed.delete(key)
1858
+ removedProcessed++
1859
+ } catch (error) {
1860
+ this.ctx.logger?.warn?.('dsh-channels: GC of processed record %s failed: %s', key, String(error))
1861
+ }
1862
+ }
1863
+ }
1864
+ this.inbound.pruneAccepted(processedCutoff)
1865
+
1866
+ const ttlMs = this.config.routeTtlMs
1867
+ if (ttlMs === 0) {
1868
+ if (removedProcessed > 0) {
1869
+ this.ctx.logger?.info?.('dsh-channels: record GC removed %d dedup record(s)', removedProcessed)
1870
+ }
1871
+ return
1872
+ }
1873
+ const cutoff = now - ttlMs
1874
+
1875
+ const routes = this.domain?.routes
1876
+ if (routes !== undefined) {
1877
+ for (const [key, record] of [...routes.entries()]) {
1878
+ if (record.lastActivityAt >= cutoff) continue
1879
+ try {
1880
+ if (record.kind === 'thread') {
1881
+ await this.routes.expireThread(key, now)
1882
+ retiredThreadRoutes++
1883
+ } else {
1884
+ await this.routes.remove(key)
1885
+ }
1886
+ removedRoutes++
1887
+ } catch (error) {
1888
+ this.ctx.logger?.warn?.('dsh-channels: GC of route %s failed: %s', key, String(error))
1889
+ }
1890
+ }
1891
+ }
1892
+
1893
+ const inflight = this.domain?.inflight
1894
+ if (inflight !== undefined) {
1895
+ for (const [key, snapshot] of [...inflight.entries()]) {
1896
+ if (snapshot.startedAt >= cutoff) continue
1897
+ try {
1898
+ await inflight.delete(key)
1899
+ removedInflight++
1900
+ } catch (error) {
1901
+ this.ctx.logger?.warn?.('dsh-channels: GC of inflight snapshot %s failed: %s', key, String(error))
1902
+ }
1903
+ }
1904
+ }
1905
+
1906
+ if (removedRoutes > 0 || removedInflight > 0 || removedProcessed > 0) {
1907
+ this.ctx.logger?.info?.(
1908
+ 'dsh-channels: record GC removed %d route(s) (%d thread tombstone(s)), %d stale snapshot(s), %d dedup record(s)',
1909
+ removedRoutes, retiredThreadRoutes, removedInflight, removedProcessed,
1910
+ )
1911
+ }
1912
+ }
1913
+
1914
+
1915
+ /**
1916
+ * HITL answerer hook: the Feishu placement of one session's approval cards,
1917
+ * or `undefined` when this channel does not route the session (the answerer
1918
+ * then delegates to other answerers).
1919
+ */
1920
+ private approvalRouteFor(sessionId: string): ApprovalRoute | undefined {
1921
+ const record = this.routes.findBySession(sessionId)
1922
+ if (record === undefined) return undefined
1923
+ return {
1924
+ chatId: record.chatId,
1925
+ cardMessageId: record.cardMessageId,
1926
+ inThread: record.kind === 'thread',
1927
+ senderOpenId: record.senderOpenId,
1928
+ }
1929
+ }
1930
+
1931
+ /**
1932
+ * Send one body-segment continuation card into the session's topic (or, for
1933
+ * degraded chat-scoped routes, as a plain reply anchored to the main card).
1934
+ * When Feishu rejects the card (e.g. per-card table limit, error 11310) the
1935
+ * segment is re-delivered as a plain-text message so the content is never
1936
+ * lost — text messages carry no card constraints.
1937
+ */
1938
+ /** Authorized Session-level `/model` view/switch operation. */
1939
+ private async runModelCommand(
1940
+ invocation: CommandInvocation,
1941
+ rawInput: string,
1942
+ admission: ModelAdmission,
1943
+ ): Promise<CommandResult> {
1944
+ if (admission.live.agent !== invocation.agent) {
1945
+ return { kind: 'error', text: '/model 只能通过已授权的飞书话题执行。' }
1946
+ }
1947
+ const session = invocation.agent.session
1948
+ const state = admission.controller.state
1949
+ const header = session.requestHeader?.()?.config
1950
+ const currentProvider = state.effectiveModel?.provider
1951
+ ?? (typeof header?.provider === 'string' && header.provider !== '' ? header.provider : undefined)
1952
+ const currentModel = state.effectiveModel?.model
1953
+ ?? (typeof header?.model === 'string' && header.model !== '' ? header.model : undefined)
1954
+ const source = state.modelSource === 'session'
1955
+ ? 'Session 覆盖'
1956
+ : state.modelSource === 'config' ? '配置默认' : '部署默认'
1957
+
1958
+ if (rawInput === '') {
1959
+ const lines = [
1960
+ `**当前模型**:${currentModel === undefined ? '尚无模型请求记录' : `\`${currentModel}\``}`
1961
+ + `${currentProvider === undefined ? '' : `(provider \`${currentProvider}\`)`} · ${source}`,
1962
+ ]
1963
+ const models = await this.listModels(currentProvider)
1964
+ if (models.length > 0) {
1965
+ lines.push(`**可选模型(${models.length})**:${models.slice(0, 20).map(id => `\`${id}\``).join('、')}`
1966
+ + (models.length > 20 ? ' …' : ''))
1967
+ } else if (currentProvider === undefined) {
1968
+ lines.push('当前还没有模型请求记录;可先用 `/model <provider>/<model>` 指定,或先发一条普通消息。')
1969
+ }
1970
+ lines.push('用法:`/model <模型-id>`(沿用当前 provider)· `/model <provider>/<model>` · `/model reset`')
1971
+ return { kind: 'success', text: lines.join('\n') }
1972
+ }
1973
+
1974
+ if (rawInput === 'reset') {
1975
+ return { kind: 'success', text: '已恢复部署默认模型(下一条消息生效)。' }
1976
+ }
1977
+
1978
+ const slash = rawInput.indexOf('/')
1979
+ const provider = slash === -1 ? currentProvider : rawInput.slice(0, slash)
1980
+ const model = slash === -1 ? rawInput : rawInput.slice(slash + 1)
1981
+ if (model === '' || (slash !== -1 && (provider === undefined || provider === ''))) {
1982
+ return { kind: 'error', text: '用法错误:/model [模型-id|provider/model|reset]。' }
1983
+ }
1984
+ // Validate against the advertised catalog when the deployment exposes one;
1985
+ // an unsupported value would otherwise only fail at the next request.
1986
+ if (provider !== undefined) {
1987
+ const models = await this.listModels(provider)
1988
+ if (models.length > 0 && !models.includes(model)) {
1989
+ return {
1990
+ kind: 'error',
1991
+ text: `provider \`${provider}\` 未提供模型 \`${model}\`。可选:${models.slice(0, 20).map(id => `\`${id}\``).join('、')}`,
1992
+ }
1993
+ }
1994
+ }
1995
+ const label = provider === undefined ? model : `${provider}/${model}`
1996
+ return { kind: 'success', text: `当前 Session 模型已切换为 \`${label}\`(下一条消息生效)。` }
1997
+ }
1998
+
1999
+ /** Model ids advertised for one provider route (empty when unavailable). */
2000
+ private async listModels(provider: string | undefined): Promise<string[]> {
2001
+ if (provider === undefined) return []
2002
+ try {
2003
+ const llm = this.ctx.get?.('llm') as LlmService | undefined
2004
+ const models = await llm?.listModels?.(provider)
2005
+ return (models ?? []).map(entry => entry.id ?? entry.name ?? '').filter(id => id !== '')
2006
+ } catch {
2007
+ return []
2008
+ }
2009
+ }
2010
+
2011
+ /**
2012
+ * Agent-scoped `/renew` handler. Only the Feishu admission path arms its
2013
+ * AbortSignal, preventing another Host client from switching a Feishu route
2014
+ * by invoking the scoped command out of band.
2015
+ */
2016
+ private async runRenewCommand(
2017
+ invocation: CommandInvocation,
2018
+ admission: RenewAdmission,
2019
+ ): Promise<CommandResult> {
2020
+ if (admission.live.agent !== invocation.agent) {
2021
+ return { kind: 'error', text: '/renew 只能通过已授权的飞书话题执行。' }
2022
+ }
2023
+ const current = this.routes.get(admission.route.key)
2024
+ if (current === undefined || current.sessionId !== admission.route.sessionId) {
2025
+ return { kind: 'error', text: '当前话题绑定已变化,请重试 /renew。' }
2026
+ }
2027
+ try {
2028
+ admission.commit = await this.sessions.renew(current, admission.live)
2029
+ return { kind: 'success', text: '新 Session 已创建并绑定。' }
2030
+ } catch (error) {
2031
+ this.ctx.logger?.warn?.(
2032
+ 'dsh-channels: renew transaction failed for session %s: %s',
2033
+ current.sessionId, String(error),
2034
+ )
2035
+ return { kind: 'error', text: 'Session 续建失败,原 Session 仍保持绑定;请稍后重试。' }
2036
+ }
2037
+ }
2038
+
2039
+ /** Authorized Session-level `/effort` view/switch operation. */
2040
+ private runEffortCommand(
2041
+ invocation: CommandInvocation,
2042
+ action: EffortCommandAction,
2043
+ admission: EffortAdmission,
2044
+ ): CommandResult {
2045
+ if (admission.live.agent !== invocation.agent) {
2046
+ return { kind: 'error', text: '/effort 只能通过已授权的飞书话题执行。' }
2047
+ }
2048
+ const state = admission.controller.state
2049
+ if (action.kind === 'show') {
2050
+ if (state.effortSource === 'session') {
2051
+ return { kind: 'success', text: `当前 Session 推理强度:\`${state.effectiveEffort}\`(Session 覆盖)。` }
2052
+ }
2053
+ if (state.effortSource === 'config') {
2054
+ return { kind: 'success', text: `当前 Session 推理强度:\`${state.effectiveEffort}\`(配置默认)。` }
2055
+ }
2056
+ return { kind: 'success', text: '当前 Session 未设置推理强度覆盖,使用模型适配器/provider 默认。' }
2057
+ }
2058
+ if (action.kind === 'reset') {
2059
+ return state.configuredEffort === undefined
2060
+ ? { kind: 'success', text: '已恢复模型适配器/provider 默认推理强度。' }
2061
+ : { kind: 'success', text: `已恢复配置默认推理强度:\`${state.configuredEffort}\`。` }
2062
+ }
2063
+ return { kind: 'success', text: `当前 Session 推理强度已切换为:\`${action.value}\`。` }
2064
+ }
2065
+
2066
+ // ---- outbound helpers ------------------------------------------------
2067
+
2068
+ /** Context pressure for the card's second info line (synchronous reads only). */
2069
+ private measureContext(session: Session): { contextTokens?: number; contextWindow?: number } {
2070
+ let contextTokens: number | undefined
2071
+ try {
2072
+ // Optional peers are read through ctx.get (safe lookup); direct property
2073
+ // access on a cordis context throws for services the plugin did not inject.
2074
+ const meter = this.ctx.get?.('tokenMeter') as TokenMeter | undefined
2075
+ contextTokens = meter?.measure(session).totalTokens
2076
+ } catch {
2077
+ contextTokens = undefined
2078
+ }
2079
+
2080
+ // The route-capacity window is published by the host's `contextPressure`
2081
+ // projection; the old request/header scan + resolveModelInfo call stays as
2082
+ // a fallback for deployments without the projection registry.
2083
+ let contextWindow = readProjectionViews(this.projectionRegistry(), session).contextPressure?.contextWindow
2084
+ if (contextWindow === undefined) {
2085
+ try {
2086
+ const header = session.requestHeader?.()?.config
2087
+ const provider = typeof header?.provider === 'string' ? header.provider : undefined
2088
+ const model = typeof header?.model === 'string' ? header.model : undefined
2089
+ if (provider !== undefined && provider !== '' && model !== undefined && model !== '') {
2090
+ const routeKey = `${provider}/${model}`
2091
+ contextWindow = this.contextWindows.get(routeKey)
2092
+ if (contextWindow === undefined && !this.windowRequests.has(routeKey)) {
2093
+ this.windowRequests.add(routeKey)
2094
+ const llm = this.ctx.get?.('llm') as LlmService | undefined
2095
+ void llm?.resolveModelInfo(provider, model)
2096
+ .then(info => {
2097
+ if (info?.context?.contextWindow !== undefined) {
2098
+ this.contextWindows.set(routeKey, info.context.contextWindow)
2099
+ }
2100
+ })
2101
+ .catch(() => {})
2102
+ }
2103
+ }
2104
+ } catch {
2105
+ contextWindow = undefined
2106
+ }
2107
+ }
2108
+
2109
+ return { contextTokens, contextWindow }
2110
+ }
2111
+
2112
+ /** Host projection registry, when this deployment mounts one. */
2113
+ private projectionRegistry(): SessionProjections | undefined {
2114
+ return this.ctx.get?.('sessionProjections') as SessionProjections | undefined
2115
+ }
2116
+
2117
+ /** Card figures from host projections (token totals / cache hit / window). */
2118
+ private projectCardStats(session: Session): ProjectedStats | undefined {
2119
+ const views = readProjectionViews(this.projectionRegistry(), session)
2120
+ const usage = views.tokenUsage
2121
+ const pressure = views.contextPressure
2122
+ if (usage === undefined && pressure === undefined) return undefined
2123
+ const last = usage?.last?.buckets
2124
+ const hit = last === undefined ? undefined : last.cacheReadTokens
2125
+ const uncached = last === undefined ? undefined : last.uncachedInputTokens
2126
+ const cacheHit = hit !== undefined && uncached !== undefined && hit + uncached > 0
2127
+ ? hit / (hit + uncached)
2128
+ : undefined
2129
+ return {
2130
+ ...(usage === undefined ? {} : {
2131
+ inputTokens: usage.totals.uncachedInputTokens,
2132
+ outputTokens: usage.totals.outputTokens,
2133
+ }),
2134
+ ...(cacheHit === undefined ? {} : { cacheHitPct: cacheHit }),
2135
+ ...(pressure?.contextWindow === undefined ? {} : { contextWindow: pressure.contextWindow }),
2136
+ }
2137
+ }
2138
+ }