@lvy-jsd/dsh-tool-manager 0.2.0 → 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 (52) hide show
  1. package/README.md +99 -2
  2. package/cordis.patch.yml +13 -0
  3. package/lib/exempt.d.ts +45 -0
  4. package/lib/exempt.d.ts.map +1 -0
  5. package/lib/exempt.js +122 -0
  6. package/lib/exempt.js.map +1 -0
  7. package/lib/index.d.ts +1 -0
  8. package/lib/index.d.ts.map +1 -1
  9. package/lib/index.js +243 -34
  10. package/lib/index.js.map +1 -1
  11. package/lib/manifest.d.ts +21 -0
  12. package/lib/manifest.d.ts.map +1 -0
  13. package/lib/manifest.js +112 -0
  14. package/lib/manifest.js.map +1 -0
  15. package/lib/metadata.d.ts +38 -0
  16. package/lib/metadata.d.ts.map +1 -0
  17. package/lib/metadata.js +102 -0
  18. package/lib/metadata.js.map +1 -0
  19. package/lib/router/bm25.d.ts +21 -0
  20. package/lib/router/bm25.d.ts.map +1 -0
  21. package/lib/router/bm25.js +63 -0
  22. package/lib/router/bm25.js.map +1 -0
  23. package/lib/router/index.d.ts +9 -0
  24. package/lib/router/index.d.ts.map +1 -0
  25. package/lib/router/index.js +422 -0
  26. package/lib/router/index.js.map +1 -0
  27. package/lib/router/sidecar.d.ts +34 -0
  28. package/lib/router/sidecar.d.ts.map +1 -0
  29. package/lib/router/sidecar.js +156 -0
  30. package/lib/router/sidecar.js.map +1 -0
  31. package/lib/router/types.d.ts +164 -0
  32. package/lib/router/types.d.ts.map +1 -0
  33. package/lib/router/types.js +7 -0
  34. package/lib/router/types.js.map +1 -0
  35. package/lib/settings.d.ts +32 -0
  36. package/lib/settings.d.ts.map +1 -0
  37. package/lib/settings.js +93 -0
  38. package/lib/settings.js.map +1 -0
  39. package/lib/types.d.ts +137 -1
  40. package/lib/types.d.ts.map +1 -1
  41. package/package.json +8 -4
  42. package/sidecar/bin/tool-router-sidecar +0 -0
  43. package/src/exempt.ts +148 -0
  44. package/src/index.ts +245 -35
  45. package/src/manifest.ts +116 -0
  46. package/src/metadata.ts +123 -0
  47. package/src/router/bm25.ts +68 -0
  48. package/src/router/index.ts +440 -0
  49. package/src/router/sidecar.ts +159 -0
  50. package/src/router/types.ts +149 -0
  51. package/src/settings.ts +108 -0
  52. package/src/types.ts +134 -3
