@modesto_ma/dsh-channels 0.1.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.
Files changed (50) hide show
  1. package/README.md +89 -55
  2. package/docs/access-control-design.md +5 -5
  3. package/docs/feasibility-and-design.md +54 -26
  4. package/docs/feishu-onboarding.md +26 -20
  5. package/docs/persistence.md +4 -4
  6. package/docs/refactor-plan.md +6 -5
  7. package/docs/workspace-isolation-design.md +78 -0
  8. package/lib/client.js +1715 -882
  9. package/lib/client.js.map +1 -1
  10. package/lib/index.js +1556 -680
  11. package/lib/index.js.map +1 -1
  12. package/package.json +1 -1
  13. package/src/cards/index.ts +1 -0
  14. package/src/cards/onboarding.ts +23 -0
  15. package/src/cards/primitives.ts +20 -0
  16. package/src/cards/streaming.ts +310 -291
  17. package/src/cleanup.ts +256 -119
  18. package/src/client/api.ts +20 -0
  19. package/src/client/catalog.ts +1 -1
  20. package/src/client/index.ts +1 -1
  21. package/src/client/panel/access-card.tsx +294 -0
  22. package/src/client/panel/access-tab.tsx +46 -0
  23. package/src/client/panel/advanced-tab.tsx +80 -0
  24. package/src/client/panel/cleanup-card.tsx +106 -0
  25. package/src/client/panel/connection-tab.tsx +214 -0
  26. package/src/client/panel/credentials-card.tsx +104 -0
  27. package/src/client/panel/index.tsx +862 -0
  28. package/src/client/panel/model-tab.tsx +23 -0
  29. package/src/client/panel/sessions-tab.tsx +21 -0
  30. package/src/client/panel/shared.tsx +488 -0
  31. package/src/client/panel/workspace-tab.tsx +40 -0
  32. package/src/client/styles.ts +24 -6
  33. package/src/config.ts +44 -12
  34. package/src/domain.ts +34 -3
  35. package/src/feishu/agent-context.ts +9 -5
  36. package/src/index.ts +32 -14
  37. package/src/routing/inbound.ts +88 -43
  38. package/src/service.ts +259 -65
  39. package/src/session/isolation.ts +277 -0
  40. package/src/session/manager.ts +63 -20
  41. package/src/session/repair.ts +2 -3
  42. package/src/session/routes.ts +2 -0
  43. package/src/session-commands.ts +41 -14
  44. package/src/settings/contract.ts +223 -47
  45. package/src/settings/directory-picker.ts +121 -0
  46. package/src/settings/routes.ts +49 -2
  47. package/src/settings/surface.ts +171 -25
  48. package/src/streamer.ts +70 -30
  49. package/src/workspaces.ts +50 -28
  50. package/src/client/panel.tsx +0 -1475
@@ -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,14 @@ 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
32
  import { installRequestPolicy } from './request-policy.ts'
33
33
  import type { RequestPolicyController } from './request-policy.ts'
34
+ import { placementServices, restoreTopicDir } from './isolation.ts'
35
+ import type { PlacementServices, TopicPlacement } from './isolation.ts'
34
36
  import { workspaceTitleFor } from '../workspaces.ts'
35
37
 
36
38
  /** One live agent plus this plugin's ownership handle. */
