dsh-connect 0.8.0 → 0.9.2

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 (117) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +236 -18
  3. package/README.zh.md +185 -18
  4. package/client/client.js +517 -84
  5. package/client/client.js.map +4 -4
  6. package/client/locale.mjs +227 -0
  7. package/client/panel-state.mjs +54 -0
  8. package/client/settings-client.mjs +282 -61
  9. package/docs/images/settings-advanced-en.png +0 -0
  10. package/docs/images/settings-advanced-zh.png +0 -0
  11. package/docs/images/settings-defaults-en.png +0 -0
  12. package/docs/images/settings-defaults-zh.png +0 -0
  13. package/docs/images/settings-overview-en.png +0 -0
  14. package/docs/images/settings-overview-zh.png +0 -0
  15. package/lib/binding.d.ts.map +1 -1
  16. package/lib/binding.js +2 -2
  17. package/lib/binding.js.map +1 -1
  18. package/lib/channels/dingtalk/index.d.ts +49 -49
  19. package/lib/channels/dingtalk/index.d.ts.map +1 -1
  20. package/lib/channels/dingtalk/message.d.ts.map +1 -1
  21. package/lib/channels/dingtalk/message.js +7 -0
  22. package/lib/channels/dingtalk/message.js.map +1 -1
  23. package/lib/channels/feishu/adapter.d.ts +14 -13
  24. package/lib/channels/feishu/adapter.d.ts.map +1 -1
  25. package/lib/channels/feishu/adapter.js +122 -56
  26. package/lib/channels/feishu/adapter.js.map +1 -1
  27. package/lib/channels/feishu/i18n.d.ts +2 -0
  28. package/lib/channels/feishu/i18n.d.ts.map +1 -1
  29. package/lib/channels/feishu/i18n.js +2 -0
  30. package/lib/channels/feishu/i18n.js.map +1 -1
  31. package/lib/channels/feishu/index.d.ts +48 -27
  32. package/lib/channels/feishu/index.d.ts.map +1 -1
  33. package/lib/channels/feishu/index.js +32 -6
  34. package/lib/channels/feishu/index.js.map +1 -1
  35. package/lib/channels/telegram/adapter.d.ts.map +1 -1
  36. package/lib/channels/telegram/adapter.js +7 -1
  37. package/lib/channels/telegram/adapter.js.map +1 -1
  38. package/lib/channels/telegram/index.d.ts +13 -13
  39. package/lib/channels/telegram/index.d.ts.map +1 -1
  40. package/lib/channels/web/adapter.d.ts +3 -1
  41. package/lib/channels/web/adapter.d.ts.map +1 -1
  42. package/lib/channels/web/adapter.js +3 -1
  43. package/lib/channels/web/adapter.js.map +1 -1
  44. package/lib/channels/web/index.d.ts +5 -5
  45. package/lib/channels/web/index.d.ts.map +1 -1
  46. package/lib/chat-key.d.ts +29 -0
  47. package/lib/chat-key.d.ts.map +1 -0
  48. package/lib/chat-key.js +38 -0
  49. package/lib/chat-key.js.map +1 -0
  50. package/lib/index.d.ts +57 -63
  51. package/lib/index.d.ts.map +1 -1
  52. package/lib/index.js +330 -55
  53. package/lib/index.js.map +1 -1
  54. package/lib/interaction.d.ts +90 -50
  55. package/lib/interaction.d.ts.map +1 -1
  56. package/lib/interaction.js +233 -272
  57. package/lib/interaction.js.map +1 -1
  58. package/lib/retry.d.ts.map +1 -1
  59. package/lib/retry.js +7 -1
  60. package/lib/retry.js.map +1 -1
  61. package/lib/runner.d.ts +54 -0
  62. package/lib/runner.d.ts.map +1 -1
  63. package/lib/runner.js +158 -23
  64. package/lib/runner.js.map +1 -1
  65. package/lib/scheduler.d.ts.map +1 -1
  66. package/lib/scheduler.js +2 -2
  67. package/lib/scheduler.js.map +1 -1
  68. package/lib/service.d.ts +16 -0
  69. package/lib/service.d.ts.map +1 -1
  70. package/lib/service.js +46 -4
  71. package/lib/service.js.map +1 -1
  72. package/lib/settings/channel-runtime.d.ts +75 -0
  73. package/lib/settings/channel-runtime.d.ts.map +1 -0
  74. package/lib/settings/channel-runtime.js +165 -0
  75. package/lib/settings/channel-runtime.js.map +1 -0
  76. package/lib/settings/channels.d.ts +11 -0
  77. package/lib/settings/channels.d.ts.map +1 -1
  78. package/lib/settings/channels.js +31 -0
  79. package/lib/settings/channels.js.map +1 -1
  80. package/lib/settings/credential-store.d.ts +52 -6
  81. package/lib/settings/credential-store.d.ts.map +1 -1
  82. package/lib/settings/credential-store.js +92 -18
  83. package/lib/settings/credential-store.js.map +1 -1
  84. package/lib/settings/legacy-import.d.ts +190 -0
  85. package/lib/settings/legacy-import.d.ts.map +1 -0
  86. package/lib/settings/legacy-import.js +373 -0
  87. package/lib/settings/legacy-import.js.map +1 -0
  88. package/lib/settings/namespace.d.ts +263 -0
  89. package/lib/settings/namespace.d.ts.map +1 -0
  90. package/lib/settings/namespace.js +287 -0
  91. package/lib/settings/namespace.js.map +1 -0
  92. package/lib/settings/secret-disclosure.d.ts +50 -0
  93. package/lib/settings/secret-disclosure.d.ts.map +1 -0
  94. package/lib/settings/secret-disclosure.js +132 -0
  95. package/lib/settings/secret-disclosure.js.map +1 -0
  96. package/lib/settings/settings-model.d.ts +17 -0
  97. package/lib/settings/settings-model.d.ts.map +1 -1
  98. package/lib/settings/settings-model.js +18 -1
  99. package/lib/settings/settings-model.js.map +1 -1
  100. package/lib/settings/settings-rpc.d.ts +71 -3
  101. package/lib/settings/settings-rpc.d.ts.map +1 -1
  102. package/lib/settings/settings-rpc.js +167 -24
  103. package/lib/settings/settings-rpc.js.map +1 -1
  104. package/lib/settings/settings-service.d.ts +22 -1
  105. package/lib/settings/settings-service.d.ts.map +1 -1
  106. package/lib/settings/settings-service.js +145 -8
  107. package/lib/settings/settings-service.js.map +1 -1
  108. package/lib/state-dir.d.ts +28 -0
  109. package/lib/state-dir.d.ts.map +1 -0
  110. package/lib/state-dir.js +33 -0
  111. package/lib/state-dir.js.map +1 -0
  112. package/lib/stream.d.ts.map +1 -1
  113. package/lib/stream.js +3 -2
  114. package/lib/stream.js.map +1 -1
  115. package/lib/types.d.ts +14 -1
  116. package/lib/types.d.ts.map +1 -1
  117. package/package.json +13 -10