package/src/exempt.ts ADDED
@@ -0,0 +1,148 @@
1
+ // File-based exemption list for dsh-tool-manager.
2
+ //
3
+ // An exempt session is never narrowed: a route whose tool list is handed to an
4
+ // external client once at session start (Claude Code, say) cannot load a tool
5
+ // that was hidden from the first request, so hiding anything there only loses
6
+ // tools. The provider list originally came from `exemptProviders` in
7
+ // cordis.patch.yml, which a user cannot edit from the browser and which is
8
+ // awkward to change per session.
9
+ //
10
+ // This file replaces it: when <DSH_HOME>/tool-manager/exempt.json exists it wins
11
+ // over the plugin config, the same way the settings file does. Two entry forms
12
+ // are supported, because they answer different questions:
13
+ //
14
+ // { "providers": ["claude-code"], "sessions": ["s_abc123"] }
15
+ //
16
+ // providers model routes never narrowed, for every session (durable)
17
+ // sessions one session exempted for its remaining lifetime, e.g. while the
18
+ // user checks whether a client handles lazy loading (temporary)
19
+ //
20
+ // Sessions are matched against the agent's session id and, as a fallback, the
21
+ // agent id, so an entry can be removed by hand without knowing which of the two
22
+ // the host populated.
23
+
24
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
25
+ import { homedir } from 'node:os'
26
+ import { dirname, join } from 'node:path'
27
+ import type { Logger } from './types.js'
28
+
29
+ /** An exemption list: routes and sessions. Both may be empty. */
30
+ export interface ExemptConfig {
31
+ providers: string[]
32
+ sessions: string[]
33
+ }
34
+
35
+ /** Where the exemption file lives unless a deployment points elsewhere. */
36
+ export function defaultExemptFile(): string {
37
+ const dshHome = process.env.DSH_HOME || join(homedir(), '.config', 'deepseek-harness')
38
+ return join(dshHome, 'tool-manager', 'exempt.json')
39
+ }
40
+
41
+ /** Keep only non-empty strings; a number or object in the file is a typo, not a name. */
42
+ function pickNames(value: unknown): string[] {
43
+ if (!Array.isArray(value)) return []
44
+ return value.filter((name): name is string => typeof name === 'string' && name.trim().length > 0).map((name) => name.trim())
45
+ }
46
+
47
+ /** The whole file, with unknown keys dropped. */
48
+ export function pickExempt(raw: unknown): ExemptConfig {
49
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return { providers: [], sessions: [] }
50
+ const record = raw as Record<string, unknown>
51
+ // `providers` is required: a file that omits it says "no route is exempt",
52
+ // not "keep the old config list", because the file is the override.
53
+ return { providers: pickNames(record.providers), sessions: pickNames(record.sessions) }
54
+ }
55
+
56
+ /** Read the exemption file; missing or corrupt is "no opinion", never a crash. */
57
+ export function readExemptFile(file: string, logger?: Logger): ExemptConfig | undefined {
58
+ let text: string
59
+ try {
60
+ text = readFileSync(file, 'utf8')
61
+ } catch (caught) {
62
+ const error = caught as NodeJS.ErrnoException
63
+ // ENOENT is the normal first run: no file means the plugin config decides.
64
+ if (error.code !== 'ENOENT') logger?.warn?.(`tool-manager: ignoring unreadable exemptions ${file}: ${error.message}`)
65
+ return undefined
66
+ }
67
+ try {
68
+ return pickExempt(JSON.parse(text))
69
+ } catch (caught) {
70
+ // A corrupt file must not silently restore the config list: the user
71
+ // intended the file to be in charge, so report and treat it as "no exempt".
72
+ logger?.warn?.(`tool-manager: unreadable exemptions ${file}: ${(caught as Error).message}`)
73
+ return { providers: [], sessions: [] }
74
+ }
75
+ }
76
+
77
+ /** Write the file atomically, so a crash cannot leave a half-written list. */
78
+ export function writeExemptFile(file: string, config: ExemptConfig, logger?: Logger): void {
79
+ mkdirSync(dirname(file), { recursive: true })
80
+ const tmp = `${file}.tmp`
81
+ try {
82
+ writeFileSync(tmp, `${JSON.stringify({ providers: config.providers, sessions: config.sessions }, null, 2)}\n`)
83
+ renameSync(tmp, file)
84
+ } catch (caught) {
85
+ logger?.warn?.(`tool-manager: writing exemptions ${file} failed: ${(caught as Error).message}`)
86
+ throw caught
87
+ }
88
+ }
89
+
90
+ /** Why a session is exempt, for the panel to show. */
91
+ export interface ExemptReason {
92
+ exempt: boolean
93
+ /** 'file:provider' | 'file:session' | 'config:provider' | '' */
94
+ source: string
95
+ }
96
+
97
+ export interface ExemptStore {
98
+ /** File the store reads and writes; the file itself is the override switch. */
99
+ file: string
100
+ /** True when the file exists, so the config provider list is ignored. */
101
+ present(): boolean
102
+ /** The lists in force: the file when it exists, else the plugin config. */
103
+ effective(): ExemptConfig
104
+ /** Why one session is exempt, if it is. */
105
+ reasonFor(provider: string | undefined, sessionId: string | undefined): ExemptReason
106
+ /** Add or remove one session, writing the file. Returns the lists in force. */
107
+ setSession(sessionId: string, exempt: boolean): ExemptConfig
108
+ /** Re-read the file, for after an external edit. Returns the lists in force. */
109
+ reload(): ExemptConfig
110
+ }
111
+
112
+ /**
113
+ * Exemption store over one file, with the plugin config as the fallback layer.
114
+ * The file wins as a whole rather than per key: an operator writing the file
115
+ * means it, and a partial merge would leave entries they cannot see.
116
+ */
117
+ export function createExemptStore({ file, configProviders = [], logger }: { file: string, configProviders?: readonly string[], logger?: Logger }): ExemptStore {
118
+ let fromFile = readExemptFile(file, logger)
119
+ const effective = (): ExemptConfig => fromFile ?? { providers: [...configProviders], sessions: [] }
120
+ return {
121
+ file,
122
+ present: () => fromFile !== undefined,
123
+ effective,
124
+ reasonFor(provider, sessionId) {
125
+ const current = effective()
126
+ if (provider !== undefined && current.providers.includes(provider)) {
127
+ return { exempt: true, source: fromFile !== undefined ? 'file:provider' : 'config:provider' }
128
+ }
129
+ if (sessionId !== undefined && current.sessions.includes(sessionId)) return { exempt: true, source: 'file:session' }
130
+ return { exempt: false, source: '' }
131
+ },
132
+ setSession(sessionId, exempt) {
133
+ // Editing starts from what is in force, so the first panel click writes a
134
+ // file that keeps the exemptions the user could already see.
135
+ const current = effective()
136
+ const sessions = new Set(current.sessions)
137
+ if (exempt) sessions.add(sessionId)
138
+ else sessions.delete(sessionId)
139
+ fromFile = { providers: [...current.providers], sessions: [...sessions] }
140
+ writeExemptFile(file, fromFile, logger)
141
+ return effective()
142
+ },
143
+ reload() {
144
+ fromFile = readExemptFile(file, logger)
145
+ return effective()
146
+ },
147
+ }
148
+ }
package/src/index.ts CHANGED
@@ -10,9 +10,14 @@
10
10
 
