@modesto_ma/dsh-channels 0.2.0 → 0.4.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 (46) hide show
  1. package/README.md +115 -39
  2. package/docs/access-control-design.md +1 -1
  3. package/docs/attachment-pipeline-v2.md +318 -0
  4. package/docs/feasibility-and-design.md +54 -26
  5. package/docs/feishu-onboarding.md +123 -15
  6. package/docs/persistence.md +4 -4
  7. package/docs/refactor-plan.md +7 -6
  8. package/docs/workspace-isolation-design.md +81 -0
  9. package/lib/client.js +30 -15
  10. package/lib/client.js.map +1 -1
  11. package/lib/index.js +11008 -5132
  12. package/lib/index.js.map +1 -1
  13. package/package.json +1 -1
  14. package/src/cards/index.ts +1 -0
  15. package/src/cards/onboarding.ts +23 -0
  16. package/src/cards/primitives.ts +20 -0
  17. package/src/cards/streaming.ts +310 -291
  18. package/src/cleanup.ts +267 -119
  19. package/src/client/panel/cleanup-card.tsx +4 -2
  20. package/src/client/panel/workspace-tab.tsx +1 -1
  21. package/src/config.ts +221 -13
  22. package/src/domain.ts +141 -3
  23. package/src/feishu/agent-context.ts +66 -16
  24. package/src/feishu/inbound-attachments.ts +440 -3
  25. package/src/feishu/manifest.ts +270 -0
  26. package/src/feishu/message-read.ts +1333 -0
  27. package/src/feishu/native-attachments.ts +998 -0
  28. package/src/feishu/pending-attachments.ts +562 -0
  29. package/src/feishu/tools.ts +2345 -0
  30. package/src/feishu-channel.ts +55 -0
  31. package/src/host/agent.ts +55 -0
  32. package/src/host/attachments.ts +210 -0
  33. package/src/host/index.ts +1 -0
  34. package/src/index.ts +8 -6
  35. package/src/routing/inbound.ts +730 -81
  36. package/src/service.ts +536 -50
  37. package/src/session/isolation.ts +277 -0
  38. package/src/session/manager.ts +120 -23
  39. package/src/session/repair.ts +2 -3
  40. package/src/session/routes.ts +2 -0
  41. package/src/session-commands.ts +41 -14
  42. package/src/settings/contract.ts +43 -21
  43. package/src/settings/overlay.ts +124 -13
  44. package/src/settings/surface.ts +147 -14
  45. package/src/streamer.ts +70 -30
  46. package/src/workspaces.ts +74 -31
@@ -10,24 +10,39 @@
10
10
  */
11
11
 
12
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'
13
+ import type {
14
+ AgentHandle, AttachmentStore, ContentBlock, HostContext, SessionId, UserMessage, Workspace,
15
+ } from '../dsh-types.ts'
16
+ import type { AttachmentStoreMode, FeishuConfig } from '../config.ts'
15
17
  import { buildCard, buildOnboardingCard, initialStreamingCard } from '../cards.ts'
16
18
  import { RouteOperationQueue } from '../commands.ts'
17
- import { chatRouteKey } from '../domain.ts'
18
- import type { FeishuDomainHandle, RouteRecord } from '../domain.ts'
19
+ import { chatRouteKey, sessionDirOf } from '../domain.ts'
20
+ import type { FeishuDomainHandle, PendingAttachmentRecord, RouteRecord } from '../domain.ts'
19
21
  import type { CardDelivery } from '../feishu/delivery.ts'
20
22
  import {
21
- adoptStagedAttachments, attachmentsDir, hasAttachmentPayload,
23
+ adoptStagedAttachments, attachmentsDir, attachmentResources, hasAttachmentPayload,
24
+ inboundAttachmentState, prefetchImageAttachments,
22
25
  } 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 {
27
+ AttachmentFailure, InboundAttachmentState,
28
+ } from '../feishu/inbound-attachments.ts'
29
+ import { buildFeishuContextBlock, manifestEntriesOfKnownMessage, triggerManifestAttachments } from '../feishu/manifest.ts'
30
+ import {
31
+ InboundAttachmentStore, NativeAttachmentStore, effectiveAttachmentMode,
32
+ } from '../feishu/native-attachments.ts'
33
+ import type { InboundAttachmentStoreResult } from '../feishu/native-attachments.ts'
34
+ import type { PendingAttachmentScope, PendingAttachmentStore } from '../feishu/pending-attachments.ts'
35
+ import type { KnownAttachmentMessageInput, KnownAttachmentSource } from '../feishu/tools.ts'
36
+ import type { FeishuChannel, NormalizedMessage } from '../feishu-channel.ts'
26
37
  import type { QuestionAnswerer } from '../interactions/questions.ts'
27
38
  import type { InflightRepair } from '../session/repair.ts'
28
39
  import type { SessionManager } from '../session/manager.ts'
29
40
  import type { RouteStore } from '../session/routes.ts'
30
41
  import type { TurnStreamer } from '../streamer.ts'
42
+ import {
43
+ enterTopic, placementServices, planTopic, restoreTopicDir,
44
+ } from '../session/isolation.ts'
45
+ import type { PlacementServices, TopicPlacement, TopicPlan } from '../session/isolation.ts'
31
46
  import { ensureWorkspace, stagingPathFor, topicTokenFor } from '../workspaces.ts'
32
47
 
33
48
  /** The command plane the inbound router delegates admission-time work to. */
@@ -51,6 +66,45 @@ export interface InboundAccessPort {
51
66
  ownsRoute(msg: NormalizedMessage, record: RouteRecord): boolean
52
67
  }
53
68
 