package/lib/index.d.ts CHANGED
@@ -37,11 +37,21 @@ export * as settings from "./settings/index.js";
37
37
  /** Cordis plugin name used by loader diagnostics. */
38
38
  export declare const name = "connect";
39
39
  /**
40
- * Host-plane services this plugin must wait for before `apply` runs. All three
41
- * are provided by the base bundle. The credential store is NOT listed here:
42
- * cordis v4 has no `?` optional-inject marker — `"credentials?"` would be treated
43
- * as a required service literally named "credentials?" and stall the plugin. The
44
- * store is instead fetched lazily inside `apply` via the optional `ctx.get()`.
40
+ * Host-plane services this plugin must wait for before `apply` runs. All four
41
+ * are rows in the always-loaded `@deepseek-ai/dsh-base` bundle — the shared core
42
+ * of every base-backed profile — so requiring them cannot stall the plugin in
43
+ * any profile that can load `dsh-connect` at all.
44
+ *
45
+ * `credentials` must be listed here, not read lazily inside `apply`: a
46
+ * `ctx.get("credentials")` at apply time returns `undefined`, because this
47
+ * plugin activates before the credentials row does. That silently disabled the
48
+ * whole credential path — `migrateConfigSecrets` never seeded the store (so an
49
+ * upgraded user's working channels displayed "未配置凭据"), `injectSecrets`
50
+ * never merged store-backed secrets into a channel config, and
51
+ * `settings.saveCredentials` threw `not-configured` for every pane save. Note
52
+ * cordis v4 has no `?` optional-inject marker, so `"credentials?"` would be read
53
+ * as a required service literally named `"credentials?"` — that is a syntax
54
+ * hazard, not a reason the service itself is unavailable.
45
55
  */
46
56
  export declare const inject: string[];
47
57
  /**
@@ -50,89 +60,73 @@ export declare const inject: string[];
50
60
  * web-settings state path. Each channel's own `Config` schema is also exported
51
61
  * (as `feishu.Config`, etc.) for reference; the merged entry validates loosely.
52
62
  */
