@modesto_ma/dsh-channels 0.5.0 → 0.6.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@modesto_ma/dsh-channels",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "DeepSeek Harness (DSH) 飞书渠道插件:一个 dsh 实例对应一个飞书机器人,私聊/群聊/话题群 @ 触发会话,话题绑定 session,卡片 2.0 流式回复;Web 左侧栏提供「飞书渠道」状态与常用配置面板",
5
5
  "repository": {
6
6
  "type": "git",
@@ -90,7 +90,7 @@
90
90
  "@semantic-release/release-notes-generator": "^14.1.1",
91
91
  "@types/node": "^26.6.5",
92
92
  "@types/react": "~18.3.31",
93
- "conventional-changelog-conventionalcommits": "10.3.0",
93
+ "conventional-changelog-conventionalcommits": "9.3.1",
94
94
  "react": "18.3.1",
95
95
  "semantic-release": "^25.0.9",
96
96
  "tsdown": "0.22.2",
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Wire contract of the log-platform analyze API.
3
3
  *
4
- * `POST /dsh-channels/analyze` on the dedicated `analyzeListen` port, with
4
+ * `POST /api/dsh-channels.logalyze` on the DSH Web server, with
5
5
  * `Authorization: Bearer <token>`, an `Idempotency-Key` header, and a JSON
6
6
  * body naming the requester, the trigger time, the subject (application +
7
7
  * error code), the log-platform detail pages, the target group, and the
@@ -17,8 +17,12 @@ import { createHash } from 'node:crypto'
17
17
  import { z } from 'zod'
18
18
  import { FEISHU_USER_ID_PATTERN } from '../domain.ts'
19
19
 
20
- /** The only path the analyze listener serves. */
21
- export const ANALYZE_ROUTE = '/dsh-channels/analyze'
20
+ /**
21
+ * Exact DSH Web route of the analyze API. It follows the host's plain-HTTP
22
+ * `/api/<name>.<method>` naming; an exact route wins over the `/api` RPC prefix
23
+ * route, so the request carries this API's bearer token, not a DSH login.
24
+ */
25
+ export const ANALYZE_ROUTE = '/api/dsh-channels.logalyze'
22
26
 
23
27
  /** Largest accepted request body (the prompt dominates it). */
24
28
  export const ANALYZE_MAX_BODY_BYTES = 1024 * 1024
@@ -85,9 +85,28 @@ export class AnalyzeLauncher implements AnalyzeHandler {
85
85
  private readonly accepting = new Map<string, PendingAccept>()
86
86
  /** Background analyses still starting (drained on shutdown). */
87
87
  private readonly running = new Set<Promise<void>>()
88
+ /** Whether requests are admitted: from {@link open} after start until {@link close}. */
89
+ private admitting = false
88
90
 
89
91
  constructor(private readonly deps: AnalyzeLauncherDeps) {}
90
92
 
93
+ /**
94
+ * Start admitting requests. Called at channel start after
95
+ * {@link markInterrupted}, so a new request is never marked interrupted.
96
+ */
97
+ open(): void {
98
+ this.admitting = true
99
+ }
100
+
101
+ /** Stop admitting requests (503) ahead of shutdown. */
102
+ close(): void {
103
+ this.admitting = false
104
+ }
105
+
106
+ enabled(): boolean {
107
+ return this.deps.config.analyzeEnabled
108
+ }
109
+
91
110
  async token(): Promise<string | undefined> {
92
111
  return await this.deps.resolveToken()
93
112
  }
@@ -109,17 +128,21 @@ export class AnalyzeLauncher implements AnalyzeHandler {
109
128
  }
110
129
 
111
130
  /**
112
- * Wait for every background analysis that is still starting. Called after
113
- * `started` turns false, so each one ends on its failure card or, when its
114
- * first prompt was already sent, as an ordinary topic.
131
+ * Wait for every acceptance in flight, then every background analysis that
132
+ * is still starting. Called after `started` turns false, so each one ends on
133
+ * its failure card or, when its first prompt was already sent, as an
134
+ * ordinary topic.
115
135
  */
116
136
  async drain(): Promise<void> {
137
+ // Acceptances still sending the root card or writing the record finish
138
+ // first; each one adds its launch to `running` before it settles.
139
+ await Promise.allSettled([...this.accepting.values()].map(pending => pending.response))
117
140
  await Promise.allSettled([...this.running])
118
141
  }
119
142
 
120
143
  /**
121
144
  * Mark every analysis still `running` as interrupted, on its card and in
122
- * its record. Called at channel start before the listener opens, so each
145
+ * its record. Called at channel start before admission opens, so each
123
146
  * such record belongs to a previous instance whose first turn never
124
147
  * settled. A card that cannot be updated (deleted, recalled) is logged and
125
148
  * still recorded as interrupted, so it is not retried on every start.
@@ -141,7 +164,7 @@ export class AnalyzeLauncher implements AnalyzeHandler {
141
164
  private async accept(request: AnalyzeRequest, key: string, bodyHash: string): Promise<AnalyzeResponse> {
142
165
  const channel = this.deps.channel()
143
166
  const domain = this.deps.domain()
144
- if (!this.deps.started() || channel === undefined || domain === undefined) {
167
+ if (!this.admitting || !this.deps.started() || channel === undefined || domain === undefined) {
145
168
  return refused(503, '飞书渠道尚未连接,请稍后重试')
146
169
  }
147
170
  const stored = domain.analyzeRequests.get(key)
@@ -1,26 +1,41 @@
1
1
  /**
2
- * The analyze API's dedicated HTTP listener.
2
+ * The analyze API's HTTP route on the DSH Web server.
3
3
  *
4
- * Serves exactly `POST /dsh-channels/analyze` (see `./contract.ts`) on the
5
- * `analyzeListen` address. This module owns the transport-level checks, in
6
- * order: path, method, bearer token, `Idempotency-Key`, media type, body size,
7
- * and JSON validation; everything after validation belongs to the
8
- * {@link AnalyzeHandler}. Bodies, tokens, and prompts are never logged.
4
+ * Registers the exact route `POST /api/dsh-channels.logalyze` (see
5
+ * `./contract.ts`) on the host `webServer`; hosts without one (headless) have
6
+ * no analyze API. This module owns the transport-level checks, in order:
7
+ * path, the `analyzeEnabled` switch, method, bearer token, `Idempotency-Key`,
8
+ * media type, body size, and JSON validation; everything after validation
9
+ * belongs to the {@link AnalyzeHandler}. Bodies, tokens, and prompts are never
10
+ * logged.
9
11
  *
10
12
  * @module dsh-channels/analyze/server
11
13
  */
12
14
 
13
15
  import { createHash, timingSafeEqual } from 'node:crypto'
14
- import { createServer } from 'node:http'
15
- import type { IncomingMessage, Server, ServerResponse } from 'node:http'
16
- import type { AddressInfo } from 'node:net'
16
+ import type { IncomingMessage, ServerResponse } from 'node:http'
17
+ import type { HostContext } from '../dsh-types.ts'
17
18
  import {
18
19
  ANALYZE_MAX_BODY_BYTES, ANALYZE_ROUTE, IDEMPOTENCY_KEY_PATTERN, parseAnalyzeRequest,
19
20
  } from './contract.ts'
20
21
  import type { AnalyzeRequest, AnalyzeResponse } from './contract.ts'
21
22
 
22
- /** The business side of the listener. */
23
+ /** The narrow `ctx.webServer` surface the analyze route registers into. */
24
+ export interface HostWebServer {
25
+ register(route: {
26
+ kind: 'exact'
27
+ path: string
28
+ handler: (req: IncomingMessage, res: ServerResponse) => void | Promise<void>
29
+ }): () => void
30
+ }
31
+
32
+ /** The business side of the route. */
23
33
  export interface AnalyzeHandler {
34
+ /**
35
+ * Whether the API is switched on (`analyzeEnabled`), read per request.
36
+ * @returns false to answer every request with 404.
37
+ */
38
+ enabled(): boolean
24
39
  /**
25
40
  * Resolve the expected bearer token for this request.
26
41
  * @returns the token, or undefined when it is not configured.
@@ -35,14 +50,6 @@ export interface AnalyzeHandler {
35
50
  analyze(request: AnalyzeRequest, idempotencyKey: string): Promise<AnalyzeResponse>
36
51
  }
37
52
 
38
- /** A bound analyze listener. */
39
- export interface AnalyzeServer {
40
- /** The bound address (the real port when `0` was requested). */
41
- readonly address: AddressInfo
42
- /** Stop accepting connections and close idle ones. */
43
- close(): Promise<void>
44
- }
45
-
46
53
  class HttpError extends Error {
47
54
  constructor(readonly status: number, message: string) {
48
55
  super(message)
@@ -112,6 +119,10 @@ export async function handleAnalyzeHttp(
112
119
  refuse(res, 404, '未知路径')
113
120
  return
114
121
  }
122
+ if (!handler.enabled()) {
123
+ refuse(res, 404, '日志分析接口未开启,请在设置面板「高级 → 日志分析接口」打开开关')
124
+ return
125
+ }
115
126
  if ((req.method ?? 'GET').toUpperCase() !== 'POST') {
116
127
  refuse(res, 405, '只接受 POST', { allow: 'POST' })
117
128
  return
@@ -155,47 +166,42 @@ export async function handleAnalyzeHttp(
155
166
  send(res, await handler.analyze(parsed.value, idempotencyKey))
156
167
  }
157
168
 
158
- /** Split `host:port` (`[v6]:port` allowed) into listen arguments. */
159
- export function parseListenAddress(listen: string): { host: string; port: number } {
160
- const separator = listen.lastIndexOf(':')
161
- const host = listen.slice(0, separator).replace(/^\[(.*)\]$/u, '$1')
162
- return { host, port: Number(listen.slice(separator + 1)) }
163
- }
164
-
165
169
  /**
166
- * Bind the analyze listener.
167
- * @param listen - `host:port` address (`analyzeListen`).
170
+ * Register the analyze route on the host Web server once it is available.
171
+ * Requests skip the DSH login gate on purpose: the bearer token is this API's
172
+ * only credential.
173
+ * @param ctx - the plugin context.
168
174
  * @param handler - the business side.
169
175
  * @param warn - diagnostics sink for unexpected handler failures.
170
- * @returns the bound server; rejects when the address cannot be bound.
171
176
  */
172
- export async function startAnalyzeServer(
173
- listen: string,
177
+ export function mountAnalyzeRoute(
178
+ ctx: HostContext,
174
179
  handler: AnalyzeHandler,
175
180
  warn: (message: string, ...args: unknown[]) => void,
176
- ): Promise<AnalyzeServer> {
177
- const server: Server = createServer((req, res) => {
178
- handleAnalyzeHttp(req, res, handler).catch((error: unknown) => {
179
- warn('dsh-channels: analyze request failed: %s', String(error))
180
- if (!res.headersSent) refuse(res, 500, `分析请求处理失败:${String(error)}`)
181
- else res.destroy()
182
- })
183
- })
184
- const { host, port } = parseListenAddress(listen)
185
- await new Promise<void>((resolve, reject) => {
186
- const onError = (error: Error): void => { reject(error) }
187
- server.once('error', onError)
188
- server.listen(port, host, () => {
189
- server.off('error', onError)
190
- resolve()
191
- })
192
- })
193
- server.on('error', (error) => { warn('dsh-channels: analyze listener error: %s', String(error)) })
194
- return {
195
- address: server.address() as AddressInfo,
196
- close: () => new Promise<void>((resolve) => {
197
- server.close(() => { resolve() })
198
- server.closeIdleConnections()
199
- }),
181
+ ): void {
182
+ const attach = (webCtx: HostContext): void => {
183
+ const server = webCtx.get?.('webServer') as HostWebServer | undefined
184
+ if (server === undefined) return
185
+ webCtx.effect(() => server.register({
186
+ kind: 'exact',
187
+ path: ANALYZE_ROUTE,
188
+ handler: async (req, res) => {
189
+ try {
190
+ await handleAnalyzeHttp(req, res, handler)
191
+ } catch (error) {
192
+ warn('dsh-channels: analyze request failed: %s', String(error))
193
+ if (!res.headersSent) refuse(res, 500, `分析请求处理失败:${String(error)}`)
194
+ else res.destroy()
195
+ }
196
+ },
197
+ }), 'dsh-channels: analyze route')
198
+ }
199
+ const inject = (ctx as {
200
+ inject?: (dependencies: string[], callback: (child: HostContext) => void) => unknown
201
+ }).inject
202
+ if (typeof inject === 'function') {
203
+ inject.call(ctx, ['webServer'], attach)
204
+ return
200
205
  }
206
+ attach(ctx)
201
207
  }
package/src/client/api.ts CHANGED
@@ -1,24 +1,29 @@
1
1
  /**
2
2
  * Same-origin HTTP client for the settings panel. No typert contract: the
3
- * Host half registers plain exact routes and this module fetches them.
3
+ * Host half registers exact DSH `/api` Fetch routes and this module fetches them.
4
4
  *
5
5
  * @module dsh-channels/client/api
6
6
  */
7
7
 
8
8
  import {
9
- ACCESS_GROUPS_ROUTE,
10
- ACCESS_REQUESTS_ROUTE,
11
9
  ACCESS_ROUTE,
12
- ACCESS_USERS_ROUTE,
13
- ACCESS_WIKI_USERS_ROUTE,
10
+ ADD_ACCESS_GROUP_ROUTE,
11
+ ADD_ACCESS_USER_ROUTE,
12
+ ADD_WIKI_USER_ROUTE,
14
13
  CATALOG_ROUTE,
15
- CONFIG_ROUTE,
16
- CREDENTIALS_ROUTE,
14
+ CLEAR_CONFIG_ROUTE,
15
+ CLEAR_CREDENTIAL_ROUTE,
17
16
  PANEL_ROUTE,
18
17
  PICK_DIRECTORY_ROUTE,
19
18
  PROFILES_MAX_IDS,
20
19
  PROFILES_ROUTE,
21
20
  RECONNECT_ROUTE,
21
+ REMOVE_ACCESS_GROUP_ROUTE,
22
+ REMOVE_ACCESS_USER_ROUTE,
23
+ REMOVE_WIKI_USER_ROUTE,
24
+ RESOLVE_ACCESS_REQUEST_ROUTE,
25
+ SAVE_CONFIG_ROUTE,
26
+ SAVE_CREDENTIALS_ROUTE,
22
27
  SESSION_ORIGIN_ROUTE,
23
28
  STATUS_ROUTE,
24
29
  } from '../settings/contract.ts'
@@ -138,8 +143,8 @@ export async function fetchCatalog(provider?: string, model?: string): Promise<C
138
143
 
139
144
  /** Persist a partial edit. Validation failures come back as `{ok:false}`. */
140
145
  export async function saveConfig(values: EditableValues): Promise<SaveConfigResponse> {
141
- const response = await request(CONFIG_ROUTE, {
142
- method: 'PUT',
146
+ const response = await request(SAVE_CONFIG_ROUTE, {
147
+ method: 'POST',
143
148
  headers: { 'content-type': 'application/json' },
144
149
  body: JSON.stringify({ values }),
145
150
  })
@@ -158,7 +163,7 @@ export async function saveConfig(values: EditableValues): Promise<SaveConfigResp
158
163
 
159
164
  /** Drop every Web override and return the refreshed panel. */
160
165
  export async function clearConfig(): Promise<ClearConfigResponse> {
161
- const response = await request(CONFIG_ROUTE, { method: 'DELETE' })
166
+ const response = await request(CLEAR_CONFIG_ROUTE, { method: 'POST' })
162
167
  const text = await response.text()
163
168
  if (response.status === 400) {
164
169
  try {
@@ -205,8 +210,8 @@ export async function pickDirectory(): Promise<PickDirectoryResponse> {
205
210
 
206
211
  /** Store provided credential values (the request/response never carry stored values). */
207
212
  export async function saveCredentials(values: SaveCredentialsRequest): Promise<SaveCredentialsResponse> {
208
- const response = await request(CREDENTIALS_ROUTE, {
209
- method: 'PUT',
213
+ const response = await request(SAVE_CREDENTIALS_ROUTE, {
214
+ method: 'POST',
210
215
  headers: { 'content-type': 'application/json' },
211
216
  body: JSON.stringify(values),
212
217
  })
@@ -224,8 +229,8 @@ export async function saveCredentials(values: SaveCredentialsRequest): Promise<S
224
229
 
225
230
  /** Remove the stored value behind one credential reference. */
226
231
  export async function clearCredential(field: CredentialField): Promise<ClearCredentialResponse> {
227
- const response = await request(CREDENTIALS_ROUTE, {
228
- method: 'DELETE',
232
+ const response = await request(CLEAR_CREDENTIAL_ROUTE, {
233
+ method: 'POST',
229
234
  headers: { 'content-type': 'application/json' },
230
235
  body: JSON.stringify({ field }),
231
236
  })
@@ -247,13 +252,9 @@ export async function fetchAccess(): Promise<AccessWire> {
247
252
  }
248
253
 
249
254
  /** One access write: a 400 body already is the wire union; others throw. */
250
- async function mutateAccess(
251
- path: string,
252
- method: 'POST' | 'DELETE',
253
- body: unknown,
254
- ): Promise<AccessMutationResponse> {
255
+ async function mutateAccess(path: string, body: unknown): Promise<AccessMutationResponse> {
255
256
  const response = await request(path, {
256
- method,
257
+ method: 'POST',
257
258
  headers: { 'content-type': 'application/json' },
258
259
  body: JSON.stringify(body),
259
260
  })
@@ -272,43 +273,40 @@ async function mutateAccess(
272
273
  /** Authorize one group; resolves with the refreshed access snapshot. */
273
274
  export async function addAccessGroup(chatId: string, name?: string): Promise<AccessMutationResponse> {
274
275
  return mutateAccess(
275
- ACCESS_GROUPS_ROUTE,
276
- 'POST',
276
+ ADD_ACCESS_GROUP_ROUTE,
277
277
  name === undefined || name === '' ? { chatId } : { chatId, name },
278
278
  )
279
279
  }
280
280
 
281
281
  /** Revoke one group authorization (takes effect on the next message). */
282
282
  export async function removeAccessGroup(chatId: string): Promise<AccessMutationResponse> {
283
- return mutateAccess(ACCESS_GROUPS_ROUTE, 'DELETE', { chatId })
283
+ return mutateAccess(REMOVE_ACCESS_GROUP_ROUTE, { chatId })
284
284
  }
285
285
 
286
286
  /** Authorize one user `user_id` (global across authorized groups). */
287
287
  export async function addAccessUser(userId: string, name?: string): Promise<AccessMutationResponse> {
288
288
  return mutateAccess(
289
- ACCESS_USERS_ROUTE,
290
- 'POST',
289
+ ADD_ACCESS_USER_ROUTE,
291
290
  name === undefined || name === '' ? { userId } : { userId, name },
292
291
  )
293
292
  }
294
293
 
295
294
  /** Revoke one user authorization (takes effect on the next message). */
296
295
  export async function removeAccessUser(userId: string): Promise<AccessMutationResponse> {
297
- return mutateAccess(ACCESS_USERS_ROUTE, 'DELETE', { userId })
296
+ return mutateAccess(REMOVE_ACCESS_USER_ROUTE, { userId })
298
297
  }
299
298
 
300
299
  /** Add one user `user_id` to the wiki whitelist. */
301
300
  export async function addWikiUser(userId: string, name?: string): Promise<AccessMutationResponse> {
302
301
  return mutateAccess(
303
- ACCESS_WIKI_USERS_ROUTE,
304
- 'POST',
302
+ ADD_WIKI_USER_ROUTE,
305
303
  name === undefined || name === '' ? { userId } : { userId, name },
306
304
  )
307
305
  }
308
306
 
309
307
  /** Remove one user from the wiki whitelist (takes effect on the next wiki tool call). */
310
308
  export async function removeWikiUser(userId: string): Promise<AccessMutationResponse> {
311
- return mutateAccess(ACCESS_WIKI_USERS_ROUTE, 'DELETE', { userId })
309
+ return mutateAccess(REMOVE_WIKI_USER_ROUTE, { userId })
312
310
  }
313
311
 
314
312
  /** Settle one pending authorization request (same resolver as the Feishu card). */
@@ -316,5 +314,5 @@ export async function resolveAccessRequest(
316
314
  token: string,
317
315
  decision: 'allow' | 'deny',
318
316
  ): Promise<AccessMutationResponse> {
319
- return mutateAccess(ACCESS_REQUESTS_ROUTE, 'POST', { token, decision })
317
+ return mutateAccess(RESOLVE_ACCESS_REQUEST_ROUTE, { token, decision })
320
318
  }
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * 浏览器半区的设置面板选择器目录折叠:把 Host 下发的字段描述与实时目录
3
- * (`/dsh-channels/catalog`)合成通用表单需要的 select 描述。
3
+ * (`/api/dsh-channels.catalog`)合成通用表单需要的 select 描述。
4
4
  *
5
5
  * 拆成独立模块(不含 JSX/React)以便单测;`panel/index.tsx` 只负责串起状态与渲染。
6
6
  *
@@ -1,5 +1,5 @@
1
1
  /**
2
- * 「高级」tab:日志分析接口(监听地址字段 + 访问令牌凭据行)、SDK 日志级别
2
+ * 「高级」tab:日志分析接口(开关 + 访问令牌凭据行)、SDK 日志级别
3
3
  * 与 Web 覆盖文件(「恢复 profile 配置」删除整个覆盖层,不走 draft)。
4
4
  *
5
5
  * @module dsh-channels/client/panel/advanced-tab
package/src/config.ts CHANGED
@@ -83,9 +83,6 @@ export const DEFAULT_PROMPT_CONTEXT_TIMEOUT_MS = 3_000
83
83
  /** Conventional credential reference holding the log-platform analyze API bearer token. */
84
84
  export const DEFAULT_ANALYZE_TOKEN_REF = 'DSH_CHANNELS_ANALYZE_TOKEN'
85
85
 
86
- /** `host:port` listen address of the analyze API (`[v6]:port` for IPv6 literals). */
87
- export const ANALYZE_LISTEN_PATTERN = /^(?:\[[0-9A-Fa-f:.]+\]|[A-Za-z0-9.-]+):(?:[1-9][0-9]{0,4})$/u
88
-
89
86
  /** A credential reference: `FEISHU_APP_ID`-style name resolved through ctx.credentials. */
90
87
  const credentialRefSchema = z.string().regex(
91
88
  CREDENTIAL_REF_PATTERN,
@@ -425,17 +422,11 @@ export const configSchema = z.object({
425
422
  */
426
423
  questionTimeoutMs: z.number().int().min(10_000).max(3_600_000).default(DEFAULT_QUESTION_TIMEOUT_MS),
427
424
  /**
428
- * Listen address (`host:port`, e.g. `0.0.0.0:3090`) of the log-platform
429
- * analyze API: a dedicated HTTP listener serving only
430
- * `POST /dsh-channels/analyze`, separate from the DSH Web port. Unset
431
- * (default) keeps the API off. The listener runs while the Feishu connection
432
- * is up and is rebound on reconnect.
425
+ * Switch for the log-platform analyze API (`POST /api/dsh-channels.logalyze`
426
+ * on the DSH Web server). Off (default) answers 404; read per request, so
427
+ * toggling it needs no reconnect. Needs a host with a Web server.
433
428
  */
434
- analyzeListen: z.string().trim().regex(
435
- ANALYZE_LISTEN_PATTERN,
436
- 'must be host:port, e.g. 0.0.0.0:3090 or 127.0.0.1:3090',
437
- ).refine(value => Number(value.slice(value.lastIndexOf(':') + 1)) <= 65_535, 'port must be 1-65535')
438
- .optional(),
429
+ analyzeEnabled: z.boolean().default(false),
439
430
  /**
440
431
  * Credential reference holding the analyze API bearer token (defaults to
441
432
  * `DSH_CHANNELS_ANALYZE_TOKEN`). Resolved per request, so rotating the value
package/src/health.ts CHANGED
@@ -55,8 +55,6 @@ export interface HealthSnapshotInput {
55
55
  routeCount: number
56
56
  liveAgentCount: number
57
57
  inflightTurnCount: number
58
- /** Why the configured analyze listener could not bind; absent when it is bound or not configured. */
59
- analyzeListenError?: string
60
58
  }
61
59
 
62
60
  export interface HealthSnapshot extends Omit<HealthSnapshotInput, 'fibers' | 'botOpenId'> {
@@ -150,7 +148,6 @@ export function foldHealthSnapshot(input: HealthSnapshotInput): HealthSnapshot {
150
148
  || fibers.pending > 0
151
149
  || fibers.loading > 0
152
150
  || fibers.unloading > 0
153
- || input.analyzeListenError !== undefined
154
151
  ) {
155
152
  level = 'degraded'
156
153
  } else {
@@ -179,10 +176,6 @@ export function foldHealthSnapshot(input: HealthSnapshotInput): HealthSnapshot {
179
176
  )
180
177
  }
181
178
 
182
- if (input.analyzeListenError !== undefined) {
183
- reasons.push(`日志分析接口监听失败:${input.analyzeListenError}`)
184
- }
185
-
186
179
  return {
187
180
  level,
188
181
  fibers,
@@ -193,7 +186,6 @@ export function foldHealthSnapshot(input: HealthSnapshotInput): HealthSnapshot {
193
186
  routeCount: input.routeCount,
194
187
  liveAgentCount: input.liveAgentCount,
195
188
  inflightTurnCount: input.inflightTurnCount,
196
- ...(input.analyzeListenError === undefined ? {} : { analyzeListenError: input.analyzeListenError }),
197
189
  inactiveFibers: inactive,
198
190
  inactiveTotal,
199
191
  reasons,
@@ -237,9 +229,5 @@ export function buildHealthCard(snapshot: HealthSnapshot): object {
237
229
  tag: 'markdown',
238
230
  content: `**dsh-channels**\n${snapshot.started ? 'started' : 'stopped'} · 持久化路由 ${snapshot.routeCount} · live Agent ${snapshot.liveAgentCount} · in-flight turn ${snapshot.inflightTurnCount}`,
239
231
  },
240
- ...(snapshot.analyzeListenError === undefined ? [] : [{
241
- tag: 'markdown',
242
- content: `**日志分析接口**\n❌ 监听失败:${snapshot.analyzeListenError}`,
243
- }]),
244
232
  ])
245
233
  }
package/src/host/agent.ts CHANGED
@@ -35,10 +35,31 @@ export interface AgentRequestConfig {
35
35
 
36
36
  /** Agent-scoped request override installed before the first prompt assembly. */
37
37
  export type AgentRequestListener = (
38
- payload: { turn: number; step: number; signal: AbortSignal },
38
+ payload: { turn: number; step: number; signal: AbortSignal; agent?: Agent },
39
39
  next: () => Promise<AgentRequestConfig>,
40
40
  ) => Promise<AgentRequestConfig>
41
41
 
42
+ /** Complete provider, model, and optional reasoning effort for one Session's model steps. */
43
+ export interface ModelSelection {
44
+ readonly provider: string
45
+ readonly model: string
46
+ /** Absent means the adapter/provider default effort. */
47
+ readonly reasoningEffort?: string
48
+ }
49
+
50
+ /**
51
+ * Agent-owned durable model choice (`agent.model`, DSH 0.2.2-alpha.1 and
52
+ * later). On these hosts an `agent/request` listener that changes provider,
53
+ * model, or reasoning effort fails the step, so a switch must go through
54
+ * `select()` before the next step starts.
55
+ */
56
+ export interface AgentModel {
57
+ /** Choice for the next step: recorded selection, then last request, then live options. */
58
+ current(): Readonly<ModelSelection> | undefined
59
+ /** Record the next step's complete selection (`model/selection`); an equal recorded choice is a no-op. */
60
+ select(selection: Readonly<ModelSelection>): void
61
+ }
62
+
42
63
  /** Cordis listener placement used to make an explicit channel override outermost. */
43
64
  export interface AgentRequestListenerOptions {
44
65
  prepend?: boolean
@@ -133,6 +154,8 @@ export interface AgentContext {
133
154
  export interface Agent {
134
155
  readonly id: SessionId
135
156
  readonly options: AgentOptions
157
+ /** Durable model choice; absent on DSH 0.2.1 hosts, which route through `agent/request`. */
158
+ readonly model?: AgentModel
136
159
  readonly session: Session
137
160
  readonly status: 'idle' | 'running'
138
161
  readonly inbox: {
package/src/index.ts CHANGED
@@ -23,6 +23,7 @@ import { defaultChannelFactory } from './feishu-channel.ts'
23
23
  import type { ChannelFactory } from './feishu-channel.ts'
24
24
  import { OverlayStore, defaultOverlayPath } from './settings/overlay.ts'
25
25
  import { mountSettingsRoutes } from './settings/routes.ts'
26
+ import { mountAnalyzeRoute } from './analyze/server.ts'
26
27
  import { SettingsSurface } from './settings/surface.ts'
27
28
  import { createHostCatalog } from './settings/catalog.ts'
28
29
  import { createSettingsDirectoryPicker } from './settings/directory-picker.ts'
@@ -41,7 +42,7 @@ export type * from './questions.ts'
41
42
  export type * from './settings/contract.ts'
42
43
  export * from './access/index.ts'
43
44
  export type { AccessGroupRecord, AccessRequestRecord, AccessUserRecord } from './domain.ts'
44
- export { CATALOG_ROUTE, CONFIG_ROUTE, CREDENTIALS_ROUTE, EDITABLE_KEYS, PANEL_COPY, PANEL_ROUTE, PICK_DIRECTORY_ROUTE, RECONNECT_ROUTE, RESTART_KEYS, STATUS_ROUTE } from './settings/contract.ts'
45
+ export { CATALOG_ROUTE, CLEAR_CONFIG_ROUTE, CLEAR_CREDENTIAL_ROUTE, EDITABLE_KEYS, PANEL_COPY, PANEL_ROUTE, PICK_DIRECTORY_ROUTE, RECONNECT_ROUTE, RESTART_KEYS, SAVE_CONFIG_ROUTE, SAVE_CREDENTIALS_ROUTE, STATUS_ROUTE } from './settings/contract.ts'
45
46
  export type { PickDirectoryResponse } from './settings/contract.ts'
46
47
  export { defaultOverlayPath, mergeOverlay, normalizeOverlay, OverlayStore, parseOverlay, pickEditable, resolveDshHome } from './settings/overlay.ts'
47
48
  export { applyCatalog, emptyCatalog, FIELD_DESCRIPTORS, normalizeCredentialValue, SettingsSurface, withDirectoryBrowse } from './settings/surface.ts'
@@ -50,8 +51,8 @@ export { createHostCatalog } from './settings/catalog.ts'
50
51
  export type { CatalogWarn, SettingsCatalog } from './settings/catalog.ts'
51
52
  export { createSettingsDirectoryPicker } from './settings/directory-picker.ts'
52
53
  export type { HostDirectoryPickerService, SettingsDirectoryPicker } from './settings/directory-picker.ts'
53
- export { isSameOriginRequest, mountSettingsRoutes, registerSettingsRoutes } from './settings/routes.ts'
54
- export type { HostRequestGate, HostWebServer } from './settings/routes.ts'
54
+ export { mountSettingsRoutes, registerSettingsRoutes } from './settings/routes.ts'
55
+ export type { HostConnection, HostFetchRoute } from './settings/routes.ts'
55
56
  export type { ApprovalOutcome, ApprovalRequest, ApprovalAnswererListener, AssistantStreamFrame, AskUserQuestionAnswer, AskUserQuestionAnswerItem, AskUserQuestionItem, AskUserQuestionRequestEvent, UserQuestionAnswererListener, AgentCancelCause, SessionStatsView, TokenUsageView, ContextPressureView, SessionProjections } from './dsh-types.ts'
56
57
  export { buildCard, buildInterruptionCard, cardJsonBytes, CARD_MAX_BYTES, CARD_TARGET_BYTES, REASONING_MAX_BYTES, INTERRUPTION_NOTICE, MAX_TABLES_PER_CARD, PANEL_TITLES, PANEL_CHEVRON_TOKEN, segmentCardContent, splitBodyForCard, stripTables, tableBlocks, TRUNCATION_SUFFIX, buildApprovalCard, buildApprovalSettledCard, buildQuestionCard, buildQuestionSettledCard, MAX_QUESTION_OPTIONS } from './cards.ts'
57
58
  export { ApprovalAnswerer } from './approvals.ts'
@@ -112,7 +113,7 @@ export function apply(ctx: HostContext, config?: unknown): void {
112
113
  { warn: (message, ...args) => ctx.logger?.warn?.(message, ...args) },
113
114
  )
114
115
  const service = new FeishuService(ctx, resolved, factory, undefined, overlay)
115
- // Settings routes ride the host's web server when this deployment has one.
116
+ // Settings routes ride DSH's authenticated `/api` channel when this deployment has one.
116
117
  // The catalog resolves the panel's model selectors from live host services;
117
118
  // the optional native directory picker backs the workspace-root「选择目录…」
118
119
  // button (absent in headless/remote compositions → plain text input).
@@ -124,6 +125,7 @@ export function apply(ctx: HostContext, config?: unknown): void {
124
125
  createHostCatalog(ctx, warn),
125
126
  createSettingsDirectoryPicker(ctx),
126
127
  ))
128
+ mountAnalyzeRoute(ctx, service.analyzeHandler(), warn)
127
129
  ctx.effect(() => {
128
130
  // Boot loads the Web overlay and connects only when the Feishu application
129
131
  // credentials are complete; an incomplete configuration or a failed