@lvy-jsd/dsh-tool-manager 0.0.0-stage → 0.4.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.
Files changed (70) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +302 -2
  3. package/cordis.patch.yml +29 -0
  4. package/lib/catalog.d.ts +35 -0
  5. package/lib/catalog.d.ts.map +1 -0
  6. package/lib/catalog.js +311 -0
  7. package/lib/catalog.js.map +1 -0
  8. package/lib/exempt.d.ts +45 -0
  9. package/lib/exempt.d.ts.map +1 -0
  10. package/lib/exempt.js +122 -0
  11. package/lib/exempt.js.map +1 -0
  12. package/lib/index.d.ts +24 -0
  13. package/lib/index.d.ts.map +1 -0
  14. package/lib/index.js +797 -0
  15. package/lib/index.js.map +1 -0
  16. package/lib/manifest.d.ts +21 -0
  17. package/lib/manifest.d.ts.map +1 -0
  18. package/lib/manifest.js +112 -0
  19. package/lib/manifest.js.map +1 -0
  20. package/lib/metadata.d.ts +38 -0
  21. package/lib/metadata.d.ts.map +1 -0
  22. package/lib/metadata.js +102 -0
  23. package/lib/metadata.js.map +1 -0
  24. package/lib/router/bm25.d.ts +21 -0
  25. package/lib/router/bm25.d.ts.map +1 -0
  26. package/lib/router/bm25.js +63 -0
  27. package/lib/router/bm25.js.map +1 -0
  28. package/lib/router/index.d.ts +9 -0
  29. package/lib/router/index.d.ts.map +1 -0
  30. package/lib/router/index.js +422 -0
  31. package/lib/router/index.js.map +1 -0
  32. package/lib/router/sidecar.d.ts +34 -0
  33. package/lib/router/sidecar.d.ts.map +1 -0
  34. package/lib/router/sidecar.js +156 -0
  35. package/lib/router/sidecar.js.map +1 -0
  36. package/lib/router/types.d.ts +164 -0
  37. package/lib/router/types.d.ts.map +1 -0
  38. package/lib/router/types.js +7 -0
  39. package/lib/router/types.js.map +1 -0
  40. package/lib/service.d.ts +51 -0
  41. package/lib/service.d.ts.map +1 -0
  42. package/lib/service.js +199 -0
  43. package/lib/service.js.map +1 -0
  44. package/lib/settings.d.ts +32 -0
  45. package/lib/settings.d.ts.map +1 -0
  46. package/lib/settings.js +93 -0
  47. package/lib/settings.js.map +1 -0
  48. package/lib/types.d.ts +453 -0
  49. package/lib/types.d.ts.map +1 -0
  50. package/lib/types.js +7 -0
  51. package/lib/types.js.map +1 -0
  52. package/lib/visibility.d.ts +33 -0
  53. package/lib/visibility.d.ts.map +1 -0
  54. package/lib/visibility.js +305 -0
  55. package/lib/visibility.js.map +1 -0
  56. package/package.json +54 -4
  57. package/sidecar/bin/tool-router-sidecar +0 -0
  58. package/src/catalog.ts +305 -0
  59. package/src/exempt.ts +148 -0
  60. package/src/index.ts +805 -0
  61. package/src/manifest.ts +116 -0
  62. package/src/metadata.ts +123 -0
  63. package/src/router/bm25.ts +68 -0
  64. package/src/router/index.ts +440 -0
  65. package/src/router/sidecar.ts +159 -0
  66. package/src/router/types.ts +149 -0
  67. package/src/service.ts +207 -0
  68. package/src/settings.ts +108 -0
  69. package/src/types.ts +450 -0
  70. package/src/visibility.ts +317 -0