11
11
  import { defineTool } from '@deepseek-ai/dsh-tools'
12
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'
13
17
  import { createVisibilityController } from './visibility.js'
14
18
  import { createRegistries, readManifestAnnotations, listPluginPackages } from './service.js'
15
- import type { Agent, Annotation, Catalog, CatalogNode, CoreConfig, Disposer, HostContext, Logger, RiskGate, SerializedState, ToolCatalogService, ToolEntry, ToolSchema } from './types.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'
16
21
 
17
22
  export type * from './types.js'
18
23
  export { buildCatalog, resolveTarget, toolsUnder, estimateTokens, summary, shortName, structuralGroup, DEFAULT_CATEGORIES, UNCATEGORIZED, MISC_GROUP, OVERRIDE_PRIORITY } from './catalog.js'
@@ -25,6 +30,12 @@ export const inject = ['tools']
25
30
 
26
31
  export const ENTRY_TOOLS = ['tools_ls', 'tools_load', 'tools_unload']
27
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
+
28
39
  // Always visible and never listed in the directory. Names the host does not
29
40
  // have are ignored, so this list survives DSH renames. Config `pinned` adds to
30
41
  // it; config `unpin` removes from it.
@@ -57,13 +68,45 @@ function pad(text: string, width: number): string {
57
68
 
58
69
  export function apply(ctx: HostContext, config: CoreConfig = {}) {
59
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 })
60
84
  // Model routes whose tool list is handed to an external client once per
61
85
  // session and never refreshed (Claude Code receives DSH tools through an MCP
62
86
  // bridge at startup). Narrowing such a session hides tools it can then never
63
87
  // load, and the client already lazy-loads schemas itself (ToolSearch), so
64
- // these agents are left unrestricted. Config `exemptProviders` replaces the
65
- // default list; [] restricts every route.
66
- const exemptProviders = new Set(config.exemptProviders ?? ['claude-code'])
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
+ }
67
110
 
68
111
  /** The route an agent's next request will use: latest model/selection, else its options. */
69
112
  const selectionScan = new WeakMap<object, { seq: number, provider: string | undefined }>() // session -> incremental scan
@@ -92,12 +135,75 @@ export function apply(ctx: HostContext, config: CoreConfig = {}) {
92
135
  const riskGates = new Set<{ name: string }>()
93
136
 
94
137
  const pinnedSet = () => {
95
- const set = new Set([...DEFAULT_PINNED, ...(config.pinned ?? [])])
96
- for (const n of config.unpin ?? []) set.delete(n)
138
+ const current = settings.effective()
139
+ const set = new Set([...DEFAULT_PINNED, ...current.pinned])
140
+ for (const n of current.unpin) set.delete(n)
97
141
  for (const n of ENTRY_TOOLS) set.add(n)
98
142
  return set
99
143
  }
100
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
+
101
207
  // --- catalog -----------------------------------------------------------------
102
208
  // The directory is built per agent: global tools plus tools registered in the
103
209
  // agent's ancestor scopes (its preset). In the DSH web profile most tools
@@ -140,7 +246,7 @@ export function apply(ctx: HostContext, config: CoreConfig = {}) {
140
246
  for (const a of agent ? [agent] : visibility.agents()) {
141
247
  for (const s of inheritedSchemas(a)) if (!byName.has(s.name)) byName.set(s.name, s)
142
248
  }
143
- const schemas = [...byName.values()].filter((s) => !pinned.has(s.name))
249
+ const schemas = [...byName.values()].filter((s) => !pinned.has(s.name) && !HOST_RESERVED.includes(s.name))
144
250
  const key = schemas.map((s) => s.name).sort().join('\n')
145
251
  const cached = catalogs.get(key)
146
252
  if (cached) return cached
@@ -148,12 +254,19 @@ export function apply(ctx: HostContext, config: CoreConfig = {}) {
148
254
  manifestAnnotations ??= readManifestAnnotations(ctx, logger)
149
255
  const catalog = buildCatalog(schemas, {
150
256
  classifiers: [...registries.classifiers],
151
- // Package declarations first, runtime annotate() after, so runtime wins.
152
- annotations: [...manifestAnnotations, ...registries.annotations],
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()],
153
260
  categories: Object.fromEntries(registries.categories),
154
- highRisk: config.highRisk ?? [],
261
+ highRisk: settings.effective().highRisk,
155
262
  })
156
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)
157
270
  ctx.emit('tool-catalog/after-rebuild', { catalog })
158
271
  return catalog
159
272
  }