53
- export declare const Config: z<Schemastery.ObjectS<{
63
+ export declare const Config: z<Schemastery.ObjectS<NoInfer<{
64
+ /** Optional path to persist web-settings edits (non-secret). */
65
+ settingsStatePath: z<string, string, "plain">;
54
66
  /** Agent preset id composed into each bound session; omit for the roster default. */
55
- agentPreset: z<string, string>;
67
+ agentPreset: z<string, string, "plain">;
56
68
  /** Absolute working directory per agent; defaults to the process cwd. */
57
- workDir: z<string, string>;
69
+ workDir: z<string, string, "plain">;
58
70
  /** Optional workspace directories offered by the `/dir` chooser. */
59
- workspaces: z<string[], string[]>;
71
+ workspaces: z<string[], string[], "plain">;
60
72
  /** Vision-capable model used to describe images when the main model can't see them. */
61
- visionModel: z<Schemastery.ObjectS<{
62
- provider: z<string, string>;
63
- model: z<string, string>;
64
- }>, Schemastery.ObjectT<{
65
- provider: z<string, string>;
66
- model: z<string, string>;
67
- }>>;
73
+ visionModel: z<Schemastery.ObjectS<NoInfer<{
74
+ provider: z<string, string, "plain">;
75
+ model: z<string, string, "plain">;
76
+ }>>, Schemastery.ObjectT<NoInfer<{
77
+ provider: z<string, string, "plain">;
78
+ model: z<string, string, "plain">;
79
+ }>>, "plain">;
68
80
  /** User-facing message language: `zh` (default) or `en`. */
69
- language: z<"zh" | "en", "zh" | "en">;
81
+ language: z<"zh" | "en", "zh" | "en", "plain">;
70
82
  /** Sender allowlist; empty = all senders allowed. */
71
- allowUsers: z<string[], string[]>;
83
+ allowUsers: z<string[], string[], "plain">;
72
84
  /** Chat allowlist; empty = all chats allowed. */
73
- allowChats: z<string[], string[]>;
85
+ allowChats: z<string[], string[], "plain">;
74
86
  /** Directory for the bindings.json routing store. */
75
- stateDir: z<string, string>;
87
+ stateDir: z<string, string, "plain">;
76
88
  /** Automatically create a Web mirror for new sessions (default: true). */
77
- autoMirror: z<boolean, boolean>;
89
+ autoMirror: z<boolean, boolean, "plain">;
78
90
  /** Liveness heartbeat interval ms for the streaming card; 0 disables it (default: 60000). */
79
- streamHeartbeatMs: z<number, number>;
91
+ streamHeartbeatMs: z<number, number, "plain">;
80
92
  /** Default notification level for streaming replies. Default 'result' (final answer only) keeps cards short. */
81
- notifyLevel: z<"full" | "important" | "result", "full" | "important" | "result">;
93
+ notifyLevel: z<"full" | "important" | "result", "full" | "important" | "result", "plain">;
82
94
  /** Proactive progress-notice interval ms when a turn stays silent (default: 300000 = 5 min; 0 disables). */
83
- progressTimeoutMs: z<number, number>;
84
- /** Channels to activate; default: all built-in channels. */
85
- channels: z<("feishu" | "web" | "telegram" | "dingtalk")[], ("feishu" | "web" | "telegram" | "dingtalk")[]>;
86
- /** Keys applied to every channel that doesn't set its own (e.g. a shared `language`). */
87
- channelDefaults: z<any, any>;
88
- feishu: z<any, any>;
89
- telegram: z<any, any>;
90
- dingtalk: z<any, any>;
91
- web: z<any, any>;
95
+ progressTimeoutMs: z<number, number, "plain">;
96
+ }>>, Schemastery.ObjectT<NoInfer<{
92
97
  /** Optional path to persist web-settings edits (non-secret). */
93
- settingsStatePath: z<string, string>;
94
- }>, Schemastery.ObjectT<{
98
+ settingsStatePath: z<string, string, "plain">;
95
99
  /** Agent preset id composed into each bound session; omit for the roster default. */
96
- agentPreset: z<string, string>;
100
+ agentPreset: z<string, string, "plain">;
97
101
  /** Absolute working directory per agent; defaults to the process cwd. */
98
- workDir: z<string, string>;
102
+ workDir: z<string, string, "plain">;
99
103
  /** Optional workspace directories offered by the `/dir` chooser. */
100
- workspaces: z<string[], string[]>;
104
+ workspaces: z<string[], string[], "plain">;
101
105
  /** Vision-capable model used to describe images when the main model can't see them. */
102
- visionModel: z<Schemastery.ObjectS<{
103
- provider: z<string, string>;
104
- model: z<string, string>;
105
- }>, Schemastery.ObjectT<{
106
- provider: z<string, string>;
107
- model: z<string, string>;
108
- }>>;
106
+ visionModel: z<Schemastery.ObjectS<NoInfer<{
107
+ provider: z<string, string, "plain">;
108
+ model: z<string, string, "plain">;
109
+ }>>, Schemastery.ObjectT<NoInfer<{
110
+ provider: z<string, string, "plain">;
111
+ model: z<string, string, "plain">;
112
+ }>>, "plain">;
109
113
  /** User-facing message language: `zh` (default) or `en`. */
110
- language: z<"zh" | "en", "zh" | "en">;
114
+ language: z<"zh" | "en", "zh" | "en", "plain">;
111
115
  /** Sender allowlist; empty = all senders allowed. */
112
- allowUsers: z<string[], string[]>;
116
+ allowUsers: z<string[], string[], "plain">;
113
117
  /** Chat allowlist; empty = all chats allowed. */
114
- allowChats: z<string[], string[]>;
118
+ allowChats: z<string[], string[], "plain">;
115
119
  /** Directory for the bindings.json routing store. */
116
- stateDir: z<string, string>;
120
+ stateDir: z<string, string, "plain">;
117
121
  /** Automatically create a Web mirror for new sessions (default: true). */
118
- autoMirror: z<boolean, boolean>;
122
+ autoMirror: z<boolean, boolean, "plain">;
119
123
  /** Liveness heartbeat interval ms for the streaming card; 0 disables it (default: 60000). */
120
- streamHeartbeatMs: z<number, number>;
124
+ streamHeartbeatMs: z<number, number, "plain">;
121
125
  /** Default notification level for streaming replies. Default 'result' (final answer only) keeps cards short. */
122
- notifyLevel: z<"full" | "important" | "result", "full" | "important" | "result">;
126
+ notifyLevel: z<"full" | "important" | "result", "full" | "important" | "result", "plain">;
123
127
  /** Proactive progress-notice interval ms when a turn stays silent (default: 300000 = 5 min; 0 disables). */
124
- progressTimeoutMs: z<number, number>;
125
- /** Channels to activate; default: all built-in channels. */
126
- channels: z<("feishu" | "web" | "telegram" | "dingtalk")[], ("feishu" | "web" | "telegram" | "dingtalk")[]>;
127
- /** Keys applied to every channel that doesn't set its own (e.g. a shared `language`). */
128
- channelDefaults: z<any, any>;
129
- feishu: z<any, any>;
130
- telegram: z<any, any>;
131
- dingtalk: z<any, any>;
132
- web: z<any, any>;
133
- /** Optional path to persist web-settings edits (non-secret). */
134
- settingsStatePath: z<string, string>;
135
- }>>;
128
+ progressTimeoutMs: z<number, number, "plain">;
129
+ }>>, "plain">;
136
130
  /** The merged plugin's config type (core fields + channel selector + settings). */
