@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.
@@ -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
  ])
@@ -20,7 +20,7 @@ export const CREDENTIALS_ROUTE = '/dsh-channels/credentials'
20
20
  export const RECONNECT_ROUTE = '/dsh-channels/reconnect'
21
21
  /** 模型/推理档位/预设选择器的宿主目录(provider → model → effort 级联刷新)。 */
22
22
  export const CATALOG_ROUTE = '/dsh-channels/catalog'
23
- /** 手动执行一次过期工作区清理(POST,无请求体)。 */
23
+ /** 手动执行一次过期目录清理(POST,无请求体)。 */
24
24
  export const CLEANUP_ROUTE = '/dsh-channels/cleanup'
25
25
  /** 访问与授权只读快照(GET/HEAD):准入模式、管理员、群/用户白名单、待处理申请。 */
26
26
  export const ACCESS_ROUTE = '/dsh-channels/access'
@@ -49,7 +49,7 @@ export const PICK_DIRECTORY_ROUTE = '/dsh-channels/pick-directory'
49
49
  * 因此不会落进覆盖层文件。
50
50
  *
51
51
  * `baseDir`(工作区根目录 = 模型/工具产生文件的落点)可覆盖,但面板只接受
52
- * **绝对路径**(`~` 会展开);已有 Session / 工作区不迁移,只影响之后新建的
52
+ * **绝对路径**(`~` 会展开);已有 Session / 目录不迁移,只影响之后新建的
53
53
  * 会话。校验与保存时建目录见 `src/workspaces.ts`。
54
54
  */