@@ -166,10 +279,7 @@ export function apply(ctx: HostContext, config: CoreConfig = {}) {
166
279
  // The catalog is rebuilt lazily after any registry change.
167
280
  invalidate,
168
281
  onStateChange: (agent, state) => ctx.emit('tool-catalog/state-changed', { agent, state }),
169
- isExempt: (agent) => {
170
- const provider = providerOf(agent)
171
- return provider !== undefined && exemptProviders.has(provider)
172
- },
282
+ isExempt: (agent) => exemptionOf(agent).exempt,
173
283
  logger,
174
284
  })
175
285
 
@@ -236,8 +346,11 @@ export function apply(ctx: HostContext, config: CoreConfig = {}) {
236
346
  /** Replace an agent's loaded state (e.g. restored after a restart). */
237
347
  importState: (agent: Agent, state: Partial<SerializedState>) => visibility.importState(agent, state),
238
348
  allowFor: (agent: Agent) => visibility.allowFor(agent),
349
+ deniedFor: (agent: Agent) => [...deniedFor(agent)],
239
350
  /** True when the agent's model route is in `exemptProviders` (never narrowed). */
240
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),
241
354
  /**
242
355
  * Load/unload on behalf of an agent, through the same path as the entry
243
356
  * tools (hooks, vetoes, risk gates and persistence all apply). Returns the
@@ -255,14 +368,69 @@ export function apply(ctx: HostContext, config: CoreConfig = {}) {
255
368
  return { agent, id: String(agent.id), parentId: parent ? String(parent.id) : null }
256
369
  })
257
370
  },
258
- /** Effective configuration, for UI display. */
259
- config: () => ({
260
- pinned: [...pinnedSet()],
261
- entryTools: [...ENTRY_TOOLS],
262
- extraPinned: config.pinned ?? [],
263
- unpinned: config.unpin ?? [],
264
- highRisk: config.highRisk ?? [],
265
- }),
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,
266
434
  /** Enabled plugin packages on this host: `{ name, dir, manifest }[]`. */
267
435
  packages: () => listPluginPackages(ctx),
268
436
  /** Registered extension points, so a UI can show what is active. */
@@ -307,6 +475,14 @@ export function apply(ctx: HostContext, config: CoreConfig = {}) {
307
475
  } satisfies ToolCatalogService
308
476
  ctx.provide('toolCatalog', service)
309
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
+
310
486
  // --- direct calls of a hidden tool -----------------------------------------------
311
487
  //
312
488
  // A tool this plugin hid is still a registered tool: the model may call it by
@@ -358,19 +534,46 @@ export function apply(ctx: HostContext, config: CoreConfig = {}) {
358
534
  return undefined
359
535
  }
360
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
+
361
564
  // --- listing -------------------------------------------------------------------
362
565
  function visibleSetFor(agent: Agent | undefined): Set<string> | null {
363
566
  const allow = agent ? visibility.allowFor(agent) : null
364
567
  return allow ? new Set(allow) : null
365
568
  }
366
569
 
