@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
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). */
@@ -281,13 +286,21 @@ export class FeishuService {
281
286
  private started = false
282
287
  /** Epoch ms when `start()` completed (uptime for `/status`). */
283
288
  private startedAt: number | undefined
284
- /** Last failed `boot()` message, surfaced in the Web settings panel. */
289
+ /** Last failed connection attempt, surfaced in the Web settings panel. */
285
290
  private lastStartError: string | undefined
291
+ /** Whether both Feishu application credentials currently resolve. */
292
+ private configured = false
293
+ /** Names of the missing credential references (never their values). */
294
+ private missingConfigItems: string[] = []
295
+ /** Guards the panel's explicit connect action (distinct from a restart). */
296
+ private connecting = false
286
297
  /** Effective config captured at the last successful connect (restart-pending diff). */
287
298
  private runningConfig: FeishuConfig | undefined
288
299
  /** Guards overlapping panel-triggered reconnects. */
289
300
  private restarting = false
290
301
  private unlisten: (() => void) | undefined
302
+ /** Disposers for host-scoped listeners registered by `start()`. */
303
+ private readonly unlistenHost: Array<() => boolean> = []
291
304
  /** Periodic route-GC timer (unref'd; only armed when `routeTtlMs > 0`). */
292
305
  private gcTimer: ReturnType<typeof setInterval> | undefined
293
306
  /** Periodic workspace-retention timer (only armed when auto cleanup is on). */
@@ -396,27 +409,105 @@ export class FeishuService {
396
409
  // ---- lifecycle / Web settings panel ----------------------------------
397
410
 
398
411
  /**
399
- * Load the Web overlay, then open the channel. Startup errors are recorded
400
- * for the settings panel and the partial service is torn down before the
401
- * error is rethrown (the plugin fiber still fails the same way).
412
+ * Load the Web overlay and open the long connection when — and only when —
413
+ * the Feishu application credentials are complete.
414
+ *
415
+ * An incomplete configuration is a normal state, not an error: the plugin
416
+ * stays loaded so the Web settings panel can guide the user through the
417
+ * credentials form, and no connection is attempted. A failed connection
418
+ * attempt is recorded for the panel instead of failing the plugin fiber.
402
419
  */
403
420
  async boot(): Promise<void> {
421
+ if (this.connecting || this.restarting) return
422
+ // Acquire the lifecycle lock before the first await so a panel action can
423
+ // never interleave with the boot sequence.
424
+ this.connecting = true
404
425
  try {
405
426
  await this.loadOverlay()
427
+ await this.refreshReadiness()
428
+ if (!this.configured) {
429
+ this.lastStartError = undefined
430
+ this.runningConfig = undefined
431
+ this.ctx.logger?.info?.(
432
+ 'dsh-channels: 飞书应用凭据未配置完整(缺少 %s),本次不连接飞书;'
433
+ + '请在 Web 设置面板 → 飞书渠道中配置,保存后会自动连接',
434
+ this.missingConfigItems.join('、'),
435
+ )
436
+ return
437
+ }
438
+ await this.openConnection()
439
+ } catch {
440
+ // Recorded in `lastStartError`; the plugin itself stays loaded.
441
+ } finally {
442
+ this.connecting = false
443
+ }
444
+ }
445
+
446
+ /**
447
+ * Refresh the cached configuration readiness from the credentials seam.
448
+ * Only presence is checked (describe/resolve), never values; the secrets
449
+ * themselves are resolved by `start()` when a connection is opened.
450
+ */
451
+ async refreshReadiness(): Promise<void> {
452
+ const states = await this.credentialStates()
453
+ const missing: string[] = []
454
+ if (!states.appId.configured) missing.push(`${this.config.appId}(飞书 App ID)`)
455
+ if (!states.appSecret.configured) missing.push(`${this.config.appSecret}(飞书 App Secret)`)
456
+ this.missingConfigItems = missing
457
+ this.configured = missing.length === 0
458
+ }
459
+
460
+ /**
461
+ * Open the connection with the live config. A failure tears the partial
462
+ * service down and records the reason for the panel before rethrowing it to
463
+ * the caller; the plugin fiber is never failed by a connection problem.
464
+ */
465
+ private async openConnection(): Promise<void> {
466
+ try {
406
467
  await this.start()
407
468
  this.lastStartError = undefined
408
469
  this.runningConfig = { ...this.config }
409
470
  } catch (error) {
410
471
  this.lastStartError = messageOf(error)
472
+ this.ctx.logger?.error?.('dsh-channels: 飞书连接失败:%s', String(error))
473
+ // Headless profiles have no settings panel; keep the failure visible on
474
+ // the console, symmetric with the success line at the end of `start()`.
475
+ console.error('[dsh-channels] feishu connection failed:', error)
411
476
  try {
412
477
  await this.shutdown()
413
478
  } catch {
414
- // The original startup error is the useful one.
479
+ // The connection error is the useful one.
415
480
  }
416
481
  throw error
417
482
  }
418
483
  }
419
484
 
485
+ /**
486
+ * Panel action: open the long connection when the channel is stopped.
487
+ * Credentials are re-checked first — an incomplete pair throws a message the
488
+ * panel shows next to the credentials form instead of attempting a connect.
489
+ */
490
+ async connect(): Promise<void> {
491
+ if (this.started) return
492
+ // Boot, an explicit connect and a restart all drive the same connection
493
+ // wiring; serialize them instead of racing two `start()` bodies. The lock
494
+ // is acquired synchronously, before any await.
495
+ if (this.connecting || this.restarting) throw new Error('飞书连接正在连接或重启,请稍候再试')
496
+ this.connecting = true
497
+ try {
498
+ // A stopped channel may have been edited on disk (the overlay file is
499
+ // writable by hand); connecting re-reads it like a restart does.
500
+ await this.loadOverlay()
501
+ await this.refreshReadiness()
502
+ if (!this.configured) {
503
+ throw new Error(`飞书应用凭据尚未配置完整:缺少 ${this.missingConfigItems.join('、')}`)
504
+ }
505
+ await this.openConnection()
506
+ } finally {
507
+ this.connecting = false
508
+ }
509
+ }
510
+
420
511
  /** Re-read the Web overlay over the profile config (no channel touch). */
421
512
  private async loadOverlay(): Promise<void> {
422
513
  const overlay = this.overlay === undefined ? {} : await this.overlay.load()
@@ -444,26 +535,24 @@ export class FeishuService {
444
535
  this.refreshRetentionTimer()
445
536
  }
446
537
 
447
- /** Tear the connection down and bring it back with the latest config. */
538
+ /**
539
+ * Tear the connection down and bring it back with the latest config. When the
540
+ * credentials are no longer complete the channel simply stays stopped (no
541
+ * error): the panel then shows the configuration guide instead of a failure.
542
+ */
448
543
  async restart(): Promise<void> {
449
- if (this.restarting) throw new Error('飞书连接正在重启,请稍候再试')
544
+ if (this.restarting || this.connecting) throw new Error('飞书连接正在连接或重启,请稍候再试')
450
545
  this.restarting = true
451
546
  try {
452
547
  await this.shutdown()
453
548
  await this.loadOverlay()
454
- await this.start()
455
- this.lastStartError = undefined
456
- this.runningConfig = { ...this.config }
457
- } catch (error) {
458
- this.lastStartError = messageOf(error)
459
- // `start()` flips `started` before wiring; a failed reconnect must not
460
- // leave the panel claiming a live connection.
461
- try {
462
- await this.shutdown()
463
- } catch {
464
- // The restart error is the useful one.
549
+ await this.refreshReadiness()
550
+ if (!this.configured) {
551
+ this.lastStartError = undefined
552
+ this.runningConfig = undefined
553
+ return
465
554
  }
466
- throw error
555
+ await this.openConnection()
467
556
  } finally {
468
557
  this.restarting = false
469
558
  }
@@ -471,6 +560,12 @@ export class FeishuService {
471
560
 
472
561
  isRunning(): boolean { return this.started }
473
562
  isRestarting(): boolean { return this.restarting }
563
+ /** True while the panel's explicit connect action is in flight. */
564
+ isConnecting(): boolean { return this.connecting }
565
+ /** Whether both Feishu application credentials currently resolve. */
566
+ isConfigured(): boolean { return this.configured }
567
+ /** Names of the missing configuration items (never values). */
568
+ missingConfig(): readonly string[] { return this.missingConfigItems }
474
569
  startedAtMs(): number | undefined { return this.startedAt }
475
570
  uptimeMs(): number | undefined {
476
571
  return this.startedAt === undefined || !this.started ? undefined : Math.max(0, Date.now() - this.startedAt)
@@ -489,6 +584,9 @@ export class FeishuService {
489
584
  const admin = this.config.adminOpenId
490
585
  return {
491
586
  mode: this.config.accessMode,
587
+ // Whitelists live in the storage domain, which is only open while the
588
+ // channel runs; an unconnected panel must not present them as empty.
589
+ loaded: this.domain !== undefined,
492
590
  admin: {
493
591
  ...(admin === undefined ? {} : { openId: admin }),
494
592
  configured: admin !== undefined,
@@ -529,7 +627,7 @@ export class FeishuService {
529
627
  const id = this.cleanAccessTargetId(chatId)
530
628
  if (id === undefined) return { ok: false, error: 'chat_id 不合法(应为 oc_ 开头的群 ID)' }
531
629
  const admin = this.config.adminOpenId
532
- if (admin === undefined) return { ok: false, error: '请先在常用配置中设置管理员 open_id' }
630
+ if (admin === undefined) return { ok: false, error: '请先在「访问授权」标签页设置管理员 open_id' }
533
631
  try {
534
632
  if (!this.accessStore.hasGroup(id)) {
535
633
  await this.accessStore.addGroup({
@@ -563,7 +661,7 @@ export class FeishuService {
563
661
  const id = this.cleanAccessUserId(openId)
564
662
  if (id === undefined) return { ok: false, error: 'open_id 不合法(应以 ou_ 开头)' }
565
663
  const admin = this.config.adminOpenId
566
- if (admin === undefined) return { ok: false, error: '请先在常用配置中设置管理员 open_id' }
664
+ if (admin === undefined) return { ok: false, error: '请先在「访问授权」标签页设置管理员 open_id' }
567
665
  try {
568
666
  if (!this.accessStore.hasUser(id)) {
569
667
  await this.accessStore.addUser({
@@ -597,7 +695,7 @@ export class FeishuService {
597
695
  const requests = this.accessRequests
598
696
  if (requests === undefined) return { ok: false, error: '授权服务尚未启动' }
599
697
  const actor = this.config.adminOpenId
600
- if (actor === undefined) return { ok: false, error: '请先配置管理员 open_id' }
698
+ if (actor === undefined) return { ok: false, error: '请先在「访问授权」标签页配置管理员 open_id' }
601
699
  try {
602
700
  const result = await requests.resolve(token, decision, actor)
603
701
  if (!result.ok) return { ok: false, error: result.error }
@@ -623,12 +721,12 @@ export class FeishuService {
623
721
  return value
624
722
  }
625
723
 
626
- // ---- workspace retention cleanup -------------------------------------
724
+ // ---- retention cleanup ------------------------------------------------
627
725
 
628
726
  /**
629
- * Workspace-retention preview for the Web panel: how many plugin-created
630
- * workspaces exist, how many are past the configured window, and their
631
- * 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.
632
730
  */
633
731
  async cleanupPreview(): Promise<{
634
732
  retention: string
@@ -646,6 +744,17 @@ export class FeishuService {
646
744
  ...(window === undefined ? {} : { thresholdMs: window }),
647
745
  auto: this.config.workspaceRetentionAuto,
648
746
  }
747
+ if (this.domain === undefined) {
748
+ // No connection: routes live in the closed storage domain, so a preview
749
+ // would mistake every directory for a candidate. Report why instead.
750
+ return {
751
+ ...base,
752
+ workspaces: 0,
753
+ candidates: 0,
754
+ candidateBytes: 0,
755
+ error: '飞书连接建立后即可预览和清理工作区',
756
+ }
757
+ }
649
758
  try {
650
759
  const workspaces = this.ctx.workspaceRegistry.list().filter(workspace => this.isPluginWorkspace(workspace))
651
760
  const plan = await planWorkspaceCleanup(this.cleanupDeps())
@@ -661,37 +770,57 @@ export class FeishuService {
661
770
  }
662
771
 
663
772
  /**
664
- * Delete every stale plugin workspace the configured retention allows.
665
- * Routes bound to a removed workspace are retired first (thread routes leave
666
- * a tombstone), so an old topic fails with the explicit expired card instead
667
- * 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.
668
782
  */
669
783
  async runWorkspaceCleanup(): Promise<{
670
784
  ok: boolean
671
785
  deletedWorkspaces: number
786
+ deletedSessionDirs: number
672
787
  retiredRoutes: number
673
788
  freedBytes: number
674
789
  failures: string[]
675
790
  error?: string
676
791
  }> {
677
- 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
+ }
800
+ if (this.domain === undefined) {
801
+ // Without the storage domain the route table is unreadable, so a cleanup
802
+ // could mistake live directories for candidates and fail to retire their
803
+ // routes. Refuse rather than rely on the panel disabling the button.
804
+ return { ...empty, error: '飞书连接建立后才能清理工作区' }
805
+ }
678
806
  if (retentionMs(this.config.workspaceRetention) === undefined) {
679
- return { ...empty, error: '未启用工作区清理阈值(workspaceRetention 为 off)' }
807
+ return { ...empty, error: '未启用目录清理阈值(workspaceRetention 为 off)' }
680
808
  }
681
- if (this.cleanupRunning) return { ...empty, error: '工作区清理正在进行中,请稍候' }
809
+ if (this.cleanupRunning) return { ...empty, error: '目录清理正在进行中,请稍候' }
682
810
  this.cleanupRunning = true
683
811
  try {
684
812
  const deps = this.cleanupDeps()
685
813
  const plan = await planWorkspaceCleanup(deps)
686
- if (plan === undefined) return { ...empty, error: '工作区清理不可用:baseDir 配置无效' }
814
+ if (plan === undefined) return { ...empty, error: '目录清理不可用:baseDir 配置无效' }
687
815
  const outcome = await applyWorkspaceCleanup(deps, plan)
688
816
  this.ctx.logger?.info?.(
689
- 'dsh-channels: workspace cleanup removed %d workspace(s), retired %d route(s), freed %d bytes',
690
- 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,
691
819
  )
692
820
  return {
693
821
  ok: true,
694
822
  deletedWorkspaces: outcome.deletedWorkspaces,
823
+ deletedSessionDirs: outcome.deletedSessionDirs,
695
824
  retiredRoutes: outcome.retiredRoutes,
696
825
  freedBytes: outcome.freedBytes,
697
826
  failures: outcome.failures.map(failure => `${failure.path}: ${failure.message}`),
@@ -716,39 +845,27 @@ export class FeishuService {
716
845
  baseDir,
717
846
  retention: this.config.workspaceRetention,
718
847
  listWorkspaces: () => this.ctx.workspaceRegistry.list(),
719
- activeRoutes: () => [...(this.domain?.routes.entries() ?? [])].map(([key, record]) => ({
848
+ listSessionDirs: () => this.listCleanableSessionDirs(baseDir),
849
+ activeRoutes: () => [...(this.domain?.routes.entries() ?? [])].map(([key, record]): CleanupRoute => ({
720
850
  key,
721
851
  kind: record.kind,
722
852
  workspacePath: record.workspacePath,
853
+ ...(record.sessionDir === undefined ? {} : { sessionDir: record.sessionDir }),
723
854
  sessionId: record.sessionId,
724
855
  lastActivityAt: record.lastActivityAt,
725
856
  })),
726
- tombstones: () => [...(this.domain?.expiredRoutes.entries() ?? [])].map(([key, record]) => ({
857
+ tombstones: () => [...(this.domain?.expiredRoutes.entries() ?? [])].map(([key, record]): CleanupTombstone => ({
727
858
  key,
728
859
  ...(record.workspacePath === undefined ? {} : { workspacePath: record.workspacePath }),
860
+ ...(record.sessionDir === undefined ? {} : { sessionDir: record.sessionDir }),
729
861
  expiredAt: record.expiredAt,
730
862
  ...(record.lastActivityAt === undefined ? {} : { lastActivityAt: record.lastActivityAt }),
731
863
  })),
732
864
  isLive: sessionId => this.sessions.get(sessionId) !== undefined
733
865
  || this.ctx.agents.get(sessionId as SessionId) !== undefined,
734
- statWorkspace: workspacePath => measureWorkspace(workspacePath),
866
+ statWorkspace: directory => measureWorkspace(directory),
735
867
  removeWorkspace: async workspace => {
736
- const target = path.resolve(workspace.path)
737
- if (!isPathInside(baseDir, target)) {
738
- throw new Error(`refusing to delete a path outside baseDir (${target})`)
739
- }
740
- // Re-verify the physical path: a symlinked ancestor inside baseDir
741
- // must not let the recursive delete escape it.
742
- try {
743
- const [physicalBase, physicalTarget] = await Promise.all([realpath(baseDir), realpath(target)])
744
- if (!isPathInside(physicalBase, physicalTarget)) {
745
- throw new Error(`refusing to delete a symlink escape outside baseDir (${physicalTarget})`)
746
- }
747
- } catch (error) {
748
- if (error instanceof Error && error.message.startsWith('refusing to delete a symlink escape')) throw error
749
- // A missing workspace is already effectively removed (rm is idempotent).
750
- }
751
- await rm(target, { recursive: true, force: true })
868
+ await this.deleteDirectoryInsideBase(baseDir, workspace.path)
752
869
  try {
753
870
  await this.ctx.workspaceRegistry.delete(workspace.id)
754
871
  } catch (error) {
@@ -757,6 +874,9 @@ export class FeishuService {
757
874
  )
758
875
  }
759
876
  },
877
+ removeSessionDir: async directory => {
878
+ await this.deleteDirectoryInsideBase(baseDir, directory)
879
+ },
760
880
  retireRoute: async route => {
761
881
  if (route.kind === 'thread') await this.routes.retire(route.key)
762
882
  else await this.routes.remove(route.key)
@@ -765,6 +885,71 @@ export class FeishuService {
765
885
  }
766
886
  }
767
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
+
768
953
  /**
769
954
  * (Re)arm the daily retention timer to match the live config. `runNow`
770
955
  * performs one pass immediately; it is only used at startup so saving any
@@ -780,7 +965,7 @@ export class FeishuService {
780
965
  || retentionMs(this.config.workspaceRetention) === undefined) return
781
966
  const sweep = (): void => {
782
967
  void this.runWorkspaceCleanup().then(result => {
783
- if (!result.ok && result.error !== '工作区清理正在进行中,请稍候') {
968
+ if (!result.ok && result.error !== '目录清理正在进行中,请稍候') {
784
969
  this.ctx.logger?.warn?.('dsh-channels: automatic workspace cleanup failed: %s', result.error)
785
970
  }
786
971
  })
@@ -840,6 +1025,7 @@ export class FeishuService {
840
1025
  } catch (error) {
841
1026
  throw new Error(`写入凭据引用 ${ref} 失败:${messageOf(error)}`)
842
1027
  }
1028
+ await this.refreshReadiness()
843
1029
  }
844
1030
 
845
1031
  /** Remove the stored value of one Feishu credential reference. */
@@ -854,6 +1040,7 @@ export class FeishuService {
854
1040
  } catch (error) {
855
1041
  throw new Error(`移除凭据引用 ${ref} 失败:${messageOf(error)}`)
856
1042
  }
1043
+ await this.refreshReadiness()
857
1044
  }
858
1045
 
859
1046
  /** The overlay-independent ref name backing one editable credential slot. */
@@ -957,14 +1144,14 @@ export class FeishuService {
957
1144
  questions.onCardAction(event)
958
1145
  })
959
1146
 
960
- this.ctx.on('session/event', (session, event) => {
1147
+ this.unlistenHost.push(this.ctx.on('session/event', (session, event) => {
961
1148
  this.streamer?.onSessionEvent(session, event)
962
- })
1149
+ }))
963
1150
  // DSH removed the durable `assistant/chunk` event (format v2 embeds the
964
1151
  // stream in the settlement); the live deltas arrive here instead.
965
- this.ctx.on('agent/assistant-stream', ({ agent, frame }) => {
1152
+ this.unlistenHost.push(this.ctx.on('agent/assistant-stream', ({ agent, frame }) => {
966
1153
  this.streamer?.onAssistantFrame(agent.session, frame)
967
- })
1154
+ }))
968
1155
  this.unlisten = channel.onMessage(message => {
969
1156
  void this.inbound.handle(message).catch((error: unknown) => {
970
1157
  this.ctx.logger?.warn?.('dsh-channels inbound error: %s', String(error))
@@ -1053,6 +1240,10 @@ export class FeishuService {
1053
1240
  this.unlistenApproval = undefined
1054
1241
  this.unlistenQuestions?.()
1055
1242
  this.unlistenQuestions = undefined
1243
+ // Host-scoped listeners must be released on every teardown: a failed
1244
+ // connect retry or a reconnect calls `start()` again, and a leaked pair
1245
+ // would feed the same session events into the streamer twice.
1246
+ for (const dispose of this.unlistenHost.splice(0)) dispose()
1056
1247
  // Withdraw in-flight approval questions (the host settles them cancelled).
1057
1248
  this.approvalAnswerer?.dispose()
1058
1249
  this.approvalAnswerer = undefined
@@ -1608,7 +1799,7 @@ export class FeishuService {
1608
1799
  await this.delivery.standaloneReply(msg, buildRenewCard({
1609
1800
  previousSessionId: renewAdmission.commit.previous.sessionId,
1610
1801
  currentSessionId: renewAdmission.commit.current.sessionId,
1611
- workspacePath: renewAdmission.commit.current.workspacePath,
1802
+ sessionDir: sessionDirOf(renewAdmission.commit.current),
1612
1803
  }))
1613
1804
  return
1614
1805
  }
@@ -1628,7 +1819,7 @@ export class FeishuService {
1628
1819
  context,
1629
1820
  extras: {
1630
1821
  permissionPreset: this.permissionPresetOf(session),
1631
- workspacePath: current.workspacePath,
1822
+ sessionDir: sessionDirOf(current),
1632
1823
  uptimeMs: this.startedAt === undefined ? undefined : Date.now() - this.startedAt,
1633
1824
  liveAgents: this.sessions.liveCount(),
1634
1825
  routeCount: this.routes.size(),
@@ -1637,7 +1828,10 @@ export class FeishuService {
1637
1828
  return
1638
1829
  }
1639
1830
  if (spec.name === 'workspace' && result.kind === 'success') {
1640
- 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
+ })
1641
1835
  if (resolved.kind === 'success') {
1642
1836
  await this.delivery.standaloneReply(msg, buildWorkDirCard(current.sessionId, resolved.path))
1643
1837
  return