@cnbmtech/dsh-zentao-session 0.0.0-stage → 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/index.js ADDED
@@ -0,0 +1,755 @@
1
+ import Schema from '@deepseek-ai/schemastery'
2
+ import { defineTool } from '@deepseek-ai/dsh-tools'
3
+ import { createZentaoSessionClient } from './src/client.js'
4
+
5
+ export const name = 'zentao-session'
6
+
7
+ // credentials is injected explicitly so ZENTAO_* refs can be resolved during apply(),
8
+ // matching the official pattern used by dsh-authorization / dsh-client-connection.
9
+ export const inject = ['tools', 'credentials']
10
+
11
+ /** 注册过程诊断记录:把被 catch 吞掉的真实错误暴露给 zentao_plugin_status 工具。 */
12
+ const diagnostics = []
13
+ const note = (step, ok, detail) => {
14
+ diagnostics.push({ step, ok, ...(detail === undefined ? {} : { detail: String(detail) }) })
15
+ if (diagnostics.length > 40) diagnostics.shift()
16
+ }
17
+
18
+ /**
19
+ * @typedef {object} Config
20
+ * @property {string} baseUrl 禅道地址(不含末尾斜杠);为空时读 DSH 凭据 ZENTAO_BASE_URL 或环境变量
21
+ * @property {string} account 禅道账号;为空时读 DSH 凭据 ZENTAO_ACCOUNT 或环境变量
22
+ * @property {string} password 禅道密码;建议存 DSH 凭据 ZENTAO_PASSWORD,不要写进 cordis.patch.yml
23
+ * @property {number} timeoutMs 单次请求超时(毫秒)
24
+ * @property {number} defaultLimit 列表工具默认返回条数
25
+ */
26
+
27
+ export const Config = Schema.object({
28
+ baseUrl: Schema.string()
29
+ .default('')
30
+ .description('禅道地址,例如 https://zentao.example.com(不含末尾斜杠);为空时读 DSH 凭据 ZENTAO_BASE_URL(~/.dsh/.credentials.yaml)或环境变量。未配置时插件休眠,不影响 profile 启动。'),
31
+ account: Schema.string()
32
+ .default('')
33
+ .description('禅道账号;为空时读 DSH 凭据 ZENTAO_ACCOUNT 或环境变量。'),
34
+ password: Schema.string()
35
+ .default('')
36
+ .description('禅道密码;建议存入 DSH 凭据 ZENTAO_PASSWORD(~/.dsh/.credentials.yaml)或环境变量,不要写进 cordis.patch.yml。'),
37
+ timeoutMs: Schema.number()
38
+ .default(30000)
39
+ .description('单次 HTTP 请求超时(毫秒)。'),
40
+ defaultLimit: Schema.number()
41
+ .default(20)
42
+ .description('列表类工具默认返回条数。'),
43
+ })
44
+
45
+ function makeCredentialResolver(ctx) {
46
+ const service = typeof ctx.get === 'function' ? ctx.get('credentials') : undefined
47
+ if (!service || typeof service.resolve !== 'function') return undefined
48
+ return async (ref) => {
49
+ const hit = await service.resolve(ref)
50
+ return hit && typeof hit.value === 'string' && hit.value.length > 0 ? hit.value : undefined
51
+ }
52
+ }
53
+
54
+ /** 工具输出必须是 content block 数组(宿主会对其调用 .some 等数组方法)。 */
55
+ function textBlock(text) {
56
+ return [{ type: 'text', text: String(text) }]
57
+ }
58
+
59
+ /** 工具返回值必须是 lossless JSON:丢弃 undefined、把不安全数字转成字符串。 */
60
+ function toLosslessJson(value, seen) {
61
+ if (value === undefined) return undefined
62
+ if (value === null) return null
63
+ const type = typeof value
64
+ if (type === 'number') {
65
+ if (!Number.isFinite(value)) return String(value)
66
+ if (Number.isInteger(value) && !Number.isSafeInteger(value)) return String(value)
67
+ return value
68
+ }
69
+ if (type === 'string' || type === 'boolean') return value
70
+ if (type === 'bigint') return value.toString()
71
+ const guard = seen || new WeakSet()
72
+ if (type === 'object') {
73
+ if (guard.has(value)) return '[circular]'
74
+ guard.add(value)
75
+ if (value instanceof Date) return value.toISOString()
76
+ if (Array.isArray(value)) {
77
+ // 数组里的 undefined 也要消掉:宿主对 canonical value 有强制校验,null 才是合法占位
78
+ return value.map((item) => (item === undefined ? null : toLosslessJson(item, guard)))
79
+ }
80
+ if (value instanceof Map) return `[Map size=${value.size}]`
81
+ if (value instanceof Set) return `[Set size=${value.size}]`
82
+ const out = {}
83
+ for (const [key, item] of Object.entries(value)) {
84
+ if (item === undefined) continue
85
+ out[key] = toLosslessJson(item, guard)
86
+ }
87
+ return out
88
+ }
89
+ return String(value)
90
+ }
91
+
92
+ /** 包一层客户端:保证所有工具返回的值都是 lossless JSON。 */
93
+ function wrapClientLossless(client) {
94
+ return new Proxy(client, {
95
+ get(target, prop, receiver) {
96
+ const value = Reflect.get(target, prop, receiver)
97
+ if (typeof value !== 'function') return value
98
+ return async (...args) => toLosslessJson(await value.apply(target, args))
99
+ },
100
+ })
101
+ }
102
+
103
+ /** 浏览范围 → 界面/输出里的中文名(与禅道 tab 逐一对应,避免模型误解范围)。 */
104
+ const SCOPE_LABEL = {
105
+ bug: { assignedTo: '指派给我', openedBy: '由我创建', resolvedBy: '由我解决', closedBy: '由我关闭' },
106
+ task: { assignedTo: '指派给我', openedBy: '由我创建', finishedBy: '由我完成', closedBy: '由我关闭', canceledBy: '由我取消' },
107
+ story: { assignedTo: '指派给我', openedBy: '我创建', reviewedBy: '我评审', closedBy: '我关闭' },
108
+ }
109
+
110
+ /** 需求所处阶段(禅道 story.stage 的取值 → 中文)。 */
111
+ const STORY_STAGE = {
112
+ wait: '未开始', planned: '已计划', projected: '已立项', developing: '研发中', developed: '研发完毕',
113
+ testing: '测试中', tested: '测试完毕', verified: '已验收', released: '已发布', closed: '已关闭',
114
+ }
115
+
116
+ /** 列表头:说清范围与"本次只返回前 N 条",避免把一页/一个 limit 当成全部。 */
117
+ function listHead(kind, value, shown, unit) {
118
+ const scope = SCOPE_LABEL[kind]?.[value?.type] || value?.type || ''
119
+ const total = Number(value?.recTotal) > 0 ? Number(value.recTotal) : shown
120
+ const body = total > shown ? `共 ${total} 条,本次返回前 ${shown} 条` : `${shown} 条`
121
+ return `「${scope}」的${unit}:${body}`
122
+ }
123
+
124
+ function renderBugList(value) {
125
+ const rows = Array.isArray(value?.bugs) ? value.bugs : []
126
+ const head = listHead('bug', value, rows.length, 'BUG')
127
+ if (!rows.length) return textBlock(`${head},没有符合条件的 BUG。`)
128
+ return textBlock([
129
+ `${head}:`,
130
+ ...rows.map((b) => `#${b.id} [${b.status}] pri=${b.pri} ${b.title}`),
131
+ ].join('\n'))
132
+ }
133
+
134
+ function renderTaskList(value) {
135
+ const rows = Array.isArray(value?.tasks) ? value.tasks : []
136
+ const head = listHead('task', value, rows.length, '任务')
137
+ if (!rows.length) return textBlock(`${head},没有符合条件的任务。`)
138
+ return textBlock([
139
+ `${head}:`,
140
+ ...rows.map((t) => `#${t.id} [${t.status}] pri=${t.pri} ${t.name}`),
141
+ ].join('\n'))
142
+ }
143
+
144
+ function renderStoryList(value) {
145
+ const rows = Array.isArray(value?.stories) ? value.stories : []
146
+ const head = listHead('story', value, rows.length, '需求')
147
+ if (!rows.length) return textBlock(`${head},没有符合条件的需求。`)
148
+ return textBlock([
149
+ `${head}:`,
150
+ ...rows.map((s) => `#${s.id} [${s.status}] pri=${s.pri} 阶段=${STORY_STAGE[s.stage] || s.stage || '-'} ${s.title}`),
151
+ ].join('\n'))
152
+ }
153
+
154
+ function renderStory(value) {
155
+ if (!value || !value.id) return textBlock('未取到需求信息。')
156
+ const s = value.story || {}
157
+ return textBlock([
158
+ `#${value.id} ${s.title || ''}`,
159
+ `状态=${s.status} 阶段=${STORY_STAGE[s.stage] || s.stage} 优先级=${s.pri} 指派=${s.assignedTo} 创建=${s.openedBy} ${s.openedDate || ''}`,
160
+ `产品=${value.productName || s.productTitle || s.product || ''} 模块=${value.modulePath || ''}`,
161
+ s.spec || s.desc ? `描述:${String(s.spec || s.desc).replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 300)}` : '',
162
+ ].filter(Boolean).join('\n'))
163
+ }
164
+
165
+ function renderBug(value) {
166
+ if (!value || !value.id) return textBlock('未取到BUG信息。')
167
+ return textBlock([
168
+ `#${value.id} ${value.title}`,
169
+ `状态=${value.status} 已确认=${value.confirmed} 类型=${value.type} 优先级=${value.pri} 指派=${value.assignedTo}`,
170
+ `创建=${value.openedBy} ${value.openedDate || ''}`,
171
+ ].join('\n'))
172
+ }
173
+
174
+ function renderWrite(value) {
175
+ const parts = [`已提交 #${value?.id}(HTTP ${value?.httpStatus})`]
176
+ if (value?.alert) parts.push(`禅道返回提示:${value.alert}`)
177
+ if (value?.note) parts.push(value.note)
178
+ return textBlock(parts.join('\n'))
179
+ }
180
+
181
+ function renderPing(value) {
182
+ const story = value.myStoryCount === null || value.myStoryCount === undefined
183
+ ? '需求接口不可用'
184
+ : `我的需求 ${value.myStoryCount} 条`
185
+ return textBlock(`禅道连通正常:${value.baseUrl}|我的BUG ${value.myBugCount} 条|我的任务 ${value.myTaskCount} 条|${story}`)
186
+ }
187
+
188
+ /**
189
+ * @param {ReturnType<typeof createZentaoSessionClient>} client
190
+ * @param {{ defaultLimit?: number }} config
191
+ */
192
+ export function buildTools(rawClient, config = {}) {
193
+ const client = wrapClientLossless(rawClient)
194
+ const baseUrl = config.baseUrl || ''
195
+ const defaultLimit = Number(config.defaultLimit) > 0 ? Number(config.defaultLimit) : 20
196
+
197
+ const ping = defineTool({
198
+ name: 'zentao_ping',
199
+ description: '测试禅道连接:用账号密码登录并返回我的BUG/任务条数,用于验证凭据与会话是否可用。',
200
+ parameters: {},
201
+ isConcurrencySafe: () => true,
202
+ output: {
203
+ schema: { type: 'object', additionalProperties: true },
204
+ render: (_args, value) => renderPing(value),
205
+ },
206
+ async execute(_args, exec) {
207
+ return client.ping({ signal: exec && exec.signal })
208
+ },
209
+ })
210
+
211
+ const myBugs = defineTool({
212
+ name: 'zentao_my_bugs',
213
+ description: '列出当前禅道账号的BUG("我的 Bug"),返回 id、标题、状态、优先级、类型等。用户问"我有哪些 Bug/BUG"时使用;默认范围是"指派给我"。',
214
+ parameters: {
215
+ limit: { type: 'integer', description: `返回条数上限,默认 ${defaultLimit},0 表示全部。` },
216
+ type: { type: 'string', description: '范围(默认 assignedTo):assignedTo=指派给我,openedBy=由我创建,resolvedBy=由我解决,closedBy=由我关闭。' },
217
+ },
218
+ isConcurrencySafe: () => true,
219
+ output: {
220
+ schema: { type: 'object', additionalProperties: true },
221
+ render: (_args, value) => renderBugList(value),
222
+ },
223
+ async execute(args, exec) {
224
+ const limit = Number.isFinite(args?.limit) ? Number(args.limit) : defaultLimit
225
+ return client.myBugs({ type: args?.type, limit, signal: exec && exec.signal })
226
+ },
227
+ })
228
+
229
+ const myTasks = defineTool({
230
+ name: 'zentao_my_tasks',
231
+ description: '列出当前禅道账号的任务("我的任务"),返回 id、名称、状态、优先级、预计/已耗工时、截止日期等;默认范围是"指派给我"。',
232
+ parameters: {
233
+ limit: { type: 'integer', description: `返回条数上限,默认 ${defaultLimit},0 表示全部。` },
234
+ type: { type: 'string', description: '范围(默认 assignedTo):assignedTo=指派给我,openedBy=由我创建,finishedBy=由我完成,closedBy=由我关闭,canceledBy=由我取消。' },
235
+ },
236
+ isConcurrencySafe: () => true,
237
+ output: {
238
+ schema: { type: 'object', additionalProperties: true },
239
+ render: (_args, value) => renderTaskList(value),
240
+ },
241
+ async execute(args, exec) {
242
+ const limit = Number.isFinite(args?.limit) ? Number(args.limit) : defaultLimit
243
+ return client.myTasks({ type: args?.type, limit, signal: exec && exec.signal })
244
+ },
245
+ })
246
+
247
+ const myStories = defineTool({
248
+ name: 'zentao_my_stories',
249
+ description: '列出当前禅道账号的需求("我的需求"),返回 id、标题、状态、阶段、优先级、创建/指派等。默认范围是"指派给我";多数账号更常用 reviewedBy=我评审、openedBy=我创建。',
250
+ parameters: {
251
+ limit: { type: 'integer', description: `返回条数上限,默认 ${defaultLimit},0 表示全部。` },
252
+ type: { type: 'string', description: '范围(默认 assignedTo):assignedTo=指派给我,openedBy=我创建,reviewedBy=我评审,closedBy=我关闭。' },
253
+ },
254
+ isConcurrencySafe: () => true,
255
+ output: {
256
+ schema: { type: 'object', additionalProperties: true },
257
+ render: (_args, value) => renderStoryList(value),
258
+ },
259
+ async execute(args, exec) {
260
+ const limit = Number.isFinite(args?.limit) ? Number(args.limit) : defaultLimit
261
+ return client.myStories({ type: args?.type, limit, signal: exec && exec.signal })
262
+ },
263
+ })
264
+
265
+ const storyView = defineTool({
266
+ name: 'zentao_story_view',
267
+ description: '按 ID 读取一条禅道需求的完整详情(标题、状态、阶段、优先级、产品/模块、描述、相关人与操作历史)。',
268
+ parameters: {
269
+ id: { type: 'string', required: true, description: '需求 ID,例如 13126。' },
270
+ },
271
+ isConcurrencySafe: () => true,
272
+ output: {
273
+ schema: { type: 'object', additionalProperties: true },
274
+ render: (_args, value) => renderStory(value),
275
+ },
276
+ async execute(args, exec) {
277
+ return client.storyFull({ id: args.id, signal: exec && exec.signal })
278
+ },
279
+ })
280
+
281
+ const bugView = defineTool({
282
+ name: 'zentao_bug_view',
283
+ description: '按 ID 读取一条禅道BUG的详情(状态、是否已确认、类型、优先级、指派人、解决情况、重现步骤)。',
284
+ parameters: {
285
+ id: { type: 'string', required: true, description: 'BUG ID,例如 200601。' },
286
+ },
287
+ isConcurrencySafe: () => true,
288
+ output: {
289
+ schema: { type: 'object', additionalProperties: true },
290
+ render: (_args, value) => renderBug(value),
291
+ },
292
+ async execute(args, exec) {
293
+ return client.bugView({ id: args.id, signal: exec && exec.signal })
294
+ },
295
+ })
296
+
297
+ const bugConfirm = defineTool({
298
+ name: 'zentao_bug_confirm',
299
+ description: '确认一条禅道BUG(把"是否确认"置为已确认)。注意:禅道确认只设置 confirmed=1,BUG状态仍为 active(激活/待解决),这是正常语义。可选附带确认说明。',
300
+ parameters: {
301
+ id: { type: 'string', required: true, description: 'BUG ID。' },
302
+ comment: { type: 'string', description: '可选的确认说明(写入该操作的备注)。' },
303
+ },
304
+ isConcurrencySafe: () => false,
305
+ output: {
306
+ schema: { type: 'object', additionalProperties: true },
307
+ render: (_args, value) => renderWrite(value),
308
+ },
309
+ async execute(args, exec) {
310
+ return client.bugConfirm({ id: args.id, comment: args.comment, signal: exec && exec.signal })
311
+ },
312
+ })
313
+
314
+ const bugComment = defineTool({
315
+ name: 'zentao_bug_comment',
316
+ description: '给一条禅道BUG添加备注(回复)。用户常见的三段式格式为「问题原因:…/解决方案:…/解决时间:…」,段间保留空行。注意:该版本禅道不支持删除备注。',
317
+ parameters: {
318
+ id: { type: 'string', required: true, description: 'BUG ID。' },
319
+ comment: { type: 'string', required: true, description: '备注正文,可含换行。' },
320
+ },
321
+ isConcurrencySafe: () => false,
322
+ output: {
323
+ schema: { type: 'object', additionalProperties: true },
324
+ render: (_args, value) => renderWrite(value),
325
+ },
326
+ async execute(args, exec) {
327
+ return client.bugComment({ id: args.id, comment: args.comment, signal: exec && exec.signal })
328
+ },
329
+ })
330
+
331
+ const status = defineTool({
332
+ name: 'zentao_plugin_status',
333
+ description: '读取禅道插件的注册诊断信息:可选服务(connection / webServer)是否可见、RPC 通道与 HTTP 路由的注册结果及真实错误。侧边栏面板取数失败时用它排查。',
334
+ parameters: {},
335
+ isConcurrencySafe: () => true,
336
+ output: {
337
+ schema: { type: 'object', additionalProperties: true },
338
+ render: (_args, value) => textBlock(
339
+ [
340
+ `baseUrl=${value.baseUrl || '-'}`,
341
+ // 面板当前可用通道:RPC 在本版宿主注册失败是已知现象,HTTP 前缀路由才是实际通道
342
+ `面板数据通道=${(value.diagnostics || []).some((i) => i.step === 'http.registered' && i.ok) ? 'HTTP 前缀路由(可用)' : '不可用'}|RPC=${(value.diagnostics || []).some((i) => i.step === 'rpc.registered' && i.ok) ? '已注册' : '未注册(本版 connection.rpc.handle 报 webServer 注入错误,已知现象)'}`,
343
+ ...(value.diagnostics || []).map((item) => `${item.ok ? '[ok]' : '[FAIL]'} ${item.step}${item.detail ? ` :: ${item.detail}` : ''}`),
344
+ ].join('\n')
345
+ ),
346
+ },
347
+ async execute() {
348
+ return { diagnostics: diagnostics.slice(), baseUrl }
349
+ },
350
+ })
351
+
352
+ return [ping, myBugs, myTasks, myStories, bugView, storyView, bugConfirm, bugComment, status]
353
+ }
354
+
355
+ /** 写操作集合:RPC 通道与 HTTP 通道必须共用,否则两条路径对同一请求的处理不一致。 */
356
+ const WRITE_ACTIONS = new Set(['confirm', 'comment', 'bugaction', 'login', 'logout'])
357
+
358
+ export async function apply(ctx, config) {
359
+ try {
360
+ const settings = config || {}
361
+ const resolveCredential = makeCredentialResolver(ctx)
362
+ const credentials = (ctx && typeof ctx.get === 'function' ? ctx.get('credentials') : undefined) || (ctx && ctx.credentials)
363
+ if (credentials) note('credentials.service', true, `set=${typeof credentials.set} unset=${typeof credentials.unset}`)
364
+ else note('credentials.service', false, 'credentials 服务不可用,登录后无法保存凭据')
365
+
366
+ const baseUrl = settings.baseUrl
367
+ || (resolveCredential ? await resolveCredential('ZENTAO_BASE_URL') : undefined)
368
+ || process.env.ZENTAO_BASE_URL
369
+ || ''
370
+ // 说明:没有 baseUrl 时**不再休眠**——否则首次安装时面板无法弹出登录卡片。
371
+ // 工具与路由照常注册,未登录期间由 dynamicClient 给出明确提示。
372
+ if (!baseUrl) note('apply.pending-login', true, '尚未配置 baseUrl,等待在面板中登录')
373
+
374
+ const resolveAccount = async () => settings.account
375
+ || (resolveCredential ? await resolveCredential('ZENTAO_ACCOUNT') : undefined)
376
+ || process.env.ZENTAO_ACCOUNT
377
+ || ''
378
+ const resolvePassword = async () => settings.password
379
+ || (resolveCredential ? await resolveCredential('ZENTAO_PASSWORD') : undefined)
380
+ || process.env.ZENTAO_PASSWORD
381
+ || ''
382
+
383
+ // 客户端可热替换:首次登录/切换账号后重建,工具与路由都通过 holder 取当前实例。
384
+ const holder = {
385
+ baseUrl,
386
+ client: createZentaoSessionClient({
387
+ baseUrl: baseUrl || 'http://127.0.0.1',
388
+ timeoutMs: settings.timeoutMs,
389
+ resolveAccount,
390
+ resolvePassword,
391
+ }),
392
+ }
393
+ const NEED_LOGIN = '尚未配置禅道地址,请在侧边栏「禅道」面板中登录'
394
+
395
+ const dynamicClient = new Proxy({}, {
396
+ get(_target, prop) {
397
+ if (!holder.baseUrl) {
398
+ return async () => { throw new Error(NEED_LOGIN) }
399
+ }
400
+ const current = holder.client
401
+ const value = current[prop]
402
+ return typeof value === 'function' ? (...args) => value.apply(current, args) : value
403
+ },
404
+ })
405
+
406
+ const tools = buildTools(dynamicClient, { ...settings, baseUrl })
407
+ for (const tool of tools) ctx.tools.register(tool)
408
+ note('apply', true, `baseUrl=${baseUrl} tools=${tools.length}`)
409
+ ctx.logger?.info?.(`[zentao-session] 已注册 ${tools.length} 个禅道工具,baseUrl=${baseUrl}`)
410
+
411
+ // 模型侧说明段:告诉模型这些工具何时用、未登录怎么办。
412
+ // systemPrompt 是可选服务——缺失时插件其余能力照常工作,所以走 ctx.inject 而不是 inject 必填项。
413
+ ctx.inject(['systemPrompt'], (promptCtx) => {
414
+ try {
415
+ const systemPrompt = promptCtx?.systemPrompt
416
+ || (typeof promptCtx?.get === 'function' ? promptCtx.get('systemPrompt') : undefined)
417
+ if (!systemPrompt || typeof systemPrompt.section !== 'function') {
418
+ note('systemPrompt.unavailable', false, 'systemPrompt.section 不可用')
419
+ return
420
+ }
421
+ // 放在工具类段落带里(MCP_SERVERS=3100 之后),紧跟内置工具说明
422
+ const base = typeof systemPrompt.getSectionOrder === 'function'
423
+ ? systemPrompt.getSectionOrder('MCP_SERVERS')
424
+ : undefined
425
+ const order = (Number.isFinite(base) ? base : 3100) + 1
426
+ const register = () => systemPrompt.section({
427
+ name: 'tool:zentao-session',
428
+ order,
429
+ text: "## ZenTao (禅道) work items\n\nThe `zentao_*` tools read and update this user's ZenTao items through a session login saved in the DSH credential store:\n- `zentao_my_bugs` / `zentao_my_tasks` / `zentao_my_stories` list the user's items; each accepts `type` for the browse scope (`assignedTo` = 指派给我 by default; also openedBy / resolvedBy / reviewedBy / finishedBy / closedBy / canceledBy depending on the kind).\n- `zentao_bug_view` / `zentao_story_view` read one item in full (status, priority, steps or spec, related people, action history).\n- `zentao_bug_confirm` and `zentao_bug_comment` write back: confirm a bug, or append a remark.\n\nUse them when the user asks about their own bugs, tasks or stories (「我的 BUG/任务/需求」), names a ZenTao item id (for example 200601), or asks to confirm an item or add a 备注; read the real item instead of guessing. If a tool reports that ZenTao is not configured or not logged in, ask the user to sign in once in the sidebar 「禅道」 panel. This ZenTao version cannot delete remarks, and a remark is public in the item history — never post one the user did not ask for.",
430
+ })
431
+ // 与路由同理:注册挂在插件自身的 ctx.effect 上,避免派生上下文回收时段落一起消失
432
+ if (typeof ctx.effect === 'function') ctx.effect(register, 'dsh-zentao-session: system prompt section')
433
+ else register()
434
+ note('systemPrompt.registered', true, `order=${order}`)
435
+ } catch (error) {
436
+ note('systemPrompt.register-failed', false, error?.message || error)
437
+ }
438
+ })
439
+
440
+ // 侧边栏面板的数据接口。
441
+ // 主通道走 connection.rpc(官方机制,桌面版 Electron 的 fetch 走 IPC 桥,自建 HTTP 路由到不了);
442
+ // 另注册一条 HTTP 前缀路由,供纯浏览器部署使用。两者共用同一个 dispatch。
443
+ // connection / webServer 都是可选服务,缺失时插件照常工作(不进 inject 必填项,避免卡启动)。
444
+ const RPC_CHANNEL = '/dsh-zentao-session'
445
+
446
+ const dispatch = async (action, { query, body } = {}) => {
447
+ const get = (key) => (query && typeof query.get === 'function' ? query.get(key) : undefined) ?? (body ? body[key] : undefined)
448
+ if (action === '' || action === 'summary') return { ok: true, baseUrl: holder.baseUrl, ...(await holder.client.ping({})) }
449
+ // 列表类都接受 type(浏览范围):assignedTo/openedBy/... 与禅道 tab 一致
450
+ if (action === 'bugs') return { ok: true, baseUrl: holder.baseUrl, ...(await holder.client.myBugs({ type: get('type') })) }
451
+ if (action === 'tasks') return { ok: true, baseUrl: holder.baseUrl, ...(await holder.client.myTasks({ type: get('type') })) }
452
+ if (action === 'stories') return { ok: true, baseUrl: holder.baseUrl, ...(await holder.client.myStories({ type: get('type') })) }
453
+ if (action === 'bug') {
454
+ const id = get('id')
455
+ if (!id) return { ok: false, error: '缺少 id 参数' }
456
+ return { ok: true, baseUrl: holder.baseUrl, bug: await holder.client.bugView({ id }) }
457
+ }
458
+ if (action === 'bugfull') {
459
+ const id = get('id')
460
+ if (!id) return { ok: false, error: '缺少 id 参数' }
461
+ const sid = await holder.client.currentSessionId({}).catch(() => '')
462
+ return { ok: true, sid, ...(await holder.client.bugFull({ id })) }
463
+ }
464
+ if (action === 'taskfull') {
465
+ const id = get('id')
466
+ if (!id) return { ok: false, error: '缺少 id 参数' }
467
+ const sid = await holder.client.currentSessionId({}).catch(() => '')
468
+ return { ok: true, sid, ...(await holder.client.taskFull({ id })) }
469
+ }
470
+ if (action === 'storyfull') {
471
+ const id = get('id')
472
+ if (!id) return { ok: false, error: '缺少 id 参数' }
473
+ const sid = await holder.client.currentSessionId({}).catch(() => '')
474
+ return { ok: true, sid, ...(await holder.client.storyFull({ id })) }
475
+ }
476
+ if (action === 'confirm') {
477
+ if (!body || !body.id) return { ok: false, error: '缺少 id' }
478
+ return { ok: true, baseUrl: holder.baseUrl, result: await holder.client.bugConfirm({ id: body.id, comment: body.comment }) }
479
+ }
480
+ if (action === 'comment') {
481
+ if (!body || !body.id || !body.comment) return { ok: false, error: '缺少 id 或 comment' }
482
+ return { ok: true, baseUrl: holder.baseUrl, result: await holder.client.bugComment({ id: body.id, comment: body.comment }) }
483
+ }
484
+ // ── 登录态 ─────────────────────────────────────────────────────────────
485
+ if (action === 'auth') {
486
+ const account = await resolveAccount()
487
+ if (!holder.baseUrl) return { ok: true, authenticated: false, baseUrl: '', account, error: '尚未配置禅道地址' }
488
+ try {
489
+ const info = await holder.client.ping({})
490
+ const sid = await holder.client.currentSessionId({}).catch(() => '')
491
+ return { ok: true, authenticated: true, baseUrl: holder.baseUrl, account, sid, ...info }
492
+ } catch (error) {
493
+ return { ok: true, authenticated: false, baseUrl: holder.baseUrl, account, error: String(error?.message || error) }
494
+ }
495
+ }
496
+ if (action === 'login') {
497
+ const targetBase = String((body && body.baseUrl) || '').trim().replace(/\/+$/, '')
498
+ const targetAccount = String((body && body.account) || '').trim()
499
+ const targetPassword = String((body && body.password) || '')
500
+ if (!targetBase || !targetAccount || !targetPassword) return { ok: false, error: '请填写禅道地址、账号与密码' }
501
+ const info = await holder.client.login({ baseUrl: targetBase, account: targetAccount, password: targetPassword })
502
+ const saved = []
503
+ const saveErrors = []
504
+ if (credentials && typeof credentials.set === 'function') {
505
+ // 凭据存进 DSH 凭据库(~/.dsh/.credentials.yaml),不进任何配置文件
506
+ const pairs = [
507
+ ['ZENTAO_BASE_URL', targetBase],
508
+ ['ZENTAO_ACCOUNT', targetAccount],
509
+ ['ZENTAO_PASSWORD', targetPassword],
510
+ ]
511
+ for (const [ref, value] of pairs) {
512
+ try {
513
+ await credentials.set(ref, value)
514
+ saved.push(ref)
515
+ } catch (error) {
516
+ saveErrors.push(`${ref}: ${String(error?.message || error)}`)
517
+ }
518
+ }
519
+ note('login.save', saveErrors.length === 0, saveErrors.length ? saveErrors.join(' | ') : saved.join(','))
520
+ } else {
521
+ saveErrors.push('credentials 服务不可用,本次登录仅在内存生效')
522
+ }
523
+ holder.baseUrl = targetBase
524
+ holder.client = createZentaoSessionClient({
525
+ baseUrl: targetBase,
526
+ timeoutMs: settings.timeoutMs,
527
+ resolveAccount,
528
+ resolvePassword,
529
+ })
530
+ // 账号脱敏:诊断信息会作为工具输出展示给模型
531
+ const masked = targetAccount.length > 2 ? `${targetAccount.slice(0, 2)}***` : '***'
532
+ note('login', true, `baseUrl=${targetBase} account=${masked} 已保存=${saved.length}`)
533
+ return { ok: true, authenticated: true, baseUrl: targetBase, account: targetAccount, saved, saveErrors, ...info }
534
+ }
535
+ if (action === 'logout') {
536
+ if (credentials && typeof credentials.unset === 'function') {
537
+ for (const ref of ['ZENTAO_PASSWORD']) {
538
+ try { await credentials.unset(ref) } catch (error) { note('logout.unset-failed', false, String(error?.message || error)) }
539
+ }
540
+ }
541
+ holder.client = createZentaoSessionClient({
542
+ baseUrl: holder.baseUrl || 'http://127.0.0.1',
543
+ timeoutMs: settings.timeoutMs,
544
+ resolveAccount: async () => '',
545
+ resolvePassword: async () => '',
546
+ })
547
+ note('logout', true)
548
+ return { ok: true, authenticated: false, baseUrl: holder.baseUrl }
549
+ }
550
+ // ── 禅道操作表单(确认/解决/关闭/激活/指派/编辑) ────────────────────────
551
+ if (action === 'bugops') {
552
+ const id = get('id')
553
+ if (!id) return { ok: false, error: '缺少 id 参数' }
554
+ return { ok: true, ...(await holder.client.bugOps({ id })) }
555
+ }
556
+ if (action === 'bugform') {
557
+ const id = get('id')
558
+ const op = get('op')
559
+ if (!id || !op) return { ok: false, error: '缺少 id 或 op' }
560
+ return { ok: true, ...(await holder.client.bugForm({ id, op })) }
561
+ }
562
+ if (action === 'bugaction') {
563
+ if (!body || !body.id || !body.op) return { ok: false, error: '缺少 id 或 op' }
564
+ const result = await holder.client.bugAction({ id: body.id, op: body.op, values: body.values || {} })
565
+ note('bugaction', true, `op=${body.op} id=${body.id}`)
566
+ return { ok: true, baseUrl: holder.baseUrl, result }
567
+ }
568
+ return { ok: false, error: `未知操作:${action}` }
569
+ }
570
+
571
+ const guard = async (action, options) => {
572
+ try {
573
+ return toLosslessJson(await dispatch(action, options))
574
+ } catch (error) {
575
+ // 运行时失败必须留在宿主日志里(诊断只覆盖注册阶段)
576
+ try { ctx.logger?.warn?.(`[zentao-session] action=${action} 失败:${error?.stack || error?.message || error}`) } catch { /* 忽略 */ }
577
+ return { ok: false, baseUrl: holder.baseUrl, error: String(error?.message || error) }
578
+ }
579
+ }
580
+
581
+ // ① RPC 通道(主通道)
582
+ ctx.inject(['connection'], (connCtx) => {
583
+ note('rpc.inject-fired', true)
584
+ try {
585
+ const connection = connCtx?.connection
586
+ || (typeof connCtx?.get === 'function' ? connCtx.get('connection') : undefined)
587
+ const rpc = connection && connection.rpc
588
+ note('rpc.service', !!connection, `connection=${!!connection} rpc=${!!rpc} handle=${typeof rpc?.handle}`)
589
+ if (!rpc || typeof rpc.handle !== 'function') {
590
+ ctx.logger?.warn?.('[zentao-session] connection.rpc 不可用,侧边栏面板将退回 HTTP 接口')
591
+ return
592
+ }
593
+ const handler = async (endpoint, payload) => {
594
+ const body = payload && typeof payload === 'object' ? payload : {}
595
+ // 写操作必须整包传 body:旧写法只认 confirm/comment,会让
596
+ // bugaction/login/logout 被拍平成 query(嵌套 values 变 "[object Object]")。
597
+ const isWrite = WRITE_ACTIONS.has(endpoint)
598
+ return guard(endpoint, { body: isWrite ? body : undefined, query: isWrite ? undefined : new URLSearchParams(body) })
599
+ }
600
+ // 与 HTTP 路由同理:注册必须挂在插件自身的 ctx.effect 上,
601
+ // 不能挂在 inject 回调的派生上下文上(回调返回后可能连同路由一起被回收)。
602
+ if (typeof ctx.effect === 'function') ctx.effect(() => rpc.handle(RPC_CHANNEL, handler), 'dsh-zentao-session: rpc channel')
603
+ else rpc.handle(RPC_CHANNEL, handler)
604
+ note('rpc.registered', true, RPC_CHANNEL)
605
+ ctx.logger?.info?.(`[zentao-session] 已注册 RPC 通道 ${RPC_CHANNEL}`)
606
+ } catch (error) {
607
+ note('rpc.register-failed', false, error?.message || error)
608
+ ctx.logger?.warn?.(`[zentao-session] RPC 通道注册失败(不影响工具):${error?.message || error}`)
609
+ }
610
+ })
611
+
612
+ // ② HTTP 前缀路由(浏览器部署备用)。注意宿主前缀匹配规则是
613
+ // `pathname === prefix || pathname.startsWith(prefix + '/')`,所以 path 不能带尾斜杠。
614
+ ctx.inject(['webServer'], (webCtx) => {
615
+ note('http.inject-fired', true)
616
+ try {
617
+ const webServer = webCtx?.webServer
618
+ || (typeof webCtx?.get === 'function' ? webCtx.get('webServer') : undefined)
619
+ note('http.service', !!webServer, `webServer=${!!webServer} register=${typeof webServer?.register} port=${webServer?.port}`)
620
+ if (!webServer || typeof webServer.register !== 'function') return
621
+
622
+ const WRITE_HEADER = 'x-dsh-zentao-session'
623
+ const readJsonBody = (req) => new Promise((resolve) => {
624
+ let raw = ''
625
+ let done = false
626
+ const finish = (value) => { if (!done) { done = true; resolve(value) } }
627
+ req.on('data', (chunk) => {
628
+ raw += chunk
629
+ // 超限:立刻判定并 settle,否则 destroy 之后 'end' 不会触发,Promise 永久挂起
630
+ if (raw.length > 65536) {
631
+ // 先回 413 再断开:只 destroy 不发响应,调用方只会看到连接被重置
632
+ try {
633
+ res.writeHead(413, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
634
+ res.end(JSON.stringify({ ok: false, error: '请求体过大(上限 64KB)' }))
635
+ } catch { /* 忽略 */ }
636
+ req.destroy()
637
+ finish({})
638
+ }
639
+ })
640
+ req.on('end', () => {
641
+ try { finish(raw ? JSON.parse(raw) : {}) } catch { finish({}) }
642
+ })
643
+ req.on('error', () => finish({}))
644
+ req.on('close', () => finish({}))
645
+ })
646
+
647
+ const handler = async (req, res) => {
648
+ const send = (payload, status = 200) => {
649
+ try {
650
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
651
+ res.end(JSON.stringify(toLosslessJson(payload)))
652
+ } catch (error) {
653
+ ctx.logger?.warn?.(`[zentao-session] 响应写出失败:${error?.message || error}`)
654
+ }
655
+ }
656
+ // 回环栅栏:只接受本机 Host(防 DNS rebinding 把本接口当成可跨站读取的同源资源)
657
+ const host = String(req.headers.host || '').toLowerCase()
658
+ if (host && !/^(127\.0\.0\.1|localhost|\[::1\])(:\d+)?$/.test(host)) {
659
+ send({ ok: false, error: '仅允许本机访问' }, 403)
660
+ return
661
+ }
662
+ if (req.method !== 'GET' && req.method !== 'HEAD' && req.method !== 'POST') {
663
+ send({ ok: false, error: `不支持的方法:${req.method}` }, 405)
664
+ return
665
+ }
666
+ // 所有 action 都要求自定义头(读也一样):这是本插件唯一的访问控制。
667
+ // 自定义头会触发 CORS 预检,跨站页面无法伪造;也避免裸 GET 触发真实登录。
668
+ if (req.headers[WRITE_HEADER] !== '1') {
669
+ send({ ok: false, error: `需要请求头 ${WRITE_HEADER}: 1` }, 403)
670
+ return
671
+ }
672
+ let action = ''
673
+ let query = new URLSearchParams('')
674
+ try {
675
+ const parsed = new URL(req.url || '/', 'http://127.0.0.1')
676
+ action = parsed.pathname.replace(/^\/api\/dsh-zentao-session\/?/, '').replace(/\/+$/, '')
677
+ query = parsed.searchParams
678
+ } catch { /* 保留默认空 action */ }
679
+
680
+ const body = req.method === 'POST' ? await readJsonBody(req) : undefined
681
+ send(await guard(action, { query, body }))
682
+ }
683
+
684
+ const route = { kind: 'prefix', path: '/api/dsh-zentao-session', handler }
685
+ try {
686
+ // 用插件自身的 ctx.effect(而非注入回调里的派生上下文):后者可能在回调返回后随即销毁,
687
+ // 会把刚注册的路由一并回收——这是上一轮「路由不生效」的怀疑根因。
688
+ if (typeof ctx.effect === 'function') ctx.effect(() => webServer.register(route), 'dsh-zentao-session: api routes')
689
+ else webServer.register(route)
690
+ note('http.registered', true, route.path)
691
+ ctx.logger?.info?.('[zentao-session] 已注册 HTTP 前缀路由 /api/dsh-zentao-session')
692
+ } catch (error) {
693
+ note('http.register-failed', false, error?.message || error)
694
+ ctx.logger?.warn?.(`[zentao-session] HTTP 路由注册失败:${error?.message || error}`)
695
+ }
696
+
697
+ // 截图/附件代理:只放行禅道 /file-read-* 与 /file-download-* 路径,且仅回图片/PDF。
698
+ // 面板里的 <img src> 直接用它,绕开跨站 Cookie 限制(本机回环 + 路径白名单)。
699
+ const fileRoute = {
700
+ kind: 'prefix',
701
+ path: '/api/dsh-zentao-session-file',
702
+ handler: async (req, res) => {
703
+ const respond = (status, contentType, payload, extraHeaders = {}) => {
704
+ try {
705
+ res.writeHead(status, {
706
+ 'content-type': contentType,
707
+ // 错误响应不缓存:否则会话恢复后坏图会继续坏 5 分钟
708
+ 'cache-control': status === 200 ? 'private, max-age=300' : 'no-store',
709
+ 'x-content-type-options': 'nosniff',
710
+ ...extraHeaders,
711
+ })
712
+ res.end(payload)
713
+ } catch (error) {
714
+ ctx.logger?.warn?.(`[zentao-session] 文件响应写出失败:${error?.message || error}`)
715
+ }
716
+ }
717
+ // HEAD 会白跑一次完整上游取数,没有意义
718
+ if (req.method !== 'GET') {
719
+ respond(405, 'text/plain; charset=utf-8', 'method not allowed')
720
+ return
721
+ }
722
+ try {
723
+ const parsed = new URL(req.url || '/', 'http://127.0.0.1')
724
+ const file = await holder.client.fetchFile({ path: parsed.searchParams.get('path') || '' })
725
+ // 只放行位图与 PDF:SVG 属于可执行文档,本路由与 DSH 同源,放行等于给出同源脚本执行面
726
+ const allowed = ['image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/bmp', 'application/pdf']
727
+ if (!allowed.includes(file.contentType)) {
728
+ respond(415, 'text/plain; charset=utf-8', 'unsupported media type')
729
+ return
730
+ }
731
+ respond(200, file.contentType, file.buffer)
732
+ } catch (error) {
733
+ // 对外只给通用文案,细节进日志(避免回显上游 URL / 内部错误)
734
+ ctx.logger?.warn?.(`[zentao-session] 取文件失败:${error?.message || error}`)
735
+ respond(404, 'text/plain; charset=utf-8', 'file unavailable')
736
+ }
737
+ },
738
+ }
739
+ try {
740
+ if (typeof ctx.effect === 'function') ctx.effect(() => webServer.register(fileRoute), 'dsh-zentao-session: file route')
741
+ else webServer.register(fileRoute)
742
+ note('http.file-route', true, fileRoute.path)
743
+ } catch (error) {
744
+ note('http.file-route-failed', false, error?.message || error)
745
+ }
746
+ } catch (error) {
747
+ note('http.block-failed', false, error?.message || error)
748
+ ctx.logger?.warn?.(`[zentao-session] HTTP 接口注册失败(不影响工具):${error?.message || error}`)
749
+ }
750
+ })
751
+ } catch (error) {
752
+ // Never let initialization failures abort the profile boot.
753
+ ctx.logger?.warn?.(`[zentao-session] 初始化失败,插件保持休眠:${error?.message || error}`)
754
+ }
755
+ }