367
- function toolLine(tool: ToolEntry, visible: Set<string> | null, withPath = false): string {
570
+ function toolLine(tool: ToolEntry, visible: Set<string> | null, denied: Set<string>, withPath = false): string {
368
571
  const marks = `${visible?.has(tool.name) ? '*' : ' '}${tool.highRisk ? '!' : ' '}`
369
572
  const label = withPath ? `/${tool.category}/${tool.group}/${tool.short}` : tool.short
370
- return ` ${marks} ${pad(label, withPath ? 48 : 34)}${pad(fmtTokens(tool.tokens), 11)}${tool.summary}`
573
+ return ` ${marks} ${pad(label, withPath ? 48 : 34)}${pad(fmtTokens(tool.tokens), 11)}${denied.has(tool.name) ? '[denied] ' : ''}${tool.summary}`
371
574
  }
372
575
 
373
- function listing(cat: Catalog, node: CatalogNode, visible: Set<string> | null): string {
576
+ function listing(cat: Catalog, node: CatalogNode, visible: Set<string> | null, denied: Set<string> = new Set()): string {
374
577
  const count = (tools: readonly ToolEntry[]) => ({
375
578
  n: tools.length,
376
579
  loaded: tools.filter((t) => visible?.has(t.name)).length,
@@ -378,7 +581,7 @@ export function apply(ctx: HostContext, config: CoreConfig = {}) {
378
581
  })
379
582
  const lines: string[] = []
380
583
  if (node.kind === 'root') {
381
- lines.push('/ (categories; * = loaded, ! = high risk; tools_ls with query searches everything)')
584
+ lines.push('/ (categories; * = loaded, ! = high risk, [denied] = loaded but blocked by another plugin; tools_ls with query searches everything)')
382
585
  for (const category of cat.categories.values()) {
383
586
  const c = count(category.groups.flatMap((g) => g.tools))
384
587
  if (c.n === 0 && category.name !== UNCATEGORIZED) continue
@@ -392,10 +595,10 @@ export function apply(ctx: HostContext, config: CoreConfig = {}) {
392
595
  }
393
596
  } else if (node.kind === 'group') {
394
597
  lines.push(`/${node.group.category}/${node.group.name}/`)
395
- for (const tool of node.group.tools) lines.push(toolLine(tool, visible))
598
+ for (const tool of node.group.tools) lines.push(toolLine(tool, visible, denied))
396
599
  } else if (node.kind === 'tool') {
397
600
  const t = node.tool
398
- 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' : ''}`)
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' : ''}`)
399
602
  }
400
603
  return lines.join('\n')
401
604
  }
@@ -410,11 +613,11 @@ export function apply(ctx: HostContext, config: CoreConfig = {}) {
410
613
  })
411
614
  }
412
615
 
413
- function search(cat: Catalog, node: CatalogNode, query: string, visible: Set<string> | null): string {
616
+ function search(cat: Catalog, node: CatalogNode, query: string, visible: Set<string> | null, denied: Set<string> = new Set()): string {
414
617
  const hits = matchingTools(cat, node, query)
415
618
  if (!hits.length) return `No tools match "${query}".`
416
619
  const shown = hits.slice(0, 40)
417
- return [`${hits.length} match(es) for "${query}" (load by path or full name):`, ...shown.map((t) => toolLine(t, visible, true)), hits.length > shown.length ? ` … ${hits.length - shown.length} more; narrow the query or the path` : ''].filter(Boolean).join('\n')
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')
418
621
  }
419
622
 
420
623
  function agentOf(exec: { agent?: unknown } | undefined): Agent {
@@ -483,10 +686,15 @@ export function apply(ctx: HostContext, config: CoreConfig = {}) {
483
686
  const after = visibility.allowFor(agent) ?? []
484
687
  const added = after.filter((n) => !before.has(n))
485
688
  ctx.emit('tool-catalog/after-load', { agent, targets, added })
486
- const tokens = added.reduce((sum, n) => sum + (cat.tools.get(n)?.tokens ?? 0), 0)
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)
487
694
  return [
488
- added.length ? `Loaded ${added.length} tool(s), ${fmtTokens(tokens)}; callable from your next step:` : 'Nothing new loaded.',
489
- ...added.map((n) => ` ${n}`),
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(', ')}`] : []),
490
698
  ...notes,
491
699
  ].join('\n')
492
700
  }
@@ -548,8 +756,10 @@ export function apply(ctx: HostContext, config: CoreConfig = {}) {
548
756
  const cat = getCatalog(exec?.agent as Agent | undefined)
549
757
  const node = resolveTarget(cat, args.path ?? '/', [...registries.resolvers])
550
758
  if (node.kind === 'missing') return `No such path: ${node.target}. Start from tools_ls("/") or search with tools_ls({query}).`
551
- const visible = visibleSetFor(exec?.agent as Agent | undefined)
552
- return args.query ? search(cat, node, args.query, visible) : listing(cat, node, visible)
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)
553
763
  },
554
764
  }))
555
765