@modesto_ma/dsh-channels 0.1.0-beta.1 → 0.2.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/config.ts CHANGED
@@ -21,6 +21,10 @@ export type FeishuLoggerLevel = (typeof FEISHU_LOGGER_LEVELS)[number]
21
21
  /** Same shape as the host credentials seam's `CredentialRef` (shell identifier). */
22
22
  export const CREDENTIAL_REF_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/
23
23
 
24
+ /** Conventional credential-reference names used when the loader passes no config. */
25
+ export const DEFAULT_APP_ID_REF = 'FEISHU_APP_ID'
26
+ export const DEFAULT_APP_SECRET_REF = 'FEISHU_APP_SECRET'
27
+
24
28
  /** Default route-table GC threshold: 30 days without activity. */
25
29
  export const DEFAULT_ROUTE_TTL_MS = 30 * 24 * 60 * 60 * 1000
26
30
 
@@ -37,10 +41,10 @@ const credentialRefSchema = z.string().regex(
37
41
  )
38
42
 
39
43
  export const configSchema = z.object({
40
- /** Feishu app id credential reference (e.g. `FEISHU_APP_ID`). */
41
- appId: credentialRefSchema,
42
- /** Feishu app secret credential reference (e.g. `FEISHU_APP_SECRET`). */
43
- appSecret: credentialRefSchema,
44
+ /** Feishu app id credential reference (defaults to `FEISHU_APP_ID`). */
45
+ appId: credentialRefSchema.default(DEFAULT_APP_ID_REF),
46
+ /** Feishu app secret credential reference (defaults to `FEISHU_APP_SECRET`). */
47
+ appSecret: credentialRefSchema.default(DEFAULT_APP_SECRET_REF),
44
48
  /**
45
49
  * Root directory for per-(user, chat) workspace directories — the filesystem
46
50
  * root where files produced by channel sessions land. Read per session
@@ -188,6 +192,15 @@ export const configSchema = z.object({
188
192
 
189
193
  export type FeishuConfig = z.infer<typeof configSchema>
190
194
 
195
+ /**
196
+ * DSH loader-facing schema. The bundle patch inserts this plugin without a
197
+ * `config` object; the loader validates that row with `undefined` before the
198
+ * user's patch layer supplies values. Accept that absence and fill the same
199
+ * defaults as {@link resolveConfig}, so enabling the plugin from the Web UI
200
+ * cannot fail with "expected object, received undefined".
201
+ */
202
+ export const pluginConfigSchema = configSchema.prefault({})
203
+
191
204
  /**
192
205
  * Validate the raw loader config. Values stay credential *references*; the
193
206
  * secrets themselves resolve through `ctx.credentials` at startup.
package/src/index.ts CHANGED
@@ -15,7 +15,7 @@
15
15
 
16
16
  import type { HostContext } from './dsh-types.ts'
17
17
  import { FeishuService } from './service.ts'
18
- import { configSchema, resolveConfig } from './config.ts'
18
+ import { pluginConfigSchema, resolveConfig } from './config.ts'
19
19
  import type { FeishuConfig } from './config.ts'
20
20
  import { defaultChannelFactory } from './feishu-channel.ts'
21
21
  import type { ChannelFactory } from './feishu-channel.ts'
@@ -23,6 +23,7 @@ import { OverlayStore, defaultOverlayPath } from './settings/overlay.ts'
23
23
  import { mountSettingsRoutes } from './settings/routes.ts'
24
24
  import { SettingsSurface } from './settings/surface.ts'
25
25
  import { createHostCatalog } from './settings/catalog.ts'
26
+ import { createSettingsDirectoryPicker } from './settings/directory-picker.ts'
26
27
 
27
28
  export type * from './config.ts'
28
29
  export type * from './cards.ts'
@@ -38,12 +39,15 @@ export type * from './questions.ts'
38
39
  export type * from './settings/contract.ts'
39
40
  export * from './access/index.ts'
40
41
  export type { AccessGroupRecord, AccessRequestRecord, AccessUserRecord } from './domain.ts'
41
- export { CATALOG_ROUTE, CLEANUP_ROUTE, CONFIG_ROUTE, CREDENTIALS_ROUTE, EDITABLE_KEYS, PANEL_COPY, PANEL_ROUTE, RECONNECT_ROUTE, RESTART_KEYS, STATUS_ROUTE } from './settings/contract.ts'
42
+ export { CATALOG_ROUTE, CLEANUP_ROUTE, CONFIG_ROUTE, CREDENTIALS_ROUTE, EDITABLE_KEYS, PANEL_COPY, PANEL_ROUTE, PICK_DIRECTORY_ROUTE, RECONNECT_ROUTE, RESTART_KEYS, STATUS_ROUTE } from './settings/contract.ts'
43
+ export type { PickDirectoryResponse } from './settings/contract.ts'
42
44
  export { defaultOverlayPath, mergeOverlay, normalizeOverlay, OverlayStore, parseOverlay, pickEditable, resolveDshHome } from './settings/overlay.ts'
43
- export { applyCatalog, emptyCatalog, FIELD_DESCRIPTORS, normalizeCredentialValue, readOnlyFields, SettingsSurface } from './settings/surface.ts'
45
+ export { applyCatalog, emptyCatalog, FIELD_DESCRIPTORS, normalizeCredentialValue, readOnlyFields, SettingsSurface, withDirectoryBrowse } from './settings/surface.ts'
44
46
  export type { SettingsTarget } from './settings/surface.ts'
45
47
  export { createHostCatalog } from './settings/catalog.ts'
46
48
  export type { CatalogWarn, SettingsCatalog } from './settings/catalog.ts'
49
+ export { createSettingsDirectoryPicker } from './settings/directory-picker.ts'
50
+ export type { HostDirectoryPickerService, SettingsDirectoryPicker } from './settings/directory-picker.ts'
47
51
  export { isSameOriginRequest, mountSettingsRoutes, registerSettingsRoutes } from './settings/routes.ts'
48
52
  export type { HostWebServer } from './settings/routes.ts'
49
53
  export type { ApprovalOutcome, ApprovalRequest, ApprovalAnswererListener, AssistantStreamFrame, AskUserQuestionAnswer, AskUserQuestionAnswerItem, AskUserQuestionItem, AskUserQuestionRequestEvent, UserQuestionAnswererListener, AgentCancelCause, SessionStatsView, TokenUsageView, ContextPressureView, SessionProjections } from './dsh-types.ts'
@@ -83,7 +87,7 @@ export const inject = ['agents', 'commands', 'workspaceRegistry', 'storageDomain
83
87
  * its `resolveConfig()` fallback for direct invocation (unit tests, embedded
84
88
  * hosts) where config has not been pre-validated.
85
89
  */
86
- export const Config = configSchema
90
+ export const Config = pluginConfigSchema
87
91
 
88
92
  export interface FeishuPluginOptions extends FeishuConfig {
89
93
  /** Test seam: substitute a channel implementation. */
@@ -106,16 +110,28 @@ export function apply(ctx: HostContext, config?: unknown): void {
106
110
  )
107
111
  const service = new FeishuService(ctx, resolved, factory, undefined, overlay)
108
112
  // Settings routes ride the host's web server when this deployment has one.
109
- // The catalog resolves the panel's model selectors from live host services.
113
+ // The catalog resolves the panel's model selectors from live host services;
114
+ // the optional native directory picker backs the workspace-root「选择目录…」
115
+ // button (absent in headless/remote compositions → plain text input).
110
116
  const warn = (message: string, ...args: unknown[]): void => { ctx.logger?.warn?.(message, ...args) }
111
- mountSettingsRoutes(ctx, new SettingsSurface(service, overlay, { warn }, createHostCatalog(ctx, warn)))
117
+ mountSettingsRoutes(ctx, new SettingsSurface(
118
+ service,
119
+ overlay,
120
+ { warn },
121
+ createHostCatalog(ctx, warn),
122
+ createSettingsDirectoryPicker(ctx),
123
+ ))
112
124
  ctx.effect(() => {
125
+ // Boot loads the Web overlay and connects only when the Feishu application
126
+ // credentials are complete; an incomplete configuration or a failed
127
+ // connection is a panel-visible state, not a plugin failure. Only an
128
+ // unexpected boot error (e.g. unreadable overlay) reaches this catch.
113
129
  void service.boot().catch((error: unknown) => {
114
- ctx.logger?.error?.('dsh-channels failed to start: %s', String(error))
130
+ ctx.logger?.error?.('dsh-channels failed to boot: %s', String(error))
115
131
  // Critical lifecycle line duplicated to the console: cordis logger
116
132
  // messages emitted before a web exporter attaches are buffered and may
117
133
  // never reach stdout, which makes real-device debugging guesswork.
118
- console.error('[dsh-channels] failed to start:', error)
134
+ console.error('[dsh-channels] failed to boot:', error)
119
135
  })
120
136
  return () => service.shutdown()
121
137
  }, 'dsh-channels')
package/src/service.ts CHANGED
@@ -281,13 +281,21 @@ export class FeishuService {
281
281
  private started = false
282
282
  /** Epoch ms when `start()` completed (uptime for `/status`). */
283
283
  private startedAt: number | undefined
284
- /** Last failed `boot()` message, surfaced in the Web settings panel. */
284
+ /** Last failed connection attempt, surfaced in the Web settings panel. */
285
285
  private lastStartError: string | undefined
286
+ /** Whether both Feishu application credentials currently resolve. */
287
+ private configured = false
288
+ /** Names of the missing credential references (never their values). */
289
+ private missingConfigItems: string[] = []
290
+ /** Guards the panel's explicit connect action (distinct from a restart). */
291
+ private connecting = false
286
292
  /** Effective config captured at the last successful connect (restart-pending diff). */
287
293
  private runningConfig: FeishuConfig | undefined
288
294
  /** Guards overlapping panel-triggered reconnects. */
289
295
  private restarting = false
290
296
  private unlisten: (() => void) | undefined
297
+ /** Disposers for host-scoped listeners registered by `start()`. */
298
+ private readonly unlistenHost: Array<() => boolean> = []
291
299
  /** Periodic route-GC timer (unref'd; only armed when `routeTtlMs > 0`). */
292
300
  private gcTimer: ReturnType<typeof setInterval> | undefined
293
301
  /** Periodic workspace-retention timer (only armed when auto cleanup is on). */
@@ -396,27 +404,105 @@ export class FeishuService {
396
404
  // ---- lifecycle / Web settings panel ----------------------------------
397
405
 
398
406
  /**
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).
407
+ * Load the Web overlay and open the long connection when — and only when —
408
+ * the Feishu application credentials are complete.
409
+ *
410
+ * An incomplete configuration is a normal state, not an error: the plugin
411
+ * stays loaded so the Web settings panel can guide the user through the
412
+ * credentials form, and no connection is attempted. A failed connection
413
+ * attempt is recorded for the panel instead of failing the plugin fiber.
402
414
  */
403
415
  async boot(): Promise<void> {
416
+ if (this.connecting || this.restarting) return
417
+ // Acquire the lifecycle lock before the first await so a panel action can
418
+ // never interleave with the boot sequence.
419
+ this.connecting = true
404
420
  try {
405
421
  await this.loadOverlay()
422
+ await this.refreshReadiness()
423
+ if (!this.configured) {
424
+ this.lastStartError = undefined
425
+ this.runningConfig = undefined
426
+ this.ctx.logger?.info?.(
427
+ 'dsh-channels: 飞书应用凭据未配置完整(缺少 %s),本次不连接飞书;'
428
+ + '请在 Web 设置面板 → 飞书渠道中配置,保存后会自动连接',
429
+ this.missingConfigItems.join('、'),
430
+ )
431
+ return
432
+ }
433
+ await this.openConnection()
434
+ } catch {
435
+ // Recorded in `lastStartError`; the plugin itself stays loaded.
436
+ } finally {
437
+ this.connecting = false
438
+ }
439
+ }
440
+
441
+ /**
442
+ * Refresh the cached configuration readiness from the credentials seam.
443
+ * Only presence is checked (describe/resolve), never values; the secrets
444
+ * themselves are resolved by `start()` when a connection is opened.
445
+ */
446
+ async refreshReadiness(): Promise<void> {
447
+ const states = await this.credentialStates()
448
+ const missing: string[] = []
449
+ if (!states.appId.configured) missing.push(`${this.config.appId}(飞书 App ID)`)
450
+ if (!states.appSecret.configured) missing.push(`${this.config.appSecret}(飞书 App Secret)`)
451
+ this.missingConfigItems = missing
452
+ this.configured = missing.length === 0
453
+ }
454
+
455
+ /**
456
+ * Open the connection with the live config. A failure tears the partial
457
+ * service down and records the reason for the panel before rethrowing it to
458
+ * the caller; the plugin fiber is never failed by a connection problem.
459
+ */
460
+ private async openConnection(): Promise<void> {
461
+ try {
406
462
  await this.start()
407
463
  this.lastStartError = undefined
408
464
  this.runningConfig = { ...this.config }
409
465
  } catch (error) {
410
466
  this.lastStartError = messageOf(error)
467
+ this.ctx.logger?.error?.('dsh-channels: 飞书连接失败:%s', String(error))
468
+ // Headless profiles have no settings panel; keep the failure visible on
469
+ // the console, symmetric with the success line at the end of `start()`.
470
+ console.error('[dsh-channels] feishu connection failed:', error)
411
471
  try {
412
472
  await this.shutdown()
413
473
  } catch {
414
- // The original startup error is the useful one.
474
+ // The connection error is the useful one.
415
475
  }
416
476
  throw error
417
477
  }
418
478
  }
419
479
 
480
+ /**
481
+ * Panel action: open the long connection when the channel is stopped.
482
+ * Credentials are re-checked first — an incomplete pair throws a message the
483
+ * panel shows next to the credentials form instead of attempting a connect.
484
+ */
485
+ async connect(): Promise<void> {
486
+ if (this.started) return
487
+ // Boot, an explicit connect and a restart all drive the same connection
488
+ // wiring; serialize them instead of racing two `start()` bodies. The lock
489
+ // is acquired synchronously, before any await.
490
+ if (this.connecting || this.restarting) throw new Error('飞书连接正在连接或重启,请稍候再试')
491
+ this.connecting = true
492
+ try {
493
+ // A stopped channel may have been edited on disk (the overlay file is
494
+ // writable by hand); connecting re-reads it like a restart does.
495
+ await this.loadOverlay()
496
+ await this.refreshReadiness()
497
+ if (!this.configured) {
498
+ throw new Error(`飞书应用凭据尚未配置完整:缺少 ${this.missingConfigItems.join('、')}`)
499
+ }
500
+ await this.openConnection()
501
+ } finally {
502
+ this.connecting = false
503
+ }
504
+ }
505
+
420
506
  /** Re-read the Web overlay over the profile config (no channel touch). */
421
507
  private async loadOverlay(): Promise<void> {
422
508
  const overlay = this.overlay === undefined ? {} : await this.overlay.load()
@@ -444,26 +530,24 @@ export class FeishuService {
444
530
  this.refreshRetentionTimer()
445
531
  }
446
532
 
447
- /** Tear the connection down and bring it back with the latest config. */
533
+ /**
534
+ * Tear the connection down and bring it back with the latest config. When the
535
+ * credentials are no longer complete the channel simply stays stopped (no
536
+ * error): the panel then shows the configuration guide instead of a failure.
537
+ */
448
538
  async restart(): Promise<void> {
449
- if (this.restarting) throw new Error('飞书连接正在重启,请稍候再试')
539
+ if (this.restarting || this.connecting) throw new Error('飞书连接正在连接或重启,请稍候再试')
450
540
  this.restarting = true
451
541
  try {
452
542
  await this.shutdown()
453
543
  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.
544
+ await this.refreshReadiness()
545
+ if (!this.configured) {
546
+ this.lastStartError = undefined
547
+ this.runningConfig = undefined
548
+ return
465
549
  }
466
- throw error
550
+ await this.openConnection()
467
551
  } finally {
468
552
  this.restarting = false
469
553
  }
@@ -471,6 +555,12 @@ export class FeishuService {
471
555
 
472
556
  isRunning(): boolean { return this.started }
473
557
  isRestarting(): boolean { return this.restarting }
558
+ /** True while the panel's explicit connect action is in flight. */
559
+ isConnecting(): boolean { return this.connecting }
560
+ /** Whether both Feishu application credentials currently resolve. */
561
+ isConfigured(): boolean { return this.configured }
562
+ /** Names of the missing configuration items (never values). */
563
+ missingConfig(): readonly string[] { return this.missingConfigItems }
474
564
  startedAtMs(): number | undefined { return this.startedAt }
475
565
  uptimeMs(): number | undefined {
476
566
  return this.startedAt === undefined || !this.started ? undefined : Math.max(0, Date.now() - this.startedAt)
@@ -489,6 +579,9 @@ export class FeishuService {
489
579
  const admin = this.config.adminOpenId
490
580
  return {
491
581
  mode: this.config.accessMode,
582
+ // Whitelists live in the storage domain, which is only open while the
583
+ // channel runs; an unconnected panel must not present them as empty.
584
+ loaded: this.domain !== undefined,
492
585
  admin: {
493
586
  ...(admin === undefined ? {} : { openId: admin }),
494
587
  configured: admin !== undefined,
@@ -529,7 +622,7 @@ export class FeishuService {
529
622
  const id = this.cleanAccessTargetId(chatId)
530
623
  if (id === undefined) return { ok: false, error: 'chat_id 不合法(应为 oc_ 开头的群 ID)' }
531
624
  const admin = this.config.adminOpenId
532
- if (admin === undefined) return { ok: false, error: '请先在常用配置中设置管理员 open_id' }
625
+ if (admin === undefined) return { ok: false, error: '请先在「访问授权」标签页设置管理员 open_id' }
533
626
  try {
534
627
  if (!this.accessStore.hasGroup(id)) {
535
628
  await this.accessStore.addGroup({
@@ -563,7 +656,7 @@ export class FeishuService {
563
656
  const id = this.cleanAccessUserId(openId)
564
657
  if (id === undefined) return { ok: false, error: 'open_id 不合法(应以 ou_ 开头)' }
565
658
  const admin = this.config.adminOpenId
566
- if (admin === undefined) return { ok: false, error: '请先在常用配置中设置管理员 open_id' }
659
+ if (admin === undefined) return { ok: false, error: '请先在「访问授权」标签页设置管理员 open_id' }
567
660
  try {
568
661
  if (!this.accessStore.hasUser(id)) {
569
662
  await this.accessStore.addUser({
@@ -597,7 +690,7 @@ export class FeishuService {
597
690
  const requests = this.accessRequests
598
691
  if (requests === undefined) return { ok: false, error: '授权服务尚未启动' }
599
692
  const actor = this.config.adminOpenId
600
- if (actor === undefined) return { ok: false, error: '请先配置管理员 open_id' }
693
+ if (actor === undefined) return { ok: false, error: '请先在「访问授权」标签页配置管理员 open_id' }
601
694
  try {
602
695
  const result = await requests.resolve(token, decision, actor)
603
696
  if (!result.ok) return { ok: false, error: result.error }
@@ -646,6 +739,17 @@ export class FeishuService {
646
739
  ...(window === undefined ? {} : { thresholdMs: window }),
647
740
  auto: this.config.workspaceRetentionAuto,
648
741
  }
742
+ if (this.domain === undefined) {
743
+ // No connection: routes live in the closed storage domain, so a preview
744
+ // would mistake every workspace for a candidate. Report why instead.
745
+ return {
746
+ ...base,
747
+ workspaces: 0,
748
+ candidates: 0,
749
+ candidateBytes: 0,
750
+ error: '飞书连接建立后即可预览和清理工作区',
751
+ }
752
+ }
649
753
  try {
650
754
  const workspaces = this.ctx.workspaceRegistry.list().filter(workspace => this.isPluginWorkspace(workspace))
651
755
  const plan = await planWorkspaceCleanup(this.cleanupDeps())
@@ -675,6 +779,12 @@ export class FeishuService {
675
779
  error?: string
676
780
  }> {
677
781
  const empty = { ok: false, deletedWorkspaces: 0, retiredRoutes: 0, freedBytes: 0, failures: [] as string[] }
782
+ if (this.domain === undefined) {
783
+ // Without the storage domain the route table is unreadable, so a cleanup
784
+ // could mistake live workspaces for candidates and fail to retire their
785
+ // routes. Refuse rather than rely on the panel disabling the button.
786
+ return { ...empty, error: '飞书连接建立后才能清理工作区' }
787
+ }
678
788
  if (retentionMs(this.config.workspaceRetention) === undefined) {
679
789
  return { ...empty, error: '未启用工作区清理阈值(workspaceRetention 为 off)' }
680
790
  }
@@ -840,6 +950,7 @@ export class FeishuService {
840
950
  } catch (error) {
841
951
  throw new Error(`写入凭据引用 ${ref} 失败:${messageOf(error)}`)
842
952
  }
953
+ await this.refreshReadiness()
843
954
  }
844
955
 
845
956
  /** Remove the stored value of one Feishu credential reference. */
@@ -854,6 +965,7 @@ export class FeishuService {
854
965
  } catch (error) {
855
966
  throw new Error(`移除凭据引用 ${ref} 失败:${messageOf(error)}`)
856
967
  }
968
+ await this.refreshReadiness()
857
969
  }
858
970
 
859
971
  /** The overlay-independent ref name backing one editable credential slot. */
@@ -957,14 +1069,14 @@ export class FeishuService {
957
1069
  questions.onCardAction(event)
958
1070
  })
959
1071
 
960
- this.ctx.on('session/event', (session, event) => {
1072
+ this.unlistenHost.push(this.ctx.on('session/event', (session, event) => {
961
1073
  this.streamer?.onSessionEvent(session, event)
962
- })
1074
+ }))
963
1075
  // DSH removed the durable `assistant/chunk` event (format v2 embeds the
964
1076
  // stream in the settlement); the live deltas arrive here instead.
965
- this.ctx.on('agent/assistant-stream', ({ agent, frame }) => {
1077
+ this.unlistenHost.push(this.ctx.on('agent/assistant-stream', ({ agent, frame }) => {
966
1078
  this.streamer?.onAssistantFrame(agent.session, frame)
967
- })
1079
+ }))
968
1080
  this.unlisten = channel.onMessage(message => {
969
1081
  void this.inbound.handle(message).catch((error: unknown) => {
970
1082
  this.ctx.logger?.warn?.('dsh-channels inbound error: %s', String(error))
@@ -1053,6 +1165,10 @@ export class FeishuService {
1053
1165
  this.unlistenApproval = undefined
1054
1166
  this.unlistenQuestions?.()
1055
1167
  this.unlistenQuestions = undefined
1168
+ // Host-scoped listeners must be released on every teardown: a failed
1169
+ // connect retry or a reconnect calls `start()` again, and a leaked pair
1170
+ // would feed the same session events into the streamer twice.
1171
+ for (const dispose of this.unlistenHost.splice(0)) dispose()
1056
1172
  // Withdraw in-flight approval questions (the host settles them cancelled).
1057
1173
  this.approvalAnswerer?.dispose()
1058
1174
  this.approvalAnswerer = undefined