69
+ /**
70
+ * Read-only message access the inbound turn assembly needs: the quoted message
71
+ * of the triggering message and the bounded first-turn topic history.
72
+ *
73
+ * Both methods return `undefined` for "nothing to show" (no quoted message,
74
+ * read failed, missing permission); implementations never throw for expected
75
+ * failures, and a silently degraded read must never block the turn (AC6).
76
+ */
77
+ export interface InboundMessageReadPort {
78
+ /**
79
+ * Resolve the message the triggering message replies to. The returned
80
+ * `block` is the ready-to-inject `<feishu_quote>` prompt block and `message`
81
+ * the quoted message's whitelist registration (merged-forward containers are
82
+ * registered by container id).
83
+ */
84
+ quote(input: {
85
+ messageId: string
86
+ record: RouteRecord
87
+ }): Promise<{ block: string; message?: KnownAttachmentMessageInput } | undefined>
88
+ /**
89
+ * Resolve the most recent topic messages for the first turn of a new
90
+ * Session: the ready-to-inject `<feishu_history>` block plus the per-message
91
+ * whitelist registrations in chronological order.
92
+ */
93
+ history(input: {
94
+ record: RouteRecord
95
+ limit: number
96
+ excludeMessageIds: readonly string[]
97
+ }): Promise<{ block: string; messages: readonly KnownAttachmentMessageInput[] } | undefined>
98
+ }
99
+
100
+ /** Live trigger-image prefetch policy (see `prefetchImages` config). */
101
+ export interface AttachmentPrefetchPolicy {
102
+ readonly enabled: boolean
103
+ readonly maxImageBytes: number
104
+ readonly maxTotalBytes: number
105
+ readonly timeoutMs: number
106
+ }
107
+
54
108
  export interface InboundDeps {
55
109
  ctx: HostContext
56
110
  config: FeishuConfig
@@ -60,7 +114,32 @@ export interface InboundDeps {
60
114
  repair: InflightRepair
61
115
  delivery: CardDelivery
62
116
  sessions: SessionManager
63
- attachments: AttachmentIntake
117
+ /** Configured inbound attachment backend (`attachmentStore`). */
118
+ attachmentMode(): AttachmentStoreMode
119
+ /** Host-level native attachment library; undefined = not mounted. */
120
+ nativeAttachments(): AttachmentStore | undefined
121
+ /** Backend-selecting archive/fallback store (legacy workspace download). */
122
+ attachmentStore: InboundAttachmentStore
123
+ /** Durable metadata registry for silently registered attachments. */
124
+ pendingAttachments: PendingAttachmentStore
125
+ /**
126
+ * Pre-authorize a message's attachments for `feishu_attachment_fetch`:
127
+ * `trigger` (the triggering message) by default, `quote`/`history` for the
128
+ * messages this turn's excerpt/history blocks name.
129
+ */
130
+ noteKnownAttachments(
131
+ record: RouteRecord,
132
+ message: KnownAttachmentMessageInput,
133
+ source?: KnownAttachmentSource,
134
+ ): void
135
+ /**
136
+ * Read-only message access: quoted-message excerpt and first-turn topic
137
+ * history. Failures degrade silently to a `undefined` value (never a throw
138
+ * into the turn).
139
+ */
140
+ messageRead: InboundMessageReadPort
141
+ /** Live image-prefetch policy for triggering messages. */
142
+ prefetchImages(): AttachmentPrefetchPolicy
64
143
  /** Channel-plane access control (administrator + whitelist gates). */
65
144
  access: InboundAccessPort
66
145
  /** Shared per-route FIFO admission (commands and turns must not interleave). */
@@ -94,6 +173,52 @@ export function feishuUserMessage(text: string): UserMessage {
94
173
  } as unknown as UserMessage
95
174
  }
96
175
 
176
+ /**
177
+ * Whitelist registration input for one triggering message.
178
+ *
179
+ * The fetch tool requires explicit per-route authorization; the trigger
180
+ * message is the one exception the admission layer can vouch for. Resources
181
+ * are copied from the same normalization the manifest uses, and merged-forward
182
+ * containers carry no direct resources (the tool expands them through the
183
+ * container id, per the P0 rule).
184
+ */
185
+ export function knownAttachmentMessageOf(msg: NormalizedMessage): KnownAttachmentMessageInput {
186
+ const forwarded = msg.rawContentType === 'merge_forward'
187
+ const resources = attachmentResources(msg).map(resource => ({
188
+ type: resource.type,
189
+ fileKey: resource.fileKey,
190
+ ...(resource.fileName === undefined ? {} : { fileName: resource.fileName }),
191
+ ...(resource.folder === true ? { folder: true } : {}),
192
+ }))
193
+ return {
194
+ messageId: msg.messageId,
195
+ kind: forwarded ? 'merge_forward' : 'message',
196
+ resources,
197
+ senderOpenId: msg.senderId,
198
+ }
199
+ }
200
+
201
+ /** Feishu message-id shape accepted for quote resolution (`om_...`). */
202
+ const QUOTE_MESSAGE_ID = /^om_[A-Za-z0-9_-]{1,128}$/u
203
+
204
+ /** Read-only prompt context assembled for one turn (quote excerpt + history). */
205
+ interface TurnReadContext {
206
+ /** Ready `<feishu_quote>` text block, when the message quotes something. */
207
+ quoteBlock?: string
208
+ /** Whitelist registration of the quoted message (manifest/discoverability). */
209
+ quoteMessage?: KnownAttachmentMessageInput
210
+ /** Ready `<feishu_history>` text block, first turn only. */
211
+ historyBlock?: string
212
+ }
213
+
214
+ /** First-turn-only inputs of the prompt assembly. */
215
+ interface TurnDeliveryOptions {
216
+ /** New Session's first turn: attach the bounded topic history bootstrap. */
217
+ firstTurn?: boolean
218
+ /** Message ids never shown in the history block (trigger + streaming card). */
219
+ excludeMessageIds?: readonly string[]
220
+ }
221
+
97
222
  export class InboundRouter {
98
223
  /**
99
224
  * In-process dedup guard: message id → accept timestamp. Set synchronously
@@ -110,6 +235,13 @@ export class InboundRouter {
110
235
  * overwrites, or the "no route, no mention" drop window.
111
236
  */
112
237
  private readonly establishing = new Map<string, Promise<RouteRecord>>()
238
+ /**
239
+ * route key → first-turn delivery gate. A new Session persists its route
240
+ * before the prompt is assembled (image prefetch); a message that observes
241
+ * the route in that window must wait for the first followup instead of
242
+ * racing it, otherwise the topic's turns reach the model out of order.
243
+ */
244
+ private readonly firstDeliveries = new Map<string, { promise: Promise<void>; release(): void }>()
113
245
 
114
246
  constructor(private readonly deps: InboundDeps) {}
115
247
 
@@ -120,7 +252,8 @@ export class InboundRouter {
120
252
  private get repair(): InflightRepair { return this.deps.repair }
121
253
  private get delivery(): CardDelivery { return this.deps.delivery }
122
254
  private get sessions(): SessionManager { return this.deps.sessions }
123
- private get attachments(): AttachmentIntake { return this.deps.attachments }
255
+ private get attachmentStore(): InboundAttachmentStore { return this.deps.attachmentStore }
256
+ private get pendingAttachments(): PendingAttachmentStore { return this.deps.pendingAttachments }
124
257
  private get routeOperations(): RouteOperationQueue { return this.deps.queue }
125
258
  private get streamer(): TurnStreamer | undefined { return this.deps.streamer() }
126
259
  private get questionAnswerer(): QuestionAnswerer | undefined { return this.deps.questionAnswerer() }
@@ -134,6 +267,28 @@ export class InboundRouter {
134
267
  }
135
268
  }
136
269
 
270
+ /**
271
+ * Open the first-turn delivery gate of one route. Registered BEFORE the
272
+ * route record is persisted, so any message that can observe the route also
273
+ * observes its gate. The returned closer must be called exactly once.
274
+ */
275
+ private openDeliveryGate(key: string): () => void {
276
+ let settle!: () => void
277
+ const promise = new Promise<void>((resolve) => { settle = resolve })
278
+ const release = (): void => {
279
+ if (this.firstDeliveries.get(key)?.promise === promise) this.firstDeliveries.delete(key)
280
+ settle()
281
+ }
282
+ this.firstDeliveries.set(key, { promise, release })
283
+ return release
284
+ }
285
+
286
+ /** Wait for the in-flight first followup of a route, when one is pending. */
287
+ private async awaitFirstDelivery(key: string): Promise<void> {
288
+ const pending = this.firstDeliveries.get(key)
289
+ if (pending !== undefined) await pending.promise.catch(() => undefined)
290
+ }
291
+
137
292
  /**
138
293
  * Send the one-time onboarding card for a private chat, then remember the
139
294
  * chat in the domain global. Best-effort: a storage failure only risks a
@@ -159,6 +314,10 @@ export class InboundRouter {
159
314
  clear(): void {
160
315
  this.accepted.clear()
161
316
  this.establishing.clear()
317
+ // Resolve pending gates before dropping them so a message waiting on a
318
+ // first-turn delivery never stays suspended across a teardown.
319
+ for (const entry of [...this.firstDeliveries.values()]) entry.release()
320
+ this.firstDeliveries.clear()
162
321
  }
163
322
 
164
323
  /** Entry point: one normalized inbound message. */
@@ -219,22 +378,45 @@ export class InboundRouter {
219
378
  return
220
379
  }
221
380
 
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))
381
+ // Attachment admission. The effective backend selects between the legacy
382
+ // synchronous workspace download (`attachmentStore: 'archive'`, or a
383
+ // native deployment whose host does not mount the attachment service) and
384
+ // the v2 lazy pipeline: no attachment bytes are downloaded before the
385
+ // session/card exists, and files are never downloaded by admission at all.
386
+ if (this.attachmentBackend() === 'archive') {
387
+ // Attachments are archived before session work: an attachment-only
388
+ // message produces no model turn, and a caption mixed with attachments
389
+ // keeps only the caption as the prompt. The Agent reads the files on
390
+ // demand later.
391
+ const archived = await this.archiveAttachments(msg)
392
+ if (archived !== undefined) {
393
+ if (archived.failures.length > 0) {
394
+ await this.delivery.noticeText(msg, buildAttachmentFailureNotice(archived.failures))
395
+ }
396
+ if (archived.attachmentOnly) return
397
+ content = archived.text
398
+ if (content === '') return
399
+ }
400
+ } else {
401
+ // Native pipeline: classify without downloading. Attachment-only
402
+ // messages and messages that cannot start a turn are silently registered
403
+ // (no reply, no reaction, no Session); a mixed caption becomes the prompt
404
+ // with its placeholders stripped.
405
+ const state = inboundAttachmentState(msg)
406
+ if (state.hasPayload) {
407
+ const resolution = await this.resolveNativeAttachmentMessage(msg, state)
408
+ if (resolution.kind === 'silent') return
409
+ content = resolution.text
410
+ if (content === '') return
229
411
  }
230
- if (archived.attachmentOnly) return
231
- content = archived.text
232
- if (content === '') return
233
412
  }
