@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
@@ -0,0 +1,277 @@
1
+ /**
2
+ * Per-topic Session placement: which directory each Feishu conversation works
3
+ * in, without growing the host workspace list with every topic.
4
+ *
5
+ * A Session's **header cwd** is fixed at creation and decides two things:
6
+ * its workspace-registry membership (the GUI project it is grouped under) and
7
+ * the sandbox write boundary. The **effective working directory** is a mutable
8
+ * Session projection (`ctx.workingDirectory`), persisted in the Session log
9
+ * and restored on resume; the bash/fs/terminal tools resolve it before every
10
+ * call.
11
+ *
12
+ * This module plans one topic's placement:
13
+ *
14
+ * - `subdir`: `<workspace>/topics/<token>` — a plain directory switched to via
15
+ * `workingDirectory.set`.
16
+ * - `worktree`: a Git worktree created through `ctx.worktrees` (branch
17
+ * `feishu-<token>`), used when the workspace is a Git repository or when the
18
+ * deployment forces it.
19
+ *
20
+ * Both modes keep the Session's header cwd on the workspace root, so the
21
+ * Session stays grouped under the user's project. When the host exposes no
22
+ * `workingDirectory` service (unusual: it ships with `dsh-base`), the Session
23
+ * is created directly in the topic directory instead — file isolation still
24
+ * holds, but the Session can no longer be attached to the workspace.
25
+ *
26
+ * @module dsh-channels/session/isolation
27
+ */
28
+
29
+ import { randomUUID } from 'node:crypto'
30
+ import { mkdir, stat } from 'node:fs/promises'
31
+ import path from 'node:path'
32
+ import type { Agent, HostContext, Session } from '../dsh-types.ts'
33
+ import type { FeishuConfig } from '../config.ts'
34
+ import { topicDirFor, worktreeBranchFor } from '../workspaces.ts'
35
+
36
+ // ---- Host service slices (structural, resolved through ctx.get) ----------
37
+
38
+ /**
39
+ * The effective-directory service (`@deepseek-ai/dsh-working-directory`,
40
+ * mounted by every base-backed profile). `set` appends a durable
41
+ * `working-directory/change` event and queues a user-context notice.
42
+ */
43
+ export interface WorkingDirectoryService {
44
+ get(session: Session): string
45
+ set(agent: Agent, path: string, signal?: AbortSignal): Promise<string>
46
+ }
47
+
48
+ /** One created Git worktree checkout (`ctx.worktrees.create` result). */
49
+ export interface CreatedWorktree {
50
+ path: string
51
+ branch: string
52
+ baseCommit: string
53
+ repositoryRoot: string
54
+ }
55
+
56
+ /**
57
+ * The optional Git-worktree service (`@deepseek-ai/dsh-experimental-worktree`).
58
+ * It is not mounted everywhere, so every use is guarded.
59
+ */
60
+ export interface WorktreeService {
61
+ create(
62
+ agent: Agent,
63
+ request?: { name?: string; from?: string },
64
+ signal?: AbortSignal,
65
+ ): Promise<CreatedWorktree>
66
+ }
67
+
68
+ /** Placement capabilities resolved once per call site. */
69
+ export interface PlacementServices {
70
+ workingDirectory?: WorkingDirectoryService
71
+ worktrees?: WorktreeService
72
+ }
73
+
74
+ /** Resolve the placement capabilities from the host context (all optional). */
75
+ export function placementServices(ctx: HostContext): PlacementServices {
76
+ const get = <T>(name: string): T | undefined => ctx.get?.(name) as T | undefined
77
+ return {
78
+ workingDirectory: get<WorkingDirectoryService>('workingDirectory'),
79
+ worktrees: get<WorktreeService>('worktrees'),
80
+ }
81
+ }
82
+
83
+ /** Isolation mode actually used for one topic. */
84
+ export type TopicMode = 'subdir' | 'worktree'
85
+
86
+ /** What the caller needs before creating the Session. */
87
+ export interface TopicPlan {
88
+ /** Isolation mode chosen for this topic. */
89
+ readonly mode: TopicMode
90
+ /** Directory to pass as `meta.cwd` (the Session header cwd). */
91
+ readonly cwd: string
92
+ /** Topic directory for `subdir` mode (already created). */
93
+ readonly subdir?: string
94
+ /** Whether the deployment can switch the Session's working directory. */
95
+ readonly switchable: boolean
96
+ }
97
+
98
+ /** Where one topic's Session actually works. */
99
+ export interface TopicPlacement {
100
+ /** Effective working directory (`working-directory` projection value). */
101
+ readonly sessionDir: string
102
+ /** Git branch owning `sessionDir` when it is a worktree checkout. */
103
+ readonly worktreeBranch?: string
104
+ }
105
+
106
+ /** Structured logger slice; best-effort warnings never throw. */
107
+ export type PlacementWarn = (message: string, ...args: unknown[]) => void
108
+
109
+ /** Whether the workspace directory is itself a Git working tree. */
110
+ export async function workspaceHasGitRepository(workspacePath: string): Promise<boolean> {
111
+ try {
112
+ const info = await stat(path.join(workspacePath, '.git'))
113
+ return info.isDirectory() || info.isFile()
114
+ } catch {
115
+ return false
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Pick the isolation mode for one topic. `auto` upgrades to a worktree only
121
+ * when both the capability and a Git repository are present; an explicit
122
+ * `worktree` preference still falls back (with a warning) when unavailable.
123
+ */
124
+ async function chooseTopicMode(
125
+ services: PlacementServices,
126
+ config: FeishuConfig,
127
+ workspacePath: string,
128
+ warn: PlacementWarn,
129
+ ): Promise<TopicMode> {
130
+ const requested = config.topicIsolation
131
+ if (requested === 'subdir') return 'subdir'
132
+ const capable = services.workingDirectory !== undefined && services.worktrees !== undefined
133
+ if (!capable) {
134
+ if (requested === 'worktree') {
135
+ warn('dsh-channels: topicIsolation=worktree needs the workingDirectory and worktrees host services; using a plain topic directory')
136
+ }
137
+ return 'subdir'
138
+ }
139
+ if (requested === 'worktree') return 'worktree'
140
+ return await workspaceHasGitRepository(workspacePath) ? 'worktree' : 'subdir'
141
+ }
142
+
143
+ /**
144
+ * Decide how a new Session is placed and materialize everything that must
145
+ * exist before the Session is created.
146
+ *
147
+ * The returned `cwd` is the header cwd: the workspace root when the runtime
148
+ * can switch directories (so the Session is grouped under the user's project),
149
+ * otherwise the topic directory itself.
150
+ */
151
+ export async function planTopic(
152
+ services: PlacementServices,
153
+ config: FeishuConfig,
154
+ workspacePath: string,
155
+ topicToken: string,
156
+ warn: PlacementWarn,
157
+ ): Promise<TopicPlan> {
158
+ const mode = await chooseTopicMode(services, config, workspacePath, warn)
159
+ if (services.workingDirectory !== undefined) {
160
+ const subdir = mode === 'subdir' ? topicDirFor(workspacePath, topicToken) : undefined
161
+ if (subdir !== undefined) await mkdir(subdir, { recursive: true })
162
+ return {
163
+ mode,
164
+ cwd: workspacePath,
165
+ ...(subdir === undefined ? {} : { subdir }),
166
+ switchable: true,
167
+ }
168
+ }
169
+ // No effective-directory service: create the Session inside the topic
170
+ // directory itself. Isolation is preserved; workspace membership is lost.
171
+ const subdir = topicDirFor(workspacePath, topicToken)
172
+ await mkdir(subdir, { recursive: true })
173
+ return { mode: 'subdir', cwd: subdir, subdir, switchable: false }
174
+ }
175
+
176
+ /** Enter the planned topic directory once the Session (and its Agent) exists. */
177
+ export async function enterTopic(
178
+ services: PlacementServices,
179
+ agent: Agent,
180
+ plan: TopicPlan,
181
+ workspacePath: string,
182
+ topicToken: string,
183
+ warn: PlacementWarn,
184
+ ): Promise<TopicPlacement> {
185
+ if (plan.mode === 'worktree' && services.worktrees !== undefined) {
186
+ try {
187
+ const created = await services.worktrees.create(agent, { name: worktreeBranchFor(topicToken) })
188
+ return { sessionDir: created.path, worktreeBranch: created.branch }
189
+ } catch (error) {
190
+ warn(
191
+ 'dsh-channels: creating a worktree for topic %s failed, using a plain topic directory instead: %s',
192
+ topicToken, String(error),
193
+ )
194
+ }
195
+ }
196
+ const dir = plan.subdir ?? topicDirFor(workspacePath, topicToken)
197
+ await mkdir(dir, { recursive: true })
198
+ if (plan.switchable) await alignWorkingDirectory(services, agent, dir, warn)
199
+ return { sessionDir: dir }
200
+ }
201
+
202
+ /**
203
+ * Align one live Session with its recorded topic directory: make sure the
204
+ * directory exists, recreate it (or a fresh worktree) when it disappeared,
205
+ * and restore the effective working directory the route expects.
206
+ *
207
+ * The returned placement may carry a new directory/branch after recovery; the
208
+ * caller persists it back into the route record.
209
+ */
210
+ export async function restoreTopicDir(
211
+ services: PlacementServices,
212
+ agent: Agent,
213
+ input: {
214
+ readonly workspacePath: string
215
+ readonly sessionDir: string
216
+ readonly worktreeBranch?: string
217
+ },
218
+ warn: PlacementWarn,
219
+ ): Promise<TopicPlacement> {
220
+ const { sessionDir, worktreeBranch } = input
221
+ if (await isDirectory(sessionDir)) {
222
+ await alignWorkingDirectory(services, agent, sessionDir, warn)
223
+ return worktreeBranch === undefined ? { sessionDir } : { sessionDir, worktreeBranch }
224
+ }
225
+ // The directory is gone (manual deletion, external cleanup, lost host):
226
+ // give the Session a fresh directory so it never silently falls back to the
227
+ // shared workspace root.
228
+ if (worktreeBranch !== undefined
229
+ && services.worktrees !== undefined
230
+ && services.workingDirectory !== undefined) {
231
+ try {
232
+ const created = await services.worktrees.create(agent, {
233
+ name: `${worktreeBranch}-r${randomUUID().slice(0, 8)}`,
234
+ })
235
+ return { sessionDir: created.path, worktreeBranch: created.branch }
236
+ } catch (error) {
237
+ warn(
238
+ 'dsh-channels: recreating the worktree for session %s failed, using a plain directory: %s',
239
+ agent.id, String(error),
240
+ )
241
+ }
242
+ }
243
+ await mkdir(sessionDir, { recursive: true })
244
+ await alignWorkingDirectory(services, agent, sessionDir, warn)
245
+ return { sessionDir }
246
+ }
247
+
248
+ /** Best-effort check used only for placement decisions, never for security. */
249
+ async function isDirectory(dir: string): Promise<boolean> {
250
+ try {
251
+ return (await stat(dir)).isDirectory()
252
+ } catch {
253
+ return false
254
+ }
255
+ }
256
+
257
+ /** Point the Session's effective working directory at `dir`. */
258
+ async function alignWorkingDirectory(
259
+ services: PlacementServices,
260
+ agent: Agent,
261
+ dir: string,
262
+ warn: PlacementWarn,
263
+ ): Promise<void> {
264
+ const workingDirectory = services.workingDirectory
265
+ if (workingDirectory === undefined) return
266
+ try {
267
+ if (workingDirectory.get(agent.session) === dir) return
268
+ await workingDirectory.set(agent, dir)
269
+ } catch (error) {
270
+ // The durable Session keeps its previous directory; the caller cannot do
271
+ // better than warning because the turn is still deliverable.
272
+ warn(
273
+ 'dsh-channels: switching session %s to its topic directory failed: %s',
274
+ agent.id, String(error),
275
+ )
276
+ }
277
+ }
@@ -25,12 +25,15 @@ import type {
25
25
  } from '../dsh-types.ts'
26
26
  import type { FeishuConfig } from '../config.ts'
27
27
  import { mountChannelCommands } from '../commands.ts'
28
- import { appendSessionHistory } from '../domain.ts'
28
+ import { appendSessionHistory, sessionDirOf } from '../domain.ts'
29
29
  import type { RouteRecord } from '../domain.ts'
30
30
  import { mountChannelGuidance } from '../feishu/agent-context.ts'
31
31
  import { attachmentsDir } from '../feishu/inbound-attachments.ts'
32
+ import { effectiveAttachmentMode, resolveAttachmentService } from '../feishu/native-attachments.ts'
32
33
  import { installRequestPolicy } from './request-policy.ts'
33
34
  import type { RequestPolicyController } from './request-policy.ts'
35
+ import { placementServices, restoreTopicDir } from './isolation.ts'
36
+ import type { PlacementServices, TopicPlacement } from './isolation.ts'
34
37
  import { workspaceTitleFor } from '../workspaces.ts'
35
38
 
36
39
  /** One live agent plus this plugin's ownership handle. */
@@ -63,6 +66,14 @@ export interface SessionManagerDeps {
63
66
  started(): boolean
64
67
  /** Drop streamer state for a session whose owner handle is being released. */
65
68
  detachStream(sessionId: string): void
69
+ /**
70
+ * Register this channel's model tools inside one Agent scope. Called from
71
+ * the agent-preset `setup` after the preset is mounted (tools live inside
72
+ * presets in web-profile deployments), and once more for a BORROWED live
73
+ * agent as a best-effort race guard (D7). Optional: unit tests that do not
74
+ * compose model tools omit it.
75
+ */
76
+ mountTools?(agentCtx: AgentContext, sessionId: string): Promise<void> | void
66
77
  warn(message: string, ...args: unknown[]): void
67
78
  }
68
79
 
@@ -80,6 +91,8 @@ export class SessionManager {
80
91
  private readonly live = new Map<string, LiveAgent>()
81
92
  private readonly pendingResume = new Map<string, Promise<LiveAgent>>()
82
93
  private readonly policies = new WeakMap<AgentContext, RequestPolicyController>()
94
+ /** Agent scopes this plugin already attempted to mount model tools into. */
95
+ private readonly toolMounts = new WeakSet<AgentContext>()
83
96
 
84
97
  constructor(private readonly deps: SessionManagerDeps) {}
85
98
 
@@ -105,14 +118,16 @@ export class SessionManager {
105
118
 
106
119
  /**
107
120
  * Create the root agent for one freshly routed session and register it live.
108
- * The caller owns the workspace attachment and the failure card.
121
+ * `cwd` is the Session header cwd (the user workspace when the deployment can
122
+ * switch directories). The caller owns placement, the workspace attachment,
123
+ * the attachment guidance, and the failure card.
109
124
  */
110
125
  async createSession(
111
126
  sessionId: SessionId,
112
127
  cwd: string,
113
128
  agentOptions: Record<string, unknown> = this.agentOptions(),
114
129
  ): Promise<AgentHandle> {
115
- const composition = await this.presetComposition(undefined, true, attachmentsDir(cwd))
130
+ const composition = await this.presetComposition(undefined, true, sessionId)
116
131
  const handle = await this.deps.ctx.agents.create({
117
132
  sessionId,
118
133
  meta: {
@@ -127,6 +142,40 @@ export class SessionManager {
127
142
  return handle
128
143
  }
129
144
 
145
+ /**
146
+ * Register the attachment guidance for one Session. Called after the
147
+ * effective working directory is known (a worktree checkout path is only
148
+ * returned once the worktree exists), so the archive guidance can name the
149
+ * absolute directory. The guidance flavor follows the *effective* attachment
150
+ * backend: an explicitly configured archive deployment, or a native
151
+ * deployment whose host does not mount `ctx.attachments`, keeps the legacy
152
+ * workspace-directory instructions. Best-effort: a missing system-prompt
153
+ * service (headless) is a no-op and a late registration never blocks the turn.
154
+ */
155
+ async mountGuidance(agent: Agent, directory: string): Promise<void> {
156
+ // With inbound attachments disabled the Agent has no attachment workflow to
157
+ // discover (and the legacy guidance would wrongly claim files are archived).
158
+ if (!this.deps.config.acceptAttachments) return
159
+ const mode = effectiveAttachmentMode(
160
+ this.deps.config.attachmentStore,
161
+ resolveAttachmentService(this.deps.ctx),
162
+ this.deps.config.feishuTools.attachmentFetch,
163
+ this.deps.config.acceptAttachments,
164
+ )
165
+ try {
166
+ await mountChannelGuidance(
167
+ agent.ctx,
168
+ mode,
169
+ mode === 'archive' ? attachmentsDir(directory) : undefined,
170
+ )
171
+ } catch (error) {
172
+ this.deps.warn(
173
+ 'dsh-channels: mounting attachment guidance for session %s failed: %s',
174
+ agent.id, String(error),
175
+ )
176
+ }
177
+ }
178
+
130
179
  /**
131
180
  * Live agent for a route: this plugin's own → a foreign live agent (borrow)
132
181
  * → a serialized cold resume.
@@ -144,7 +193,7 @@ export class SessionManager {
144
193
  if (own !== undefined) return own
145
194
 
146
195
  const foreign = this.deps.ctx.agents.get(sessionId as SessionId)
147
- if (foreign !== undefined) return this.borrow(sessionId, foreign)
196
+ if (foreign !== undefined) return await this.borrow(sessionId, foreign)
148
197
 
149
198
  let pending = this.pendingResume.get(sessionId)
150
199
  if (pending === undefined) {
@@ -157,9 +206,14 @@ export class SessionManager {
157
206
  }
158
207
 
159
208
  /** Borrow one foreign live agent and mount this channel's scoped policy. */
160
- private borrow(sessionId: string, agent: Agent): LiveAgent {
209
+ private async borrow(sessionId: string, agent: Agent): Promise<LiveAgent> {
161
210
  const borrowed = borrowLiveAgent(agent, this.mountRequestPolicy(agent.ctx, agent.session))
162
211
  this.live.set(sessionId, borrowed)
212
+ // Best-effort tool backfill (D7): the foreign owner may have composed the
213
+ // session without this plugin's setup (web UI resolver, previous plugin
214
+ // instance). mountToolsFor swallows failures, so the borrow itself never
215
+ // fails; awaiting it makes the tool available before the first turn.
216
+ await this.mountToolsFor(agent.ctx, sessionId)
163
217
  return borrowed
164
218
  }
165
219
 
@@ -171,9 +225,7 @@ export class SessionManager {
171
225
  // (config override or roster default). A session persisted before the
172
226
  // preset fix has no preset recorded on its header; re-resolving here is
173
227
  // what repairs it on the first resume after a restart.
174
- const composition = await this.presetComposition(
175
- undefined, true, attachmentsDir(record.workspacePath),
176
- )
228
+ const composition = await this.presetComposition(undefined, true, sessionId)
177
229
  const handle = await this.deps.ctx.agents.resume({
178
230
  resumeSessionId: sessionId,
179
231
  agentOptions: this.agentOptions(),
@@ -187,6 +239,7 @@ export class SessionManager {
187
239
  throw new Error('dsh-channels: session resume aborted by shutdown')
188
240
  }
189
241
  this.requestPolicy(handle.agent)
242
+ await this.mountGuidance(handle.agent, sessionDirOf(record))
190
243
  const liveAgent: LiveAgent = { agent: handle.agent, handle }
191
244
  this.live.set(sessionId, liveAgent)
192
245
  return liveAgent
@@ -194,7 +247,7 @@ export class SessionManager {
194
247
  // Lost a cold-resume race: another owner published the session while our
195
248
  // resume was in flight. Borrow the winner instead of failing the turn.
196
249
  const winner = this.deps.ctx.agents.get(sessionId)
197
- if (winner !== undefined) return this.borrow(sessionId, winner)
250
+ if (winner !== undefined) return await this.borrow(sessionId, winner)
198
251
  throw error
199
252
  }
200
253
  }
@@ -224,17 +277,23 @@ export class SessionManager {
224
277
  return controller
225
278
  }
226
279
 
227
- /** Create, workspace-attach, and atomically publish one renewed route. */
280
+ /**
281
+ * Create, place, workspace-attach, and atomically publish one renewed route.
282
+ *
283
+ * A renewal keeps the topic's working directory: the new Session is created
284
+ * with the user workspace as its header cwd (so it stays grouped under the
285
+ * project) and then re-entered into the same topic directory or worktree.
286
+ */
228
287
  async renew(record: RouteRecord, oldLive: LiveAgent): Promise<RenewCommit> {
229
288
  const sessionId = randomUUID() as SessionId
230
289
  const preset = this.effectivePreset(oldLive.agent.session)
231
- const composition = await this.presetComposition(preset, false, attachmentsDir(record.workspacePath))
290
+ const composition = await this.presetComposition(preset, false, sessionId)
291
+ const services: PlacementServices = placementServices(this.deps.ctx)
292
+ const switchable = services.workingDirectory !== undefined
293
+ const workspacePath = record.workspacePath
232
294
  const workspace = await this.deps.ctx.workspaceRegistry.create(
233
- record.workspacePath,
234
- workspaceTitleFor(
235
- record.chatType, record.chatId, record.senderOpenId,
236
- record.kind === 'thread' ? record.key : record.sessionId,
237
- ),
295
+ workspacePath,
296
+ workspaceTitleFor(record.chatType, record.chatId, record.senderOpenId),
238
297
  )
239
298
 
240
299
  let newHandle: AgentHandle | undefined
@@ -243,23 +302,38 @@ export class SessionManager {
243
302
  newHandle = await this.deps.ctx.agents.create({
244
303
  sessionId,
245
304
  meta: {
246
- cwd: record.workspacePath,
305
+ // Without an effective-directory service the Session must be born in
306
+ // its topic directory; otherwise the workspace root keeps it grouped.
307
+ cwd: switchable ? workspacePath : sessionDirOf(record),
247
308
  ...(composition.agentPreset === undefined ? {} : { agentPreset: composition.agentPreset }),
248
309
  },
249
310
  agentOptions: { ...oldLive.agent.options },
250
311
  setup: composition.setup,
251
312
  })
252
313
  this.requestPolicy(newHandle.agent)
253
- await workspace.attachSession(sessionId)
254
- attached = true
314
+ const placement: TopicPlacement = await restoreTopicDir(services, newHandle.agent, {
315
+ workspacePath,
316
+ sessionDir: sessionDirOf(record),
317
+ ...(record.worktreeBranch === undefined ? {} : { worktreeBranch: record.worktreeBranch }),
318
+ }, this.deps.warn.bind(this.deps))
319
+ if (switchable) {
320
+ await workspace.attachSession(sessionId)
321
+ attached = true
322
+ }
323
+ await this.mountGuidance(newHandle.agent, placement.sessionDir)
255
324
  const createdAt = newHandle.agent.session.header.createdAt
256
325
 
257
- const current: RouteRecord = {
326
+ const base: RouteRecord = {
258
327
  ...record,
259
328
  sessionId,
260
329
  sessionHistory: appendSessionHistory(record, sessionId, createdAt),
261
330
  lastActivityAt: Date.now(),
331
+ sessionDir: placement.sessionDir,
262
332
  }
333
+ delete base.worktreeBranch
334
+ const current: RouteRecord = placement.worktreeBranch === undefined
335
+ ? base
336
+ : { ...base, worktreeBranch: placement.worktreeBranch }
263
337
  await this.deps.persistRoute(record.key, current)
264
338
  this.live.set(sessionId, { agent: newHandle.agent, handle: newHandle })
265
339
  return { previous: record, current, oldLive }
@@ -308,11 +382,15 @@ export class SessionManager {
308
382
  * resumes. The id resolves BEFORE the session exists because the session
309
383
  * boundary snapshots `meta` before asynchronous setup begins; mounting stays
310
384
  * inside `setup`, where a failure rolls the whole creation back.
385
+ *
386
+ * The attachment guidance is mounted later through {@link mountGuidance},
387
+ * because a Git worktree's checkout path only exists after the Agent (and so
388
+ * the worktree) has been created.
311
389
  */
312
390
  private async presetComposition(
313
391
  requestedPreset: string | undefined = this.deps.config.agentPreset,
314
392
  useRosterDefault = true,
315
- attachmentsDir?: string,
393
+ sessionId?: string,
316
394
  ): Promise<PresetComposition> {
317
395
  // Read through ctx.get (safe lookup): only the web profile ships a roster,
318
396
  // and direct property access on a cordis context throws for services the
@@ -324,7 +402,7 @@ export class SessionManager {
324
402
  // cannot guarantee that the explicit channel value wins.
325
403
  this.mountRequestPolicy(agentCtx)
326
404
  await mountChannelCommands(agentCtx)
327
- await mountChannelGuidance(agentCtx, attachmentsDir)
405
+ if (sessionId !== undefined) await this.mountToolsFor(agentCtx, sessionId)
328
406
  }
329
407
  if (presets === undefined || (requestedPreset === undefined && !useRosterDefault)) {
330
408
  return { setup: channelCommandsOnly }
@@ -336,11 +414,30 @@ export class SessionManager {
336
414
  this.mountRequestPolicy(agentCtx)
337
415
  await presets.mount(agentCtx, resolved.id)
338
416
  await mountChannelCommands(agentCtx)
339
- await mountChannelGuidance(agentCtx, attachmentsDir)
417
+ if (sessionId !== undefined) await this.mountToolsFor(agentCtx, sessionId)
340
418
  },
341
419
  }
342
420
  }
343
421
 
422
+ /**
423
+ * Mount the channel model tools into one Agent scope, at most once per
424
+ * scope. A failure is logged and swallowed: missing tools never fail a
425
+ * session (attachment fetch degrades to its structured failures).
426
+ */
427
+ private async mountToolsFor(agentCtx: AgentContext, sessionId: string): Promise<void> {
428
+ if (this.deps.mountTools === undefined) return
429
+ if (this.toolMounts.has(agentCtx)) return
430
+ try {
431
+ await this.deps.mountTools(agentCtx, sessionId)
432
+ this.toolMounts.add(agentCtx)
433
+ } catch (error) {
434
+ this.deps.warn(
435
+ 'dsh-channels: mounting channel tools for session %s failed: %s',
436
+ sessionId, String(error),
437
+ )
438
+ }
439
+ }
440
+
344
441
  /** Effective preset after folding any durable runtime preset switch. */
345
442
  private effectivePreset(session: Session): string | undefined {
346
443
  let selected = session.header.agentPreset
@@ -11,7 +11,7 @@
11
11
  * @module dsh-channels/session/repair
12
12
  */
13
13
 
14
- import { buildInterruptionCard } from '../cards/streaming.ts'
14
+ import { buildInterruptionCard, segmentCardOptions } from '../cards/streaming.ts'
15
15
  import type { InflightSnapshot } from '../domain.ts'
16
16
  import type { FeishuChannel } from '../feishu-channel.ts'
17
17
  import type { FeishuDomainHandle } from '../domain.ts'
@@ -146,8 +146,7 @@ export class InflightRepair {
146
146
  if (card.mode !== 'card') continue
147
147
  // Full replacement: the interrupted card's streamed content lived only
148
148
  // in the previous process. Continuation cards keep their segment header.
149
- const options = index === 0 ? {} : { panel: false, segment: index + 1 }
150
- await channel.updateCard(card.messageId, buildInterruptionCard(options))
149
+ await channel.updateCard(card.messageId, buildInterruptionCard(segmentCardOptions(index)))
151
150
  }
152
151
  if (snapshot.reactionId !== undefined) {
153
152
  await channel.removeReaction(snapshot.triggerMessageId, snapshot.reactionId).catch(() => {})
@@ -148,6 +148,8 @@ export class RouteStore {
148
148
  senderOpenId: record.senderOpenId,
149
149
  expiredAt: now,
150
150
  workspacePath: record.workspacePath,
151
+ ...(record.sessionDir === undefined ? {} : { sessionDir: record.sessionDir }),
152
+ ...(record.worktreeBranch === undefined ? {} : { worktreeBranch: record.worktreeBranch }),
151
153
  lastActivityAt: record.lastActivityAt,
152
154
  }
153
155
  await this.deps.domain()?.expiredRoutes.put(key, tombstone)