137
131
  export interface ConnectSettingsConfig extends ConnectConfig {
138
132
  /** Channels to activate; default: all built-in channels. */
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAaA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAC;AACnD,OAAO,CAAC,MAAM,0BAA0B,CAAC;AACzC,OAAO,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AAC9C,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AACjD,OAAO,EAKL,KAAK,WAAW,EACjB,MAAM,wBAAwB,CAAC;AAchC,OAAO,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AAC9C,OAAO,EAAE,WAAW,EAAE,oBAAoB,EAAE,MAAM,aAAa,CAAC;AAChE,OAAO,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AACtD,YAAY,EAAE,QAAQ,EAAE,MAAM,sBAAsB,CAAC;AACrD,YAAY,EAAE,aAAa,EAAE,qBAAqB,EAAE,MAAM,aAAa,CAAC;AACxE,OAAO,EAAE,gBAAgB,EAAE,aAAa,EAAE,aAAa,EAAE,cAAc,EAAE,aAAa,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAC7H,YAAY,EAAE,aAAa,EAAE,WAAW,EAAE,eAAe,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAC5F,OAAO,EAAE,iBAAiB,EAAE,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AACvE,YAAY,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAC;AACxD,OAAO,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAC5C,YAAY,EAAE,WAAW,EAAE,iBAAiB,EAAE,MAAM,cAAc,CAAC;AACnE,OAAO,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AAC/C,OAAO,EAAE,SAAS,EAAE,gBAAgB,EAAE,WAAW,EAAE,cAAc,EAAE,cAAc,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAClH,YAAY,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AACnD,OAAO,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAC1C,OAAO,EAAE,KAAK,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;AACtD,YAAY,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAC/C,OAAO,EAAE,OAAO,IAAI,WAAW,EAAE,QAAQ,IAAI,YAAY,EAAE,cAAc,EAAE,OAAO,IAAI,gBAAgB,EAAE,uBAAuB,EAAE,MAAM,kBAAkB,CAAC;AAC1J,YAAY,EAAE,SAAS,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AACjE,OAAO,EAAE,aAAa,EAAE,cAAc,EAAE,eAAe,EAAE,MAAM,gBAAgB,CAAC;AAChF,YAAY,EAAE,iBAAiB,EAAE,MAAM,gBAAgB,CAAC;AACxD,OAAO,EAAE,YAAY,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAC;AACvD,YAAY,EAAE,OAAO,EAAE,MAAM,eAAe,CAAC;AAC7C,OAAO,EAAE,QAAQ,EAAE,MAAM,WAAW,CAAC;AACrC,YAAY,EAAE,QAAQ,EAAE,QAAQ,EAAE,MAAM,WAAW,CAAC;AACpD,YAAY,EACV,QAAQ,EACR,YAAY,EACZ,YAAY,EACZ,YAAY,EACZ,cAAc,EACd,cAAc,EACd,WAAW,EACX,UAAU,EACV,WAAW,EACX,UAAU,EACV,cAAc,GACf,MAAM,YAAY,CAAC;AAIpB,OAAO,KAAK,MAAM,MAAM,4BAA4B,CAAC;AACrD,OAAO,KAAK,QAAQ,MAAM,8BAA8B,CAAC;AACzD,OAAO,KAAK,QAAQ,MAAM,8BAA8B,CAAC;AACzD,OAAO,KAAK,GAAG,MAAM,yBAAyB,CAAC;AAG/C,OAAO,KAAK,QAAQ,MAAM,qBAAqB,CAAC;AAEhD,qDAAqD;AACrD,eAAO,MAAM,IAAI,YAAY,CAAC;AAE9B;;;;;;GAMG;AACH,eAAO,MAAM,MAAM,UAA8C,CAAC;AAElE;;;;;GAKG;AACH,eAAO,MAAM,MAAM;IACjB,qFAAqF;;IAErF,yEAAyE;;IAEzE,oEAAoE;;IAEpE,uFAAuF;;;;;;;;IAEvF,4DAA4D;;IAE5D,qDAAqD;;IAErD,iDAAiD;;IAEjD,qDAAqD;;IAErD,0EAA0E;;IAE1E,6FAA6F;;IAE7F,gHAAgH;;IAEhH,4GAA4G;;IAE5G,4DAA4D;;IAE5D,yFAAyF;;;;;;IAMzF,gEAAgE;;;IAhChE,qFAAqF;;IAErF,yEAAyE;;IAEzE,oEAAoE;;IAEpE,uFAAuF;;;;;;;;IAEvF,4DAA4D;;IAE5D,qDAAqD;;IAErD,iDAAiD;;IAEjD,qDAAqD;;IAErD,0EAA0E;;IAE1E,6FAA6F;;IAE7F,gHAAgH;;IAEhH,4GAA4G;;IAE5G,4DAA4D;;IAE5D,yFAAyF;;;;;;IAMzF,gEAAgE;;GAEhE,CAAC;AAEH,mFAAmF;AACnF,MAAM,WAAW,qBAAsB,SAAQ,aAAa;IAC1D,4DAA4D;IAC5D,QAAQ,CAAC,EAAE,WAAW,EAAE,CAAC;IACzB,yFAAyF;IACzF,eAAe,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC1C,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACjC,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACnC,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACnC,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC9B,gEAAgE;IAChE,iBAAiB,CAAC,EAAE,MAAM,CAAC;CAC5B;AAED,+EAA+E;AAC/E,YAAY,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,wBAAwB,CAAC;AAE3E,OAAO,QAAQ,qBAAqB,CAAC;IACnC,UAAU,OAAO;QACf,OAAO,EAAE,cAAc,CAAC;KACzB;CACF;AAED;;;;;;GAMG;AACH,wBAAsB,KAAK,CAAC,GAAG,EAAE,OAAO,EAAE,MAAM,GAAE,qBAAqB,GAAG,IAAS,GAAG,OAAO,CAAC,IAAI,CAAC,CAiElG;;;;;;AAaD,wBAAuC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
package/lib/index.js CHANGED
@@ -13,12 +13,15 @@
13
13
  import { join } from "node:path";
14
14
  import z from "@deepseek-ai/schemastery";
15
15
  import { ConnectService } from "./service.js";
16
- import { activateChannels, CHANNELS, injectSecrets, } from "./settings/channels.js";
16
+ import { resolveStateDir } from "./state-dir.js";
17
+ import { CHANNELS, extractConfigSecrets, injectSecrets, } from "./settings/channels.js";
18
+ import { ChannelRuntime } from "./settings/channel-runtime.js";
19
+ import { CONNECT_PRESENTATION, installConnectSection, materializeConfig, paneConfigFields, sectionOf, } from "./settings/namespace.js";
20
+ import { importLegacySection } from "./settings/legacy-import.js";
17
21
  import { installSettingsRpc } from "./settings/settings-rpc.js";
18
22
  import { createSettingsService } from "./settings/settings-service.js";
19
- import { CHANNEL_CONFIG_FIELDS } from "./settings/settings-model.js";
20
- import { createCredentialStore, } from "./settings/credential-store.js";
21
- import { register as feishuRegister } from "./channels/feishu/index.js";
23
+ import { CHANNEL_SECRET_KEYS, createCredentialStore, } from "./settings/credential-store.js";
24
+ import { register as feishuRegister, loadCredentials } from "./channels/feishu/index.js";
22
25
  import { register as telegramRegister } from "./channels/telegram/index.js";
23
26
  import { register as dingtalkRegister } from "./channels/dingtalk/index.js";
24
27
  import { register as webRegister } from "./channels/web/index.js";
@@ -44,16 +47,145 @@ export * as dingtalk from "./channels/dingtalk/index.js";
44
47
  export * as web from "./channels/web/index.js";
45
48
  // Web-settings stack (host RPC + credential store + persistence + client model).
46
49
  export * as settings from "./settings/index.js";
50
+ /**
51
+ * Backward-compat: on boot, seed the credential store from secrets a channel
52
+ * config already carries (e.g. `feishu.appId`/`appSecret` written into the
53
+ * profile before the web-settings pane existed). Keeps the pane's presence
54
+ * reporting honest — an upgraded user whose channels work via config-file
55
+ * secrets sees them as "已配置" instead of "未配置凭据".
56
+ *
57
+ * Non-destructive by design: a ref the store already holds is never re-written
58
+ * (pane-saved credentials always win), and only refs present in the config
59
+ * (non-empty string, at their real flat/nested location) are migrated. Secrets
60
+ * still never reach the settings state file — they only move into the store.
61
+ */
62
+ async function seedCredentialRefs(provider, secretKeys, secrets) {
63
+ for (const [configKey, ref] of Object.entries(secretKeys)) {
64
+ const value = secrets[configKey];
65
+ if (value === undefined)
66
+ continue;
67
+ let alreadyConfigured = false;
68
+ try {
69
+ alreadyConfigured = (await provider.describe(ref)).configured === true;
70
+ }
71
+ catch {
72
+ alreadyConfigured = false;
73
+ }
74
+ if (alreadyConfigured)
75
+ continue;
76
+ try {
77
+ await provider.set(ref, value);
78
+ }
79
+ catch {
80
+ // Per-ref migration failure should never block activation.
81
+ }
82
+ }
83
+ }
84
+ /**
85
+ * Fold credentials left behind by the legacy one-click onboarding file into the
86
+ * store. Feishu's onboarding predates the credential store and wrote its own
87
+ * JSON, which nothing but the adapter read — so a user who onboarded before the
88
+ * store existed had a working bot that the settings pane reported as
89
+ * 「未配置凭据」 forever. Migration is idempotent and never overwrites a value the
90
+ * store already holds.
91
+ */
92
+ async function migrateOnboardedSecrets(provider, enabled) {
93
+ if (!enabled.includes("feishu"))
94
+ return;
95
+ let onboarded = null;
96
+ try {
97
+ onboarded = loadCredentials();
98
+ }
99
+ catch {
100
+ return;
101
+ }
102
+ if (onboarded === null)
103
+ return;
104
+ await seedCredentialRefs(provider, CHANNEL_SECRET_KEYS.feishu, {
105
+ appId: onboarded.appId,
106
+ appSecret: onboarded.appSecret,
107
+ });
108
+ }
109
+ async function migrateConfigSecrets(provider, cfg, enabled) {
110
+ for (const ch of enabled) {
111
+ const secretKeys = CHANNEL_SECRET_KEYS[ch] ?? {};
112
+ if (Object.keys(secretKeys).length === 0)
113
+ continue;
114
+ const raw = cfg[ch];
115
+ const secrets = extractConfigSecrets(raw, ch, secretKeys);
116
+ if (Object.keys(secrets).length === 0)
117
+ continue;
118
+ await seedCredentialRefs(provider, secretKeys, secrets);
119
+ }
120
+ await migrateOnboardedSecrets(provider, enabled);
121
+ }
122
+ /**
123
+ * The profile entry id this instance was loaded from (`connect`).
124
+ *
125
+ * This — not the package name — is what `SettingsForms.replace()` addresses:
126
+ * its own jsdoc calls the first argument "Profile entry id". It comes from the
127
+ * loader entry, which is attached to the fiber at runtime but *typed* only in
128
+ * `cordis-plugin-loader`'s `declare module` augmentation; that package is the
129
+ * host's loader, not a dependency of this plugin, so importing the type is not
130
+ * an option and the narrow cast below is the whole of the compromise. Returns
131
+ * `undefined` outside a loader (`new Context().plugin(...)` in a test), which
132
+ * `installConnectSection` reports and degrades on rather than failing.
133
+ */
134
+ function entryId(ctx) {
135
+ const fiber = ctx.fiber;
136
+ const id = fiber?.entry?.options?.id;
137
+ return typeof id === "string" && id.length > 0 ? id : undefined;
138
+ }
139
+ /**
140
+ * The Harness home as the host resolved it, when it told us.
141
+ *
142
+ * `profileContext.home` is the authoritative answer (it also covers a home
143
+ * configured outside the environment); `undefined` hands the question to
144
+ * `resolveHarnessHome()`, which repeats the `$DSH_HOME` → `~/.dsh` precedence on
145
+ * its own. The pre-0.2 settings document lived in the *home*, not in the
146
+ * profile directory, which is why this is not derived from the state dir.
147
+ */
148
+ function harnessHome(ctx) {
149
+ const profile = ctx.get?.("profileContext");
150
+ return typeof profile?.home === "string" ? profile.home : undefined;
151
+ }
152
+ /**
153
+ * The active profile's patch file — `~/.dsh/profiles/<profile>/cordis.patch.yml`.
154
+ *
155
+ * Same `profileContext` service as above (`profileContext.patchPath`), and the
156
+ * same reason for going through `ctx.get`: the field is the host's, not ours.
157
+ * The one-shot legacy import anchors its marker on this path, because the
158
+ * marker means "this profile entry has already consumed the legacy document" and
159
+ * a profile is the only unit that can move without that staying true.
160
+ */
161
+ function profilePatchPath(ctx) {
162
+ const profile = ctx.get?.("profileContext");
163
+ return typeof profile?.patchPath === "string" ? profile.patchPath : undefined;
164
+ }
165
+ /** One-line error text for a warning line; `String()` for a thrown non-Error. */
166
+ function errorText(error) {
167
+ return error instanceof Error ? error.message : String(error);
168
+ }
47
169
  /** Cordis plugin name used by loader diagnostics. */
48
170
  export const name = "connect";
49
171
  /**
50
- * Host-plane services this plugin must wait for before `apply` runs. All three
51
- * are provided by the base bundle. The credential store is NOT listed here:
52
- * cordis v4 has no `?` optional-inject marker — `"credentials?"` would be treated
53
- * as a required service literally named "credentials?" and stall the plugin. The
54
- * store is instead fetched lazily inside `apply` via the optional `ctx.get()`.
172
+ * Host-plane services this plugin must wait for before `apply` runs. All four
173
+ * are rows in the always-loaded `@deepseek-ai/dsh-base` bundle — the shared core
174
+ * of every base-backed profile — so requiring them cannot stall the plugin in
175
+ * any profile that can load `dsh-connect` at all.
176
+ *
177
+ * `credentials` must be listed here, not read lazily inside `apply`: a
178
+ * `ctx.get("credentials")` at apply time returns `undefined`, because this
179
+ * plugin activates before the credentials row does. That silently disabled the
180
+ * whole credential path — `migrateConfigSecrets` never seeded the store (so an
181
+ * upgraded user's working channels displayed "未配置凭据"), `injectSecrets`
182
+ * never merged store-backed secrets into a channel config, and
183
+ * `settings.saveCredentials` threw `not-configured` for every pane save. Note
184
+ * cordis v4 has no `?` optional-inject marker, so `"credentials?"` would be read
185
+ * as a required service literally named `"credentials?"` — that is a syntax
186
+ * hazard, not a reason the service itself is unavailable.
55
187
  */
56
- export const inject = ["agents", "sessions", "agentDefaultModel"];
188
+ export const inject = ["agents", "sessions", "agentDefaultModel", "credentials"];
57
189
  /**
58
190
  * Full plugin config: the channel-agnostic core fields plus the channel
59
191
  * selector (`channels`), shared `channelDefaults`, per-channel config, and the
@@ -85,14 +217,16 @@ export const Config = z.object({
85
217
  notifyLevel: z.union([z.const("full"), z.const("important"), z.const("result")]),
86
218
  /** Proactive progress-notice interval ms when a turn stays silent (default: 300000 = 5 min; 0 disables). */
87
219
  progressTimeoutMs: z.number(),
88
- /** Channels to activate; default: all built-in channels. */
89
- channels: z.array(z.union([z.const("feishu"), z.const("telegram"), z.const("dingtalk"), z.const("web")])),
90
- /** Keys applied to every channel that doesn't set its own (e.g. a shared `language`). */
91
- channelDefaults: z.any(),
92
- feishu: z.any(),
93
- telegram: z.any(),
94
- dingtalk: z.any(),
95
- web: z.any(),
220
+ /**
221
+ * Channels to activate, shared `channelDefaults`, and the per-channel options
222
+ * the settings pane edits. Spread from `paneConfigFields()` rather than
223
+ * restated: those are exactly the fields a profile entry is allowed to
224
+ * hot-commit, so declaring them here as `volatile` leaves is what turns a pane
225
+ * save into a live change instead of a remount (see `settings/namespace.ts`).
226
+ * Each channel's `appSecret`/`appId`/`botToken` stays *outside* this set on
227
+ * purpose — a volatile container would copy the secret into the profile.
228
+ */
229
+ ...paneConfigFields(),
96
230
  /** Optional path to persist web-settings edits (non-secret). */
97
231
  settingsStatePath: z.string(),
98
232
  });