234
413
 
235
414
  // A session blocked on `ask_user_question` has no other use for an inbound
236
415
  // text message: the next reply becomes the free-text answer.
237
- if (await this.tryAnswerPendingQuestion(msg, content)) return
416
+ if (await this.tryAnswerPendingQuestion(msg, content)) {
417
+ await this.registerConsumedTriggerAttachments(msg)
418
+ return
419
+ }
238
420
 
239
421
  // 1. Real topic route first (works without a mention, per product spec).
240
422
  if (msg.threadId !== undefined) {
@@ -243,6 +425,9 @@ export class InboundRouter {
243
425
  // Only the topic creator may continue its Session (admission already
244
426
  // passed, but another authorized user could be in the same group).
245
427
  if (!this.access.ownsRoute(msg, record)) return
428
+ // The route may be durable while its first turn is still being
429
+ // prepared (prefetch): stay behind that turn instead of racing it.
430
+ await this.awaitFirstDelivery(record.key)
246
431
  await this.routeOperations.run(record.key, () => {
247
432
  if (!this.started) return
248
433
  return this.continueSession(msg, this.routes.get(record.key) ?? record, content)
@@ -256,6 +441,7 @@ export class InboundRouter {
256
441
  const chatRecord = this.routes.get(chatKey)
257
442
  if (chatRecord !== undefined) {
258
443
  if (!this.access.ownsRoute(msg, chatRecord)) return
444
+ await this.awaitFirstDelivery(chatRecord.key)
259
445
  await this.routeOperations.run(chatRecord.key, () => {
260
446
  if (!this.started) return
261
447
  return this.continueSession(msg, this.routes.get(chatRecord.key) ?? chatRecord, content)
@@ -288,14 +474,17 @@ export class InboundRouter {
288
474
  }
289
475
 
290
476
  /**
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).
477
+ * Legacy archive path: download every resource of one message into the
478
+ * conversation workspace (or its pre-topic staging directory). Returns
479
+ * `undefined` when the message carries no archivable payload; otherwise the
480
+ * storage result whose `text` is the remaining prompt (possibly empty).
294
481
  *
295
482
  * 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.
483
+ * a file is queued behind the file's download instead of racing it. Only the
484
+ * `archive` effective backend calls this; the native pipeline never downloads
485
+ * files during admission.
297
486
  */
298
- private async archiveAttachments(msg: NormalizedMessage): Promise<AttachmentArchiveResult | undefined> {
487
+ private async archiveAttachments(msg: NormalizedMessage): Promise<InboundAttachmentStoreResult | undefined> {
299
488
  if (!hasAttachmentPayload(msg)) return undefined
300
489
 
301
490
  const threadRecord = msg.threadId === undefined ? undefined : this.routes.get(msg.threadId)
@@ -308,20 +497,431 @@ export class InboundRouter {
308
497
  // already owns does not.
309
498
  if (record === undefined && msg.chatType === 'group'
310
499
  && this.config.requireMention && !msg.mentionedBot) {
311
- return { saved: 0, failed: 0, failures: [], attachmentOnly: true, text: '' }
500
+ return {
501
+ mode: 'archive', saved: 0, failed: 0, failures: [], stored: [], attachmentOnly: true, text: '',
502
+ }
312
503
  }
313
504
 
314
505
  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.
506
+ if (!this.started) {
507
+ return {
508
+ mode: 'archive', saved: 0, failed: 0, failures: [], stored: [], attachmentOnly: true, text: '',
509
+ } satisfies InboundAttachmentStoreResult
510
+ }
511
+ // An existing topic already has its own working directory; a
512
+ // conversation without one stages files below baseDir until its first
513
+ // Session creates the topic directory and adopts them.
319
514
  const directory = record === undefined
320
515
  ? stagingPathFor(this.config.baseDir, msg.chatType, msg.chatId, msg.senderId)
321
- : attachmentsDir(record.workspacePath)
322
- return await this.attachments.archive(msg, directory)
516
+ : attachmentsDir(sessionDirOf(record))
517
+ return await this.attachmentStore.store(msg, directory)
518
+ })
519
+ }
520
+
521
+ /**
522
+ * One-time legacy migration: move files older plugin versions staged below
523
+ * `<baseDir>/_staging/...` into this conversation's topic directory.
524
+ *
525
+ * The v2 pipeline stores inbound attachments in the DSH native library and
526
+ * never writes staging, so this path only adopts files left by a pre-v2
527
+ * deployment. It is kept for one release cycle after attachment_pipeline_v2
528
+ * ships and is then removed together with `stagingPathFor` (decisions D12).
529
+ * A successful adoption logs one warning so operators can retire the old
530
+ * data instead of relying on this compatibility path indefinitely.
531
+ */
532
+ private async adoptLegacyStagedAttachments(
533
+ msg: NormalizedMessage,
534
+ sessionDir: string,
535
+ warn: (message: string, ...args: unknown[]) => void,
536
+ ): Promise<void> {
537
+ const adopted = await adoptStagedAttachments(
538
+ stagingPathFor(this.config.baseDir, msg.chatType, msg.chatId, msg.senderId),
539
+ sessionDir,
540
+ ).catch((error: unknown) => {
541
+ warn('dsh-channels: adopting staged attachments failed: %s', String(error))
542
+ return 0
323
543
  })
544
+ if (adopted > 0) {
545
+ warn(
546
+ 'dsh-channels: adopted %d legacy staged attachment(s) into %s; the one-time _staging migration is deprecated and will be removed after one release cycle (decisions D12)',
547
+ adopted,
548
+ sessionDir,
549
+ )
550
+ }
551
+ }
552
+
553
+ /**
554
+ * Effective inbound attachment backend for the live deployment.
555
+ *
556
+ * The lazy native pipeline can only deliver files through
557
+ * `feishu_attachment_fetch`; with that tool disabled (or without the host
558
+ * attachment library) the deployment falls back to the legacy archive
559
+ * backend, where the Agent reads the downloaded workspace copies instead of
560
+ * a manifest that would name an unavailable tool.
561
+ */
562
+ private attachmentBackend(): AttachmentStoreMode {
563
+ return effectiveAttachmentMode(
564
+ this.deps.attachmentMode(),
565
+ this.deps.nativeAttachments(),
566
+ this.config.feishuTools.attachmentFetch,
567
+ this.config.acceptAttachments,
568
+ )
569
+ }
570
+
571
+ /**
572
+ * Native-pipeline attachment decision for one message, without downloading.
573
+ *
574
+ * Returns `{ kind: 'silent' }` when the message must stay inert (metadata
575
+ * registration only: no reply, no reaction, no Session, no download) and
576
+ * `{ kind: 'prompt', text }` when a model turn starts with this text. The
577
+ * caller keeps the message flowing through the normal route decision tree.
578
+ */
579
+ private async resolveNativeAttachmentMessage(
580
+ msg: NormalizedMessage,
581
+ state: InboundAttachmentState,
582
+ ): Promise<{ kind: 'silent' } | { kind: 'prompt'; text: string }> {
583
+ // A foreign sender's attachment never registers (and is dropped later by
584
+ // the route-ownership checks): silent metadata must stay per-sender.
585
+ const threadRecord = msg.threadId === undefined ? undefined : this.routes.get(msg.threadId)
586
+ const chatKey = chatRouteKey(msg.chatType, msg.chatId, msg.senderId)
587
+ const chatRecord = threadRecord === undefined ? this.routes.get(chatKey) : undefined
588
+ const foreign = (threadRecord !== undefined && !this.access.ownsRoute(msg, threadRecord))
589
+ || (chatRecord !== undefined && !this.access.ownsRoute(msg, chatRecord))
590
+ if (foreign) return { kind: 'prompt', text: state.text }
591
+
592
+ if (!this.config.acceptAttachments && state.attachmentOnly) return { kind: 'silent' }
593
+ if (state.attachmentOnly) {
594
+ await this.registerSilentAttachment(msg)
595
+ return { kind: 'silent' }
596
+ }
597
+ if (!this.config.acceptAttachments) return { kind: 'prompt', text: state.text }
598
+
599
+ // Group messages without an @-mention (and without an already-bound topic)
600
+ // never start a turn, but their attachments must remain discoverable from
601
+ // the topic a later @-mentioned message creates (Roadmap AC-4).
602
+ const pendingEstablishment = this.establishing.get(chatKey)
603
+ const requiresMention = msg.chatType === 'group' && this.config.requireMention && !msg.mentionedBot
604
+ const triggers = threadRecord !== undefined || chatRecord !== undefined
605
+ || pendingEstablishment !== undefined || !requiresMention
606
+ if (!triggers) {
607
+ await this.registerSilentAttachment(msg)
608
+ return { kind: 'silent' }
609
+ }
610
+ return { kind: 'prompt', text: state.text }
611
+ }
612
+
613
+ /**
614
+ * Record attachment metadata without any side effect beyond the durable
615
+ * registry write. Never replies, reacts, downloads, or establishes a Session.
616
+ */
617
+ private async registerSilentAttachment(msg: NormalizedMessage): Promise<void> {
618
+ if (!this.config.acceptAttachments) return
619
+ // `admitted: true` is safe because every call site runs after
620
+ // `access.admit` returned true; the registry itself has no admission policy.
621
+ try {
622
+ await this.pendingAttachments.registerMessage(msg, { admitted: true })
623
+ } catch (error) {
624
+ // Metadata only: a registry failure must never fail the inbound pipeline.
625
+ this.ctx.logger?.warn?.(
626
+ 'dsh-channels: registering pending attachment metadata for %s failed: %s',
627
+ msg.messageId, String(error),
628
+ )
629
+ }
630
+ }
631
+
632
+ /**
633
+ * A native-pipeline message whose caption was consumed as a structured
634
+ * question answer starts no model turn, so its attachments would otherwise
635
+ * vanish from the conversation. Register them silently for later turns;
636
+ * the archive backend already downloaded them before question interception.
637
+ */
638
+ private async registerConsumedTriggerAttachments(msg: NormalizedMessage): Promise<void> {
639
+ if (this.attachmentBackend() !== 'native') return
640
+ if (!inboundAttachmentState(msg).hasPayload) return
641
+ await this.registerSilentAttachment(msg)
642
+ }
643
+ /**
644
+ * Build the first-turn user message for the native pipeline: the caption
645
+ * text, the quoted-message excerpt, the prefetched trigger images, the
646
+ * `<feishu_context>` manifest block listing every attachment still unloaded,
647
+ * and (first turn only) the `<feishu_history>` topic bootstrap. Optional
648
+ * blocks are omitted entirely (no token cost) when empty.
649
+ *
650
+ * Runs after the session/card/route exist and before `followup`; only the
651
+ * triggering message's images are prefetched (bounded and short-timeout),
652
+ * files never are. Block order is stable: text, quote, images, manifest,
653
+ * history.
654
+ */
655
+ private async nativeTurnMessage(
656
+ msg: NormalizedMessage,
657
+ record: RouteRecord,
658
+ content: string,
659
+ options: TurnDeliveryOptions,
660
+ ): Promise<UserMessage> {
661
+ const message = feishuUserMessage(content)
662
+ const blocks: ContentBlock[] = content === '' ? [] : [{ type: 'text', text: content }]
663
+ const state = inboundAttachmentState(msg)
664
+ const attachmentPipeline = this.config.acceptAttachments
665
+
666
+ // Authorize the trigger message before any prefetch attempt so the model
667
+ // can fetch whatever the prefetch could not load.
668
+ if (state.hasPayload && attachmentPipeline) {
669
+ try {
670
+ this.deps.noteKnownAttachments(record, knownAttachmentMessageOf(msg))
671
+ } catch (error) {
672
+ this.ctx.logger?.warn?.(
673
+ 'dsh-channels: authorizing trigger attachments of %s failed: %s',
674
+ msg.messageId, String(error),
675
+ )
676
+ }
677
+ }
678
+
679
+ // Quote excerpt + first-turn history: read only after the trigger message
680
+ // is durable and whitelisted (the record exists and the message is known),
681
+ // so a quoted/earlier attachment can reference the trigger's route. The
682
+ // read runs CONCURRENTLY with the bounded image prefetch below: neither
683
+ // waits for the other, and a failed read degrades to its absence (AC6).
684
+ const contextPromise = this.readTurnContext(msg, record, options)
685
+
686
+ const loadedFileKeys = new Set<string>()
687
+ const prefetchedBlocks: ContentBlock[] = []
688
+ const policy = this.deps.prefetchImages()
689
+ const images = state.resources.filter(resource => resource.type === 'image')
690
+ if (state.hasPayload && attachmentPipeline && policy.enabled && images.length > 0) {
691
+ const prefetched = await prefetchImageAttachments({
692
+ msg,
693
+ resources: images,
694
+ channel: this.liveChannel(),
695
+ maxImageBytes: policy.maxImageBytes,
696
+ maxTotalBytes: policy.maxTotalBytes,
697
+ timeoutMs: policy.timeoutMs,
698
+ saveImage: async (bytes, name) => {
699
+ const store = new NativeAttachmentStore({ service: () => this.deps.nativeAttachments() })
700
+ const saved = await store.saveImage({ bytes, ...(name === undefined ? {} : { name }) })
701
+ return saved.ok
702
+ ? { ok: true, block: saved.value.block }
703
+ : { ok: false, reason: saved.reason }
704
+ },
705
+ warn: (warning, ...args) => this.ctx.logger?.warn?.(warning, ...args),
706
+ })
707
+ // Spread each block into a plain object literal: the host `ContentBlock`
708
+ // union carries an index signature that the named `ImageBlock` interface
709
+ // does not declare, and the clone is structurally identical.
710
+ prefetchedBlocks.push(...prefetched.blocks.map(block => ({ ...block })))
711
+ for (const fileKey of prefetched.loadedFileKeys) loadedFileKeys.add(fileKey)
712
+ }
713
+ // Stable block order: text, quote, image blocks, manifest, history.
714
+ const context = await contextPromise
715
+ if (context.quoteBlock !== undefined) blocks.push({ type: 'text', text: context.quoteBlock })
716
+ blocks.push(...prefetchedBlocks)
717
+
718
+ let pending: readonly PendingAttachmentRecord[] = []
719
+ if (attachmentPipeline) {
720
+ try {
721
+ pending = this.pendingAttachments.list(this.pendingScopeOfRecord(record))
722
+ } catch (error) {
723
+ this.ctx.logger?.warn?.(
724
+ 'dsh-channels: listing pending attachments for %s failed: %s',
725
+ record.key, String(error),
726
+ )
727
+ }
728
+ }
729
+ const manifest = attachmentPipeline
730
+ ? buildFeishuContextBlock({
731
+ trigger: triggerManifestAttachments(msg, loadedFileKeys),
732
+ // A quoted file must be DISCOVERABLE in the manifest (AC1): the quote
733
+ // block names it, and this row carries the message_id/file_key the
734
+ // model needs for feishu_attachment_fetch.
735
+ ...(context.quoteMessage === undefined
736
+ ? {}
737
+ : { quote: manifestEntriesOfKnownMessage(context.quoteMessage) }),
738
+ pending,
739
+ })
740
+ : ''
741
+ if (manifest !== '') blocks.push({ type: 'text', text: manifest })
742
+ if (context.historyBlock !== undefined) blocks.push({ type: 'text', text: context.historyBlock })
743
+ // A trigger always carries caption text, but keep the message well-formed
744
+ // if a future call path ever reaches here with nothing to say.
745
+ if (blocks.length === 0) blocks.push({ type: 'text', text: '' })
746
+ message.content = blocks
747
+ return message
748
+ }
749
+
750
+ /**
751
+ * Deliver the prompt for one turn. The lazy native pipeline assembles the
752
+ * attachment blocks + manifest; every other deployment keeps the plain text
753
+ * prompt. Both backends still carry the quoted-message excerpt and (first
754
+ * turn only) the bounded topic history: those are read-only text context and
755
+ * remain useful without the lazy fetch tool.
756
+ *
757
+ * A failure in prompt assembly degrades to the plain text prompt (the turn
758
+ * must run); a failed quote/history read degrades silently to its absence.
759
+ */
760
+ private async deliverTurnMessage(
761
+ msg: NormalizedMessage,
762
+ record: RouteRecord,
763
+ content: string,
764
+ options: TurnDeliveryOptions = {},
765
+ ): Promise<UserMessage> {
766
+ try {
767
+ if (this.attachmentBackend() !== 'native') {
768
+ return await this.textTurnMessage(msg, record, content, options)
769
+ }
770
+ return await this.nativeTurnMessage(msg, record, content, options)
771
+ } catch (error) {
772
+ this.ctx.logger?.warn?.(
773
+ 'dsh-channels: assembling the attachment manifest for %s failed: %s',
774
+ msg.messageId, String(error),
775
+ )
776
+ return feishuUserMessage(content)
777
+ }
778
+ }
779
+
780
+ /**
781
+ * Text-only turn prompt (archive/fallback deployments): the legacy single
782
+ * text block, extended with the quote excerpt and the first-turn history
783
+ * block when they exist. With nothing extra this is byte-identical to
784
+ * {@link feishuUserMessage}.
785
+ */
786
+ private async textTurnMessage(
787
+ msg: NormalizedMessage,
788
+ record: RouteRecord,
789
+ content: string,
790
+ options: TurnDeliveryOptions,
791
+ ): Promise<UserMessage> {
792
+ const context = await this.readTurnContext(msg, record, options)
793
+ if (context.quoteBlock === undefined && context.historyBlock === undefined) {
794
+ return feishuUserMessage(content)
795
+ }
796
+ const blocks: ContentBlock[] = content === '' ? [] : [{ type: 'text', text: content }]
797
+ if (context.quoteBlock !== undefined) blocks.push({ type: 'text', text: context.quoteBlock })
798
+ if (context.historyBlock !== undefined) blocks.push({ type: 'text', text: context.historyBlock })
799
+ const message = feishuUserMessage(content)
800
+ message.content = blocks
801
+ return message
802
+ }
803
+
804
+ /**
805
+ * Read the read-only prompt context of one turn: the quoted-message excerpt
806
+ * (every triggered message, per D3) and the bounded topic history (new
807
+ * Session's first turn only). Both reads run concurrently and are bounded by
808
+ * a short prompt budget, so a slow/stalled read can delay the turn by at
809
+ * most {@link PROMPT_CONTEXT_TIMEOUT} — never by a model-tool timeout (AC4:
810
+ * the first turn is not blocked by enrichment).
811
+ */
812
+ private async readTurnContext(
813
+ msg: NormalizedMessage,
814
+ record: RouteRecord,
815
+ options: TurnDeliveryOptions,
816
+ ): Promise<TurnReadContext> {
817
+ const [quote, historyBlock] = await Promise.all([
818
+ this.readQuoteBlock(msg, record),
819
+ this.readHistoryBlock(record, options),
820
+ ])
821
+ return {
822
+ ...(quote === undefined
823
+ ? {}
824
+ : {
825
+ quoteBlock: quote.block,
826
+ ...(quote.message === undefined ? {} : { quoteMessage: quote.message }),
827
+ }),
828
+ ...(historyBlock === undefined ? {} : { historyBlock }),
829
+ }
830
+ }
831
+
832
+ /**
833
+ * Resolve the quoted message into a `<feishu_quote>` block and register the
834
+ * quoted message (including a merged-forward container, by container id) in
835
+ * the route's attachment whitelist. Only messages that already triggered a
836
+ * turn reach this point (D3: quoting the bot without @ never triggers).
837
+ */
838
+ private async readQuoteBlock(
839
+ msg: NormalizedMessage,
840
+ record: RouteRecord,
841
+ ): Promise<{ block: string; message?: KnownAttachmentMessageInput } | undefined> {
842
+ const messageId = msg.replyToMessageId
843
+ if (messageId === undefined || !QUOTE_MESSAGE_ID.test(messageId)) return undefined
844
+ try {
845
+ const quote = await this.deps.messageRead.quote({ messageId, record })
846
+ if (quote === undefined) return undefined
847
+ if (quote.message !== undefined) {
848
+ try {
849
+ this.deps.noteKnownAttachments(record, quote.message, 'quote')
850
+ } catch (error) {
851
+ // Best-effort whitelist registration: the excerpt is still useful.
852
+ this.ctx.logger?.debug?.(
853
+ 'dsh-channels: registering quoted attachments of %s failed: %s',
854
+ messageId, String(error),
855
+ )
856
+ }
857
+ }
858
+ return quote.block === '' ? undefined : quote
859
+ } catch (error) {
860
+ // Missing read scope / deleted message / channel down: no excerpt.
861
+ this.ctx.logger?.debug?.(
862
+ 'dsh-channels: reading quoted message %s failed: %s', messageId, String(error),
863
+ )
864
+ return undefined
865
+ }
866
+ }
867
+
868
+ /**
869
+ * Resolve the first-turn topic history into a `<feishu_history>` block and
870
+ * register every message it shows in the route's attachment whitelist.
871
+ * Disabled by `historyBootstrapLimit: 0`; continuations never read history.
872
+ */
873
+ private async readHistoryBlock(
874
+ record: RouteRecord,
875
+ options: TurnDeliveryOptions,
876
+ ): Promise<string | undefined> {
877
+ if (options.firstTurn !== true) return undefined
878
+ const limit = this.config.historyBootstrapLimit
879
+ if (!Number.isFinite(limit) || limit <= 0) return undefined
880
+ try {
881
+ const history = await this.deps.messageRead.history({
882
+ record,
883
+ limit,
884
+ excludeMessageIds: options.excludeMessageIds ?? [],
885
+ })
886
+ if (history === undefined) return undefined
887
+ for (const message of history.messages) {
888
+ try {
889
+ this.deps.noteKnownAttachments(record, message, 'history')
890
+ } catch (error) {
891
+ this.ctx.logger?.debug?.(
892
+ 'dsh-channels: registering history attachments of %s failed: %s',
893
+ message.messageId, String(error),
894
+ )
895
+ }
896
+ }
897
+ return history.block === '' ? undefined : history.block
898
+ } catch (error) {
899
+ this.ctx.logger?.debug?.(
900
+ 'dsh-channels: reading topic history for %s failed: %s', record.key, String(error),
901
+ )
902
+ return undefined
903
+ }
904
+ }
905
+
906
+
907
+ /** Pending-registry isolation scope for one persisted route. */
908
+ private pendingScopeOfRecord(record: RouteRecord): PendingAttachmentScope {
909
+ return {
910
+ chatId: record.chatId,
911
+ senderOpenId: record.senderOpenId,
912
+ ...(record.kind === 'thread' ? { threadId: record.key } : {}),
913
+ }
914
+ }
915
+
916
+ /** Live channel accessor for best-effort prefetch; undefined when gone. */
917
+ private liveChannel(): FeishuChannel | undefined {
918
+ try {
919
+ return this.delivery.requireChannel()
920
+ } catch {
921
+ return undefined
922
+ }
324
923
  }
924
+
325
925
  /**
326
926
  * Consume one plain reply as an `ask_user_question` free-text answer.
327
927
  *
@@ -395,25 +995,20 @@ export class InboundRouter {
395
995
  const initialStats = { sessionId, inputTokens: 0, outputTokens: 0 }
396
996
 
397
997
  // 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.
998
+ // topic identity is only known after Feishu answers, and it names the
999
+ // topic's private working directory inside the user's workspace.
400
1000
  const sent = await this.delivery.replyFirstCard(msg, initialStreamingCard(sessionId))
401
1001
  const typingId = await typingPromise
402
1002
  const key = sent.degraded || sent.threadId === undefined ? chatKey : sent.threadId
403
1003
  const kind = sent.degraded || sent.threadId === undefined ? 'chat' : 'thread'
404
1004
 
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) {
1005
+ const topicToken = topicTokenFor(key, kind, sessionId)
1006
+ const services: PlacementServices = placementServices(this.ctx)
1007
+ const warn = (message: string, ...args: unknown[]): void => {
1008
+ this.ctx.logger?.warn?.(message, ...args)
1009
+ }
1010
+ /** Patch the streaming card to its terminal creation-failure state. */
1011
+ const failCreation = async (error: unknown): Promise<void> => {
417
1012
  void this.repair.clear(sent.messageId)
418
1013
  await this.delivery.requireChannel().updateCard(sent.messageId, buildCard({
419
1014
  phase: 'done',
@@ -421,6 +1016,20 @@ export class InboundRouter {
421
1016
  body: `会话创建失败:${String(error)}`,
422
1017
  stats: { ...initialStats },
423
1018
  })).catch(() => {})
1019
+ }
1020
+
1021
+ // 3. User workspace (the durable project this Session is grouped under)
1022
+ // plus the per-topic placement plan. No Agent exists yet; the plan
1023
+ // materializes anything that must exist before creation.
1024
+ let workspace: Workspace
1025
+ let plan: TopicPlan
1026
+ try {
1027
+ workspace = await ensureWorkspace(
1028
+ this.ctx, this.config.baseDir, msg.chatType, msg.chatId, msg.senderId,
1029
+ )
1030
+ plan = await planTopic(services, this.config, workspace.path, topicToken, warn)
1031
+ } catch (error) {
1032
+ await failCreation(error)
424
1033
  throw error
425
1034
  }
426
1035
 
@@ -439,27 +1048,35 @@ export class InboundRouter {
439
1048
  attempts: 0,
440
1049
  })
441
1050
 
442
- // 4. Create the agent/session bound to the workspace directory.
443
- let handle: AgentHandle
1051
+ // 4. Create the Session (header cwd = user workspace when directories can
1052
+ // be switched), then enter its own topic directory / worktree.
1053
+ let handle: AgentHandle | undefined
1054
+ let placement: TopicPlacement | undefined
444
1055
  try {
445
- handle = await this.sessions.createSession(sessionId, workspace.path)
1056
+ handle = await this.sessions.createSession(sessionId, plan.cwd)
1057
+ placement = await enterTopic(services, handle.agent, plan, workspace.path, topicToken, warn)
1058
+ // A workspace attachment only validates when the header cwd is the
1059
+ // workspace itself; the hard fallback (no workingDirectory service) must
1060
+ // skip it instead of logging a guaranteed failure.
1061
+ if (plan.cwd === workspace.path) {
1062
+ void Promise.resolve(workspace.attachSession(sessionId)).catch((error: unknown) => {
1063
+ warn('dsh-channels: attaching initial session %s to workspace failed: %s', sessionId, String(error))
1064
+ })
1065
+ }
1066
+ // Files staged before this topic existed by a pre-v2 deployment belong
1067
+ // to this conversation (one-time legacy adoption, see decisions D12).
1068
+ await this.adoptLegacyStagedAttachments(msg, placement.sessionDir, warn)
1069
+ await this.sessions.mountGuidance(handle.agent, placement.sessionDir)
446
1070
  } 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(() => {})
1071
+ // The card lands in a final failure state — no repair needed later. A
1072
+ // Session created just before the failure must not stay live unnoticed.
1073
+ await handle?.dispose().catch(() => {})
1074
+ await failCreation(error)
455
1075
  throw error
456
1076
  }
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
- })
1077
+ if (handle === undefined || placement === undefined) {
1078
+ throw new Error('dsh-channels: session creation completed without a handle')
1079
+ }
463
1080
 
464
1081
  // 5. Persist the route (thread-scoped, or degraded chat-scoped).
465
1082
  const createdAt = handle.agent.session.header.createdAt
@@ -471,40 +1088,72 @@ export class InboundRouter {
471
1088
  chatType: msg.chatType,
472
1089
  senderOpenId: msg.senderId,
473
1090
  workspacePath: workspace.path,
1091
+ sessionDir: placement.sessionDir,
1092
+ ...(placement.worktreeBranch === undefined ? {} : { worktreeBranch: placement.worktreeBranch }),
474
1093
  cardMessageId: sent.messageId,
475
1094
  createdAt,
476
1095
  lastActivityAt: createdAt,
477
1096
  sessionHistory: [{ sessionId, createdAt }],
478
1097
  }
479
- await this.routes.put(key, record)
1098
+ // The delivery gate is opened BEFORE the route becomes durable: a message
1099
+ // that can see the route must also wait for this Session's first followup
1100
+ // (the image prefetch below keeps the route-to-followup window open).
1101
+ const closeDeliveryGate = this.openDeliveryGate(key)
1102
+ try {
1103
+ await this.routes.put(key, record)
480
1104
 
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))
1105
+ // 6. Stream into the card and hand the message to the driver. The prompt
1106
+ // is assembled only now (after the route is durable and tools are
1107
+ // mounted): images may be prefetched, files stay lazy behind the
1108
+ // manifest, and the (first turn only) bounded topic history plus the
1109
+ // quoted-message excerpt are attached as text blocks. `routes.put` and
1110
+ // `followup` stay synchronous with respect to other turns because
1111
+ // later messages wait on the delivery gate.
1112
+ this.streamer?.attach(handle.agent.session, sent.messageId, msg.messageId, typingId)
1113
+ handle.agent.followup(await this.deliverTurnMessage(msg, record, content, {
1114
+ firstTurn: true,
1115
+ excludeMessageIds: [msg.messageId, sent.messageId],
1116
+ }))
1117
+ } finally {
1118
+ closeDeliveryGate()
1119
+ }
484
1120
  return record
485
1121
  }
486
1122
  private async continueSession(msg: NormalizedMessage, record: RouteRecord, content: string): Promise<void> {
487
1123
  // Defence in depth: every current caller checks this first, but a future
488
1124
  // call path must not be able to continue another user's Session.
489
1125
  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
- })
1126
+ const services = placementServices(this.ctx)
1127
+ const warn = (message: string, ...args: unknown[]): void => {
1128
+ this.ctx.logger?.warn?.(message, ...args)
1129
+ }
498
1130
  const typingPromise = this.delivery.requireChannel().addReaction(msg.messageId, 'Typing').catch(() => undefined)
499
1131
  const live = await this.sessions.ensureLive(record)
1132
+ // The topic directory can disappear independently of the Session (manual
1133
+ // deletion, external cleanup). Recreate it and re-align the effective
1134
+ // working directory so the turn never leaks into the shared workspace root.
1135
+ const placement = await restoreTopicDir(services, live.agent, {
1136
+ workspacePath: record.workspacePath,
1137
+ sessionDir: sessionDirOf(record),
1138
+ ...(record.worktreeBranch === undefined ? {} : { worktreeBranch: record.worktreeBranch }),
1139
+ }, warn)
1140
+ let current: RouteRecord = record
1141
+ if (placement.sessionDir !== sessionDirOf(record) || placement.worktreeBranch !== record.worktreeBranch) {
1142
+ current = { ...record, sessionDir: placement.sessionDir }
1143
+ delete current.worktreeBranch
1144
+ if (placement.worktreeBranch !== undefined) current = { ...current, worktreeBranch: placement.worktreeBranch }
1145
+ }
1146
+ // Files staged before this conversation's topic existed by a pre-v2
1147
+ // deployment follow its next turn (one-time legacy adoption, D12).
1148
+ await this.adoptLegacyStagedAttachments(msg, placement.sessionDir, warn)
500
1149
 
501
- const sent = await this.delivery.replyFirstCard(msg, initialStreamingCard(record.sessionId))
1150
+ const sent = await this.delivery.replyFirstCard(msg, initialStreamingCard(current.sessionId))
502
1151
  const typingId = await typingPromise
503
1152
 
504
1153
  // Repair snapshot first — same crash-window reasoning as `startSession`.
505
1154
  await this.repair.write({
506
1155
  key: sent.messageId,
507
- sessionId: record.sessionId,
1156
+ sessionId: current.sessionId,
508
1157
  chatId: msg.chatId,
509
1158
  triggerMessageId: msg.messageId,
510
1159
  reactionId: typingId,
@@ -513,12 +1162,12 @@ export class InboundRouter {
513
1162
  attempts: 0,
514
1163
  })
515
1164
 
516
- await this.routes.put(record.key, {
517
- ...record,
1165
+ await this.routes.put(current.key, {
1166
+ ...current,
518
1167
  cardMessageId: sent.messageId,
519
1168
  lastActivityAt: Date.now(),
520
1169
  })
521
1170
  this.streamer?.attach(live.agent.session, sent.messageId, msg.messageId, typingId)
522
- live.agent.followup(feishuUserMessage(content))
1171
+ live.agent.followup(await this.deliverTurnMessage(msg, current, content))
523
1172
  }
524
1173
  }