@modesto_ma/dsh-channels 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/README.md +302 -2
  2. package/cordis.patch.yml +3 -0
  3. package/docs/access-control-design.md +550 -0
  4. package/docs/assets/web-settings-panel.png +0 -0
  5. package/docs/assets/web-settings-restart-pending.png +0 -0
  6. package/docs/feasibility-and-design.md +213 -0
  7. package/docs/feishu-onboarding.md +268 -0
  8. package/docs/persistence.md +199 -0
  9. package/docs/refactor-plan.md +56 -0
  10. package/docs/review-dsh-0.2.0.md +389 -0
  11. package/docs/slash-commands-implementation-plan.md +410 -0
  12. package/docs/web-display-only-surface-roadmap.md +363 -0
  13. package/lib/client.js +2226 -0
  14. package/lib/client.js.map +1 -0
  15. package/lib/index.js +10217 -0
  16. package/lib/index.js.map +1 -0
  17. package/package.json +98 -4
  18. package/src/access/cards.ts +188 -0
  19. package/src/access/controller.ts +201 -0
  20. package/src/access/index.ts +13 -0
  21. package/src/access/policy.ts +103 -0
  22. package/src/access/requests.ts +512 -0
  23. package/src/access/store.ts +220 -0
  24. package/src/approvals.ts +8 -0
  25. package/src/cards/approval.ts +114 -0
  26. package/src/cards/index.ts +11 -0
  27. package/src/cards/primitives.ts +82 -0
  28. package/src/cards/question.ts +141 -0
  29. package/src/cards/streaming.ts +563 -0
  30. package/src/cards.ts +8 -0
  31. package/src/cleanup.ts +398 -0
  32. package/src/client/api.ts +262 -0
  33. package/src/client/catalog.ts +92 -0
  34. package/src/client/index.ts +61 -0
  35. package/src/client/panel.tsx +1475 -0
  36. package/src/client/styles.ts +98 -0
  37. package/src/commands/registry.ts +155 -0
  38. package/src/commands.ts +335 -0
  39. package/src/config.ts +206 -0
  40. package/src/domain.ts +428 -0
  41. package/src/dsh-types.ts +11 -0
  42. package/src/feishu/agent-context.ts +62 -0
  43. package/src/feishu/delivery.ts +188 -0
  44. package/src/feishu/inbound-attachments.ts +436 -0
  45. package/src/feishu-channel.ts +339 -0
  46. package/src/health.ts +233 -0
  47. package/src/host/agent.ts +181 -0
  48. package/src/host/commands.ts +63 -0
  49. package/src/host/core.ts +345 -0
  50. package/src/host/index.ts +12 -0
  51. package/src/host/interaction.ts +101 -0
  52. package/src/host/services.ts +320 -0
  53. package/src/index.ts +122 -0
  54. package/src/interactions/approvals.ts +154 -0
  55. package/src/interactions/pending.ts +162 -0
  56. package/src/interactions/questions.ts +274 -0
  57. package/src/questions.ts +8 -0
  58. package/src/routing/inbound.ts +524 -0
  59. package/src/service.ts +2138 -0
  60. package/src/session/manager.ts +352 -0
  61. package/src/session/repair.ts +157 -0
  62. package/src/session/request-policy.ts +192 -0
  63. package/src/session/routes.ts +165 -0
  64. package/src/session-commands.ts +310 -0
  65. package/src/session-export.ts +112 -0
  66. package/src/settings/catalog.ts +309 -0
  67. package/src/settings/contract.ts +508 -0
  68. package/src/settings/overlay.ts +164 -0
  69. package/src/settings/routes.ts +409 -0
  70. package/src/settings/surface.ts +773 -0
  71. package/src/stats.ts +485 -0
  72. package/src/streamer.ts +1108 -0
  73. package/src/workspaces.ts +199 -0
