dsh-tool-switch 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,719 @@
1
+ // dsh-tool-switch — 会话级工具可见性开关(Host 半侧)
2
+ //
3
+ // 目标:让用户在某个会话里自己决定「这一轮对话中,模型能用哪些工具」。
4
+ //
5
+ // 机制:
6
+ // 1. `ctx.tools.restrict({ allow })` 是 DSH 已有的按 agent 遮罩 API:只有
7
+ // 在 agent 作用域上下文(agent.ctx)里调用才会只影响该 agent。遮罩会从
8
+ // 模型可见的 schema 集合、PTC 绑定与执行集合里同时移除工具,因此隐藏的
9
+ // 工具既不进提示词,也无法被调用(返回 UNKNOWN_TOOL)。
10
+ // 2. 本插件按 sessionId 记住「被关闭的工具名」,在 agent 创建时、以及工具
11
+ // 注册表变化(MCP 服务器晚到、插件热插拔)时重新计算 allow 掩码并重装。
12
+ // 3. 浏览器半侧(lib/client.js)通过本文件注册的 HTTP 路由读取/写入选择;
13
+ // 路由挂在框架自带的 webServer 上,页面与它是同源(桌面外壳会带 cookie
14
+ // 转发),因此不需要额外的鉴权通道。
15
+ //
16
+ // 语义说明:只记「关闭」集合而不是「开启」集合,这样后到的工具默认可用,
17
+ // 新增 MCP 工具不会被悄悄藏起来。
18
+
19
+ import fs from 'node:fs'
20
+ import os from 'node:os'
21
+ import path from 'node:path'
22
+
23
+ export const name = 'dsh-tool-switch'
24
+
25
+ /** Cordis:这两个服务不存在时插件不激活。 */
26
+ export const inject = ['tools', 'agents']
27
+
28
+ /** 浏览器半侧使用的路由(同源,精确匹配)。 */
29
+ export const ROUTE = '/tool-switch/state'
30
+
31
+ /** 请求体上限,避免误传大文件。 */
32
+ const MAX_BODY_BYTES = 64 * 1024
33
+
34
+ /** 工具名长度上限(防止脏状态文件把 restrict 撑爆)。 */
35
+ const MAX_NAME_LENGTH = 200
36
+
37
+ /** 模型可调用的开关工具名;它自己永远不会被关掉,否则唯一的 headless 入口就没了。 */
38
+ const SWITCH_TOOL_NAME = 'tool_switch'
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // 分组规则:先按内置能力分类,MCP 工具按服务器单独成组,认不出来的再按
42
+ // 名字前缀兜底(community 插件通常用 <产物>_<动作> 命名,例如 x_post、
43
+ // comfyui_run),这样 300+ 个工具也不会挤进一个「其他」。
44
+ // ---------------------------------------------------------------------------
45
+
46
+ const CORE_GROUPS = [
47
+ {
48
+ id: 'session',
49
+ label: '会话与计划',
50
+ order: 0,
51
+ test: (name) =>
52
+ /^(todo_write|create_goal|get_goal|update_goal|ask_user_question|present|skill|exit_plan_mode)$/.test(name) ||
53
+ /^schedule_/.test(name),
54
+ },
55
+ { id: 'shell', label: '终端与执行', order: 1, test: (name) => /^(pwsh|bash|run_code|shell|terminal|exec)$/.test(name) || /^job_/.test(name) },
56
+ {
57
+ id: 'fs',
58
+ label: '文件与检索',
59
+ order: 2,
60
+ test: (name) =>
61
+ /^(read|write|edit|glob|grep|read_image|read_animation)$/.test(name) ||
62
+ /^(ls|list_dir|list_files|move|copy|remove|mkdir)/.test(name) ||
63
+ name === 'load_workspace_dependencies',
64
+ },
65
+ { id: 'web', label: '网络检索', order: 3, test: (name) => /^(web_search|web_fetch)$/.test(name) },
66
+ {
67
+ id: 'agent',
68
+ label: '子代理与编排',
69
+ order: 4,
70
+ test: (name) =>
71
+ /^(subagent|subagent_fork|send_message|list_agents|interrupt_agent|workflow)$/.test(name) || /^list_subagent_/.test(name),
72
+ },
73
+ { id: 'office', label: '文档与演示', order: 5, test: (name) => /^(office_|ppt)/.test(name) },
74
+ { id: 'media', label: '图像与音视频', order: 6, test: (name) => /^(comfyui_|image_generate)/.test(name) },
75
+ {
76
+ id: 'mcp-resource',
77
+ label: 'MCP 资源',
78
+ order: 7,
79
+ test: (name) => /^(list_mcp_resource|list_mcp_resources|list_mcp_resource_templates|read_mcp_resource)$/.test(name),
80
+ },
81
+ { id: 'social', label: '社交与发布', order: 8, test: (name) => /^x_(post|delete|search|timeline|thread|status|probe)$/.test(name) },
82
+ {
83
+ id: 'toolchain',
84
+ label: '工具链与维护',
85
+ order: 9,
86
+ test: (name) => /^(purge_|install_local_workbench|plugin_manager|working_directory|gain_qq_tools|ops_)/.test(name),
87
+ },
88
+ ]
89
+
90
+ const MCP_PATTERN = /^mcp__([A-Za-z0-9_.-]+)__/
91
+
92
+ /** 把工具名归到一个分组。 */
93
+ function groupOf(name) {
94
+ const mcp = MCP_PATTERN.exec(name)
95
+ if (mcp !== null) {
96
+ const server = mcp[1]
97
+ return { id: `mcp:${server}`, label: `${server} · MCP`, order: 100, tool: name.slice(mcp[0].length) }
98
+ }
99
+ for (const group of CORE_GROUPS) {
100
+ if (group.test(name)) return { id: group.id, label: group.label, order: group.order, tool: name }
101
+ }
102
+ const underscore = name.indexOf('_')
103
+ if (underscore > 0) {
104
+ const prefix = name.slice(0, underscore)
105
+ return { id: `x:${prefix}`, label: prefix, order: 800, tool: name }
106
+ }
107
+ return { id: 'other', label: '其他', order: 900, tool: name }
108
+ }
109
+
110
+ // ---------------------------------------------------------------------------
111
+ // 选择持久化:~/.dsh-tool-switch/state.json
112
+ // ---------------------------------------------------------------------------
113
+
114
+ function readJson(file, fallback) {
115
+ try {
116
+ return JSON.parse(fs.readFileSync(file, 'utf8'))
117
+ } catch {
118
+ return fallback
119
+ }
120
+ }
121
+
122
+ function sessionIdOf(agent) {
123
+ const sessionId = agent?.session?.id ?? agent?.id
124
+ return typeof sessionId === 'string' ? sessionId : undefined
125
+ }
126
+
127
+ class SelectionStore {
128
+ constructor(file) {
129
+ this.file = file
130
+ const loaded = readJson(file, undefined)
131
+ this.data =
132
+ loaded !== null && typeof loaded === 'object' && typeof loaded.sessions === 'object' && loaded.sessions !== null
133
+ ? loaded
134
+ : { version: 1, sessions: {} }
135
+ if (this.data.version !== 1) this.data.version = 1
136
+ }
137
+
138
+ /** 该会话被关闭的工具名集合。 */
139
+ disabled(sessionId) {
140
+ const entry = this.data.sessions[sessionId]
141
+ const list = Array.isArray(entry?.disabled) ? entry.disabled : []
142
+ const result = new Set()
143
+ for (const name of list) {
144
+ if (typeof name !== 'string') continue
145
+ if (name.length === 0 || name.length > MAX_NAME_LENGTH) continue
146
+ result.add(name)
147
+ }
148
+ return result
149
+ }
150
+
151
+ /** 覆盖该会话的关闭集合;空集合等于「全部启用」。 */
152
+ set(sessionId, disabled) {
153
+ const cleaned = new Set()
154
+ for (const name of disabled) {
155
+ if (typeof name !== 'string') continue
156
+ if (name.length === 0 || name.length > MAX_NAME_LENGTH) continue
157
+ cleaned.add(name)
158
+ }
159
+ const list = [...cleaned].sort()
160
+ if (list.length === 0) delete this.data.sessions[sessionId]
161
+ else this.data.sessions[sessionId] = { disabled: list, updatedAt: new Date().toISOString() }
162
+ this.flush()
163
+ return cleaned
164
+ }
165
+
166
+ flush() {
167
+ try {
168
+ fs.mkdirSync(path.dirname(this.file), { recursive: true })
169
+ fs.writeFileSync(this.file, `${JSON.stringify(this.data, null, 2)}\n`, 'utf8')
170
+ } catch {
171
+ /* 持久化失败不影响本次运行内的效果 */
172
+ }
173
+ }
174
+ }
175
+
176
+ // ---------------------------------------------------------------------------
177
+ // 插件入口
178
+ // ---------------------------------------------------------------------------
179
+
180
+ export function apply(ctx, config) {
181
+ const settings = config ?? {}
182
+ const stateDir = settings.stateDir ? String(settings.stateDir) : path.join(os.homedir(), '.dsh-tool-switch')
183
+ const store = new SelectionStore(path.join(stateDir, 'state.json'))
184
+
185
+ const warn = (message, cause) => {
186
+ try {
187
+ ctx.logger?.warn?.(`[tool-switch] ${message}${cause === undefined ? '' : `: ${String(cause?.message ?? cause)}`}`)
188
+ } catch {
189
+ /* logger 不可用就算了 */
190
+ }
191
+ }
192
+
193
+ /** sessionId -> 活着的 agent */
194
+ const live = new Map()
195
+ /** sessionId -> { signature, dispose } */
196
+ const installed = new Map()
197
+
198
+ function viewOf(agent) {
199
+ try {
200
+ return ctx.tools.view(agent)
201
+ } catch (error) {
202
+ warn('tools.view() failed', error)
203
+ return undefined
204
+ }
205
+ }
206
+
207
+ /** 该 agent 真正可以被 restrict 遮掉的名字(全局继承层的工具)。 */
208
+ function restrictableOf(agent) {
209
+ const view = viewOf(agent)
210
+ const names = view?.restrictableNames
211
+ return names instanceof Set ? names : new Set()
212
+ }
213
+
214
+ /**
215
+ * 重算并重装该会话的遮罩。
216
+ * 只有「关闭集合非空且 allow 掩码变了」才会真的动注册表,避免无谓的
217
+ * tools/change 抖动(每次重装都会让提示词前缀失效一次)。
218
+ */
219
+ function syncSession(sessionId) {
220
+ const agent = live.get(sessionId)
221
+ if (agent === undefined) {
222
+ installed.delete(sessionId)
223
+ return
224
+ }
225
+ const disabled = store.disabled(sessionId)
226
+ const restrictable = restrictableOf(agent)
227
+ const allow = [...restrictable].filter((name) => !disabled.has(name))
228
+ const signature = disabled.size === 0 ? 'all' : `allow:${allow.slice().sort().join('\u0000')}`
229
+ const current = installed.get(sessionId)
230
+ if (current !== undefined && current.signature === signature) return
231
+
232
+ if (current !== undefined) {
233
+ try {
234
+ current.dispose()
235
+ } catch (error) {
236
+ warn(`lifting previous restriction failed (${sessionId})`, error)
237
+ }
238
+ installed.delete(sessionId)
239
+ }
240
+ if (disabled.size === 0) return
241
+
242
+ try {
243
+ // 关键一行:必须走 agent.ctx,遮罩才只作用于这个 agent。
244
+ const dispose = agent.ctx.tools.restrict({ allow })
245
+ installed.set(sessionId, { signature, dispose })
246
+ } catch (error) {
247
+ warn(`restrict() failed (${sessionId})`, error)
248
+ }
249
+ }
250
+
251
+ function track(agent) {
252
+ const sessionId = sessionIdOf(agent)
253
+ if (sessionId === undefined) return
254
+ live.set(sessionId, agent)
255
+ syncSession(sessionId)
256
+ }
257
+
258
+ function forget(agent) {
259
+ const sessionId = sessionIdOf(agent)
260
+ if (sessionId === undefined) return
261
+ live.delete(sessionId)
262
+ installed.delete(sessionId)
263
+ }
264
+
265
+ // --- 生命周期接线 -------------------------------------------------------
266
+
267
+ ctx.on('agent/created', (payload) => {
268
+ const agent = payload?.agent ?? payload
269
+ if (agent === undefined || agent === null) return
270
+ try {
271
+ track(agent)
272
+ } catch (error) {
273
+ warn('tracking a created agent failed', error)
274
+ }
275
+ })
276
+
277
+ ctx.on('agent/disposed', (payload) => {
278
+ const agent = payload?.agent ?? payload
279
+ if (agent === undefined || agent === null) return
280
+ try {
281
+ forget(agent)
282
+ } catch (error) {
283
+ warn('forgetting a disposed agent failed', error)
284
+ }
285
+ })
286
+
287
+ // 工具集合变化(MCP 服务器连接、插件启停)后 allow 掩码可能过期:重算一次。
288
+ let resyncTimer
289
+ ctx.effect(() => () => clearTimeout(resyncTimer), 'tool-switch: resync timer')
290
+
291
+ ctx.on('tools/change', () => {
292
+ clearTimeout(resyncTimer)
293
+ resyncTimer = setTimeout(() => {
294
+ for (const sessionId of [...live.keys()]) {
295
+ try {
296
+ syncSession(sessionId)
297
+ } catch (error) {
298
+ warn(`resync after tools/change failed (${sessionId})`, error)
299
+ }
300
+ }
301
+ }, 250)
302
+ resyncTimer.unref?.()
303
+ })
304
+
305
+ // 插件卸载时把遮罩全部撤掉:插件不在,就不该继续改会话能力。
306
+ ctx.effect(
307
+ () => () => {
308
+ for (const [, record] of installed) {
309
+ try {
310
+ record.dispose()
311
+ } catch {
312
+ /* 关闭路径不抛 */
313
+ }
314
+ }
315
+ installed.clear()
316
+ live.clear()
317
+ },
318
+ 'tool-switch: restrictions',
319
+ )
320
+
321
+ // 插件激活前就已经存在的会话也要接上。
322
+ try {
323
+ const existing = ctx.agents.list?.() ?? []
324
+ for (const agent of existing) track(agent)
325
+ } catch (error) {
326
+ warn('enumerating live agents failed', error)
327
+ }
328
+
329
+ // --- 只读目录 / 读写状态 ------------------------------------------------
330
+
331
+ function descriptionOf(name, fallback) {
332
+ const raw = typeof fallback === 'string' && fallback.length > 0 ? fallback : ''
333
+ const firstLine = raw.split(/\r?\n/, 1)[0]?.trim() ?? ''
334
+ if (firstLine.length === 0) return ''
335
+ return firstLine.length > 160 ? `${firstLine.slice(0, 157)}…` : firstLine
336
+ }
337
+
338
+ function buildState(sessionId) {
339
+ const agent = live.get(sessionId)
340
+ const view = agent === undefined ? undefined : viewOf(agent)
341
+
342
+ const descriptions = new Map()
343
+ let globalNames = []
344
+ try {
345
+ const definitions = ctx.tools.definitions()
346
+ if (Array.isArray(definitions)) {
347
+ globalNames = definitions.map((definition) => definition?.name).filter((entry) => typeof entry === 'string')
348
+ for (const definition of definitions) {
349
+ if (typeof definition?.name === 'string') descriptions.set(definition.name, definition.description ?? '')
350
+ }
351
+ }
352
+ } catch (error) {
353
+ warn('reading the tool catalog failed', error)
354
+ }
355
+
356
+ const knownNames = view?.knownNames instanceof Set ? [...view.knownNames] : globalNames
357
+ const restrictable = view?.restrictableNames instanceof Set ? view.restrictableNames : new Set(globalNames)
358
+ const disabled = store.disabled(sessionId)
359
+
360
+ const groups = new Map()
361
+ let enabledCount = 0
362
+ let fixedCount = 0
363
+ const missing = []
364
+ for (const name of disabled) if (!knownNames.includes(name)) missing.push(name)
365
+
366
+ for (const name of [...knownNames].sort()) {
367
+ const isRestrictable = restrictable.has(name)
368
+ const enabled = !isRestrictable || !disabled.has(name)
369
+ if (enabled) enabledCount += 1
370
+ if (!isRestrictable) fixedCount += 1
371
+ const group = groupOf(name)
372
+ let bucket = groups.get(group.id)
373
+ if (bucket === undefined) {
374
+ bucket = { id: group.id, label: group.label, order: group.order, tools: [] }
375
+ groups.set(group.id, bucket)
376
+ }
377
+ bucket.tools.push({
378
+ name,
379
+ label: group.tool,
380
+ description: descriptionOf(name, descriptions.get(name)),
381
+ enabled,
382
+ locked: !isRestrictable,
383
+ })
384
+ }
385
+
386
+ const ordered = [...groups.values()].sort((left, right) => left.order - right.order || left.label.localeCompare(right.label))
387
+ for (const group of ordered) group.tools.sort((left, right) => left.label.localeCompare(right.label))
388
+
389
+ return {
390
+ ok: true,
391
+ session: sessionId,
392
+ live: agent !== undefined,
393
+ mode: disabled.size === 0 ? 'all' : 'custom',
394
+ disabled: [...disabled].sort(),
395
+ missing,
396
+ counts: { total: knownNames.length, enabled: enabledCount, disabled: knownNames.length - enabledCount, fixed: fixedCount },
397
+ groups: ordered,
398
+ updatedAt: new Date().toISOString(),
399
+ }
400
+ }
401
+
402
+ // --- HTTP 路由 ---------------------------------------------------------
403
+
404
+ function send(res, status, payload) {
405
+ const body = `${JSON.stringify(payload)}\n`
406
+ res.writeHead(status, {
407
+ 'content-type': 'application/json; charset=utf-8',
408
+ 'cache-control': 'no-store',
409
+ 'x-content-type-options': 'nosniff',
410
+ })
411
+ res.end(body)
412
+ }
413
+
414
+ async function readJsonBody(req) {
415
+ const chunks = []
416
+ let size = 0
417
+ for await (const chunk of req) {
418
+ size += chunk.length
419
+ if (size > MAX_BODY_BYTES) throw new Error('请求体过大')
420
+ chunks.push(chunk)
421
+ }
422
+ const text = Buffer.concat(chunks).toString('utf8').trim()
423
+ if (text.length === 0) return {}
424
+ const parsed = JSON.parse(text)
425
+ if (parsed === null || typeof parsed !== 'object') throw new Error('请求体必须是 JSON 对象')
426
+ return parsed
427
+ }
428
+
429
+ const handler = async (req, res) => {
430
+ try {
431
+ const url = new URL(req.url ?? ROUTE, 'http://localhost')
432
+ if (req.method === 'GET' || req.method === 'HEAD') {
433
+ const sessionId = url.searchParams.get('session') ?? ''
434
+ if (sessionId.length === 0) {
435
+ send(res, 400, { ok: false, error: 'missing ?session=<id>' })
436
+ return
437
+ }
438
+ send(res, 200, buildState(sessionId))
439
+ return
440
+ }
441
+ if (req.method !== 'POST') {
442
+ const body = `${JSON.stringify({ ok: false, error: `method not allowed: ${req.method}` })}\n`
443
+ res.writeHead(405, {
444
+ allow: 'GET, HEAD, POST',
445
+ 'content-type': 'application/json; charset=utf-8',
446
+ 'cache-control': 'no-store',
447
+ })
448
+ res.end(body)
449
+ return
450
+ }
451
+
452
+ const body = await readJsonBody(req)
453
+ const sessionId = typeof body.session === 'string' ? body.session : ''
454
+ if (sessionId.length === 0) {
455
+ send(res, 400, { ok: false, error: 'missing session' })
456
+ return
457
+ }
458
+ if (!Array.isArray(body.disabled)) {
459
+ send(res, 400, { ok: false, error: 'missing disabled[]' })
460
+ return
461
+ }
462
+ // 走同一个写入路径:过滤掉「永远不许关」的 tool_switch,再重装遮罩。
463
+ send(res, 200, applySelection(sessionId, body.disabled))
464
+ } catch (error) {
465
+ try {
466
+ send(res, 500, { ok: false, error: String(error?.message ?? error) })
467
+ } catch {
468
+ /* 响应已经开始写了 */
469
+ }
470
+ }
471
+ }
472
+
473
+ ctx.inject(['webServer'], (webCtx) => {
474
+ webCtx.effect(() => webCtx.webServer.register({ kind: 'exact', path: ROUTE, handler }), 'tool-switch: state route')
475
+ try {
476
+ ctx.logger?.info?.(`[tool-switch] ready — ${ROUTE} on ${webCtx.webServer.getOrigin?.() ?? 'webServer'}`)
477
+ } catch {
478
+ /* origin 只在绑定后可用 */
479
+ }
480
+ })
481
+
482
+ // ---------------------------------------------------------------------------
483
+ // 不依赖 UI 的两条通道
484
+ //
485
+ // 桌面版/网页版靠 composer 下那个按钮;但 CLI、QQ 这类入口没有按钮,也没有
486
+ // 一定存在的 webServer。所以同一个选择逻辑再开两条口:
487
+ // · 模型可调用的工具 `tool_switch`(任何入口都通,用户在聊天里说一句就行)
488
+ // · 斜杠命令 `/tools`(有命令适配器的交互式入口,直接给用户用,不进模型上下文)
489
+ // 三者写的是同一份 store、走同一个 syncSession,选哪个都一样生效。
490
+ // ---------------------------------------------------------------------------
491
+
492
+ /** 这个会话现在能看见的工具(按 agent 视图)。 */
493
+ function catalogOf(sessionId) {
494
+ const state = buildState(sessionId)
495
+ const tools = []
496
+ for (const group of state.groups) {
497
+ for (const tool of group.tools) {
498
+ tools.push({ name: tool.name, group: group.id, label: group.label, locked: tool.locked })
499
+ }
500
+ }
501
+ return { state, tools }
502
+ }
503
+
504
+ /**
505
+ * 把用户/模型写的目标解析成工具名集合。
506
+ * 支持:all、分组 id(shell / mcp:rea / x:comfyui)、分组标签(终端与执行 / rea / rea · MCP)、
507
+ * 精确工具名、前缀(mcp__rea__)、通配(mcp__rea__*)。
508
+ */
509
+ function resolveTargets(sessionId, spec) {
510
+ const needle = String(spec ?? '').trim()
511
+ if (needle.length === 0) return { error: '缺少目标' }
512
+ const { tools } = catalogOf(sessionId)
513
+ const restrictable = tools.filter((tool) => !tool.locked)
514
+ if (restrictable.length === 0) return { error: '这个会话没有可开关的工具' }
515
+
516
+ if (/^all$/i.test(needle) || needle === '全部') {
517
+ return { names: restrictable.map((tool) => tool.name), label: '全部可关闭工具' }
518
+ }
519
+
520
+ const byName = restrictable.filter((tool) => tool.name === needle)
521
+ if (byName.length > 0) return { names: byName.map((tool) => tool.name), label: needle }
522
+
523
+ const normalise = (text) => text.toLowerCase().replace(/\s+/g, '').replace(/·/g, '').replace(/mcp$/u, '')
524
+ const wanted = normalise(needle)
525
+ const groups = new Map()
526
+ for (const tool of restrictable) {
527
+ const list = groups.get(tool.group) ?? []
528
+ list.push(tool.name)
529
+ groups.set(tool.group, list)
530
+ }
531
+ const groupIds = [...groups.keys()]
532
+ const hitGroup = groupIds.find((id) => id.toLowerCase() === needle.toLowerCase())
533
+ ?? groupIds.find((id) => normalise(id) === wanted)
534
+ ?? groupIds.find((id) => id.startsWith('mcp:') && normalise(id.slice(4)) === wanted)
535
+ if (hitGroup !== undefined) {
536
+ const label = restrictable.find((tool) => tool.group === hitGroup)?.label ?? hitGroup
537
+ return { names: groups.get(hitGroup), label }
538
+ }
539
+ const hitLabel = [...new Set(restrictable.map((tool) => tool.label))]
540
+ .find((label) => normalise(label) === wanted)
541
+ if (hitLabel !== undefined) {
542
+ return { names: restrictable.filter((tool) => tool.label === hitLabel).map((tool) => tool.name), label: hitLabel }
543
+ }
544
+
545
+ if (needle.includes('*')) {
546
+ const pattern = new RegExp(`^${needle.split('*').map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('.*')}$`)
547
+ const names = restrictable.filter((tool) => pattern.test(tool.name)).map((tool) => tool.name)
548
+ if (names.length > 0) return { names, label: needle }
549
+ return { error: `「${needle}」没有匹配到工具` }
550
+ }
551
+
552
+ const prefixed = restrictable.filter((tool) => tool.name.startsWith(needle)).map((tool) => tool.name)
553
+ if (prefixed.length > 0) return { names: prefixed, label: `${needle}*` }
554
+
555
+ return { error: `「${needle}」没有匹配到工具或分组(用 /tools 看分组名)` }
556
+ }
557
+
558
+ /** 写选择并立刻重装遮罩。`tool_switch` 自己永不被关掉,免得把唯一的入口关了。 */
559
+ function applySelection(sessionId, disabled) {
560
+ const next = new Set()
561
+ for (const name of disabled) if (name !== SWITCH_TOOL_NAME) next.add(name)
562
+ store.set(sessionId, next)
563
+ syncSession(sessionId)
564
+ return buildState(sessionId)
565
+ }
566
+
567
+ function summaryLines(sessionId) {
568
+ const state = buildState(sessionId)
569
+ const lines = []
570
+ for (const group of state.groups) {
571
+ const closeable = group.tools.filter((tool) => !tool.locked)
572
+ const off = closeable.filter((tool) => !tool.enabled).length
573
+ const closed = closeable.length > 0 && off === closeable.length
574
+ lines.push(` ${closed ? '✕' : '·'} ${group.label} ${group.tools.length - off}/${group.tools.length}`)
575
+ }
576
+ return { state, lines }
577
+ }
578
+
579
+ function summaryText(sessionId) {
580
+ const { state, lines } = summaryLines(sessionId)
581
+ return [
582
+ `工具:${state.counts.enabled}/${state.counts.total} 可用${state.mode === 'custom' ? '(有工具被关)' : ''}`,
583
+ ...lines,
584
+ '用法:/tools off <分组|工具|前缀> | /tools on <…> | /tools all on | /tools all off',
585
+ ].join('\n')
586
+ }
587
+
588
+ /** 工具执行时拿到的 agent:优先用 exec 上的,其次用驱动链上的发起者。 */
589
+ function agentOf(exec) {
590
+ if (exec?.agent !== undefined && exec.agent !== null) return exec.agent
591
+ try {
592
+ return ctx.agents.currentInitiator?.()
593
+ } catch {
594
+ return undefined
595
+ }
596
+ }
597
+
598
+ /**
599
+ * 执行一个开关动作。action: list | off | on | reset。
600
+ * 返回给命令/工具的文本与状态。
601
+ */
602
+ function runAction(sessionId, action, target) {
603
+ const verb = String(action ?? '').trim().toLowerCase()
604
+ if (verb === '' || verb === 'list') return { ok: true, state: buildState(sessionId), text: summaryText(sessionId) }
605
+
606
+ const disabled = store.disabled(sessionId)
607
+
608
+ if (verb === 'reset') {
609
+ const state = applySelection(sessionId, [])
610
+ return { ok: true, state, text: `已恢复全部启用(${state.counts.total}/${state.counts.total})` }
611
+ }
612
+ if (verb !== 'off' && verb !== 'on') return { ok: false, text: `不认识的动作「${action}」(用 list/off/on/reset)` }
613
+
614
+ const specs = String(target ?? '').trim().split(/[\s,]+/).filter((part) => part.length > 0)
615
+ if (specs.length === 0) return { ok: false, text: `要 ${verb} 什么?例如 /tools ${verb} rea` }
616
+
617
+ const applied = []
618
+ for (const spec of specs) {
619
+ const resolved = resolveTargets(sessionId, spec)
620
+ if (resolved.error !== undefined) return { ok: false, text: resolved.error }
621
+ for (const name of resolved.names) {
622
+ if (verb === 'off') disabled.add(name)
623
+ else disabled.delete(name)
624
+ }
625
+ applied.push(`${resolved.label}(${resolved.names.length})`)
626
+ }
627
+
628
+ const state = applySelection(sessionId, disabled)
629
+ const now = verb === 'off' ? '已关闭' : '已启用'
630
+ return {
631
+ ok: true,
632
+ state,
633
+ text: `${now}:${applied.join('、')} → 现在 ${state.counts.enabled}/${state.counts.total} 可用(下一条消息生效)`,
634
+ }
635
+ }
636
+
637
+ // --- 斜杠命令(有命令适配器的入口:CLI / 网页 composer) -------------------
638
+ ctx.inject(['commands'], (commandCtx) => {
639
+ commandCtx.effect(
640
+ () =>
641
+ commandCtx.commands.register({
642
+ name: 'tools',
643
+ description: '查看或开关本会话里模型可用的工具',
644
+ input: { hint: '[off|on] <分组|工具名|前缀> | all on|all off | reset' },
645
+ handler: ({ agent, rawInput }) => {
646
+ const sessionId = sessionIdOf(agent)
647
+ if (sessionId === undefined) return { kind: 'error', text: '拿不到会话 id' }
648
+ const input = String(rawInput ?? '').trim()
649
+ // `all on` / `all off` 这种「动词在前」的写法也认
650
+ const allFirst = /^(all|全部)\s+(on|off|启用|关闭)$/i.exec(input)
651
+ const [word, ...rest] = input.split(/\s+/)
652
+ const result = allFirst !== null
653
+ ? runAction(sessionId, allFirst[2].toLowerCase().replace('启用', 'on').replace('关闭', 'off'), 'all')
654
+ : runAction(sessionId, word === undefined ? 'list' : word, rest.join(' '))
655
+ return { kind: result.ok ? 'success' : 'error', text: result.text }
656
+ },
657
+ }),
658
+ 'tool-switch: /tools command',
659
+ )
660
+ })
661
+
662
+ // --- 模型可调用的工具(任何入口都通,包括 QQ / headless) -------------------
663
+ try {
664
+ const definition = {
665
+ name: SWITCH_TOOL_NAME,
666
+ description:
667
+ '查看或开关当前会话里模型可用的工具。用户说「把 XX 类工具关掉/打开」时用它;关掉的工具会从工具清单里消失,下一条消息生效。',
668
+ parameters: {
669
+ type: 'object',
670
+ properties: {
671
+ action: {
672
+ type: 'string',
673
+ enum: ['list', 'off', 'on', 'reset'],
674
+ description: 'list 只看;off 关闭;on 启用;reset 全部启用',
675
+ },
676
+ target: {
677
+ type: 'string',
678
+ description: '分组名(如 rea、终端与执行、mcp:rea)、精确工具名、前缀(mcp__rea__)或 all;list/reset 可省略',
679
+ },
680
+ },
681
+ required: ['action'],
682
+ additionalProperties: false,
683
+ },
684
+ output: {
685
+ schema: {
686
+ type: 'object',
687
+ properties: {
688
+ ok: { type: 'boolean' },
689
+ action: { type: 'string' },
690
+ enabled: { type: 'number' },
691
+ total: { type: 'number' },
692
+ message: { type: 'string' },
693
+ },
694
+ required: ['ok', 'message'],
695
+ additionalProperties: false,
696
+ },
697
+ render: (_args, value) => [{ type: 'text', text: String(value?.message ?? '') }],
698
+ },
699
+ async execute(args, exec) {
700
+ const sessionId = sessionIdOf(agentOf(exec) ?? {})
701
+ if (sessionId === undefined) {
702
+ return { ok: false, action: String(args?.action ?? ''), message: '拿不到当前会话,无法改工具开关' }
703
+ }
704
+ const result = runAction(sessionId, args?.action, args?.target)
705
+ return {
706
+ ok: result.ok,
707
+ action: String(args?.action ?? ''),
708
+ enabled: result.state?.counts?.enabled ?? 0,
709
+ total: result.state?.counts?.total ?? 0,
710
+ message: result.text,
711
+ }
712
+ },
713
+ }
714
+ ctx.effect(() => ctx.tools.register(definition), 'tool-switch: tool_switch tool')
715
+ } catch (error) {
716
+ warn('registering tool_switch failed', error)
717
+ }
718
+ }
719
+