@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/service.ts CHANGED
@@ -6,6 +6,10 @@
6
6
  * - p2p: one workspace per (bot, user), all of that user's sessions inside;
7
7
  * - group: one workspace per (chat, user), sessions grouped per user.
8
8
  *
9
+ * Every topic's Session works in its own directory inside that workspace
10
+ * (`topics/<token>` or a Git worktree), so topics never mix files while the
11
+ * workspace list grows with users instead of conversations.
12
+ *
9
13
  * Routing model:
10
14
  * - `thread_id` → bound session (no @ needed inside an owned topic);
11
15
  * - degraded chat-scoped keys when Feishu refuses topic replies;
@@ -45,11 +49,11 @@ import type { EffortCommandAction, ParsedSlashCommand } from './commands.ts'
45
49
  import { commandSpec } from './commands/registry.ts'
46
50
  import type { ChannelCommandSpec } from './commands/registry.ts'
47
51
  import type { PermissionPresets, SessionQuery } from './dsh-types.ts'
48
- import { feishuDomain } from './domain.ts'
52
+ import { feishuDomain, sessionDirOf } from './domain.ts'
49
53
  import {
50
54
  applyWorkspaceCleanup, isPathInside, planWorkspaceCleanup, retentionMs,
51
55
  } from './cleanup.ts'