@@ -0,0 +1,524 @@
1
+ /**
2
+ * Inbound routing: dedup, admission, the route decision tree, and the
3
+ * session-establishment / continuation flow.
4
+ *
5
+ * Everything here is channel-plane policy; the command plane (slash commands)
6
+ * and the answerers stay in the service and are reached through narrow ports.
7
+ * Dependency reads go through accessors so the moved logic stays readable.
8
+ *
9
+ * @module dsh-channels/routing/inbound
10
+ */
11
+
12
+ import { randomUUID } from 'node:crypto'
13
+ import type { AgentHandle, HostContext, SessionId, UserMessage, Workspace } from '../dsh-types.ts'
14
+ import type { FeishuConfig } from '../config.ts'
15
+ import { buildCard, buildOnboardingCard, initialStreamingCard } from '../cards.ts'
16
+ import { RouteOperationQueue } from '../commands.ts'
17
+ import { chatRouteKey } from '../domain.ts'
18
+ import type { FeishuDomainHandle, RouteRecord } from '../domain.ts'
19
+ import type { CardDelivery } from '../feishu/delivery.ts'
20
+ import {
21
+ adoptStagedAttachments, attachmentsDir, hasAttachmentPayload,
22
+ } from '../feishu/inbound-attachments.ts'
23
+ import { AttachmentIntake } from '../feishu/inbound-attachments.ts'
24
+ import type { AttachmentArchiveResult, AttachmentFailure } from '../feishu/inbound-attachments.ts'
25
+ import type { NormalizedMessage } from '../feishu-channel.ts'
26
+ import type { QuestionAnswerer } from '../interactions/questions.ts'
27
+ import type { InflightRepair } from '../session/repair.ts'
28
+ import type { SessionManager } from '../session/manager.ts'
29
+ import type { RouteStore } from '../session/routes.ts'
30
+ import type { TurnStreamer } from '../streamer.ts'
31
+ import { ensureWorkspace, stagingPathFor, topicTokenFor } from '../workspaces.ts'
32
+
33
+ /** The command plane the inbound router delegates admission-time work to. */
34
+ export interface CommandPlanePort {
35
+ /** Slash-command admission + dispatch. */
36
+ handleSlash(msg: NormalizedMessage, line: string): Promise<void>
37
+ }
38
+
39
+ /**
40
+ * Channel-plane admission port (structurally satisfied by the access
41
+ * controller). Kept as a local interface so routing has no dependency on the
42
+ * access module's implementation details.
43
+ */
44
+ export interface InboundAccessPort {
45
+ /** Full admission for a non-slash message; false = handled (deny/notice). */
46
+ admit(msg: NormalizedMessage): Promise<boolean>
47
+ /**
48
+ * Route ownership: only the sender who created a topic may continue it.
49
+ * Also guards against cross-chat id confusion.
50
+ */
51
+ ownsRoute(msg: NormalizedMessage, record: RouteRecord): boolean
52
+ }
53
+
54
+ export interface InboundDeps {
55
+ ctx: HostContext
56
+ config: FeishuConfig
57
+ started(): boolean
58
+ domain(): FeishuDomainHandle | undefined
59
+ routes: RouteStore
60
+ repair: InflightRepair
61
+ delivery: CardDelivery
62
+ sessions: SessionManager
63
+ attachments: AttachmentIntake
64
+ /** Channel-plane access control (administrator + whitelist gates). */
65
+ access: InboundAccessPort
66
+ /** Shared per-route FIFO admission (commands and turns must not interleave). */
67
+ queue: RouteOperationQueue
68
+ streamer(): TurnStreamer | undefined
69
+ questionAnswerer(): QuestionAnswerer | undefined
70
+ commands: CommandPlanePort
71
+ }
72
+
73
+ /**
74
+ * User-facing text for attachments that could not be archived. Delivered as a
75
+ * plain reply (never a card) so the user learns why the Agent cannot read a
76
+ * file instead of silently getting an answer about older attachments.
77
+ */
78
+ export function buildAttachmentFailureNotice(failures: readonly AttachmentFailure[]): string {
79
+ const lines = failures.map(failure => `• ${failure.name}:${failure.reason}`)
80
+ return [
81
+ '⚠️ 附件保存失败,Agent 无法读取:',
82
+ ...lines,
83
+ '可尝试压缩/拆分后重发,或把文件直接放到工作区目录。',
84
+ ].join('\n')
85
+ }
86
+
87
+ /** Build one durable user message from inbound Feishu text. */
88
+ export function feishuUserMessage(text: string): UserMessage {
89
+ return {
90
+ id: randomUUID(),
91
+ role: 'user',
92
+ content: [{ type: 'text', text }],
93
+ source: { kind: 'user' },
94
+ } as unknown as UserMessage
95
+ }
96
+
97
+ export class InboundRouter {
98
+ /**
99
+ * In-process dedup guard: message id → accept timestamp. Set synchronously
100
+ * at acceptance so a same-process redelivery can never slip through the
101
+ * window between the persisted dedup check and its `put` landing.
102
+ */
103
+ private readonly accepted = new Map<string, number>()
104
+ /**
105
+ * chat-scoped key → in-flight session establishment. Concurrent first
106
+ * messages of one (chat, user) pair all observe the same promise: they wait
107
+ * for the establishment to settle, then either continue the just-created
108
+ * session (same topic, or degraded chat route) or fall through to start
109
+ * their own — never racing each other into duplicate sessions, route
110
+ * overwrites, or the "no route, no mention" drop window.
111
+ */
112
+ private readonly establishing = new Map<string, Promise<RouteRecord>>()
113
+
114
+ constructor(private readonly deps: InboundDeps) {}
115
+
116
+ private get ctx(): HostContext { return this.deps.ctx }
117
+ private get config(): FeishuConfig { return this.deps.config }
118
+ private get started(): boolean { return this.deps.started() }
119
+ private get routes(): RouteStore { return this.deps.routes }
120
+ private get repair(): InflightRepair { return this.deps.repair }
121
+ private get delivery(): CardDelivery { return this.deps.delivery }
122
+ private get sessions(): SessionManager { return this.deps.sessions }
123
+ private get attachments(): AttachmentIntake { return this.deps.attachments }
124
+ private get routeOperations(): RouteOperationQueue { return this.deps.queue }
125
+ private get streamer(): TurnStreamer | undefined { return this.deps.streamer() }
126
+ private get questionAnswerer(): QuestionAnswerer | undefined { return this.deps.questionAnswerer() }
127
+ private get domain(): FeishuDomainHandle | undefined { return this.deps.domain() }
128
+ private get access(): InboundAccessPort { return this.deps.access }
129
+
130
+ /** Retire dedup entries older than the retention cutoff (GC sweep). */
131
+ pruneAccepted(cutoff: number): void {
132
+ for (const [messageId, acceptedAt] of this.accepted) {
133
+ if (acceptedAt < cutoff) this.accepted.delete(messageId)
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Send the one-time onboarding card for a private chat, then remember the
139
+ * chat in the domain global. Best-effort: a storage failure only risks a
140
+ * repeated card, never a blocked session.
141
+ */
142
+ private async sendOnboardingCard(msg: NormalizedMessage): Promise<void> {
143
+ const domain = this.domain
144
+ if (domain === undefined) return
145
+ try {
146
+ const global = domain.getGlobal()
147
+ const welcomed = global.welcomedChats ?? []
148
+ if (welcomed.includes(msg.chatId)) return
149
+ await this.delivery.standaloneReply(msg, buildOnboardingCard(), { createThread: false })
150
+ // Bounded: keep the most recent 500 chats.
151
+ const next = [...welcomed, msg.chatId].slice(-500)
152
+ await domain.setGlobal({ ...global, welcomedChats: next })
153
+ } catch (error) {
154
+ this.ctx.logger?.warn?.('dsh-channels: onboarding card failed: %s', String(error))
155
+ }
156
+ }
157
+
158
+ /** Drop all in-memory routing state (shutdown). */
159
+ clear(): void {
160
+ this.accepted.clear()
161
+ this.establishing.clear()
162
+ }
163
+
164
+ /** Entry point: one normalized inbound message. */
165
+ async handle(msg: NormalizedMessage): Promise<void> {
166
+ const botId = this.delivery.requireChannel().botOpenId()
167
+ if (botId !== undefined && msg.senderId === botId) return
168
+ if (msg.chatType !== 'p2p' && msg.chatType !== 'group') return
169
+ let content = (msg.content ?? '').trim()
170
+ if (content === '') return
171
+
172
+ if (msg.chatType === 'p2p' && this.config.dmMode === 'disabled') return
173
+
174
+ // 0. Idempotency gate: Feishu redelivers un-acked events on WS reconnect.
175
+ // The in-process guard closes the window between the persisted check
176
+ // and its `put` landing; the durable record covers restarts. Recording
177
+ // happens BEFORE any side effect so a redelivery is dropped, never
178
+ // double-processed (at-most-once per message id).
179
+ if (this.accepted.has(msg.messageId)) return
180
+ if (this.domain?.processed.get(msg.messageId) !== undefined) return
181
+ this.accepted.set(msg.messageId, Date.now())
182
+ try {
183
+ await this.domain?.processed.put(msg.messageId, {
184
+ key: msg.messageId,
185
+ processedAt: Date.now(),
186
+ })
187
+ this.accepted.delete(msg.messageId)
188
+ } catch (error) {
189
+ // Best-effort: the memory guard still covers this process; the failed
190
+ // record only widens the crash window (pruned by the GC sweep later).
191
+ this.ctx.logger?.warn?.('dsh-channels: recording processed message %s failed: %s', msg.messageId, String(error))
192
+ }
193
+
194
+ // Slash admission stays behind message-id dedup but ahead of every route,
195
+ // mention, workspace, Session, card-stream, and model side effect. The
196
+ // command plane applies its own administrator/bootstrap rules.
197
+ if (content.startsWith('/')) {
198
+ await this.deps.commands.handleSlash(msg, content)
199
+ return
200
+ }
201
+
202
+ // Channel-plane admission (administrator / group whitelist / user
203
+ // whitelist). Nothing below this point may run for a denied sender: no
204
+ // TTL tombstone, attachment archive, workspace, Session, or model call.
205
+ if (!(await this.access.admit(msg))) return
206
+
207
+ // Enforce TTL at admission as well as in the periodic sweep. A message that
208
+ // arrives after the deadline must not get one chance to revive the route
209
+ // merely because the background GC has not run yet.
210
+ const expiredThread = msg.threadId === undefined
211
+ ? undefined
212
+ : await this.routes.expireThread(msg.threadId)
213
+
214
+ // A TTL-retired topic is permanently closed on the channel plane. Keeping
215
+ // a durable tombstone avoids silently forking a fresh Session in a topic
216
+ // whose visible history still belongs to the old Session.
217
+ if (expiredThread !== undefined) {
218
+ await this.delivery.expiredRouteCard(msg)
219
+ return
220
+ }
221
+
222
+ // Attachments are archived before session work: an attachment-only message
223
+ // produces no model turn, and a caption mixed with attachments keeps only
224
+ // the caption as the prompt. The Agent reads the files on demand later.
225
+ const archived = await this.archiveAttachments(msg)
226
+ if (archived !== undefined) {
227
+ if (archived.failures.length > 0) {
228
+ await this.delivery.noticeText(msg, buildAttachmentFailureNotice(archived.failures))
229
+ }
230
+ if (archived.attachmentOnly) return
231
+ content = archived.text
232
+ if (content === '') return
233
+ }
234
+
235
+ // A session blocked on `ask_user_question` has no other use for an inbound
236
+ // text message: the next reply becomes the free-text answer.
237
+ if (await this.tryAnswerPendingQuestion(msg, content)) return
238
+
239
+ // 1. Real topic route first (works without a mention, per product spec).
240
+ if (msg.threadId !== undefined) {
241
+ const record = this.routes.get(msg.threadId)
242
+ if (record !== undefined) {
243
+ // Only the topic creator may continue its Session (admission already
244
+ // passed, but another authorized user could be in the same group).
245
+ if (!this.access.ownsRoute(msg, record)) return
246
+ await this.routeOperations.run(record.key, () => {
247
+ if (!this.started) return
248
+ return this.continueSession(msg, this.routes.get(record.key) ?? record, content)
249
+ })
250
+ return
251
+ }
252
+ }
253
+
254
+ // 2. Degraded chat-scoped route (topic-less fallback sessions).
255
+ const chatKey = chatRouteKey(msg.chatType, msg.chatId, msg.senderId)
256
+ const chatRecord = this.routes.get(chatKey)
257
+ if (chatRecord !== undefined) {
258
+ if (!this.access.ownsRoute(msg, chatRecord)) return
259
+ await this.routeOperations.run(chatRecord.key, () => {
260
+ if (!this.started) return
261
+ return this.continueSession(msg, this.routes.get(chatRecord.key) ?? chatRecord, content)
262
+ })
263
+ return
264
+ }
265
+
266
+ // 3. In-flight establishment for this (chat, user) pair: wait for it and
267
+ // re-route with serial semantics (see the `establishing` field docs).
268
+ const pending = this.establishing.get(chatKey)
269
+ if (pending !== undefined) {
270
+ const record = await pending.catch(() => undefined)
271
+ if (record !== undefined
272
+ && (record.kind === 'chat' || (msg.threadId !== undefined && msg.threadId === record.key))) {
273
+ await this.routeOperations.run(record.key, () => {
274
+ if (!this.started) return
275
+ return this.continueSession(msg, this.routes.get(record.key) ?? record, content)
276
+ })
277
+ return
278
+ }
279
+ // Thread-scoped record but this message is not in that topic (or the
280
+ // establishment failed): fall through — a fresh session is what the
281
+ // serial order would have produced too.
282
+ }
283
+
284
+ // 4. New session: groups require a mention (unless disabled).
285
+ if (msg.chatType === 'group' && this.config.requireMention && !msg.mentionedBot) return
286
+
287
+ await this.startSession(msg, content)
288
+ }
289
+
290
+ /**
291
+ * Archive the attachment payload of one message, if any. Returns `undefined`
292
+ * when the message carries no archivable payload; otherwise the archive
293
+ * result whose `text` is the remaining prompt (possibly empty).
294
+ *
295
+ * Runs inside the same per-route FIFO as turns so a caption sent right after
296
+ * a file is queued behind the file's download instead of racing it.
297
+ */
298
+ private async archiveAttachments(msg: NormalizedMessage): Promise<AttachmentArchiveResult | undefined> {
299
+ if (!hasAttachmentPayload(msg)) return undefined
300
+
301
+ const threadRecord = msg.threadId === undefined ? undefined : this.routes.get(msg.threadId)
302
+ // A foreign topic must never receive this sender's attachments: the
303
+ // archive path resolves the workspace from the route record.
304
+ if (threadRecord !== undefined && !this.access.ownsRoute(msg, threadRecord)) return undefined
305
+ const chatKey = chatRouteKey(msg.chatType, msg.chatId, msg.senderId)
306
+ const record = threadRecord ?? this.routes.get(chatKey)
307
+ // A brand-new group conversation still needs a mention; a topic the bot
308
+ // already owns does not.
309
+ if (record === undefined && msg.chatType === 'group'
310
+ && this.config.requireMention && !msg.mentionedBot) {
311
+ return { saved: 0, failed: 0, failures: [], attachmentOnly: true, text: '' }
312
+ }
313
+
314
+ return await this.routeOperations.run(record?.key ?? chatKey, async () => {
315
+ if (!this.started) return { saved: 0, failed: 0, failures: [], attachmentOnly: true, text: '' }
316
+ // An existing topic has a workspace already; a conversation without one
317
+ // stages files below baseDir until its first Session creates the topic
318
+ // workspace and adopts them.
319
+ const directory = record === undefined
320
+ ? stagingPathFor(this.config.baseDir, msg.chatType, msg.chatId, msg.senderId)
321
+ : attachmentsDir(record.workspacePath)
322
+ return await this.attachments.archive(msg, directory)
323
+ })
324
+ }
325
+ /**
326
+ * Consume one plain reply as an `ask_user_question` free-text answer.
327
+ *
328
+ * Only sessions with a pending question are intercepted, so ordinary
329
+ * conversation and commands are untouched.
330
+ */
331
+ private async tryAnswerPendingQuestion(msg: NormalizedMessage, content: string): Promise<boolean> {
332
+ const answerer = this.questionAnswerer
333
+ if (answerer === undefined || answerer.pendingCount() === 0) return false
334
+ const route = await this.commandRoute(msg)
335
+ if (route === undefined || !this.access.ownsRoute(msg, route)) return false
336
+ if (!answerer.hasPendingFor(route.sessionId)) return false
337
+ if (!answerer.submitText(route.sessionId, content)) return false
338
+ await this.delivery.commandCard(msg, {
339
+ kind: 'success', command: 'ask_user_question', sessionId: route.sessionId,
340
+ text: '已记录你的文字回答。',
341
+ }, { createThread: false })
342
+ return true
343
+ }
344
+ /** Resolve the route visible to one command without creating a Session. */
345
+ /** Public read-only route resolution (used by the command plane too). */
346
+ async resolveRoute(msg: NormalizedMessage): Promise<RouteRecord | undefined> {
347
+ return await this.commandRoute(msg)
348
+ }
349
+
350
+ private async commandRoute(msg: NormalizedMessage): Promise<RouteRecord | undefined> {
351
+ if (msg.threadId !== undefined) {
352
+ const threaded = this.routes.get(msg.threadId)
353
+ if (threaded !== undefined) return threaded
354
+ }
355
+ const chatKey = chatRouteKey(msg.chatType, msg.chatId, msg.senderId)
356
+ const degraded = this.routes.get(chatKey)
357
+ if (degraded !== undefined) return degraded
358
+ const pending = this.establishing.get(chatKey)
359
+ if (pending === undefined) return undefined
360
+ const record = await pending.catch(() => undefined)
361
+ if (record?.kind === 'chat') return record
362
+ if (record !== undefined && msg.threadId === record.key) return record
363
+ return undefined
364
+ }
365
+ /**
366
+ * Serialize session establishment per (chat, user) pair. The promise is
367
+ * registered under the chat-scoped key SYNCHRONOUSLY (the key is known
368
+ * before any network op), so concurrent first messages all observe the same
369
+ * in-flight establishment instead of racing each other. The entry lives
370
+ * exactly as long as the establishment; waiters holding the promise still
371
+ * see the settled record after the entry is gone.
372
+ */
373
+ private startSession(msg: NormalizedMessage, content: string): Promise<RouteRecord> {
374
+ const chatKey = chatRouteKey(msg.chatType, msg.chatId, msg.senderId)
375
+ const existing = this.establishing.get(chatKey)
376
+ if (existing !== undefined) return existing
377
+ const promise = this.establishSession(msg, content, chatKey)
378
+ this.establishing.set(chatKey, promise)
379
+ void promise.then(
380
+ () => { this.establishing.delete(chatKey) },
381
+ () => { this.establishing.delete(chatKey) },
382
+ )
383
+ return promise
384
+ }
385
+ private async establishSession(msg: NormalizedMessage, content: string, chatKey: string): Promise<RouteRecord> {
386
+ // 0. One-time onboarding for a private chat (persisted, never repeats).
387
+ if (this.config.welcomeOnboarding && msg.chatType === 'p2p') {
388
+ await this.sendOnboardingCard(msg)
389
+ }
390
+
391
+ // 1. "Typing" reaction immediately (id captured for the swap).
392
+ const typingPromise = this.delivery.requireChannel().addReaction(msg.messageId, 'Typing').catch(() => undefined)
393
+
394
+ const sessionId = randomUUID() as SessionId
395
+ const initialStats = { sessionId, inputTokens: 0, outputTokens: 0 }
396
+
397
+ // 2. First card frame — also creates the topic (reply_in_thread). The
398
+ // topic identity is only known after Feishu answers, and it decides the
399
+ // workspace: every new topic gets its own directory.
400
+ const sent = await this.delivery.replyFirstCard(msg, initialStreamingCard(sessionId))
401
+ const typingId = await typingPromise
402
+ const key = sent.degraded || sent.threadId === undefined ? chatKey : sent.threadId
403
+ const kind = sent.degraded || sent.threadId === undefined ? 'chat' : 'thread'
404
+
405
+ // 3. Workspace for this topic, then adopt attachments staged before it.
406
+ let workspace: Workspace
407
+ try {
408
+ workspace = await ensureWorkspace(
409
+ this.ctx, this.config.baseDir, msg.chatType, msg.chatId, msg.senderId,
410
+ topicTokenFor(key, kind, sessionId),
411
+ )
412
+ await adoptStagedAttachments(
413
+ stagingPathFor(this.config.baseDir, msg.chatType, msg.chatId, msg.senderId),
414
+ workspace.path,
415
+ )
416
+ } catch (error) {
417
+ void this.repair.clear(sent.messageId)
418
+ await this.delivery.requireChannel().updateCard(sent.messageId, buildCard({
419
+ phase: 'done',
420
+ reasoning: '',
421
+ body: `会话创建失败:${String(error)}`,
422
+ stats: { ...initialStats },
423
+ })).catch(() => {})
424
+ throw error
425
+ }
426
+
427
+ // 3.5 Repair snapshot FIRST (before the route record and the agent):
428
+ // the crash window between the two writes then degrades to an orphan
429
+ // snapshot — which startup repair handles safely — instead of a zombie
430
+ // streaming card with no snapshot at all.
431
+ await this.repair.write({
432
+ key: sent.messageId,
433
+ sessionId,
434
+ chatId: msg.chatId,
435
+ triggerMessageId: msg.messageId,
436
+ reactionId: typingId,
437
+ cards: [{ messageId: sent.messageId, mode: 'card' }],
438
+ startedAt: Date.now(),
439
+ attempts: 0,
440
+ })
441
+
442
+ // 4. Create the agent/session bound to the workspace directory.
443
+ let handle: AgentHandle
444
+ try {
445
+ handle = await this.sessions.createSession(sessionId, workspace.path)
446
+ } catch (error) {
447
+ // The card lands in a final failure state — no repair needed later.
448
+ void this.repair.clear(sent.messageId)
449
+ await this.delivery.requireChannel().updateCard(sent.messageId, buildCard({
450
+ phase: 'done',
451
+ reasoning: '',
452
+ body: `会话创建失败:${String(error)}`,
453
+ stats: { ...initialStats },
454
+ })).catch(() => {})
455
+ throw error
456
+ }
457
+ void Promise.resolve(workspace.attachSession(sessionId)).catch(error => {
458
+ this.ctx.logger?.warn?.(
459
+ 'dsh-channels: attaching initial session %s to workspace failed: %s',
460
+ sessionId, String(error),
461
+ )
462
+ })
463
+
464
+ // 5. Persist the route (thread-scoped, or degraded chat-scoped).
465
+ const createdAt = handle.agent.session.header.createdAt
466
+ const record: RouteRecord = {
467
+ key,
468
+ kind,
469
+ sessionId,
470
+ chatId: msg.chatId,
471
+ chatType: msg.chatType,
472
+ senderOpenId: msg.senderId,
473
+ workspacePath: workspace.path,
474
+ cardMessageId: sent.messageId,
475
+ createdAt,
476
+ lastActivityAt: createdAt,
477
+ sessionHistory: [{ sessionId, createdAt }],
478
+ }
479
+ await this.routes.put(key, record)
480
+
481
+ // 6. Stream into the card and hand the message to the driver.
482
+ this.streamer?.attach(handle.agent.session, sent.messageId, msg.messageId, typingId)
483
+ handle.agent.followup(feishuUserMessage(content))
484
+ return record
485
+ }
486
+ private async continueSession(msg: NormalizedMessage, record: RouteRecord, content: string): Promise<void> {
487
+ // Defence in depth: every current caller checks this first, but a future
488
+ // call path must not be able to continue another user's Session.
489
+ if (!this.access.ownsRoute(msg, record)) return
490
+ // Files staged before this conversation's topic existed follow its next
491
+ // turn (for example a file sent in the chat before the first prompt).
492
+ await adoptStagedAttachments(
493
+ stagingPathFor(this.config.baseDir, msg.chatType, msg.chatId, msg.senderId),
494
+ record.workspacePath,
495
+ ).catch((error: unknown) => {
496
+ this.ctx.logger?.warn?.('dsh-channels: adopting staged attachments failed: %s', String(error))
497
+ })
498
+ const typingPromise = this.delivery.requireChannel().addReaction(msg.messageId, 'Typing').catch(() => undefined)
499
+ const live = await this.sessions.ensureLive(record)
500
+
501
+ const sent = await this.delivery.replyFirstCard(msg, initialStreamingCard(record.sessionId))
502
+ const typingId = await typingPromise
503
+
504
+ // Repair snapshot first — same crash-window reasoning as `startSession`.
505
+ await this.repair.write({
506
+ key: sent.messageId,
507
+ sessionId: record.sessionId,
508
+ chatId: msg.chatId,
509
+ triggerMessageId: msg.messageId,
510
+ reactionId: typingId,
511
+ cards: [{ messageId: sent.messageId, mode: 'card' }],
512
+ startedAt: Date.now(),
513
+ attempts: 0,
514
+ })
515
+
516
+ await this.routes.put(record.key, {
517
+ ...record,
518
+ cardMessageId: sent.messageId,
519
+ lastActivityAt: Date.now(),
520
+ })
521
+ this.streamer?.attach(live.agent.session, sent.messageId, msg.messageId, typingId)
522
+ live.agent.followup(feishuUserMessage(content))
523
+ }
524
+ }