@@ -104,13 +238,23 @@ export const Config = z.object({
104
238
  * `apply` (the fiber is still LOADING until this function resolves).
105
239
  */
106
240
  export async function apply(ctx, config = {}) {
107
- // Shallow-clone so we never mutate the caller's config object. Only core
108
- // fields are consumed by ConnectService; the rest are channel/settings.
109
- const cfg = { ...(config ?? {}) };
241
+ // `config` as the loader resolved it. The pane-editable field set is declared
242
+ // `volatile` (see `Config` above), so those fields arrive here as *references*
243
+ // (`{ get() }`) whose value the loader replaces in place, while ordinary
244
+ // fields arrive as plain data. Both are kept: every consumer below works on a
245
+ // materialized plain-data copy (`cfg`, and nothing mutates the caller's
246
+ // object — `materializeConfig` copies as it walks), and the references stay
247
+ // behind in `rawConfig` so a pane save can be read back without re-running
248
+ // `apply`.
249
+ const rawConfig = (config ?? {});
250
+ const cfg = materializeConfig(rawConfig);
110
251
  const connect = new ConnectService(ctx, cfg);
111
- // Optional DSH credentials store: report presence + inject secrets into each
112
- // channel config so store-backed secrets reach the adapter without the config
113
- // file carrying them (they apply on the next plugin load).
252
+ // DSH credentials store: report presence + inject secrets into each channel
253
+ // config so store-backed secrets reach the adapter without the config file
254
+ // carrying them. `credentials` is a declared inject (see `inject` above), so
255
+ // it is guaranteed present by the time `apply` runs; the optional `ctx.get()`
256
+ // read is kept only so a bare context (unit tests) degrades to "no store"
257
+ // rather than throwing.
114
258
  const credentialsProvider = ctx.get?.("credentials");
115
259
  let credentialStore;
116
260
  if (credentialsProvider) {
@@ -122,50 +266,181 @@ export async function apply(ctx, config = {}) {
122
266
  }
123
267
  }
124
268
  const wanted = cfg.channels ?? CHANNELS;
269
+ // Migrate config-file secrets into the store before activation, so an upgraded
270
+ // user's already-working channels report as configured (and the store becomes
271
+ // the single source of truth). Best-effort: never blocks the plugin load.
272
+ if (credentialStore && credentialsProvider) {
273
+ try {
274
+ await migrateConfigSecrets(credentialsProvider, cfg, wanted);
275
+ }
276
+ catch {
277
+ // Ignore a migration failure; activation continues with existing config.
278
+ }
279
+ }
125
280
  const finalCfg = credentialStore
126
281
  ? await injectSecrets(cfg, wanted, (name) => credentialStore.get(name))
127
282
  : cfg;
128
283
  // Each channel's register receives the connect instance directly.
129
284
  const channels = {
130
- feishu: (_ctx, channelConfig) => feishuRegister(connect, channelConfig, _ctx),
285
+ // Feishu additionally gets the credential store so a completed one-click
286
+ // onboarding lands in the DSH store (and not only in its own legacy file).
287
+ feishu: (_ctx, channelConfig) => feishuRegister(connect, channelConfig, _ctx, { credentialStore }),
131
288
  telegram: (_ctx, channelConfig) => telegramRegister(connect, channelConfig, _ctx),
132
289
  dingtalk: (_ctx, channelConfig) => dingtalkRegister(connect, channelConfig, _ctx),
133
290
  web: (_ctx, channelConfig) => webRegister(connect, channelConfig, _ctx),
134
291
  };
135
- activateChannels(ctx, finalCfg, channels);
136
- // Expose the web-settings RPC. The host `connection` service is loaded as a
137
- // base plugin AFTER this user plugin's apply runs, so a plain optional inject
138
- // would see `ctx.connection === undefined` (installSettingsRpc would no-op and
139
- // no `/dsh-connect` channel would be mounted → the settings pane hangs on
140
- // "加载中"). Instead defer the registration into a nested injection scope that
141
- // waits for `connection` to be ready — the same pattern dsh-api-gateway uses
142
- // for its `/api` RPC. If the host never provides `connection` (non-web
143
- // runtime), the callback never runs and the RPC is simply absent.
144
- // Web settings pane state. The pane edits a settings-shape config persisted to
145
- // a JSON file and seeded from the live plugin config so it reflects the
146
- // channels actually enabled. Only NON-SECRET editable fields are seeded per
147
- // channel (secrets live in the credential store, never in the state file).
148
- // Default the file path under the state dir so Save persists out of the box.
149
- const settingsSeed = {
150
- channels: finalCfg.channels ?? CHANNELS,
151
- ...(finalCfg.channelDefaults ? { channelDefaults: finalCfg.channelDefaults } : {}),
292
+ // Channel lifecycle is owned by a runtime, not a one-shot activation: once
293
+ // the channel list is user-editable, dropping a channel has to *stop* its
294
+ // adapter (a live Feishu long connection would otherwise outlive its config)
295
+ // and a reconfigured channel has to restart before the old transport is
296
+ // still delivering. See `settings/channel-runtime.ts`.
297
+ const runtime = new ChannelRuntime({
298
+ ctx,
299
+ channels,
300
+ teardown: (name) => connect.unregisterAdapter(name),
301
+ getSecrets: credentialStore ? (name) => credentialStore.get(name) : undefined,
302
+ });
303
+ await runtime.apply(finalCfg);
304
+ // Web-settings pane state. The pane's store is the profile entry itself (see
305
+ // the namespace block below); this path backs only the *fallback* store, used
306
+ // by a host that has no settings service. (The one-shot legacy import used to
307
+ // record itself here too; it is anchored on the profile entry now, see
308
+ // `profilePatchPath` — this path is user-relocatable and deletable, which made
309
+ // it the wrong place to remember that a migration had run.) Resolved through
310
+ // the shared helper and off the *merged* config, so the state file lands beside
311
+ // `bindings.json` and can't disagree with the stores. This used to read the raw `cfg.stateDir`,
312
+ // which made the path `undefined` for every profile that never set
313
+ // `stateDir` — and an undefined path made the settings service degrade to
314
+ // in-memory, so a pane save silently vanished.
315
+ const settingsStatePath = finalCfg.settingsStatePath
316
+ ?? join(resolveStateDir(finalCfg), "dsh-connect-settings.json");
317
+ // One projection, two triggers. A change reaches the adapters either through
318
+ // a write (the settings pane, or the legacy import) or through the loader
319
+ // committing new values into the volatile references behind `rawConfig`, and
320
+ // both must hand the runtime the *same* shape: `ChannelRuntime` restarts an
321
+ // adapter whose config it reads as different, so a mismatch here would bounce
322
+ // a live channel on every commit.
323
+ const reconcile = () => {
324
+ void runtime.apply(sectionOf(materializeConfig(rawConfig)));
152
325
  };
153
- for (const ch of CHANNELS) {
154
- const raw = finalCfg[ch] ?? {};
155
- const nonSecret = {};
156
- for (const field of CHANNEL_CONFIG_FIELDS[ch] ?? []) {
157
- if (raw[field.key] !== undefined)
158
- nonSecret[field.key] = raw[field.key];
326
+ // Register the `connect` profile entry's pane fields with the host, so the
327
+ // channel selection and the per-channel non-secret options become
328
+ // user-editable and *effective*: the pane writes them onto the entry, the
329
+ // loader commits them into the volatile references `rawConfig` carries, and
330
+ // every change re-reconciles the running adapters through `reconcile`. The
331
+ // panel's own RPC (`settings/settings-rpc.ts`) is a separate transport onto
332
+ // the same service and is kept for the pane's compatibility path.
333
+ //
334
+ // Deferred through `inject` for the same reason as the RPC below: `settings`
335
+ // is a `dsh-base` row loaded after a user plugin's `apply` runs, so a plain
336
+ // optional read would see `undefined` and never register anything. If the
337
+ // host never provides it the callback never runs and the plugin config
338
+ // stands on its own — which is exactly what `installConnectSection` falls
339
+ // back to.
340
+ //
341
+ // Set once the seam is live; read lazily by the settings service below, which
342
+ // is built before the deferred callback runs.
343
+ let liveSection;
344
+ const installNamespace = (scopeCtx) => {
345
+ const settings = scopeCtx.get?.("settings");
346
+ // Suppress the host's automatic Plugins-page form for this entry: this
347
+ // plugin ships its own client pane, and two generated editors of the same
348
+ // fields is how a save silently reverts. `owner` must be *our* fiber — the
349
+ // method's default is the settings service's own fiber, which would attach
350
+ // the policy to the service and leave this entry's page alone.
351
+ if (typeof settings?.configure === "function") {
352
+ try {
353
+ const release = settings.configure(CONNECT_PRESENTATION, ctx.fiber);
354
+ if (typeof release === "function")
355
+ ctx.effect(() => release);
356
+ }
357
+ catch (error) {
358
+ scopeCtx.logger?.warn?.(`connect: could not suppress the automatic settings page (${errorText(error)})`);
359
+ }
360
+ }
361
+ const installed = installConnectSection({
362
+ owner: scopeCtx,
363
+ settings,
364
+ config: rawConfig,
365
+ ns: entryId(ctx),
366
+ onChange: () => reconcile(),
367
+ });
368
+ liveSection = installed.handle;
369
+ // One-shot: fold a pre-0.2 `$DSH_HOME/settings.yaml` `dsh-connect:` section
370
+ // into the entry. The host's own legacy import renames that document and
371
+ // then drops this section — it has no entry by that name — so on an
372
+ // upgraded install this is the only remaining copy of the user's pane
373
+ // choices. Nothing happens when there is no such document, and `current` is
374
+ // the section in force, so the write cannot clear a field the legacy
375
+ // document does not mention.
376
+ const handle = installed.handle;
377
+ if (handle !== undefined) {
378
+ void importLegacySection({
379
+ home: harnessHome(scopeCtx),
380
+ write: (section) => handle.write(section),
381
+ current: installed.section,
382
+ statePath: settingsStatePath,
383
+ entryPath: profilePatchPath(scopeCtx),
384
+ logger: scopeCtx.logger,
385
+ });
159
386
  }
160
- if (Object.keys(nonSecret).length)
161
- settingsSeed[ch] = nonSecret;
387
+ };
388
+ if (typeof ctx.inject === "function") {
389
+ ctx.inject(["settings"], installNamespace);
162
390
  }
163
- const settingsStatePath = finalCfg.settingsStatePath
164
- ?? (cfg.stateDir ? join(cfg.stateDir, "dsh-connect-settings.json") : undefined);
165
- const settingsService = createSettingsService({ statePath: settingsStatePath, credentialStore, initialConfig: settingsSeed });
391
+ else {
392
+ // Bare context (unit tests): no inject-scope API, install directly.
393
+ installNamespace(ctx);
394
+ }
395
+ // A pane save is not the only writer: editing a running profile's patch (or
396
+ // reloading the plugin entry) commits volatile paths without re-running
397
+ // `apply`. The host announces that here, so the adapters follow the config
398
+ // they were started from.
399
+ ctx.on("loader/volatile-update", () => reconcile());
400
+ // Expose the web-settings RPC. The host `connection`/`webServer` services are
401
+ // loaded as base plugins AFTER this user plugin's apply runs, so a plain
402
+ // optional inject would see `ctx.connection === undefined` and mount nothing
403
+ // (the settings pane then hangs on "加载中"). Instead defer the registration
404
+ // into a nested injection scope that waits for both to be ready — the same
405
+ // pattern dsh-api-gateway uses for its `/api` route. If the host never
406
+ // provides them (non-web runtime), the callback never runs and the RPC is
407
+ // simply absent.
408
+ //
409
+ // Both services are required in the injected set: `installSettingsRpc` mounts
410
+ // the channel as a `prefix` route on `webServer` itself (see its doc for why
411
+ // `connection.rpc.handle` cannot be used for a third-party channel), and it
412
+ // reads `connection.requestRejection` for the Host/Origin + browser-auth
413
+ // fence.
414
+ //
415
+ // Web settings pane state. When the namespace above is live, that registration
416
+ // *is* the store: the pane reads and writes this plugin's entry in the active
417
+ // profile patch (`cordis.patch.yml`) through `liveSection`, a save reconciles
418
+ // the adapters immediately (via the same `onChange`) and survives a restart.
419
+ // The JSON file below is then only a fallback for a host with no settings
420
+ // service, which is why the service takes the handle as a getter, not a value.
421
+ // Credentials are a second call, and a secret is not config: `onCredentialsSaved`
422
+ // is the reconcile trigger for that plane, since no config write happens.
423
+ //
424
+ // Seeded from the live plugin config so the pane reflects the channels
425
+ // actually enabled; only NON-SECRET *declared* fields (secrets live in the
426
+ // credential store, never in either store this file touches), which is
427
+ // exactly the projection `sectionOf` performs — the same one the write path
428
+ // applies, so the fallback store cannot drift from the entry.
429
+ const settingsSeed = { ...sectionOf(finalCfg) };
430
+ const settingsService = createSettingsService({
431
+ statePath: settingsStatePath,
432
+ credentialStore,
433
+ initialConfig: settingsSeed,
434
+ live: () => liveSection,
435
+ // A rotated secret is inert until the adapters are re-applied, and the
436
+ // namespace's `onChange` only fires for a config write — the pane saves
437
+ // credentials as a second, separate call. Without this the user pastes a
438
+ // new appSecret, is told 「已保存」, and the bot stays broken until restart.
439
+ onCredentialsSaved: () => reconcile(),
440
+ });
166
441
  if (typeof ctx.inject === "function") {
167
- ctx.inject(["connection"], (connectionCtx) => {
168
- installSettingsRpc(connectionCtx, { service: settingsService });
442
+ ctx.inject(["connection", "webServer"], (scopeCtx) => {
443
+ installSettingsRpc(scopeCtx, { service: settingsService });
169
444
  });
170
445
  }
171
446
  else {