52
- import type { CleanupDeps, WorkspaceStats } from './cleanup.ts'
56
+ import type { CleanupDeps, CleanupRoute, CleanupTombstone, ScannedSessionDir, WorkspaceStats } from './cleanup.ts'
53
57
  import type {
54
58
  AccessGroupRecord, AccessRequestRecord, AccessUserRecord,
55
59
  ExpiredRouteRecord, FeishuDomainGlobal, FeishuDomainHandle, InflightSnapshot, ProcessedRecord, RawDomainHandle,
@@ -83,6 +87,7 @@ import type {
83
87
  AccessMutationResponse, AccessRequestWire, AccessWire,
84
88
  } from './settings/contract.ts'
85
89
  import type { OverlayStore } from './settings/overlay.ts'
90
+ import { legacyTopicWorkspaceRoot } from './workspaces.ts'
86
91
 
87
92
  /** Periodic route-GC sweep cadence (a long-running instance also accumulates
88
93
  * dead records; TTL semantics make 6 h granularity more than sufficient). */
@@ -716,12 +721,12 @@ export class FeishuService {
716
721
  return value
717
722
  }
718
723
 
719
- // ---- workspace retention cleanup -------------------------------------
724
+ // ---- retention cleanup ------------------------------------------------
720
725
 
721
726
  /**
722
- * Workspace-retention preview for the Web panel: how many plugin-created
723
- * workspaces exist, how many are past the configured window, and their
724
- * measured footprint. Never deletes anything.
727
+ * Retention preview for the Web panel: how many plugin workspaces exist,
728
+ * how many directories are past the configured window, and their measured
729
+ * footprint. Never deletes anything.
725
730
  */
726
731
  async cleanupPreview(): Promise<{
727
732
  retention: string
@@ -741,7 +746,7 @@ export class FeishuService {
741
746
  }
742
747
  if (this.domain === undefined) {
743
748
  // No connection: routes live in the closed storage domain, so a preview
744
- // would mistake every workspace for a candidate. Report why instead.
749
+ // would mistake every directory for a candidate. Report why instead.
745
750
  return {
746
751
  ...base,
747
752
  workspaces: 0,
@@ -765,43 +770,57 @@ export class FeishuService {
765
770
  }
766
771
 
767
772
  /**
768
- * Delete every stale plugin workspace the configured retention allows.
769
- * Routes bound to a removed workspace are retired first (thread routes leave
770
- * a tombstone), so an old topic fails with the explicit expired card instead
771
- * of resuming into a missing directory. DSH Session logs are not touched.
773
+ * Delete every stale plugin directory the configured retention allows: the
774
+ * per-topic working directories of current conversations and the legacy
775
+ * one-workspace-per-topic directories of older versions. User workspaces are
776
+ * never deleted.
777
+ *
778
+ * Active routes bound to a removed directory are retired first (thread
779
+ * routes leave a tombstone), so an old topic fails with the explicit expired
780
+ * card instead of resuming into a missing directory. DSH Session logs are not
781
+ * touched.
772
782
  */
773
783
  async runWorkspaceCleanup(): Promise<{
774
784
  ok: boolean
775
785
  deletedWorkspaces: number
786
+ deletedSessionDirs: number
776
787
  retiredRoutes: number
777
788
  freedBytes: number
778
789
  failures: string[]
779
790
  error?: string
780
791
  }> {
781
- const empty = { ok: false, deletedWorkspaces: 0, retiredRoutes: 0, freedBytes: 0, failures: [] as string[] }
792
+ const empty = {
793
+ ok: false,
794
+ deletedWorkspaces: 0,
795
+ deletedSessionDirs: 0,
796
+ retiredRoutes: 0,
797
+ freedBytes: 0,
798
+ failures: [] as string[],
799
+ }
782
800
  if (this.domain === undefined) {
783
801
  // Without the storage domain the route table is unreadable, so a cleanup
784
- // could mistake live workspaces for candidates and fail to retire their
802
+ // could mistake live directories for candidates and fail to retire their
785
803
  // routes. Refuse rather than rely on the panel disabling the button.
786
804
  return { ...empty, error: '飞书连接建立后才能清理工作区' }
787
805
  }
788
806
  if (retentionMs(this.config.workspaceRetention) === undefined) {
789
- return { ...empty, error: '未启用工作区清理阈值(workspaceRetention 为 off)' }
807
+ return { ...empty, error: '未启用目录清理阈值(workspaceRetention 为 off)' }
790
808
  }
791
- if (this.cleanupRunning) return { ...empty, error: '工作区清理正在进行中,请稍候' }
809
+ if (this.cleanupRunning) return { ...empty, error: '目录清理正在进行中,请稍候' }
792
810
  this.cleanupRunning = true
793
811
  try {
794
812
  const deps = this.cleanupDeps()
795
813
  const plan = await planWorkspaceCleanup(deps)
796
- if (plan === undefined) return { ...empty, error: '工作区清理不可用:baseDir 配置无效' }
814
+ if (plan === undefined) return { ...empty, error: '目录清理不可用:baseDir 配置无效' }
797
815
  const outcome = await applyWorkspaceCleanup(deps, plan)
798
816
  this.ctx.logger?.info?.(
799
- 'dsh-channels: workspace cleanup removed %d workspace(s), retired %d route(s), freed %d bytes',
800
- outcome.deletedWorkspaces, outcome.retiredRoutes, outcome.freedBytes,
817
+ 'dsh-channels: retention cleanup removed %d legacy workspace(s) and %d topic dir(s), retired %d route(s), freed %d bytes',
818
+ outcome.deletedWorkspaces, outcome.deletedSessionDirs, outcome.retiredRoutes, outcome.freedBytes,
801
819
  )
802
820
  return {
803
821
  ok: true,
804
822
  deletedWorkspaces: outcome.deletedWorkspaces,
823
+ deletedSessionDirs: outcome.deletedSessionDirs,
805
824
  retiredRoutes: outcome.retiredRoutes,
806
825
  freedBytes: outcome.freedBytes,
807
826
  failures: outcome.failures.map(failure => `${failure.path}: ${failure.message}`),
@@ -826,39 +845,27 @@ export class FeishuService {
826
845
  baseDir,
827
846
  retention: this.config.workspaceRetention,
828
847
  listWorkspaces: () => this.ctx.workspaceRegistry.list(),
829
- activeRoutes: () => [...(this.domain?.routes.entries() ?? [])].map(([key, record]) => ({
848
+ listSessionDirs: () => this.listCleanableSessionDirs(baseDir),
849
+ activeRoutes: () => [...(this.domain?.routes.entries() ?? [])].map(([key, record]): CleanupRoute => ({
830
850
  key,
831
851
  kind: record.kind,
832
852
  workspacePath: record.workspacePath,
853
+ ...(record.sessionDir === undefined ? {} : { sessionDir: record.sessionDir }),
833
854
  sessionId: record.sessionId,
834
855
  lastActivityAt: record.lastActivityAt,
835
856
  })),
836
- tombstones: () => [...(this.domain?.expiredRoutes.entries() ?? [])].map(([key, record]) => ({
857
+ tombstones: () => [...(this.domain?.expiredRoutes.entries() ?? [])].map(([key, record]): CleanupTombstone => ({
837
858
  key,
838
859
  ...(record.workspacePath === undefined ? {} : { workspacePath: record.workspacePath }),
860
+ ...(record.sessionDir === undefined ? {} : { sessionDir: record.sessionDir }),
839
861
  expiredAt: record.expiredAt,
840
862
  ...(record.lastActivityAt === undefined ? {} : { lastActivityAt: record.lastActivityAt }),
841
863
  })),
842
864
  isLive: sessionId => this.sessions.get(sessionId) !== undefined
843
865
  || this.ctx.agents.get(sessionId as SessionId) !== undefined,
844
- statWorkspace: workspacePath => measureWorkspace(workspacePath),
866
+ statWorkspace: directory => measureWorkspace(directory),
845
867
  removeWorkspace: async workspace => {
846
- const target = path.resolve(workspace.path)
847
- if (!isPathInside(baseDir, target)) {
848
- throw new Error(`refusing to delete a path outside baseDir (${target})`)
849
- }
850
- // Re-verify the physical path: a symlinked ancestor inside baseDir
851
- // must not let the recursive delete escape it.
852
- try {
853
- const [physicalBase, physicalTarget] = await Promise.all([realpath(baseDir), realpath(target)])
854
- if (!isPathInside(physicalBase, physicalTarget)) {
855
- throw new Error(`refusing to delete a symlink escape outside baseDir (${physicalTarget})`)
856
- }
857
- } catch (error) {
858
- if (error instanceof Error && error.message.startsWith('refusing to delete a symlink escape')) throw error
859
- // A missing workspace is already effectively removed (rm is idempotent).
860
- }
861
- await rm(target, { recursive: true, force: true })
868
+ await this.deleteDirectoryInsideBase(baseDir, workspace.path)
862
869
  try {
863
870
  await this.ctx.workspaceRegistry.delete(workspace.id)
864
871
  } catch (error) {
@@ -867,6 +874,9 @@ export class FeishuService {
867
874
  )
868
875
  }
869
876
  },
877
+ removeSessionDir: async directory => {
878
+ await this.deleteDirectoryInsideBase(baseDir, directory)
879
+ },
870
880
  retireRoute: async route => {
871
881
  if (route.kind === 'thread') await this.routes.retire(route.key)
872
882
  else await this.routes.remove(route.key)
@@ -875,6 +885,71 @@ export class FeishuService {
875
885
  }
876
886
  }
877
887
 
888
+ /**
889
+ * Scan the live user workspaces for per-topic working directories that no
890
+ * route record points at anymore (for example the old route of a degraded
891
+ * chat conversation was deleted by TTL retirement). Route/tombstone evidence
892
+ * wins for age; the scan mtime only covers true orphans.
893
+ */
894
+ private async listCleanableSessionDirs(baseDir: string): Promise<ScannedSessionDir[]> {
895
+ const legacyRoot = legacyTopicWorkspaceRoot(baseDir)
896
+ const roots: string[] = []
897
+ for (const workspace of this.ctx.workspaceRegistry.list()) {
898
+ if (!this.isPluginWorkspace(workspace)) continue
899
+ const resolved = path.resolve(workspace.path)
900
+ // Legacy per-topic workspaces are cleaned as workspaces, not scanned here.
901
+ if (isPathInside(legacyRoot, resolved)) continue
902
+ roots.push(resolved)
903
+ }
904
+ const scanned: ScannedSessionDir[] = []
905
+ for (const root of roots) {
906
+ for (const relative of ['topics', path.join('.agents', 'worktrees')]) {
907
+ const directory = path.join(root, relative)
908
+ let entries
909
+ try {
910
+ entries = await readdir(directory, { withFileTypes: true })
911
+ } catch {
912
+ continue
913
+ }
914
+ for (const entry of entries) {
915
+ if (!entry.isDirectory()) continue
916
+ const candidate = path.join(directory, entry.name)
917
+ try {
918
+ const info = await lstat(candidate)
919
+ if (info.isDirectory()) scanned.push({ path: candidate, mtimeMs: info.mtimeMs })
920
+ } catch {
921
+ // Removed or unreadable between readdir and lstat; skip it.
922
+ }
923
+ }
924
+ }
925
+ }
926
+ return scanned
927
+ }
928
+
929
+ /**
930
+ * Recursively delete one plugin directory below `baseDir`.
931
+ *
932
+ * The lexical check refuses paths outside `baseDir`; the realpath check then
933
+ * refuses a symlinked ancestor that would let the recursive delete escape
934
+ * the plugin root. A missing directory is already effectively removed.
935
+ */
936
+ private async deleteDirectoryInsideBase(baseDir: string, target: string): Promise<void> {
937
+ const resolved = path.resolve(target)
938
+ if (!isPathInside(baseDir, resolved)) {
939
+ throw new Error(`refusing to delete a path outside baseDir (${resolved})`)
940
+ }
941
+ try {
942
+ const [physicalBase, physicalTarget] = await Promise.all([realpath(baseDir), realpath(resolved)])
943
+ if (!isPathInside(physicalBase, physicalTarget)) {
944
+ throw new Error(`refusing to delete a symlink escape outside baseDir (${physicalTarget})`)
945
+ }
946
+ } catch (error) {
947
+ if (error instanceof Error && error.message.startsWith('refusing to delete a symlink escape')) throw error
948
+ // A missing directory is already effectively removed (rm is idempotent).
949
+ }
950
+ await rm(resolved, { recursive: true, force: true })
951
+ }
952
+
878
953
  /**
879
954
  * (Re)arm the daily retention timer to match the live config. `runNow`
880
955
  * performs one pass immediately; it is only used at startup so saving any
@@ -890,7 +965,7 @@ export class FeishuService {
890
965
  || retentionMs(this.config.workspaceRetention) === undefined) return
891
966
  const sweep = (): void => {
892
967
  void this.runWorkspaceCleanup().then(result => {
893
- if (!result.ok && result.error !== '工作区清理正在进行中,请稍候') {
968
+ if (!result.ok && result.error !== '目录清理正在进行中,请稍候') {
894
969
  this.ctx.logger?.warn?.('dsh-channels: automatic workspace cleanup failed: %s', result.error)
895
970
  }
896
971
  })
@@ -1724,7 +1799,7 @@ export class FeishuService {
1724
1799
  await this.delivery.standaloneReply(msg, buildRenewCard({
1725
1800
  previousSessionId: renewAdmission.commit.previous.sessionId,
1726
1801
  currentSessionId: renewAdmission.commit.current.sessionId,
1727
- workspacePath: renewAdmission.commit.current.workspacePath,
1802
+ sessionDir: sessionDirOf(renewAdmission.commit.current),
1728
1803
  }))
1729
1804
  return
1730
1805
  }
@@ -1744,7 +1819,7 @@ export class FeishuService {
1744
1819
  context,
1745
1820
  extras: {
1746
1821
  permissionPreset: this.permissionPresetOf(session),
1747
- workspacePath: current.workspacePath,
1822
+ sessionDir: sessionDirOf(current),
1748
1823
  uptimeMs: this.startedAt === undefined ? undefined : Date.now() - this.startedAt,
1749
1824
  liveAgents: this.sessions.liveCount(),
1750
1825
  routeCount: this.routes.size(),
@@ -1753,7 +1828,10 @@ export class FeishuService {
1753
1828
  return
1754
1829
  }
1755
1830
  if (spec.name === 'workspace' && result.kind === 'success') {
1756
- const resolved = resolveWorkDir(live.agent.session.header.cwd, current.workspacePath)
1831
+ const resolved = resolveWorkDir(live.agent.session.header.cwd, {
1832
+ workspacePath: current.workspacePath,
1833
+ ...(current.sessionDir === undefined ? {} : { sessionDir: current.sessionDir }),
1834
+ })
1757
1835
  if (resolved.kind === 'success') {
1758
1836
  await this.delivery.standaloneReply(msg, buildWorkDirCard(current.sessionId, resolved.path))
1759
1837
  return
@@ -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
+ }