@@ -105,14 +107,16 @@ export class SessionManager {
105
107
 
106
108
  /**
107
109
  * Create the root agent for one freshly routed session and register it live.
108
- * The caller owns the workspace attachment and the failure card.
110
+ * `cwd` is the Session header cwd (the user workspace when the deployment can
111
+ * switch directories). The caller owns placement, the workspace attachment,
112
+ * the attachment guidance, and the failure card.
109
113
  */
110
114
  async createSession(
111
115
  sessionId: SessionId,
112
116
  cwd: string,
113
117
  agentOptions: Record<string, unknown> = this.agentOptions(),
114
118
  ): Promise<AgentHandle> {
115
- const composition = await this.presetComposition(undefined, true, attachmentsDir(cwd))
119
+ const composition = await this.presetComposition(undefined, true)
116
120
  const handle = await this.deps.ctx.agents.create({
117
121
  sessionId,
118
122
  meta: {
@@ -127,6 +131,24 @@ export class SessionManager {
127
131
  return handle
128
132
  }
129
133
 
134
+ /**
135
+ * Register the attachment-directory guidance for one Session. Called after
136
+ * the effective working directory is known (a worktree checkout path is
137
+ * only returned once the worktree exists), so the prompt section can name
138
+ * the absolute directory. Best-effort: a missing system-prompt service
139
+ * (headless) is a no-op and a late registration never blocks the turn.
140
+ */
141
+ async mountGuidance(agent: Agent, directory: string): Promise<void> {
142
+ try {
143
+ await mountChannelGuidance(agent.ctx, attachmentsDir(directory))
144
+ } catch (error) {
145
+ this.deps.warn(
146
+ 'dsh-channels: mounting attachment guidance for session %s failed: %s',
147
+ agent.id, String(error),
148
+ )
149
+ }
150
+ }
151
+
130
152
  /**
131
153
  * Live agent for a route: this plugin's own → a foreign live agent (borrow)
132
154
  * → a serialized cold resume.
@@ -171,9 +193,7 @@ export class SessionManager {
171
193
  // (config override or roster default). A session persisted before the
172
194
  // preset fix has no preset recorded on its header; re-resolving here is
173
195
  // what repairs it on the first resume after a restart.
174
- const composition = await this.presetComposition(
175
- undefined, true, attachmentsDir(record.workspacePath),
176
- )
196
+ const composition = await this.presetComposition(undefined, true)
177
197
  const handle = await this.deps.ctx.agents.resume({
178
198
  resumeSessionId: sessionId,
179
199
  agentOptions: this.agentOptions(),
@@ -187,6 +207,7 @@ export class SessionManager {
187
207
  throw new Error('dsh-channels: session resume aborted by shutdown')
188
208
  }
189
209
  this.requestPolicy(handle.agent)
210
+ await this.mountGuidance(handle.agent, sessionDirOf(record))
190
211
  const liveAgent: LiveAgent = { agent: handle.agent, handle }
191
212
  this.live.set(sessionId, liveAgent)
192
213
  return liveAgent
@@ -224,17 +245,23 @@ export class SessionManager {
224
245
  return controller
225
246
  }
226
247
 
227
- /** Create, workspace-attach, and atomically publish one renewed route. */
248
+ /**
249
+ * Create, place, workspace-attach, and atomically publish one renewed route.
250
+ *
251
+ * A renewal keeps the topic's working directory: the new Session is created
252
+ * with the user workspace as its header cwd (so it stays grouped under the
253
+ * project) and then re-entered into the same topic directory or worktree.
254
+ */
228
255
  async renew(record: RouteRecord, oldLive: LiveAgent): Promise<RenewCommit> {
229
256
  const sessionId = randomUUID() as SessionId
230
257
  const preset = this.effectivePreset(oldLive.agent.session)
231
- const composition = await this.presetComposition(preset, false, attachmentsDir(record.workspacePath))
258
+ const composition = await this.presetComposition(preset, false)
259
+ const services: PlacementServices = placementServices(this.deps.ctx)
260
+ const switchable = services.workingDirectory !== undefined
261
+ const workspacePath = record.workspacePath
232
262
  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
- ),
263
+ workspacePath,
264
+ workspaceTitleFor(record.chatType, record.chatId, record.senderOpenId),
238
265
  )
239
266
 
240
267
  let newHandle: AgentHandle | undefined
@@ -243,23 +270,38 @@ export class SessionManager {
243
270
  newHandle = await this.deps.ctx.agents.create({
244
271
  sessionId,
245
272
  meta: {
246
- cwd: record.workspacePath,
273
+ // Without an effective-directory service the Session must be born in
274
+ // its topic directory; otherwise the workspace root keeps it grouped.
275
+ cwd: switchable ? workspacePath : sessionDirOf(record),
247
276
  ...(composition.agentPreset === undefined ? {} : { agentPreset: composition.agentPreset }),
248
277
  },
249
278
  agentOptions: { ...oldLive.agent.options },
250
279
  setup: composition.setup,
251
280
  })
252
281
  this.requestPolicy(newHandle.agent)
253
- await workspace.attachSession(sessionId)
254
- attached = true
282
+ const placement: TopicPlacement = await restoreTopicDir(services, newHandle.agent, {
283
+ workspacePath,
284
+ sessionDir: sessionDirOf(record),
285
+ ...(record.worktreeBranch === undefined ? {} : { worktreeBranch: record.worktreeBranch }),
286
+ }, this.deps.warn.bind(this.deps))
287
+ if (switchable) {
288
+ await workspace.attachSession(sessionId)
289
+ attached = true
290
+ }
291
+ await this.mountGuidance(newHandle.agent, placement.sessionDir)
255
292
  const createdAt = newHandle.agent.session.header.createdAt
256
293
 
257
- const current: RouteRecord = {
294
+ const base: RouteRecord = {
258
295
  ...record,
259
296
  sessionId,
260
297
  sessionHistory: appendSessionHistory(record, sessionId, createdAt),
261
298
  lastActivityAt: Date.now(),
299
+ sessionDir: placement.sessionDir,
262
300
  }
301
+ delete base.worktreeBranch
302
+ const current: RouteRecord = placement.worktreeBranch === undefined
303
+ ? base
304
+ : { ...base, worktreeBranch: placement.worktreeBranch }
263
305
  await this.deps.persistRoute(record.key, current)
264
306
  this.live.set(sessionId, { agent: newHandle.agent, handle: newHandle })
265
307
  return { previous: record, current, oldLive }
@@ -308,11 +350,14 @@ export class SessionManager {
308
350
  * resumes. The id resolves BEFORE the session exists because the session
309
351
  * boundary snapshots `meta` before asynchronous setup begins; mounting stays
310
352
  * inside `setup`, where a failure rolls the whole creation back.
353
+ *
354
+ * The attachment guidance is mounted later through {@link mountGuidance},
355
+ * because a Git worktree's checkout path only exists after the Agent (and so
356
+ * the worktree) has been created.
311
357
  */
312
358
  private async presetComposition(
313
359
  requestedPreset: string | undefined = this.deps.config.agentPreset,
314
360
  useRosterDefault = true,
315
- attachmentsDir?: string,
316
361
  ): Promise<PresetComposition> {
317
362
  // Read through ctx.get (safe lookup): only the web profile ships a roster,
318
363
  // and direct property access on a cordis context throws for services the
@@ -324,7 +369,6 @@ export class SessionManager {
324
369
  // cannot guarantee that the explicit channel value wins.
325
370
  this.mountRequestPolicy(agentCtx)
326
371
  await mountChannelCommands(agentCtx)
327
- await mountChannelGuidance(agentCtx, attachmentsDir)
328
372
  }
329
373
  if (presets === undefined || (requestedPreset === undefined && !useRosterDefault)) {
330
374
  return { setup: channelCommandsOnly }
@@ -336,7 +380,6 @@ export class SessionManager {
336
380
  this.mountRequestPolicy(agentCtx)
337
381
  await presets.mount(agentCtx, resolved.id)
338
382
  await mountChannelCommands(agentCtx)
339
- await mountChannelGuidance(agentCtx, attachmentsDir)
340
383
  },
341
384
  }
342
385
  }
@@ -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)
@@ -61,8 +61,8 @@ export interface SessionStatusCardInput {
61
61
  export interface SessionStatusExtras {
62
62
  /** Effective permission preset of this Session (`ctx.permissionPresets`). */
63
63
  permissionPreset?: string
64
- /** Workspace directory bound to this route. */
65
- workspacePath?: string
64
+ /** Effective working directory of this Session (its topic directory). */
65
+ sessionDir?: string
66
66
  /** Plugin uptime in milliseconds. */
67
67
  uptimeMs?: number
68
68
  /** Live agent entries this channel owns/borrows. */
@@ -123,7 +123,7 @@ function statusExtrasElements(extras: SessionStatusExtras | undefined): object[]
123
123
  if (extras === undefined) return []
124
124
  const lines: string[] = []
125
125
  if (extras.permissionPreset !== undefined) lines.push(`**权限预设**:\`${escapeInlineCode(extras.permissionPreset)}\``)
126
- if (extras.workspacePath !== undefined) lines.push(`**工作区**:\`${escapeInlineCode(extras.workspacePath)}\``)
126
+ if (extras.sessionDir !== undefined) lines.push(`**会话目录**:\`${escapeInlineCode(extras.sessionDir)}\``)
127
127
  if (extras.uptimeMs !== undefined) lines.push(`**插件运行时长**:${fmtUptime(extras.uptimeMs)}`)
128
128
  if (extras.liveAgents !== undefined || extras.routeCount !== undefined) {
129
129
  const parts: string[] = []
@@ -148,33 +148,59 @@ export type WorkDirResolution =
148
148
  | { kind: 'missing'; text: string }
149
149
  | { kind: 'mismatch'; text: string }
150
150
 
151
- /** Validate the Session header cwd against the plugin's durable route. */
152
- export function resolveWorkDir(headerCwd: string | undefined, routeWorkspacePath: string): WorkDirResolution {
151
+ /** The durable route facts needed to verify a Session's working directory. */
152
+ export interface WorkDirRoute {
153
+ /** Workspace root owning the Session (its header cwd when grouped). */
154
+ readonly workspacePath: string
155
+ /** Effective working directory (topic directory / worktree); absent on v1 routes. */
156
+ readonly sessionDir?: string
157
+ }
158
+
159
+ /**
160
+ * Validate the Session header cwd against the plugin's durable route and
161
+ * return the Session's effective working directory.
162
+ *
163
+ * The header cwd is normally the workspace root (the Session stays grouped
164
+ * under the user's project while working in a per-topic directory). In the
165
+ * degraded placement used when the host exposes no `workingDirectory` service,
166
+ * the header cwd is the topic directory itself.
167
+ */
168
+ export function resolveWorkDir(
169
+ headerCwd: string | undefined,
170
+ route: WorkDirRoute,
171
+ ): WorkDirResolution {
153
172
  if (headerCwd === undefined || headerCwd === '' || !path.isAbsolute(headerCwd)) {
154
173
  return {
155
174
  kind: 'missing',
156
175
  text: '当前 Session header 未记录可用的绝对工作目录;请检查 Session 创建或恢复配置。',
157
176
  }
158
177
  }
159
- if (!path.isAbsolute(routeWorkspacePath)) {
178
+ if (!path.isAbsolute(route.workspacePath)) {
179
+ return {
180
+ kind: 'missing',
181
+ text: '当前飞书路由未记录可用的工作区目录;请检查路由持久化状态。',
182
+ }
183
+ }
184
+ if (route.sessionDir !== undefined && !path.isAbsolute(route.sessionDir)) {
160
185
  return {
161
186
  kind: 'missing',
162
- text: '当前飞书路由未记录可用的绝对工作目录;请检查路由持久化状态。',
187
+ text: '当前飞书路由未记录可用的会话目录;请检查路由持久化状态。',
163
188
  }
164
189
  }
165
- if (headerCwd !== routeWorkspacePath) {
190
+ const expected = route.sessionDir ?? route.workspacePath
191
+ if (headerCwd !== route.workspacePath && headerCwd !== expected) {
166
192
  return {
167
193
  kind: 'mismatch',
168
194
  text: 'Session header 与飞书路由记录的工作目录不一致;为避免返回错误目录,已拒绝显示。',
169
195
  }
170
196
  }
171
- return { kind: 'success', path: headerCwd }
197
+ return { kind: 'success', path: expected }
172
198
  }
173
199
 
174
- /** Render one verified absolute workspace path. */
175
- export function buildWorkDirCard(sessionId: string, workspacePath: string): object {
200
+ /** Render one verified absolute working directory. */
201
+ export function buildWorkDirCard(sessionId: string, sessionDir: string): object {
176
202
  return standaloneCard('**📁 当前工作目录**', [
177
- { tag: 'markdown', content: `\`${escapeInlineCode(workspacePath)}\`` },
203
+ { tag: 'markdown', content: `\`${escapeInlineCode(sessionDir)}\`` },
178
204
  { tag: 'markdown', content: sessionId, text_size: 'notation' },
179
205
  ])
180
206
  }
@@ -182,7 +208,8 @@ export function buildWorkDirCard(sessionId: string, workspacePath: string): obje
182
208
  export interface RenewCardInput {
183
209
  previousSessionId: string
184
210
  currentSessionId: string
185
- workspacePath: string
211
+ /** Topic directory that the renewed Session reuses. */
212
+ sessionDir: string
186
213
  }
187
214
 
188
215
  /** Render the committed route switch without implying that the old log was deleted. */
@@ -192,7 +219,7 @@ export function buildRenewCard(input: RenewCardInput): object {
192
219
  tag: 'markdown',
193
220
  content: `**旧 Session**:${input.previousSessionId}\n`
194
221
  + `**新 Session**:${input.currentSessionId}\n`
195
- + `**复用工作区**:\`${escapeInlineCode(input.workspacePath)}\``,
222
+ + `**复用目录**:\`${escapeInlineCode(input.sessionDir)}\``,
196
223
  },
197
224
  { tag: 'markdown', content: '旧 Session 及其日志仍由 Harness 保留。', text_size: 'notation' },
198
225
  ])