55
55
  export const EDITABLE_KEYS = [
@@ -361,9 +361,9 @@ export interface CleanupPreviewWire {
361
361
  thresholdMs?: number
362
362
  /** Periodic cleanup runs at startup and once per day. */
363
363
  auto: boolean
364
- /** Plugin-created workspaces under the current baseDir. */
364
+ /** Plugin-created workspaces under the current baseDir (user projects). */
365
365
  workspaces: number
366
- /** Workspaces older than the threshold (the deletable set). */
366
+ /** Directories older than the threshold (legacy topic workspaces + per-topic dirs). */
367
367
  candidates: number
368
368
  /** Total bytes currently occupied by the candidates. */
369
369
  candidateBytes: number
@@ -375,9 +375,11 @@ export interface CleanupPreviewWire {
375
375
  export interface CleanupRunWire {
376
376
  ok: boolean
377
377
  deletedWorkspaces: number
378
+ /** Per-topic working directories removed (current layout). */
379
+ deletedSessionDirs: number
378
380
  retiredRoutes: number
379
381
  freedBytes: number
380
- /** One `path: message` line per workspace that could not be deleted. */
382
+ /** One `path: message` line per directory that could not be deleted. */
381
383
  failures: string[]
382
384
  error?: string
383
385
  }
@@ -570,7 +572,7 @@ export const PANEL_COPY = {
570
572
  connectionTabHelp: '连接状态与飞书应用凭据。凭据不完整时不会连接飞书。',
571
573
  sessionsTabHelp: '私聊、群聊触发方式与入站内容;「需重连」项在下次连接时读取。',
572
574
  modelTabHelp: '飞书会话使用的模型服务商、模型、推理档位与 Agent 预设;下拉选项来自宿主机实时目录,保存后对新会话生效。',
573
- workspaceTabHelp: '每个飞书话题一个独立工作区(模型/工具产生文件的落点);只影响之后新建的话题。',
575
+ workspaceTabHelp: '每个用户一个工作区(模型/工具产生文件的落点),每个话题在其中拥有独立工作目录;只影响之后新建的话题。',
574
576
  accessTabHelp: '准入采用 fail-closed:管理员不受白名单限制,非管理员私聊会被拒绝,群聊需要「群已授权 + 用户在白名单」两层同时命中。',
575
577
  advancedTabHelp: '流式刷新等低层参数、Web 覆盖文件与只读信息。',
576
578
  save: '保存更改',
@@ -618,11 +620,12 @@ export const PANEL_COPY = {
618
620
  readOnlyCard: '部署级参数',
619
621
  // ---- model --------------------------------------------------------------
620
622
  // ---- cleanup ------------------------------------------------------------
621
- cleanupCard: '工作区清理',
622
- cleanupCardHelp: '删除超过保留期且不再活跃的飞书工作区目录;只会删除本插件在「工作区根目录」下创建的目录,DSH 会话日志不删除。删除不可恢复:相关旧话题会失效并提示已过期,工作区列表记录同步移除。',
623
+ cleanupCard: '目录清理',
624
+ cleanupCardHelp: '删除超过保留期且不再活跃的飞书话题目录(以及旧版本按话题建立的工作区目录);用户工作区本身、DSH 会话日志都不会删除。删除不可恢复:相关旧话题会失效并提示已过期。',
623
625
  cleanupRetention: '保留期',
624
626
  cleanupAutoLabel: '自动清理',
625
- cleanupCandidates: '可清理工作区',
627
+ cleanupCandidates: '可清理目录',
628
+ cleanupWorkspaces: '插件工作区',
626
629
  cleanupCandidateBytes: '预计释放空间',
627
630
  cleanupRetentionOff: '关闭(不删除)',
628
631
  cleanupAutoOn: '已开启(启动时 + 每天一次)',
@@ -630,17 +633,18 @@ export const PANEL_COPY = {
630
633
  cleanupAutoInactive: '未执行(保留期为关闭)',
631
634
  cleanupNow: '立即清理',
632
635
  cleanupRunning: '清理中…',
633
- cleanupConfirm: '确定清理过期工作区吗?该操作不可恢复,会删除这些工作区内的全部文件(包括 attachments/ 附件)。',
634
- cleanupConfirmHint: '删除前会再次确认;保留期为「关闭」或没有过期工作区时按钮不可用。',
635
- cleanupDisabledOff: '保留策略为「关闭」:请先在上方「过期工作区保留期」中选择保留天数。',
636
- cleanupDisabledEmpty: '没有超过保留期的过期工作区。',
636
+ cleanupConfirm: '确定清理过期目录吗?该操作不可恢复,会删除这些目录内的全部文件(包括 attachments/ 附件)。',
637
+ cleanupConfirmHint: '删除前会再次确认;保留期为「关闭」或没有过期目录时按钮不可用。',
638
+ cleanupDisabledOff: '保留策略为「关闭」:请先在上方「过期目录保留期」中选择保留天数。',
639
+ cleanupDisabledEmpty: '没有超过保留期的过期目录。',
637
640
  cleanupPreviewFailed: '无法读取清理预览',
638
641
  cleanupDone: '清理完成',
639
642
  cleanupFailed: '清理失败',
640
643
  cleanupDeleted: '已删除工作区',
644
+ cleanupDeletedDirs: '话题目录',
641
645
  cleanupRetired: '已清理话题路由',
642
646
  cleanupFreed: '释放空间',
643
- cleanupFailures: '以下工作区未能删除:',
647
+ cleanupFailures: '以下目录未能删除:',
644
648
  // ---- access -------------------------------------------------------------
645
649
  accessCard: '访问与授权',
646
650
  accessCardHelp: '准入采用 fail-closed:管理员不受白名单限制(私聊、任意群聊都可直接对话);非管理员私聊会被拒绝,群聊需要「群已授权 + 用户在白名单」两层同时命中。管理员与准入模式在上方「准入与授权」中编辑;私聊机器人发送 /userid 获取自己的 open_id。',
@@ -160,7 +160,7 @@ const SELECT_OPTIONS: Partial<Record<EditableKey, ReadonlyArray<{ value: string;
160
160
  const FIELD_META: Record<EditableKey, { label: string; help: string; min?: number; max?: number; step?: number; placeholder?: string; wide?: boolean }> = {
161
161
  baseDir: {
162
162
  label: '工作区根目录(文件落点)',
163
- help: '每个飞书话题拥有独立工作区:私聊 <baseDir>/topics/p2p/<open_id>/<topic>/,群聊 <baseDir>/topics/groups/<chat_id>/<open_id>/<topic>/(topic 来自话题 id)。必须是绝对路径(~ 开头按家目录展开),保存时会创建该目录;只影响之后新建立的工作区 —— 已有话题即使 /renew 也沿用原工作区,旧文件不会迁移。',
163
+ help: '每个用户/群成员一个工作区:私聊 <baseDir>/p2p/<open_id>/,群聊 <baseDir>/groups/<chat_id>/<open_id>/;每个话题在其中拥有独立工作目录 <工作区>/topics/<话题>/(Git 仓库工作区可自动改用 worktree)。必须是绝对路径(~ 开头按家目录展开),保存时会创建该目录;只影响之后新建立的会话 —— 已有话题即使 /renew 也沿用原目录,旧文件不会迁移。',
164
164
  placeholder: '/Users/you/.dsh/dsh-channels/workspaces',
165
165
  wide: true,
166
166
  },
@@ -186,8 +186,8 @@ const FIELD_META: Record<EditableKey, { label: string; help: string; min?: numbe
186
186
  step: 1,
187
187
  },
188
188
  acceptAttachments: { label: '接收并保存附件', help: '接收飞书图片、文件与合并转发记录,保存到工作区的 attachments/ 目录;模型不会自动读取,需要时用文件工具自行查看。关闭后附件消息不保存、不触发回复。' },
189
- workspaceRetention: { label: '过期工作区保留期', help: '超过所选天数没有活动的飞书工作区会进入下方「工作区清理」列表;「关闭」表示永不删除。DSH 会话日志不受影响。' },
190
- workspaceRetentionAuto: { label: '自动执行工作区清理', help: '开启后插件在启动时及每天执行一次过期工作区清理;关闭时只能在下方卡片手动清理。开启前请确认保留期已设置。' },
189
+ workspaceRetention: { label: '过期目录保留期', help: '超过所选天数没有活动的飞书话题目录(以及旧版本按话题建立的工作区目录)会进入下方清理列表;用户工作区本身永不删除。DSH 会话日志不受影响。' },
190
+ workspaceRetentionAuto: { label: '自动执行目录清理', help: '开启后插件在启动时及每天执行一次过期目录清理;关闭时只能在下方卡片手动清理。开启前请确认保留期已设置。' },
191
191
  provider: { label: '模型服务商', help: '留空 = 使用部署默认;例如 deepseek-official。', placeholder: '留空 = 部署默认' },
192
192
  model: { label: '模型 id', help: '留空 = 使用部署默认;例如 deepseek-v4-flash。', placeholder: '留空 = 部署默认' },
193
193
  reasoningEffort: { label: '推理档位', help: '模型声明的精确档位 id(如 high / max);留空 = 适配器默认。', placeholder: '留空 = 适配器默认' },
@@ -403,6 +403,14 @@ export function normalizeAccessName(
403
403
  /** Build the read-only advanced card from the effective config. */
404
404
  export function readOnlyFields(config: FeishuConfig): ReadOnlyFieldWire[] {
405
405
  return [
406
+ {
407
+ key: 'topicIsolation',
408
+ label: '话题目录隔离',
409
+ value: config.topicIsolation === 'auto'
410
+ ? '自动(Git 仓库用 worktree,否则普通子目录)'
411
+ : config.topicIsolation === 'worktree' ? 'Git worktree(失败回退子目录)' : '普通子目录',
412
+ help: '每个话题的独立工作目录形式;在 profile 配置里用 topicIsolation 设置(auto / subdir / worktree)。',
413
+ },
406
414
  { key: 'routeTtlMs', label: '话题路由过期', value: formatDays(config.routeTtlMs), help: '超期话题的后续消息会被明确拒绝,而不是新建 Context-free Session。' },
407
415
  { key: 'approvalTimeoutMs', label: '审批卡片等待', value: formatSeconds(config.approvalTimeoutMs) },
408
416
  { key: 'questionTimeoutMs', label: '提问卡片等待', value: formatSeconds(config.questionTimeoutMs) },
@@ -569,7 +577,7 @@ export class SettingsSurface {
569
577
  try {
570
578
  cleanup = await this.target.cleanupPreview()
571
579
  } catch (error) {
572
- this.logger?.warn?.('dsh-channels: 工作区清理预览失败:%s', messageOf(error))
580
+ this.logger?.warn?.('dsh-channels: 目录清理预览失败:%s', messageOf(error))
573
581
  cleanup = {
574
582
  retention: effective.workspaceRetention,
575
583
  auto: effective.workspaceRetentionAuto,