package/src/index.ts ADDED
@@ -0,0 +1,805 @@
1
+ // dsh-tool-manager: browse every DSH tool as a directory tree
2
+ // (category -> group -> tool) and load tools on demand per agent.
3
+ //
4
+ // Loaded tools are native: the model receives their full parameter schema.
5
+ // Visibility is enforced with the host's per-agent `tools.restrict({ allow })`.
6
+ //
7
+ // The core ships no placement rules: without a classifier plugin every group
8
+ // is `uncategorized`. Placement, metadata, hooks and extra visibility narrowing
9
+ // are provided by other plugins through the `toolCatalog` service.
10
+
11
+ import { defineTool } from '@deepseek-ai/dsh-tools'
12
+ import { buildCatalog, resolveTarget, toolsUnder, UNCATEGORIZED } from './catalog.js'
13
+ import { createMetadataStore, defaultMetadataFile } from './metadata.js'
14
+ import { createExemptStore, defaultExemptFile } from './exempt.js'
15
+ import { defaultManifestFile, syncManifest } from './manifest.js'
16
+ import { createSettingsStore, defaultSettingsFile } from './settings.js'
17
+ import { createVisibilityController } from './visibility.js'
18
+ import { createRegistries, readManifestAnnotations, listPluginPackages } from './service.js'
19
+ import { registerRouter } from './router/index.js'
20
+ import type { Agent, Annotation, Catalog, CatalogNode, CoreConfig, Disposer, HostContext, Logger, RiskGate, SerializedState, ToolCatalogService, ToolEntry, ToolMeta, ToolSchema } from './types.js'
21
+
22
+ export type * from './types.js'
23
+ export { buildCatalog, resolveTarget, toolsUnder, estimateTokens, summary, shortName, structuralGroup, DEFAULT_CATEGORIES, UNCATEGORIZED, MISC_GROUP, OVERRIDE_PRIORITY } from './catalog.js'
24
+ export type { BuildOptions } from './catalog.js'
25
+ export type { RegisteredClassifier, RegisteredFilter } from './service.js'
26
+ export type { VisibilityController, VisibilityOptions } from './visibility.js'
27
+
28
+ export const name = 'tool-manager'
29
+ export const inject = ['tools']
30
+
31
+ export const ENTRY_TOOLS = ['tools_ls', 'tools_load', 'tools_unload']
32
+
33
+ // Host transports that are not capabilities: never listed, loaded or restricted.
34
+ // `run_code` carries every call in DSH's PTC mode; the host refuses to restrict
35
+ // it and adds it to the view after all restrictions, so treating it as a hidden
36
+ // tool would block PTC mode outright.
37
+ export const HOST_RESERVED = ['run_code']
38
+
39
+ // Always visible and never listed in the directory. Names the host does not
40
+ // have are ignored, so this list survives DSH renames. Config `pinned` adds to
41
+ // it; config `unpin` removes from it.
42
+ export const DEFAULT_PINNED = [
43
+ // DSH core
44
+ 'bash', 'read', 'write', 'edit', 'str_replace_editor', 'glob', 'grep', 'read_image',
45
+ 'ask_user_question', 'ask_user', 'exit_plan_mode', 'workflow', 'todo_write', 'present', 'skill', 'plan',
46
+ 'web_fetch', 'web_search', 'job_list', 'job_output', 'job_kill', 'create_goal', 'get_goal', 'update_goal',
47
+ // delegation
48
+ 'subagent', 'subagent_fork', 'list_subagent_models', 'wait_agent', 'interrupt_agent', 'list_agents',
49
+ // memory lookup is small and frequent
50
+ 'recall',
51
+ // team chat: a Bot's group prompt tells it to answer through these, so they
52
+ // must never sit behind tools_load
53
+ 'group_relay', 'post_to_group', 'read_group_chat', 'read_own_chat', 'message_bot', 'list_bots',
54
+ ]
55
+
56
+ const TEXT_OUTPUT = {
57
+ schema: { type: 'string' },
58
+ render: (_args: unknown, value: string) => [{ type: 'text' as const, text: String(value) }],
59
+ } as const
60
+
61
+ function fmtTokens(n: number): string {
62
+ return n >= 1000 ? `~${(n / 1000).toFixed(1)}k tok` : `~${n} tok`
63
+ }
64
+
65
+ function pad(text: string, width: number): string {
66
+ return text.length >= width ? `${text} ` : text + ' '.repeat(width - text.length)
67
+ }
68
+
69
+ export function apply(ctx: HostContext, config: CoreConfig = {}) {
70
+ const logger: Logger = (typeof ctx.logger === 'function' ? ctx.logger('tool-manager') : ctx.logger) ?? console
71
+ // Values a UI may edit live in their own file, which wins per key over the
72
+ // plugin config: an operator editing cordis.patch.yml must not have a stale
73
+ // panel overwrite it on the next click, and a panel edit must not be undone
74
+ // by the next restart.
75
+ const settings = createSettingsStore({
76
+ file: config.settingsFile ?? defaultSettingsFile(),
77
+ base: { pinned: config.pinned, unpin: config.unpin, highRisk: config.highRisk },
78
+ logger,
79
+ })
80
+ // Panel-edited tool metadata (group, category, risk, summary). Applied after
81
+ // every other annotation, so an edit corrects a package declaration instead of
82
+ // being overruled by it.
83
+ const metadata = createMetadataStore({ file: config.metadataFile ?? defaultMetadataFile(), logger })
84
+ // Model routes whose tool list is handed to an external client once per
85
+ // session and never refreshed (Claude Code receives DSH tools through an MCP
86
+ // bridge at startup). Narrowing such a session hides tools it can then never
87
+ // load, and the client already lazy-loads schemas itself (ToolSearch), so
88
+ // these agents are left unrestricted.
89
+ //
90
+ // The list in force comes from the exemption file when it exists, else from
91
+ // config `exemptProviders` (default ["claude-code"]; [] restricts every
92
+ // route). A file entry can also name a single session id, so one session can
93
+ // be exempted without touching the route it uses.
94
+ const exemptions = createExemptStore({
95
+ file: config.exemptFile ?? defaultExemptFile(),
96
+ configProviders: config.exemptProviders ?? ['claude-code'],
97
+ logger,
98
+ })
99
+
100
+ /** Session id used to match a per-session exemption, with the agent id as fallback. */
101
+ function sessionIdOf(agent: Agent): string | undefined {
102
+ const id = agent?.session?.id ?? agent?.id
103
+ return id === undefined || id === null || id === '' ? undefined : String(id)
104
+ }
105
+
106
+ /** Why this agent is not narrowed, if it is not. */
107
+ function exemptionOf(agent: Agent) {
108
+ return exemptions.reasonFor(providerOf(agent), sessionIdOf(agent))
109
+ }
110
+
111
+ /** The route an agent's next request will use: latest model/selection, else its options. */
112
+ const selectionScan = new WeakMap<object, { seq: number, provider: string | undefined }>() // session -> incremental scan
113
+ function providerOf(agent: Agent): string | undefined {
114
+ const session = agent?.session
115
+ try {
116
+ if (session && typeof session.eventAt === 'function' && Number.isInteger(session.seq)) {
117
+ const memo = selectionScan.get(session) ?? { seq: 0, provider: undefined }
118
+ for (let seq = memo.seq; seq < session.seq!; seq += 1) {
119
+ const event = session.eventAt(seq)
120
+ if (event?.type === 'model/selection' && event.data?.provider) memo.provider = event.data.provider
121
+ }
122
+ memo.seq = session.seq!
123
+ selectionScan.set(session, memo)
124
+ if (memo.provider) return memo.provider
125
+ }
126
+ } catch {
127
+ // fall back to the agent's options
128
+ }
129
+ return agent?.options?.provider ?? session?.requestHeader?.()?.provider
130
+ }
131
+
132
+ // High-risk tools can be loaded individually only while at least one risk
133
+ // gate (e.g. dsh-tool-manager-risk-gate) is registered; the gate confirms the
134
+ // load before it executes. With no gate, high-risk loads are refused.
135
+ const riskGates = new Set<{ name: string }>()
136
+
137
+ const pinnedSet = () => {
138
+ const current = settings.effective()
139
+ const set = new Set([...DEFAULT_PINNED, ...current.pinned])
140
+ for (const n of current.unpin) set.delete(n)
141
+ for (const n of ENTRY_TOOLS) set.add(n)
142
+ return set
143
+ }
144
+
145
+ /**
146
+ * Configuration as one object for UIs. `extraPinned`/`unpinned` show the
147
+ * settings file, so the panel edits exactly what it displays; `pinned` is the
148
+ * resulting always-on set, defaults and entry tools included.
149
+ */
150
+ function configSnapshot() {
151
+ const current = settings.effective()
152
+ return {
153
+ pinned: [...pinnedSet()],
154
+ entryTools: [...ENTRY_TOOLS],
155
+ extraPinned: [...current.pinned],
156
+ unpinned: [...current.unpin],
157
+ highRisk: [...current.highRisk],
158
+ exemptProviders: [...exemptions.effective().providers],
159
+ exemptSessions: [...exemptions.effective().sessions],
160
+ exemptFile: exemptions.file,
161
+ hasExemptFile: exemptions.present(),
162
+ defaults: [...DEFAULT_PINNED],
163
+ settingsFile: settings.file,
164
+ // An empty file is not an override, so count keys rather than existence.
165
+ hasSettings: Object.keys(settings.stored()).length > 0,
166
+ }
167
+ }
168
+
169
+ /**
170
+ * One tool's metadata plus every layer behind it, so the panel can show what
171
+ * is declared, what was edited, and what the model actually gets. A tool that
172
+ * is pinned away or not registered is reported as missing rather than absent,
173
+ * so the UI can explain why there is nothing to edit.
174
+ */
175
+ function toolMetaSnapshot(name: string) {
176
+ const entry = getCatalog().tools.get(name)
177
+ const declared: ToolMeta = { ...annotationForLayers(name) }
178
+ return {
179
+ name,
180
+ found: entry !== undefined,
181
+ effective: entry?.annotation ?? declared,
182
+ edited: metadata.get(name) ?? {},
183
+ declared,
184
+ group: entry?.group ?? declared.group ?? '',
185
+ category: entry?.category ?? declared.category ?? '',
186
+ summary: entry?.summary ?? declared.summary ?? '',
187
+ highRisk: entry?.highRisk === true,
188
+ path: entry ? `/${entry.category}/${entry.group}/${entry.short}` : '',
189
+ missing: entry === undefined,
190
+ }
191
+ }
192
+
193
+ /**
194
+ * The declared metadata only: package.json first, runtime annotate() after,
195
+ * with panel edits left out so the UI can tell the two apart.
196
+ */
197
+ function annotationForLayers(name: string): ToolMeta {
198
+ manifestAnnotations ??= readManifestAnnotations(ctx, logger)
199
+ const merged: ToolMeta = {}
200
+ for (const annotation of [...manifestAnnotations, ...registries.annotations]) {
201
+ const hit = typeof annotation.match === 'string' ? annotation.match === name : annotation.match.test(name)
202
+ if (hit) Object.assign(merged, annotation.meta)
203
+ }
204
+ return merged
205
+ }
206
+
207
+ // --- catalog -----------------------------------------------------------------
208
+ // The directory is built per agent: global tools plus tools registered in the
209
+ // agent's ancestor scopes (its preset). In the DSH web profile most tools
210
+ // live in a preset layer, so a global-only catalog would hide them without
211
+ // listing them. Agents with the same tool set share one cached catalog.
212
+ const catalogs = new Map<string, Catalog>() // key (sorted names) -> catalog
213
+ let manifestAnnotations: Annotation[] | null = null
214
+ const invalidate = () => { catalogs.clear() }
215
+ const registries = createRegistries(() => {
216
+ invalidate()
217
+ visibility?.refresh()
218
+ })
219
+
220
+ function schemaOf(definition: { description?: string, parameters?: unknown }, name: string): ToolSchema {
221
+ try {
222
+ if (typeof ctx.tools.schemaOf === 'function') return ctx.tools.schemaOf(definition, false)
223
+ } catch {
224
+ // fall through to the public fields
225
+ }
226
+ return { name, description: definition.description ?? '', parameters: definition.parameters ?? {} }
227
+ }
228
+
229
+ /** Schemas of tools an agent inherits from its ancestor (preset) layers. */
230
+ function inheritedSchemas(agent: Agent | undefined): ToolSchema[] {
231
+ const layers = ctx.tools.layers
232
+ if (!agent || typeof layers?.chainLayers !== 'function') return []
233
+ const own = layers.peek?.(agent)
234
+ const out: ToolSchema[] = []
235
+ for (const layer of layers.chainLayers(agent)) {
236
+ if (layer === own) continue
237
+ for (const [name, definition] of layer.tools.entries()) out.push(schemaOf(definition, name))
238
+ }
239
+ return out
240
+ }
241
+
242
+ /** Catalog for one agent, or for the union of all live agents when omitted. */
243
+ const getCatalog = (agent?: Agent): Catalog => {
244
+ const pinned = pinnedSet()
245
+ const byName = new Map<string, ToolSchema>(ctx.tools.schemas().map((s) => [s.name, s]))
246
+ for (const a of agent ? [agent] : visibility.agents()) {
247
+ for (const s of inheritedSchemas(a)) if (!byName.has(s.name)) byName.set(s.name, s)
248
+ }
249
+ const schemas = [...byName.values()].filter((s) => !pinned.has(s.name) && !HOST_RESERVED.includes(s.name))
250
+ const key = schemas.map((s) => s.name).sort().join('\n')
251
+ const cached = catalogs.get(key)
252
+ if (cached) return cached
253
+ ctx.emit('tool-catalog/before-rebuild', {})
254
+ manifestAnnotations ??= readManifestAnnotations(ctx, logger)
255
+ const catalog = buildCatalog(schemas, {
256
+ classifiers: [...registries.classifiers],
257
+ // Package declarations first, runtime annotate() after, so runtime wins —
258
+ // and panel edits last of all, so they win over both.
259
+ annotations: [...manifestAnnotations, ...registries.annotations, ...metadata.annotations()],
260
+ categories: Object.fromEntries(registries.categories),
261
+ highRisk: settings.effective().highRisk,
262
+ })
263
+ catalogs.set(key, catalog)
264
+ // A client that receives its tool list once per session cannot discover the
265
+ // directory by asking, so it gets the index as a file instead. Written here,
266
+ // after every rebuild, so it cannot drift from what tools_load would answer.
267
+ // An empty path turns it off.
268
+ const manifestFile = config.manifestFile ?? defaultManifestFile()
269
+ if (manifestFile) syncManifest(catalog, manifestFile, logger)
270
+ ctx.emit('tool-catalog/after-rebuild', { catalog })
271
+ return catalog
272
+ }
273
+
274
+ const visibility = createVisibilityController(ctx, {
275
+ pinned: () => [...pinnedSet()],
276
+ entryTools: ENTRY_TOOLS,
277
+ getFilters: () => [...registries.filters],
278
+ getCatalog,
279
+ // The catalog is rebuilt lazily after any registry change.
280
+ invalidate,
281
+ onStateChange: (agent, state) => ctx.emit('tool-catalog/state-changed', { agent, state }),
282
+ isExempt: (agent) => exemptionOf(agent).exempt,
283
+ logger,
284
+ })
285
+
286
+ if (typeof ctx.inject === 'function') {
287
+ ctx.inject(['agents'], (agentCtx) => {
288
+ agentCtx.effect(() => visibility.mount(agentCtx), 'tool-manager.visibility()')
289
+ })
290
+ } else {
291
+ logger.warn?.('tool-manager: host has no ctx.inject; per-agent visibility is disabled')
292
+ }
293
+ ctx.effect(() => () => visibility.dispose(), 'tool-manager.dispose()')
294
+
295
+ // Rescue a direct call of a hidden tool (see rescueHiddenCall). Runs before
296
+ // dispatch resolves the name, so loading here makes the call succeed.
297
+ ctx.effect(() => ctx.on('tools/pre-execute', async (exec: { agent?: Agent, name: string } | undefined, next: () => unknown) => {
298
+ const agent = exec?.agent
299
+ if (!agent || pinnedSet().has(exec!.name) || ENTRY_TOOLS.includes(exec!.name)) return next()
300
+ const refusal = await rescueHiddenCall(agent, exec!.name)
301
+ if (refusal !== undefined) return { kind: 'deny', reason: refusal }
302
+ return next()
303
+ }), 'tool-manager.rescue-hidden-call()')
304
+
305
+ // --- the toolCatalog service ---------------------------------------------------
306
+ const service = {
307
+ registerClassifier: registries.registerClassifier,
308
+ annotate: registries.annotate,
309
+ defineCategory: registries.defineCategory,
310
+ registerVisibilityFilter: registries.registerVisibilityFilter,
311
+ /**
312
+ * Own a target prefix (`"cap:"`) in tools_load / tools_ls / tools_unload.
313
+ * `resolve(target)` returns one or more ordinary targets to resolve
314
+ * instead, or undefined to pass. Returns a disposer.
315
+ */
316
+ registerTargetResolver: registries.registerTargetResolver,
317
+ /**
318
+ * Let loaded state outlive the process (DSH restart). `load(agent)` returns
319
+ * a serialized state or null, `save(agent, state)` receives plain JSON.
320
+ * Returns a disposer. See dsh-tool-manager-persist.
321
+ */
322
+ registerStateProvider: visibility.registerStateProvider,
323
+ /**
324
+ * Declare that a plugin confirms high-risk loads (normally by returning
325
+ * {kind:'ask'} from tools/pre-execute for tools_load). Returns a disposer.
326
+ */
327
+ registerRiskGate(gate: RiskGate = {}): Disposer {
328
+ const entry = { name: gate.name ?? 'anonymous' }
329
+ riskGates.add(entry)
330
+ return () => { riskGates.delete(entry) }
331
+ },
332
+ riskGates: () => [...riskGates].map((g) => g.name),
333
+ /** Re-read package declarations and rebuild. */
334
+ refresh() {
335
+ manifestAnnotations = null
336
+ invalidate()
337
+ visibility.refresh()
338
+ },
339
+ /** Catalog as one agent sees it, or the union over live agents. */
340
+ get: (agent?: Agent) => getCatalog(agent),
341
+ resolve: (target: string, agent?: Agent) => resolveTarget(getCatalog(agent), target, [...registries.resolvers]),
342
+ /** Loaded state of one agent: { loaded, groups, excluded, handoffGroups, handoffTools } (Sets). */
343
+ stateOf: (agent: Agent) => visibility.stateOf(agent),
344
+ /** Plain JSON of an agent's loaded state, for persistence plugins. */
345
+ exportState: (agent: Agent) => visibility.exportState(agent),
346
+ /** Replace an agent's loaded state (e.g. restored after a restart). */
347
+ importState: (agent: Agent, state: Partial<SerializedState>) => visibility.importState(agent, state),
348
+ allowFor: (agent: Agent) => visibility.allowFor(agent),
349
+ deniedFor: (agent: Agent) => [...deniedFor(agent)],
350
+ /** True when the agent's model route is in `exemptProviders` (never narrowed). */
351
+ isExempt: (agent: Agent) => visibility.isExempt(agent),
352
+ /** Why an agent is exempt, so the panel can label a route-wide exemption. */
353
+ exemptionOf: (agent: Agent) => exemptionOf(agent),
354
+ /**
355
+ * Load/unload on behalf of an agent, through the same path as the entry
356
+ * tools (hooks, vetoes, risk gates and persistence all apply). Returns the
357
+ * same text the model would see. Intended for UI plugins such as
358
+ * dsh-tool-manager-panel.
359
+ */
360
+ load: (agent: Agent, targets: readonly string[], options?: LoadOptions) => loadTargets(agent, targets, options),
361
+ unload: (agent: Agent, targets: readonly string[]) => unloadTargets(agent, targets),
362
+ /** Live agents, each with its parent when it is a subagent. */
363
+ agents: () => {
364
+ // The parent lookup goes through the injected agents API: the plugin ctx
365
+ // itself does not inject `agents`, and reading it there throws.
366
+ return visibility.agents().map((agent) => {
367
+ const parent = visibility.parentOf(agent)
368
+ return { agent, id: String(agent.id), parentId: parent ? String(parent.id) : null }
369
+ })
370
+ },
371
+ /** Effective configuration, for UI display and editing. */
372
+ config: () => configSnapshot(),
373
+ /**
374
+ * Persist UI-edited settings and apply them at once. The catalog is rebuilt
375
+ * because a changed always-on or high-risk list changes every directory.
376
+ */
377
+ updateConfig(patch) {
378
+ settings.update(patch)
379
+ invalidate()
380
+ visibility.refresh()
381
+ ctx.emit('tool-catalog/settings-changed', { config: configSnapshot() })
382
+ return configSnapshot()
383
+ },
384
+ /** Re-read the settings file, for after an external edit. */
385
+ reloadConfig() {
386
+ settings.reload()
387
+ invalidate()
388
+ visibility.refresh()
389
+ ctx.emit('tool-catalog/settings-changed', { config: configSnapshot() })
390
+ return configSnapshot()
391
+ },
392
+ /**
393
+ * Exempt one session from narrowing, or stop exempting it. Written to the
394
+ * exemption file, so the choice survives a restart and is reversible by
395
+ * editing or deleting that one file.
396
+ */
397
+ setExemptSession(sessionId: string, exempt: boolean) {
398
+ exemptions.setSession(String(sessionId), exempt)
399
+ // An exemption installs or lifts a restriction, so re-apply it now.
400
+ visibility.refresh()
401
+ ctx.emit('tool-catalog/settings-changed', { config: configSnapshot() })
402
+ return configSnapshot()
403
+ },
404
+ /** Re-read the exemption file, for after an external edit. */
405
+ reloadExempt() {
406
+ exemptions.reload()
407
+ visibility.refresh()
408
+ ctx.emit('tool-catalog/settings-changed', { config: configSnapshot() })
409
+ return configSnapshot()
410
+ },
411
+ /** Metadata of one tool, with the layers that decide each field. */
412
+ toolMeta: (name: string) => toolMetaSnapshot(String(name)),
413
+ /**
414
+ * Edit metadata from the panel. The store wins over the package
415
+ * declaration, and the catalog is rebuilt so the Directory tab moves the
416
+ * tool at once.
417
+ */
418
+ updateToolMeta(name, meta) {
419
+ metadata.set(String(name), meta)
420
+ invalidate()
421
+ visibility.refresh()
422
+ ctx.emit('tool-catalog/metadata-changed', { name: String(name) })
423
+ return toolMetaSnapshot(String(name))
424
+ },
425
+ /** Drop every edited field of one tool, falling back to its declaration. */
426
+ clearToolMeta(name) {
427
+ metadata.clear(String(name))
428
+ invalidate()
429
+ visibility.refresh()
430
+ ctx.emit('tool-catalog/metadata-changed', { name: String(name) })
431
+ return toolMetaSnapshot(String(name))
432
+ },
433
+ metadataFile: () => metadata.file,
434
+ /** Enabled plugin packages on this host: `{ name, dir, manifest }[]`. */
435
+ packages: () => listPluginPackages(ctx),
436
+ /** Registered extension points, so a UI can show what is active. */
437
+ extensions: () => ({
438
+ classifiers: [...registries.classifiers].map((c) => ({ name: c.name, priority: c.priority ?? 0 })),
439
+ filters: [...registries.filters].map((f) => f.name),
440
+ annotations: registries.annotations.map((a) => ({ source: a.source, match: String(a.match) })),
441
+ categories: [...registries.categories.keys()],
442
+ riskGates: [...riskGates].map((g) => g.name),
443
+ stateProviders: visibility.stateProviders(),
444
+ }),
445
+ /**
446
+ * Structured cross-directory search for UIs: the same matching rules as
447
+ * tools_ls(query), returned as data instead of text.
448
+ */
449
+ find: (query: string, path = '/') => {
450
+ const cat = getCatalog()
451
+ const node = resolveTarget(cat, path, [...registries.resolvers])
452
+ if (node.kind === 'missing') return []
453
+ return matchingTools(cat, node, query).slice(0, 200).map((tool) => ({
454
+ name: tool.name,
455
+ short: tool.short,
456
+ path: `/${tool.category}/${tool.group}/${tool.short}`,
457
+ category: tool.category,
458
+ group: tool.group,
459
+ summary: tool.summary,
460
+ tokens: tool.tokens,
461
+ highRisk: tool.highRisk === true,
462
+ }))
463
+ },
464
+ /** Schema tokens actually sent for an agent: pinned tools plus its loaded set. */
465
+ wireTokens: (agent: Agent) => {
466
+ const visible = new Set(visibility.allowFor(agent) ?? [])
467
+ const cat = getCatalog(agent)
468
+ let tokens = 0
469
+ for (const schema of ctx.tools.schemas(agent)) {
470
+ if (!visible.has(schema.name)) continue
471
+ tokens += cat.tools.get(schema.name)?.tokens ?? 0
472
+ }
473
+ return tokens
474
+ },
475
+ } satisfies ToolCatalogService
476
+ ctx.provide('toolCatalog', service)
477
+
478
+ // --- the retrieval + routing layer ---------------------------------------------
479
+ //
480
+ // The router reuses the toolCatalog service just provided: it indexes the same
481
+ // catalog, loads through the same path, and reports the same token budget. It
482
+ // adds tools_find / tools_route / skills_find / skills_route on top of the
483
+ // directory tools above.
484
+ registerRouter(ctx, config, service)
485
+
486
+ // --- direct calls of a hidden tool -----------------------------------------------
487
+ //
488
+ // A tool this plugin hid is still a registered tool: the model may call it by
489
+ // name, and the host would answer "unknown tool" — which reads like a broken
490
+ // plugin and, for a protocol tool a plugin's own prompt tells the model to
491
+ // use, breaks the feature outright (a group Bot replying through group_relay,
492
+ // for instance).
493
+ //
494
+ // So a direct call of a hidden tool is handled here, before dispatch:
495
+ // - an ordinary tool is loaded on the spot and the call proceeds;
496
+ // - a high-risk tool is refused with the exact load instruction, so it still
497
+ // has to go through the risk gate and its approval.
498
+ // The cost is a few tokens when the model skips the directory; the benefit is
499
+ // that forgetting to pin a protocol tool degrades instead of breaking.
500
+ async function rescueHiddenCall(agent: Agent, name: string): Promise<string | undefined> {
501
+ // allowFor() is null for an agent this plugin does not track (and for one
502
+ // whose context cannot restrict at all): nothing to rescue there.
503
+ if (visibility.allowFor(agent) === null) return undefined
504
+ const state = visibility.stateOf(agent)
505
+ if (state.loaded.has(name)) return undefined
506
+ // Only tools this plugin manages: pinned tools and entry tools are always
507
+ // visible, and anything else is hidden by the host or another plugin, which
508
+ // is not ours to override.
509
+ const catalog = getCatalog(agent)
510
+ const tool = catalog.tools.get(name)
511
+ if (!tool) return undefined
512
+ if (tool.highRisk) {
513
+ return `"${name}" is high risk and is not loaded. Load it first with tools_load(["${name}"]), which asks for approval.`
514
+ }
515
+ // An auto-load is still a load: a before-load veto must stop it too.
516
+ try {
517
+ await ctx.serial('tool-catalog/before-load', { agent, targets: [name], names: [name], rescue: true })
518
+ } catch (error) {
519
+ return `Load of "${name}" refused: ${errorText(error)}`
520
+ }
521
+ visibility.update(agent, (next) => {
522
+ next.loaded.add(name)
523
+ next.excluded.delete(name)
524
+ })
525
+ // A visibility filter (or a connector deny) may still keep it out; say so
526
+ // rather than letting the host report an unknown tool.
527
+ if (!(visibility.allowFor(agent) ?? []).includes(name)) {
528
+ // Undo the load, or a tool the session cannot see would sit in its state
529
+ // (and be persisted) as if it had been loaded.
530
+ visibility.update(agent, (next) => { next.loaded.delete(name) })
531
+ return `"${name}" is not available in this session. Use tools_ls to see what is.`
532
+ }
533
+ ctx.emit('tool-catalog/auto-loaded', { agent, name })
534
+ return undefined
535
+ }
536
+
537
+ // --- tools another layer hides ---------------------------------------------------
538
+ // Our allowlist intersects with every other restriction on the agent (MCP
539
+ // connector governance, its per-session injection, other plugins), so a tool
540
+ // can be pinned or loaded here and still never reach the model. The host's
541
+ // final view tells them apart; without it nothing is reported.
542
+ function deniedFor(agent: Agent | undefined): Set<string> {
543
+ const allow = agent ? visibility.allowFor(agent) : null
544
+ if (!agent || !allow || typeof ctx.tools.view !== 'function') return new Set()
545
+ let visible: { has(name: string): boolean } | undefined
546
+ try {
547
+ visible = ctx.tools.view(agent).visible
548
+ } catch {
549
+ return new Set()
550
+ }
551
+ if (!visible) return new Set()
552
+ const cat = getCatalog(agent)
553
+ return new Set(allow.filter((name) => cat.tools.has(name) && !visible!.has(name)))
554
+ }
555
+
556
+ const warnedDenied = new Set<string>()
557
+ function warnDenied(names: readonly string[]) {
558
+ const fresh = names.filter((name) => !warnedDenied.has(name))
559
+ if (!fresh.length) return
560
+ for (const name of fresh) warnedDenied.add(name)
561
+ logger?.warn?.(`tool-manager: loaded but hidden by another restriction: ${fresh.join(', ')}. If these are MCP tools, check the connector's governance rules and set the connection to "always inject" (per-session injection narrows on top of this plugin).`)
562
+ }
563
+
564
+ // --- listing -------------------------------------------------------------------
565
+ function visibleSetFor(agent: Agent | undefined): Set<string> | null {
566
+ const allow = agent ? visibility.allowFor(agent) : null
567
+ return allow ? new Set(allow) : null
568
+ }
569
+
570
+ function toolLine(tool: ToolEntry, visible: Set<string> | null, denied: Set<string>, withPath = false): string {
571
+ const marks = `${visible?.has(tool.name) ? '*' : ' '}${tool.highRisk ? '!' : ' '}`
572
+ const label = withPath ? `/${tool.category}/${tool.group}/${tool.short}` : tool.short
573
+ return ` ${marks} ${pad(label, withPath ? 48 : 34)}${pad(fmtTokens(tool.tokens), 11)}${denied.has(tool.name) ? '[denied] ' : ''}${tool.summary}`
574
+ }
575
+
576
+ function listing(cat: Catalog, node: CatalogNode, visible: Set<string> | null, denied: Set<string> = new Set()): string {
577
+ const count = (tools: readonly ToolEntry[]) => ({
578
+ n: tools.length,
579
+ loaded: tools.filter((t) => visible?.has(t.name)).length,
580
+ tokens: tools.reduce((s, t) => s + t.tokens, 0),
581
+ })
582
+ const lines: string[] = []
583
+ if (node.kind === 'root') {
584
+ lines.push('/ (categories; * = loaded, ! = high risk, [denied] = loaded but blocked by another plugin; tools_ls with query searches everything)')
585
+ for (const category of cat.categories.values()) {
586
+ const c = count(category.groups.flatMap((g) => g.tools))
587
+ if (c.n === 0 && category.name !== UNCATEGORIZED) continue
588
+ lines.push(` ${pad(`${category.name}/`, 16)}${pad(`${c.n} tools`, 10)}${pad(`${c.loaded} loaded`, 11)}${pad(fmtTokens(c.tokens), 11)}${category.description}`)
589
+ }
590
+ } else if (node.kind === 'category') {
591
+ lines.push(`/${node.category.name}/ ${node.category.description}`)
592
+ for (const group of node.category.groups) {
593
+ const c = count(group.tools)
594
+ lines.push(` ${pad(`${group.name}/`, 22)}${pad(`${c.n} tools`, 10)}${pad(`${c.loaded} loaded`, 11)}${fmtTokens(c.tokens)}`)
595
+ }
596
+ } else if (node.kind === 'group') {
597
+ lines.push(`/${node.group.category}/${node.group.name}/`)
598
+ for (const tool of node.group.tools) lines.push(toolLine(tool, visible, denied))
599
+ } else if (node.kind === 'tool') {
600
+ const t = node.tool
601
+ lines.push(`/${t.category}/${t.group}/${t.short}`, ` name: ${t.name}`, ` ${t.summary}`, ` schema ${fmtTokens(t.tokens)}${t.highRisk ? ' · high risk: load it on its own' : ''}${visible?.has(t.name) ? ' · loaded' : ''}${denied.has(t.name) ? ' · denied by another plugin (e.g. an MCP connector rule): not callable' : ''}`)
602
+ }
603
+ return lines.join('\n')
604
+ }
605
+
606
+ /** Tools under `node` matching every word of `query` (name, group, category, description). */
607
+ function matchingTools(cat: Catalog, node: CatalogNode, query: unknown): ToolEntry[] {
608
+ const words = String(query ?? '').toLowerCase().split(/\s+/).filter(Boolean)
609
+ if (!words.length) return []
610
+ return toolsUnder(cat, node).filter((t) => {
611
+ const hay = `${t.name} ${t.group} ${t.category} ${t.description}`.toLowerCase()
612
+ return words.every((w) => hay.includes(w))
613
+ })
614
+ }
615
+
616
+ function search(cat: Catalog, node: CatalogNode, query: string, visible: Set<string> | null, denied: Set<string> = new Set()): string {
617
+ const hits = matchingTools(cat, node, query)
618
+ if (!hits.length) return `No tools match "${query}".`
619
+ const shown = hits.slice(0, 40)
620
+ return [`${hits.length} match(es) for "${query}" (load by path or full name):`, ...shown.map((t) => toolLine(t, visible, denied, true)), hits.length > shown.length ? ` … ${hits.length - shown.length} more; narrow the query or the path` : ''].filter(Boolean).join('\n')
621
+ }
622
+
623
+ function agentOf(exec: { agent?: unknown } | undefined): Agent {
624
+ if (!exec?.agent) throw new Error('tool-manager: this tool must be called by an agent')
625
+ return exec.agent as Agent
626
+ }
627
+
628
+ // --- load / unload (shared by the entry tools and the toolCatalog service) -------
629
+
630
+ /**
631
+ * Subscribe an agent to targets. Runs the before-load hook (which may veto) and
632
+ * returns the model-facing text; the panel calls this same path, so hooks
633
+ * apply identically; risk-gate approval does not, since a panel click is
634
+ * itself the user's consent.
635
+ */
636
+ async function loadTargets(agent: Agent, targets: readonly string[], { forSubagents = false }: LoadOptions = {}): Promise<string> {
637
+ const cat = getCatalog(agent)
638
+ const nodes = targets.map((raw) => ({ raw, node: resolveTarget(cat, raw, [...registries.resolvers]) }))
639
+ const names = nodes.flatMap(({ node }) => (node.kind === 'root' || node.kind === 'missing' ? [] : toolsUnder(cat, node).map((t) => t.name)))
640
+ try {
641
+ await ctx.serial('tool-catalog/before-load', { agent, targets, names })
642
+ } catch (error) {
643
+ return `Load refused: ${errorText(error)}`
644
+ }
645
+ const before = new Set(visibility.allowFor(agent) ?? [])
646
+ const notes: string[] = []
647
+ visibility.update(agent, (state) => {
648
+ for (const { raw, node } of nodes) {
649
+ if (node.kind === 'missing' || node.kind === 'root') {
650
+ notes.push(`skipped ${raw}: ${node.kind === 'root' ? 'cannot load the whole tree' : 'not found (try tools_ls with query)'}`)
651
+ continue
652
+ }
653
+ // An alias (e.g. a capability pack) names exact tools: add those, never
654
+ // the whole group they happen to live in.
655
+ if (node.kind === 'category' && node.category.aliasTools) {
656
+ for (const tool of node.category.aliasTools) {
657
+ if (tool.highRisk && riskGates.size === 0) {
658
+ notes.push(`${tool.name} is high risk and no risk gate is installed to confirm it; not loaded`)
659
+ continue
660
+ }
661
+ state.loaded.add(tool.name)
662
+ state.excluded.delete(tool.name)
663
+ if (forSubagents) state.handoffTools.add(tool.name)
664
+ }
665
+ continue
666
+ }
667
+ const groups = node.kind === 'category' ? node.category.groups : node.kind === 'group' ? [node.group] : []
668
+ for (const group of groups) {
669
+ state.groups.add(group.name)
670
+ if (forSubagents) state.handoffGroups.add(group.name)
671
+ for (const tool of group.tools) state.excluded.delete(tool.name)
672
+ const risky = group.tools.filter((t) => t.highRisk).map((t) => t.short)
673
+ if (risky.length) notes.push(`${group.name}: high-risk tools not included (${risky.join(', ')}); load them one by one`)
674
+ }
675
+ if (node.kind === 'tool') {
676
+ if (node.tool.highRisk && riskGates.size === 0) {
677
+ notes.push(`${node.tool.name} is high risk and no risk gate is installed to confirm it; not loaded`)
678
+ continue
679
+ }
680
+ state.loaded.add(node.tool.name)
681
+ state.excluded.delete(node.tool.name)
682
+ if (forSubagents) state.handoffTools.add(node.tool.name)
683
+ }
684
+ }
685
+ })
686
+ const after = visibility.allowFor(agent) ?? []
687
+ const added = after.filter((n) => !before.has(n))
688
+ ctx.emit('tool-catalog/after-load', { agent, targets, added })
689
+ const denied = deniedFor(agent)
690
+ const blocked = added.filter((n) => denied.has(n))
691
+ const callable = added.filter((n) => !denied.has(n))
692
+ if (blocked.length) warnDenied(blocked)
693
+ const tokens = callable.reduce((sum, n) => sum + (cat.tools.get(n)?.tokens ?? 0), 0)
694
+ return [
695
+ callable.length ? `Loaded ${callable.length} tool(s), ${fmtTokens(tokens)}; callable from your next step:` : 'Nothing new loaded.',
696
+ ...callable.map((n) => ` ${n}`),
697
+ ...(blocked.length ? [`Denied by another plugin (e.g. an MCP connector rule), not callable even when loaded: ${blocked.join(', ')}`] : []),
698
+ ...notes,
699
+ ].join('\n')
700
+ }
701
+
702
+ /** Hide targets again. Returns the model-facing text. */
703
+ async function unloadTargets(agent: Agent, targets: readonly string[]): Promise<string> {
704
+ const cat = getCatalog(agent)
705
+ try {
706
+ await ctx.serial('tool-catalog/before-unload', { agent, targets })
707
+ } catch (error) {
708
+ return `Unload refused: ${errorText(error)}`
709
+ }
710
+ const before = new Set(visibility.allowFor(agent) ?? [])
711
+ visibility.update(agent, (state) => {
712
+ for (const raw of targets) {
713
+ if (String(raw).trim().toLowerCase() === 'all') {
714
+ for (const set of Object.values(state)) set.clear()
715
+ continue
716
+ }
717
+ const node = resolveTarget(cat, raw, [...registries.resolvers])
718
+ if (node.kind === 'category' && node.category.aliasTools) {
719
+ for (const tool of node.category.aliasTools) {
720
+ state.loaded.delete(tool.name)
721
+ state.handoffTools.delete(tool.name)
722
+ }
723
+ continue
724
+ }
725
+ const groups = node.kind === 'category' ? node.category.groups : node.kind === 'group' ? [node.group] : []
726
+ for (const group of groups) {
727
+ state.groups.delete(group.name)
728
+ state.handoffGroups.delete(group.name)
729
+ for (const tool of group.tools) state.loaded.delete(tool.name)
730
+ }
731
+ if (node.kind === 'tool') {
732
+ state.loaded.delete(node.tool.name)
733
+ state.handoffTools.delete(node.tool.name)
734
+ if (state.groups.has(node.tool.group)) state.excluded.add(node.tool.name)
735
+ }
736
+ }
737
+ })
738
+ const after = new Set(visibility.allowFor(agent) ?? [])
739
+ const removed = [...before].filter((n) => !after.has(n))
740
+ ctx.emit('tool-catalog/after-unload', { agent, targets, removed })
741
+ return removed.length ? `Unloaded ${removed.length} tool(s):\n${removed.map((n) => ` ${n}`).join('\n')}` : 'Nothing was unloaded.'
742
+ }
743
+
744
+ // --- tools -----------------------------------------------------------------------
745
+ ctx.tools.register(defineTool({
746
+ name: 'tools_ls',
747
+ description:
748
+ 'Browse every available tool as a directory: / lists categories, /<category> lists groups (a connector or plugin), /<category>/<group> lists tools with a one-line summary and schema size. Tools marked * are loaded (callable now). Most tools are hidden until loaded with tools_load. Pass query to search names and descriptions across the whole tree (or under path) in one step.',
749
+ parameters: {
750
+ path: { type: 'string', description: 'Directory path such as "/", "/code", "/code/github", or a group or tool name. Default "/".' },
751
+ query: { type: 'string', description: 'Search words matched against tool name, group, category and description; searches everything under path.' },
752
+ },
753
+ output: TEXT_OUTPUT,
754
+ isConcurrencySafe: () => true,
755
+ async execute(args, exec) {
756
+ const cat = getCatalog(exec?.agent as Agent | undefined)
757
+ const node = resolveTarget(cat, args.path ?? '/', [...registries.resolvers])
758
+ if (node.kind === 'missing') return `No such path: ${node.target}. Start from tools_ls("/") or search with tools_ls({query}).`
759
+ const agent = exec?.agent as Agent | undefined
760
+ const visible = visibleSetFor(agent)
761
+ const denied = deniedFor(agent)
762
+ return args.query ? search(cat, node, args.query, visible, denied) : listing(cat, node, visible, denied)
763
+ },
764
+ }))
765
+
766
+ ctx.tools.register(defineTool({
767
+ name: 'tools_load',
768
+ description:
769
+ 'Make tools callable for the rest of this session. Each target is a group path ("/code/github"), a category ("/maps"), or one tool by path or exact name ("mcp__github__get_file_contents"). Loading a group also includes tools the group gains later. Prefer one group load over many single tools. Loaded tools are available from your next step. Set forSubagents to also hand these targets to subagents you start afterwards.',
770
+ parameters: {
771
+ targets: { type: 'array', items: { type: 'string' }, required: true, description: 'Paths, group names or exact tool names.' },
772
+ forSubagents: { type: 'boolean', description: 'Also give these targets to subagents started from now on.' },
773
+ },
774
+ output: TEXT_OUTPUT,
775
+ async execute(args, exec) {
776
+ return loadTargets(agentOf(exec), args.targets ?? [], { forSubagents: args.forSubagents === true })
777
+ },
778
+ }))
779
+
780
+ ctx.tools.register(defineTool({
781
+ name: 'tools_unload',
782
+ description:
783
+ 'Hide loaded tools again to keep context small. Targets use the same forms as tools_load; "all" returns to the always-on set. Always-on tools cannot be unloaded.',
784
+ parameters: {
785
+ targets: { type: 'array', items: { type: 'string' }, required: true, description: 'Paths, group names, exact tool names, or "all".' },
786
+ },
787
+ output: TEXT_OUTPUT,
788
+ async execute(args, exec) {
789
+ return unloadTargets(agentOf(exec), args.targets ?? [])
790
+ },
791
+ }))
792
+ }
793
+
794
+ /** Options for toolCatalog.load / tools_load. */
795
+ export interface LoadOptions {
796
+ /** Also hand the targets to subagents started afterwards. */
797
+ forSubagents?: boolean
798
+ }
799
+
800
+ /** The message of a thrown value, whatever was thrown. */
801
+ function errorText(error: unknown): string {
802
+ return String((error as { message?: unknown } | null | undefined)?.message ?? error)
803
+ }
804
+
805
+ export default { name, inject, apply }