@modesto_ma/dsh-channels 0.2.0 → 0.3.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.
package/src/config.ts CHANGED
@@ -103,6 +103,23 @@ export const configSchema = z.object({
103
103
  * `strict` fails the reply instead.
104
104
  */
105
105
  threadFallback: z.enum(['degrade', 'strict']).default('degrade'),
106
+ /**
107
+ * Per-topic working-directory isolation inside one user workspace.
108
+ *
109
+ * - `auto` (default): use a Git worktree per topic when the user workspace
110
+ * is a Git repository (the workspace directory contains `.git`), otherwise
111
+ * a plain subdirectory.
112
+ * - `subdir`: always a plain subdirectory (no Git requirement, no duplicated
113
+ * checkouts).
114
+ * - `worktree`: always try a Git worktree; when the workspace is not a Git
115
+ * repository, the `worktrees` service is unavailable, or creation is
116
+ * denied, log a warning and fall back to a subdirectory.
117
+ *
118
+ * The workspace itself stays the Session header cwd either way, so Sessions
119
+ * remain grouped under the user's project; only the effective working
120
+ * directory changes. See `docs/workspace-isolation-design.md`.
121
+ */
122
+ topicIsolation: z.enum(['auto', 'subdir', 'worktree']).default('auto'),
106
123
  /**
107
124
  * Route-table GC threshold. Route records (and stale in-flight repair
108
125
  * snapshots) whose last activity is older than this many milliseconds are
@@ -129,17 +146,19 @@ export const configSchema = z.object({
129
146
  */
130
147
  acceptAttachments: z.boolean().default(true),
131
148
  /**
132
- * Delete the workspace directories of stale conversations. `off` (default)
133
- * never deletes; `7d`/`30d`/`90d`/`180d` remove plugin-created workspaces
134
- * whose last activity is older than the threshold. The managed Web panel
135
- * exposes this as a select, previews the candidates, and can run one
136
- * cleanup manually. DSH Session logs are never deleted.
149
+ * Delete the topic directories of stale conversations, plus legacy
150
+ * one-workspace-per-topic directories written by older plugin versions.
151
+ * `off` (default) never deletes; `7d`/`30d`/`90d`/`180d` remove those
152
+ * directories when their last activity is older than the threshold. User
153
+ * workspaces themselves are never deleted. The managed Web panel exposes
154
+ * this as a select, previews the candidates, and can run one cleanup
155
+ * manually. DSH Session logs are never deleted.
137
156
  */
138
157
  workspaceRetention: z.enum(WORKSPACE_RETENTION_VALUES).default('off'),
139
158
  /**
140
- * Run the workspace-retention cleanup at startup and once per day. Off by
141
- * default: deletion is irreversible, so it stays an explicit opt-in even
142
- * when a retention threshold is configured.
159
+ * Run the retention cleanup at startup and once per day. Off by default:
160
+ * deletion is irreversible, so it stays an explicit opt-in even when a
161
+ * retention threshold is configured.
143
162
  */
144
163
  workspaceRetentionAuto: z.boolean().default(false),
145
164
  /**
package/src/domain.ts CHANGED
@@ -10,8 +10,16 @@
10
10
 
11
11
  import { z } from 'zod'
12
12
 
13
- /** Route key kinds. `thread` = real Feishu topic; `chat` = degraded chat-scoped fallback. */
14
- export const ROUTE_RECORD_VERSION = 1
13
+ /**
14
+ * Route key kinds. `thread` = real Feishu topic; `chat` = degraded chat-scoped
15
+ * fallback.
16
+ *
17
+ * Version 2 adds the optional `sessionDir` / `worktreeBranch` fields (the
18
+ * Session's effective working directory). Version-1 records stay readable: an
19
+ * absent `sessionDir` means the workspace path itself is the working directory
20
+ * (the pre-v2 model).
21
+ */
22
+ export const ROUTE_RECORD_VERSION = 2
15
23
 
16
24
  export const sessionHistoryEntrySchema = z.object({
17
25
  /** One Session that has been bound to this route. */
@@ -35,8 +43,16 @@ export const routeRecordSchema = z.object({
35
43
  chatType: z.enum(['p2p', 'group']),
36
44
  /** Workspace owner (sender open_id). */
37
45
  senderOpenId: z.string().min(1),
38
- /** Workspace directory for this (chat, user) pair. */
46
+ /** Workspace root owning this route: the Session header cwd / sandbox root. */
39
47
  workspacePath: z.string().min(1),
48
+ /**
49
+ * Effective working directory of the bound Session (the per-topic directory
50
+ * or Git worktree checkout). Absent on records written before per-topic
51
+ * directories existed: `workspacePath` is then the working directory too.
52
+ */
53
+ sessionDir: z.string().min(1).optional(),
54
+ /** Git branch owning `sessionDir` when it is a worktree checkout. */
55
+ worktreeBranch: z.string().min(1).optional(),
40
56
  /** Message id of the latest streaming card (updated per turn). */
41
57
  cardMessageId: z.string().min(1),
42
58
  /** Epoch ms when the record was created. */
@@ -71,6 +87,12 @@ export const expiredRouteRecordSchema = z.object({
71
87
  /** Workspace the retired route belonged to; absent in tombstones written by
72
88
  * older plugin versions, so the field must stay optional. */
73
89
  workspacePath: z.string().min(1).optional(),
90
+ /** Effective working directory the retired route's Session used; absent in
91
+ * legacy tombstones (retention cleanup then treats `workspacePath` as the
92
+ * directory, matching pre-v2 routes). */
93
+ sessionDir: z.string().min(1).optional(),
94
+ /** Git branch of that directory when it was a worktree checkout. */
95
+ worktreeBranch: z.string().min(1).optional(),
74
96
  /** Epoch ms of the last activity through the route before retirement.
75
97
  * Optional for the same legacy-compatibility reason; consumers fall back to
76
98
  * `expiredAt` when it is absent. */
@@ -116,6 +138,15 @@ export function appendSessionHistory(
116
138
  })
117
139
  }
118
140
 
141
+ /**
142
+ * Effective working directory of one route's Session. Records written before
143
+ * per-topic directories have no `sessionDir`; their workspace path is the
144
+ * directory the Session really works in.
145
+ */
146
+ export function sessionDirOf(record: Pick<RouteRecord, 'workspacePath' | 'sessionDir'>): string {
147
+ return record.sessionDir ?? record.workspacePath
148
+ }
149
+
119
150
  /**
120
151
  * In-flight turn snapshot: everything needed to repair a streaming card that a
121
152
  * restart left mid-flight (stuck panel, leftover Typing reaction, never-patched
@@ -2,12 +2,16 @@
2
2
  * Agent-scoped system-prompt guidance for Feishu attachments.
3
3
  *
4
4
  * The channel archives inbound images/files/forwarded records below
5
- * `<workspace>/attachments/` without any model turn. This section is what
6
- * makes that convention discoverable: it tells the Agent where attachments
7
- * live, how to read them, and that reading is opt-in per user request.
5
+ * `<session working directory>/attachments/` without any model turn. This
6
+ * section is what makes that convention discoverable: it tells the Agent where
7
+ * attachments live, how to read them, and that reading is opt-in per user
8
+ * request.
8
9
  *
9
10
  * Registered inside the Agent scope (through `inject(['systemPrompt'])`), so
10
11
  * it is disposed with the Agent and does not leak into other compositions.
12
+ * It is mounted only after the Session's effective working directory is known
13
+ * (see `SessionManager.mountGuidance`), so the absolute path is accurate even
14
+ * when that directory is a Git worktree created after Agent creation.
11
15
  *
12
16
  * @module dsh-channels/feishu/agent-context
13
17
  */
@@ -26,13 +30,13 @@ const FEISHU_ATTACHMENTS_ORDER = 950
26
30
 
27
31
  /** Model-facing text. Kept stable so unchanged sessions keep their KV cache. */
28
32
  export const FEISHU_ATTACHMENTS_GUIDANCE = `## Feishu attachments
29
- Every Feishu conversation (topic) has its own workspace and its own attachments directory. Images, files, and forwarded chat records sent through Feishu are archived verbatim in this conversation's \`attachments/\` directory. Each file name starts with its receipt time (\`YYYYMMDD-HHmmss-\`), so newer attachments sort later and \`ls -lt <dir>\` lists the newest first.
33
+ Every Feishu conversation (topic) has its own working directory inside its user's workspace, and every working directory has its own \`attachments/\` directory. Images, files, and forwarded chat records sent through Feishu are archived verbatim in this conversation's \`attachments/\` directory. Each file name starts with its receipt time (\`YYYYMMDD-HHmmss-\`), so newer attachments sort later and \`ls -lt <dir>\` lists the newest first.
30
34
 
31
35
  Attachments are never inserted into the conversation automatically. Read them only when the user's request needs them, and do not read attachments the user did not mention:
32
36
  - images: use \`read_image\` with the file path;
33
37
  - text and code: use \`read\` when the session mounts that tool, otherwise \`bash\` (for example \`sed -n '1,200p' <file>\`);
34
38
  - other formats (PDF, Office, archives, ...): inspect or convert them with \`bash\`.
35
- If the user refers to "the file/image I just sent" without a name, list this conversation's attachments directory and pick the most recent matching attachment. Other Feishu conversations have separate workspaces: never read outside this workspace, and never mention files from another conversation.`
39
+ If the user refers to "the file/image I just sent" without a name, list this conversation's attachments directory and pick the most recent matching attachment. Other Feishu conversations work in separate directories: never read outside this conversation's working directory, and never mention files from another conversation.`
36
40
 
37
41
  /**
38
42
  * Full guidance for one Session. The absolute per-session directory is
package/src/index.ts CHANGED
@@ -3,12 +3,14 @@
3
3
  *
4
4
  * 一个 dsh 实例对应一个飞书机器人;用户通过私聊、群聊 @、话题群 @ 三种方式
5
5
  * 发起会话。每条触发消息:立即加 “Typing” 表情 → 以话题形式回复(创建话题)
6
- * → 绑定 DSH session → 卡片 2.0 流式回复(推理折叠面板 + 富文本正文 + 两行
7
- * session 信息,默认 500ms 刷新)→ 流式结束移除 Typing,成功更新为
6
+ * → 绑定 DSH session → 卡片 2.0 流式回复(推理折叠面板 + 临时活动行 +
7
+ * 富文本正文 + 结算页脚,默认 500ms 刷新)→ 流式结束移除 Typing,成功更新为
8
8
  * “DONE”,失败显示结构化错误并更新为 “No”。
9
9
  *
10
10
  * 工作区归属:私聊一人一工作区;群聊中每个用户在该群拥有独立工作区
11
- * (目录即工作区,会话以 cwd 落盘)。
11
+ * (工作区是会话 header cwd,保证 GUI 归属与沙箱边界)。每个话题在该工作区
12
+ * 内拥有独立的工作目录(`topics/<话题>`,Git 仓库工作区可自动改用 worktree),
13
+ * 因此话题之间的文件互不干扰,而工作区数量只随用户增长。
12
14
  *
13
15
  * @module dsh-channels
14
16
  */
@@ -51,7 +53,7 @@ export type { HostDirectoryPickerService, SettingsDirectoryPicker } from './sett
51
53
  export { isSameOriginRequest, mountSettingsRoutes, registerSettingsRoutes } from './settings/routes.ts'
52
54
  export type { HostWebServer } from './settings/routes.ts'
53
55
  export type { ApprovalOutcome, ApprovalRequest, ApprovalAnswererListener, AssistantStreamFrame, AskUserQuestionAnswer, AskUserQuestionAnswerItem, AskUserQuestionItem, AskUserQuestionRequestEvent, UserQuestionAnswererListener, AgentCancelCause, SessionStatsView, TokenUsageView, ContextPressureView, SessionProjections } from './dsh-types.ts'
54
- export { buildCard, buildInterruptionCard, cardJsonBytes, CARD_MAX_BYTES, CARD_TARGET_BYTES, CARD_PANEL_RESERVE_BYTES, REASONING_MAX_BYTES, INTERRUPTION_NOTICE, MAX_TABLES_PER_CARD, PANEL_TITLES, PANEL_CHEVRON_TOKEN, segmentCardContent, splitBodyForCard, stripTables, tableBlocks, TRUNCATION_SUFFIX, buildApprovalCard, buildApprovalSettledCard, buildQuestionCard, buildQuestionSettledCard, MAX_QUESTION_OPTIONS } from './cards.ts'
56
+ export { buildCard, buildInterruptionCard, cardJsonBytes, CARD_MAX_BYTES, CARD_TARGET_BYTES, REASONING_MAX_BYTES, INTERRUPTION_NOTICE, MAX_TABLES_PER_CARD, PANEL_TITLES, PANEL_CHEVRON_TOKEN, segmentCardContent, splitBodyForCard, stripTables, tableBlocks, TRUNCATION_SUFFIX, buildApprovalCard, buildApprovalSettledCard, buildQuestionCard, buildQuestionSettledCard, MAX_QUESTION_OPTIONS } from './cards.ts'
55
57
  export { ApprovalAnswerer } from './approvals.ts'
56
58
  export { QuestionAnswerer } from './questions.ts'
57
59
  export { armModelCommand, buildCommandResultCard, CHANNEL_COMMAND_NAMES, commandResultCardKind, isSupportedCommand, parseSlashCommand, registerChannelCommands, registerModelCommand, RouteOperationQueue, SUPPORTED_COMMAND_NAMES, supportedCommandHelp, supportedCommandList } from './commands.ts'
@@ -61,8 +63,8 @@ export { createLogicalSessionExport, DSH_CHANNELS_VERSION, LOGICAL_EXPORT_FORMAT
61
63
  export { addUsage, applyProjectionViews, averageDecodeTokensPerSecond, averageTtftMs, cacheHitPct, EMPTY_TOTALS, foldAll, foldEvent, foldSessionStats, measureSessionContext, readProjectionViews, usageFromBuckets } from './stats.ts'
62
64
  export { FeishuService } from './service.ts'
63
65
  export { TurnStreamer } from './streamer.ts'
64
- export { ensureWorkspace, stagingPathFor, topicTokenFor, workspacePathFor, workspaceTitleFor } from './workspaces.ts'
65
- export { accessGroupRecordSchema, accessRequestRecordSchema, accessUserRecordSchema, appendSessionHistory, chatRouteKey, expiredRouteRecordSchema, feishuDomain, normalizeRouteRecord, normalizeSessionHistory, routeRecordSchema, sessionHistoryEntrySchema, inflightSnapshotSchema, processedRecordSchema } from './domain.ts'
66
+ export { ensureWorkspace, legacyTopicWorkspaceRoot, stagingPathFor, topicDirFor, topicTokenFor, workspacePathFor, workspaceTitleFor, worktreeBranchFor } from './workspaces.ts'
67
+ export { accessGroupRecordSchema, accessRequestRecordSchema, accessUserRecordSchema, appendSessionHistory, chatRouteKey, expiredRouteRecordSchema, feishuDomain, normalizeRouteRecord, normalizeSessionHistory, routeRecordSchema, sessionDirOf, sessionHistoryEntrySchema, inflightSnapshotSchema, processedRecordSchema } from './domain.ts'
66
68
 
67
69
  /** Cordis function-plugin name. */
68
70
  export const name = 'channels'
@@ -14,7 +14,7 @@ import type { AgentHandle, HostContext, SessionId, UserMessage, Workspace } from
14
14
  import type { FeishuConfig } from '../config.ts'
15
15
  import { buildCard, buildOnboardingCard, initialStreamingCard } from '../cards.ts'
16
16
  import { RouteOperationQueue } from '../commands.ts'
17
- import { chatRouteKey } from '../domain.ts'
17
+ import { chatRouteKey, sessionDirOf } from '../domain.ts'
18
18
  import type { FeishuDomainHandle, RouteRecord } from '../domain.ts'
19
19
  import type { CardDelivery } from '../feishu/delivery.ts'
20
20
  import {
@@ -28,6 +28,10 @@ import type { InflightRepair } from '../session/repair.ts'
28
28
  import type { SessionManager } from '../session/manager.ts'
29
29
  import type { RouteStore } from '../session/routes.ts'
30
30
  import type { TurnStreamer } from '../streamer.ts'
31
+ import {
32
+ enterTopic, placementServices, planTopic, restoreTopicDir,
33
+ } from '../session/isolation.ts'
34
+ import type { PlacementServices, TopicPlacement, TopicPlan } from '../session/isolation.ts'
31
35
  import { ensureWorkspace, stagingPathFor, topicTokenFor } from '../workspaces.ts'
32
36
 
33
37
  /** The command plane the inbound router delegates admission-time work to. */
@@ -313,12 +317,12 @@ export class InboundRouter {
313
317
 
314
318
  return await this.routeOperations.run(record?.key ?? chatKey, async () => {
315
319
  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.
320
+ // An existing topic already has its own working directory; a
321
+ // conversation without one stages files below baseDir until its first
322
+ // Session creates the topic directory and adopts them.
319
323
  const directory = record === undefined
320
324
  ? stagingPathFor(this.config.baseDir, msg.chatType, msg.chatId, msg.senderId)
321
- : attachmentsDir(record.workspacePath)
325
+ : attachmentsDir(sessionDirOf(record))
322
326
  return await this.attachments.archive(msg, directory)
323
327
  })
324
328
  }
@@ -395,25 +399,20 @@ export class InboundRouter {
395
399
  const initialStats = { sessionId, inputTokens: 0, outputTokens: 0 }
396
400
 
397
401
  // 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.
402
+ // topic identity is only known after Feishu answers, and it names the
403
+ // topic's private working directory inside the user's workspace.
400
404
  const sent = await this.delivery.replyFirstCard(msg, initialStreamingCard(sessionId))
401
405
  const typingId = await typingPromise
402
406
  const key = sent.degraded || sent.threadId === undefined ? chatKey : sent.threadId
403
407
  const kind = sent.degraded || sent.threadId === undefined ? 'chat' : 'thread'
404
408
 
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) {
409
+ const topicToken = topicTokenFor(key, kind, sessionId)
410
+ const services: PlacementServices = placementServices(this.ctx)
411
+ const warn = (message: string, ...args: unknown[]): void => {
412
+ this.ctx.logger?.warn?.(message, ...args)
413
+ }
414
+ /** Patch the streaming card to its terminal creation-failure state. */
415
+ const failCreation = async (error: unknown): Promise<void> => {
417
416
  void this.repair.clear(sent.messageId)
418
417
  await this.delivery.requireChannel().updateCard(sent.messageId, buildCard({
419
418
  phase: 'done',
@@ -421,6 +420,20 @@ export class InboundRouter {
421
420
  body: `会话创建失败:${String(error)}`,
422
421
  stats: { ...initialStats },
423
422
  })).catch(() => {})
423
+ }
424
+
425
+ // 3. User workspace (the durable project this Session is grouped under)
426
+ // plus the per-topic placement plan. No Agent exists yet; the plan
427
+ // materializes anything that must exist before creation.
428
+ let workspace: Workspace
429
+ let plan: TopicPlan
430
+ try {
431
+ workspace = await ensureWorkspace(
432
+ this.ctx, this.config.baseDir, msg.chatType, msg.chatId, msg.senderId,
433
+ )
434
+ plan = await planTopic(services, this.config, workspace.path, topicToken, warn)
435
+ } catch (error) {
436
+ await failCreation(error)
424
437
  throw error
425
438
  }
426
439
 
@@ -439,27 +452,39 @@ export class InboundRouter {
439
452
  attempts: 0,
440
453
  })
441
454
 
442
- // 4. Create the agent/session bound to the workspace directory.
443
- let handle: AgentHandle
455
+ // 4. Create the Session (header cwd = user workspace when directories can
456
+ // be switched), then enter its own topic directory / worktree.
457
+ let handle: AgentHandle | undefined
458
+ let placement: TopicPlacement | undefined
444
459
  try {
445
- handle = await this.sessions.createSession(sessionId, workspace.path)
460
+ handle = await this.sessions.createSession(sessionId, plan.cwd)
461
+ placement = await enterTopic(services, handle.agent, plan, workspace.path, topicToken, warn)
462
+ // A workspace attachment only validates when the header cwd is the
463
+ // workspace itself; the hard fallback (no workingDirectory service) must
464
+ // skip it instead of logging a guaranteed failure.
465
+ if (plan.cwd === workspace.path) {
466
+ void Promise.resolve(workspace.attachSession(sessionId)).catch((error: unknown) => {
467
+ warn('dsh-channels: attaching initial session %s to workspace failed: %s', sessionId, String(error))
468
+ })
469
+ }
470
+ // Files staged before this topic existed belong to this conversation.
471
+ await adoptStagedAttachments(
472
+ stagingPathFor(this.config.baseDir, msg.chatType, msg.chatId, msg.senderId),
473
+ placement.sessionDir,
474
+ ).catch((error: unknown) => {
475
+ warn('dsh-channels: adopting staged attachments failed: %s', String(error))
476
+ })
477
+ await this.sessions.mountGuidance(handle.agent, placement.sessionDir)
446
478
  } 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(() => {})
479
+ // The card lands in a final failure state — no repair needed later. A
480
+ // Session created just before the failure must not stay live unnoticed.
481
+ await handle?.dispose().catch(() => {})
482
+ await failCreation(error)
455
483
  throw error
456
484
  }
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
- })
485
+ if (handle === undefined || placement === undefined) {
486
+ throw new Error('dsh-channels: session creation completed without a handle')
487
+ }
463
488
 
464
489
  // 5. Persist the route (thread-scoped, or degraded chat-scoped).
465
490
  const createdAt = handle.agent.session.header.createdAt
@@ -471,6 +496,8 @@ export class InboundRouter {
471
496
  chatType: msg.chatType,
472
497
  senderOpenId: msg.senderId,
473
498
  workspacePath: workspace.path,
499
+ sessionDir: placement.sessionDir,
500
+ ...(placement.worktreeBranch === undefined ? {} : { worktreeBranch: placement.worktreeBranch }),
474
501
  cardMessageId: sent.messageId,
475
502
  createdAt,
476
503
  lastActivityAt: createdAt,
@@ -487,24 +514,42 @@ export class InboundRouter {
487
514
  // Defence in depth: every current caller checks this first, but a future
488
515
  // call path must not be able to continue another user's Session.
489
516
  if (!this.access.ownsRoute(msg, record)) return
517
+ const services = placementServices(this.ctx)
518
+ const warn = (message: string, ...args: unknown[]): void => {
519
+ this.ctx.logger?.warn?.(message, ...args)
520
+ }
521
+ const typingPromise = this.delivery.requireChannel().addReaction(msg.messageId, 'Typing').catch(() => undefined)
522
+ const live = await this.sessions.ensureLive(record)
523
+ // The topic directory can disappear independently of the Session (manual
524
+ // deletion, external cleanup). Recreate it and re-align the effective
525
+ // working directory so the turn never leaks into the shared workspace root.
526
+ const placement = await restoreTopicDir(services, live.agent, {
527
+ workspacePath: record.workspacePath,
528
+ sessionDir: sessionDirOf(record),
529
+ ...(record.worktreeBranch === undefined ? {} : { worktreeBranch: record.worktreeBranch }),
530
+ }, warn)
531
+ let current: RouteRecord = record
532
+ if (placement.sessionDir !== sessionDirOf(record) || placement.worktreeBranch !== record.worktreeBranch) {
533
+ current = { ...record, sessionDir: placement.sessionDir }
534
+ delete current.worktreeBranch
535
+ if (placement.worktreeBranch !== undefined) current = { ...current, worktreeBranch: placement.worktreeBranch }
536
+ }
490
537
  // Files staged before this conversation's topic existed follow its next
491
538
  // turn (for example a file sent in the chat before the first prompt).
492
539
  await adoptStagedAttachments(
493
540
  stagingPathFor(this.config.baseDir, msg.chatType, msg.chatId, msg.senderId),
494
- record.workspacePath,
541
+ placement.sessionDir,
495
542
  ).catch((error: unknown) => {
496
543
  this.ctx.logger?.warn?.('dsh-channels: adopting staged attachments failed: %s', String(error))
497
544
  })
498
- const typingPromise = this.delivery.requireChannel().addReaction(msg.messageId, 'Typing').catch(() => undefined)
499
- const live = await this.sessions.ensureLive(record)
500
545
 
501
- const sent = await this.delivery.replyFirstCard(msg, initialStreamingCard(record.sessionId))
546
+ const sent = await this.delivery.replyFirstCard(msg, initialStreamingCard(current.sessionId))
502
547
  const typingId = await typingPromise
503
548
 
504
549
  // Repair snapshot first — same crash-window reasoning as `startSession`.
505
550
  await this.repair.write({
506
551
  key: sent.messageId,
507
- sessionId: record.sessionId,
552
+ sessionId: current.sessionId,
508
553
  chatId: msg.chatId,
509
554
  triggerMessageId: msg.messageId,
510
555
  reactionId: typingId,
@@ -513,8 +558,8 @@ export class InboundRouter {
513
558
  attempts: 0,
514
559
  })
515
560
 
516
- await this.routes.put(record.key, {
517
- ...record,
561
+ await this.routes.put(current.key, {
562
+ ...current,
518
563
  cardMessageId: sent.messageId,
519
564
  lastActivityAt: Date.now(),
520
565
  })