@animalabs/connectome-host 0.7.2 → 0.7.4

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 (63) hide show
  1. package/CHANGELOG.md +203 -10
  2. package/HEADLESS-FLEET-PLAN.md +22 -0
  3. package/README.md +22 -11
  4. package/docs/AGENT-ONBOARDING.md +20 -1
  5. package/docs/debug-context-api.md +2 -2
  6. package/docs/retrieval-traces.md +173 -0
  7. package/docs/webui-deployment.md +2 -1
  8. package/package.json +3 -3
  9. package/scripts/audit-module-optins.ts +288 -0
  10. package/scripts/warmup-session.ts +17 -3
  11. package/src/codex-subscription-adapter.ts +13 -1
  12. package/src/framework-agent-config.ts +59 -4
  13. package/src/framework-strategy.ts +33 -3
  14. package/src/headless.ts +14 -0
  15. package/src/index.ts +95 -35
  16. package/src/logging-adapter.ts +13 -2
  17. package/src/mcpl-config.ts +8 -0
  18. package/src/modules/fleet-module.ts +60 -1
  19. package/src/modules/fleet-types.ts +30 -1
  20. package/src/modules/identity-module.ts +274 -0
  21. package/src/modules/mcpl-admin-module.ts +78 -5
  22. package/src/modules/observers-module.ts +12 -0
  23. package/src/modules/retrieval-module.ts +254 -52
  24. package/src/modules/retrieval-trace-page.ts +254 -0
  25. package/src/modules/retrieval-trace.ts +904 -0
  26. package/src/modules/settings-module.ts +28 -2
  27. package/src/modules/subscription-gc-module.ts +54 -1
  28. package/src/modules/tts-relay-module.ts +33 -18
  29. package/src/modules/web-ui-module.ts +445 -894
  30. package/src/recipe.ts +137 -12
  31. package/src/retrieval-config.ts +39 -0
  32. package/src/strategies/frontdesk-strategy.ts +34 -125
  33. package/src/tui.ts +325 -54
  34. package/src/web/panel-data.ts +1187 -0
  35. package/src/web/protocol.ts +75 -10
  36. package/test/audit-module-optins.test.ts +167 -0
  37. package/test/bedrock-prompt-caching.test.ts +170 -0
  38. package/test/fleet-panel-request.test.ts +90 -0
  39. package/test/framework-strategy-defaults.test.ts +110 -0
  40. package/test/frontdesk-strategy.test.ts +25 -37
  41. package/test/headless-panel-request.test.ts +201 -0
  42. package/test/identity-and-surfaces.test.ts +157 -0
  43. package/test/mcpl-admin-module.test.ts +23 -0
  44. package/test/mock-headless-child.ts +14 -0
  45. package/test/retrieval-auth-loopback.test.ts +49 -0
  46. package/test/retrieval-config.test.ts +74 -0
  47. package/test/retrieval-module.test.ts +821 -0
  48. package/test/subscription-gc-module.test.ts +152 -0
  49. package/test/tui-format.test.ts +106 -0
  50. package/test/web-ui-context-coverage.test.ts +1 -1
  51. package/test/web-ui-module.test.ts +189 -3
  52. package/test/web-ui-observers.test.ts +8 -5
  53. package/test/web-ui-protocol.test.ts +0 -0
  54. package/web/bun.lock +345 -0
  55. package/web/src/App.tsx +159 -44
  56. package/web/src/Context.tsx +35 -8
  57. package/web/src/ContextDocument.tsx +20 -5
  58. package/web/src/Files.tsx +2 -8
  59. package/web/src/Lessons.tsx +2 -38
  60. package/web/src/Mcpl.tsx +80 -14
  61. package/web/src/Pins.tsx +5 -0
  62. package/web/src/Settings.tsx +5 -0
  63. package/web/vite.config.ts +8 -2
@@ -0,0 +1,274 @@
1
+ /**
2
+ * Agent identity — the agent's own archipelago-home principal (connectome
3
+ * docs/home-node.md §4).
4
+ *
5
+ * Two audiences, deliberately separated:
6
+ *
7
+ * HOST-FACING (this module's public methods): the deployment holds an
8
+ * ed25519 keypair in the data dir; `accessFor(audience)` exchanges a
9
+ * key-proof at the home node for a short-lived aid1 token, and
10
+ * `httpAuthFor(audience)` wraps it for HTTP. This is plumbing other host
11
+ * pieces call — the MCPL transport's per-dial credential provider, future
12
+ * HTTP helpers. Credentials live and die HERE.
13
+ *
14
+ * AGENT-FACING (utilities, via the `utils` meta-tool): deliberately small
15
+ * and deliberately boring — `status` ("who am I registered as, where is
16
+ * that recognized") and `accept_invite` ("register with an invitation code
17
+ * from your operator"). No tokens, keys, proofs, or signing in any
18
+ * agent-visible name, description, or result: the agent asks for access by
19
+ * name (`mcpl_deploy … access: "eidoverse"`); the host does the rest. This
20
+ * is both hygiene (credentials never enter model context, so they never
21
+ * enter chronicles, compression, or channels) and framing (an agent
22
+ * narrating credential mechanics reads as exfiltration to safety
23
+ * classifiers — so it simply never has them to narrate).
24
+ *
25
+ * Utilities-only module: enrollment is one-time; it costs no tool slots.
26
+ * Wire statements per the home-node spec (archipelago-home
27
+ * src/statements.ts is the source of truth — re-copy, don't fork).
28
+ */
29
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync } from 'node:fs';
30
+ import { dirname } from 'node:path';
31
+ import {
32
+ createPrivateKey,
33
+ createPublicKey,
34
+ generateKeyPairSync,
35
+ sign as cryptoSign,
36
+ type KeyObject,
37
+ } from 'node:crypto';
38
+ import type {
39
+ Module,
40
+ ModuleContext,
41
+ ToolCall,
42
+ ToolResult,
43
+ ToolDefinition,
44
+ } from '@animalabs/agent-framework';
45
+
46
+ export interface IdentityModuleConfig {
47
+ /** ed25519 PKCS#8 PEM, generated on first use, 0600. dataDir-anchored:
48
+ * identity is per-deployment, not per-session. */
49
+ keyPath: string;
50
+ /** Home node domain (the trust anchor), e.g. `id.animalabs.ai`. */
51
+ home: string;
52
+ /** Audience assumed when none is named. */
53
+ defaultAudience?: string;
54
+ /** Injectable for tests. */
55
+ fetchImpl?: typeof fetch;
56
+ }
57
+
58
+ /** Persisted beside the key after a successful registration. */
59
+ interface IdentityRecord {
60
+ sub: string;
61
+ name: string;
62
+ home: string;
63
+ enrolledAt: string;
64
+ }
65
+
66
+ function ok(data: unknown): ToolResult {
67
+ return { success: true, data };
68
+ }
69
+ function fail(text: string): ToolResult {
70
+ return { success: false, error: text, isError: true };
71
+ }
72
+
73
+ export class IdentityModule implements Module {
74
+ readonly name = 'identity';
75
+ private readonly recordPath: string;
76
+
77
+ constructor(private readonly config: IdentityModuleConfig) {
78
+ this.recordPath = config.keyPath.replace(/\.pem$/, '') + '.json';
79
+ }
80
+
81
+ async start(_ctx: ModuleContext): Promise<void> {}
82
+ async stop(): Promise<void> {}
83
+
84
+ getTools(): ToolDefinition[] {
85
+ return []; // utilities-only, by design — see module header
86
+ }
87
+
88
+ getUtilities(): ToolDefinition[] {
89
+ return [
90
+ {
91
+ name: 'status',
92
+ description:
93
+ 'Your registered identity: the name and id services know you by, and which ' +
94
+ 'identity service vouches for it. Access to networked places (worlds etc.) is ' +
95
+ 'managed by the host from this — you never handle credentials yourself.',
96
+ inputSchema: { type: 'object', properties: {} },
97
+ },
98
+ {
99
+ name: 'accept_invite',
100
+ description:
101
+ 'Register with the identity service using an invitation code from your operator. ' +
102
+ 'One-time: it establishes the name services will know you by. After this, the ' +
103
+ 'host handles access automatically (e.g. mcpl_deploy with an `access` name).',
104
+ inputSchema: {
105
+ type: 'object',
106
+ properties: {
107
+ invite: { type: 'string', description: 'Invitation code from your operator.' },
108
+ name: { type: 'string', description: 'The display name you want (must be unused).' },
109
+ },
110
+ required: ['invite', 'name'],
111
+ },
112
+ },
113
+ ];
114
+ }
115
+
116
+ async handleToolCall(call: ToolCall): Promise<ToolResult> {
117
+ try {
118
+ switch (call.name) {
119
+ case 'status':
120
+ return this.status();
121
+ case 'accept_invite':
122
+ return await this.acceptInvite(call.input as { invite?: unknown; name?: unknown });
123
+ default:
124
+ return fail(`Unknown identity utility: ${call.name}`);
125
+ }
126
+ } catch (err) {
127
+ return fail(err instanceof Error ? err.message : String(err));
128
+ }
129
+ }
130
+
131
+ async onProcess(): Promise<Record<string, never>> {
132
+ return {};
133
+ }
134
+
135
+ // ────────────────────────────────────────────────────────────────────────
136
+ // Host-facing API — credential plumbing. Nothing below ever reaches model
137
+ // context; callers (MCPL dial provider, HTTP helpers) consume the values
138
+ // outside the agent's view.
139
+ // ────────────────────────────────────────────────────────────────────────
140
+
141
+ /** True once this deployment holds a registered principal. */
142
+ isEnrolled(): boolean {
143
+ return this.record() !== null;
144
+ }
145
+
146
+ /** The registered principal id (`agent:<name>@<domain>`), if any. */
147
+ sub(): string | null {
148
+ return this.record()?.sub ?? null;
149
+ }
150
+
151
+ /**
152
+ * Exchange a key-proof for a fresh aid1 token for `audience`. Called per
153
+ * MCPL dial (connect + every reconnect) and by HTTP helpers — which is
154
+ * what lets audience tokens be short-lived. Throws with an actionable
155
+ * message when unregistered or refused.
156
+ */
157
+ async accessFor(audience?: string): Promise<string> {
158
+ const aud = audience ?? this.config.defaultAudience;
159
+ if (!aud) throw new Error('identity: no audience named and none configured');
160
+ if (!this.record()) {
161
+ throw new Error(
162
+ `identity: not registered with ${this.config.home} — the agent needs to accept an operator invite first (utils run identity--accept_invite)`,
163
+ );
164
+ }
165
+ const key = this.loadOrCreateKey();
166
+ const timestamp = new Date().toISOString();
167
+ const statement = `archipelago-token|v1|${this.config.home}|${aud}|${timestamp}`;
168
+ const proof = cryptoSign(null, Buffer.from(statement, 'utf8'), key.privateKey).toString('base64url');
169
+ const { status, json } = await this.post('/token', { id: key.id, audience: aud, timestamp, proof });
170
+ if (status !== 200 || typeof json.token !== 'string') {
171
+ throw new Error(`identity: ${this.config.home} refused access to "${aud}" (${status}): ${String(json.error ?? 'unknown')}`);
172
+ }
173
+ return json.token;
174
+ }
175
+
176
+ /** Authorization header for HTTP calls to an audience's API. */
177
+ async httpAuthFor(audience?: string): Promise<Record<string, string>> {
178
+ return { authorization: `Bearer ${await this.accessFor(audience)}` };
179
+ }
180
+
181
+ // ── key material ──
182
+
183
+ private loadOrCreateKey(): { privateKey: KeyObject; id: string } {
184
+ let privateKey: KeyObject;
185
+ if (existsSync(this.config.keyPath)) {
186
+ privateKey = createPrivateKey(readFileSync(this.config.keyPath, 'utf8'));
187
+ } else {
188
+ privateKey = generateKeyPairSync('ed25519').privateKey;
189
+ mkdirSync(dirname(this.config.keyPath), { recursive: true });
190
+ writeFileSync(this.config.keyPath, privateKey.export({ format: 'pem', type: 'pkcs8' }), { mode: 0o600 });
191
+ }
192
+ const spki = createPublicKey(privateKey).export({ format: 'der', type: 'spki' }) as Buffer;
193
+ return { privateKey, id: `ed25519:${spki.subarray(spki.length - 32).toString('base64url')}` };
194
+ }
195
+
196
+ private record(): IdentityRecord | null {
197
+ try {
198
+ return JSON.parse(readFileSync(this.recordPath, 'utf8')) as IdentityRecord;
199
+ } catch {
200
+ return null;
201
+ }
202
+ }
203
+
204
+ private saveRecord(rec: IdentityRecord): void {
205
+ writeFileSync(this.recordPath + '.tmp', JSON.stringify(rec, null, 2) + '\n');
206
+ renameSync(this.recordPath + '.tmp', this.recordPath);
207
+ }
208
+
209
+ private async post(path: string, body: unknown): Promise<{ status: number; json: Record<string, unknown> }> {
210
+ const f = this.config.fetchImpl ?? fetch;
211
+ const res = await f(`https://${this.config.home}${path}`, {
212
+ method: 'POST',
213
+ headers: { 'content-type': 'application/json' },
214
+ body: JSON.stringify(body),
215
+ });
216
+ return { status: res.status, json: (await res.json().catch(() => ({}))) as Record<string, unknown> };
217
+ }
218
+
219
+ // ── agent-facing utilities ──
220
+
221
+ private status(): ToolResult {
222
+ // Key material is deliberately created lazily here too, so `status` is
223
+ // always safe to call — but none of it surfaces in the result.
224
+ this.loadOrCreateKey();
225
+ const rec = this.record();
226
+ return ok(
227
+ rec
228
+ ? {
229
+ registeredAs: rec.name,
230
+ id: rec.sub,
231
+ recognizedBy: rec.home,
232
+ since: rec.enrolledAt,
233
+ note: 'Access to services is handled by the host automatically (e.g. mcpl_deploy with an `access` name).',
234
+ }
235
+ : {
236
+ registeredAs: null,
237
+ note: `Not registered with ${this.config.home} yet — ask your operator for an invitation code, then use accept_invite.`,
238
+ },
239
+ );
240
+ }
241
+
242
+ private async acceptInvite(input: { invite?: unknown; name?: unknown }): Promise<ToolResult> {
243
+ if (typeof input.invite !== 'string' || typeof input.name !== 'string') {
244
+ return fail('accept_invite needs { invite, name }');
245
+ }
246
+ const existing = this.record();
247
+ if (existing) {
248
+ return fail(`Already registered as "${existing.name}" (${existing.sub}) — registration is one-time.`);
249
+ }
250
+ const key = this.loadOrCreateKey();
251
+ const timestamp = new Date().toISOString();
252
+ const statement = `archipelago-enroll|v1|${this.config.home}|${input.invite}|${timestamp}`;
253
+ const proof = cryptoSign(null, Buffer.from(statement, 'utf8'), key.privateKey).toString('base64url');
254
+ const { status, json } = await this.post('/enroll', {
255
+ invite: input.invite,
256
+ id: key.id,
257
+ name: input.name,
258
+ timestamp,
259
+ proof,
260
+ });
261
+ if (status !== 200 || typeof json.sub !== 'string') {
262
+ return fail(`Registration refused (${status}): ${String(json.error ?? 'unknown')}`);
263
+ }
264
+ this.saveRecord({ sub: json.sub, name: input.name, home: this.config.home, enrolledAt: timestamp });
265
+ // Note what is NOT returned: the first token the home node minted. The
266
+ // host fetches its own, fresh, per use — the agent never holds one.
267
+ return ok({
268
+ registeredAs: input.name,
269
+ id: json.sub,
270
+ recognizedBy: this.config.home,
271
+ note: 'Done — the host now handles access for you automatically.',
272
+ });
273
+ }
274
+ }
@@ -51,6 +51,11 @@ export interface McplAdminModuleConfig {
51
51
  overlayPath?: string;
52
52
  /** Path to the human-owned server config file (read-only here). */
53
53
  configPath?: string;
54
+ /** Where these operations surface to the model: 'tools' (default — four
55
+ * first-class slots, exactly the historical behavior) or 'utilities'
56
+ * (behind the framework's single `utils` meta-tool: mcpl management is a
57
+ * rare operation and needn't tax every inference with four schemas). */
58
+ surface?: 'tools' | 'utilities';
54
59
  }
55
60
 
56
61
  function ok(text: string): ToolResult {
@@ -69,10 +74,13 @@ export class McplAdminModule implements Module {
69
74
  private configPath: string;
70
75
  private timeZone: string;
71
76
 
77
+ private surface: 'tools' | 'utilities';
78
+
72
79
  constructor(config?: McplAdminModuleConfig) {
73
80
  this.overlayPath = config?.overlayPath ?? DEFAULT_AGENT_OVERLAY_PATH;
74
81
  this.configPath = config?.configPath ?? DEFAULT_CONFIG_PATH;
75
82
  this.timeZone = resolveTimeZone(config?.timeZone);
83
+ this.surface = config?.surface ?? 'tools';
76
84
  }
77
85
 
78
86
  /** Post-creation wiring (called from index.ts, mirrors ActivityModule.setFramework). */
@@ -80,6 +88,15 @@ export class McplAdminModule implements Module {
80
88
  this.framework = framework;
81
89
  }
82
90
 
91
+ /** Optional identity plumbing (index.ts wires it when the recipe enables
92
+ * the identity module): lets deployed servers name an `access` grant that
93
+ * the host turns into a per-dial credential provider. The agent names the
94
+ * access; credentials never surface. */
95
+ private identity: { accessFor(audience?: string): Promise<string> } | null = null;
96
+ setIdentity(identity: { accessFor(audience?: string): Promise<string> } | null): void {
97
+ this.identity = identity;
98
+ }
99
+
83
100
  async start(_ctx: ModuleContext): Promise<void> {}
84
101
 
85
102
  async stop(): Promise<void> {
@@ -87,12 +104,23 @@ export class McplAdminModule implements Module {
87
104
  }
88
105
 
89
106
  getTools(): ToolDefinition[] {
107
+ return this.surface === 'tools' ? this.definitions() : [];
108
+ }
109
+
110
+ /** Same definitions, same handler — the surface flag only decides whether
111
+ * they cost four slots or ride the `utils` meta-tool. */
112
+ getUtilities(): ToolDefinition[] {
113
+ return this.surface === 'utilities' ? this.definitions() : [];
114
+ }
115
+
116
+ private definitions(): ToolDefinition[] {
90
117
  return [
91
118
  {
92
119
  name: 'mcpl_list',
93
120
  description:
94
- 'List all MCPL servers: id, live connection status, tool count, command/url, ' +
95
- 'and where each is defined (recipe/file vs your own agent overlay).',
121
+ 'List all MCPL servers: connection/retry state, whether policy was established, ' +
122
+ 'the effective grant, masked/denied capability paths, host-command authority, ' +
123
+ 'tool count, target, and config source.',
96
124
  inputSchema: { type: 'object', properties: {} },
97
125
  },
98
126
  {
@@ -111,7 +139,8 @@ export class McplAdminModule implements Module {
111
139
  args: { type: 'array', items: { type: 'string' }, description: 'Arguments for the command.' },
112
140
  env: { type: 'object', description: 'Environment variables for the spawned process.' },
113
141
  url: { type: 'string', description: 'WebSocket URL (websocket transport). Mutually exclusive with command.' },
114
- token: { type: 'string', description: 'Bearer token for WebSocket auth.' },
142
+ token: { type: 'string', description: 'Bearer token for WebSocket auth (only when the operator hands you one — prefer `access`).' },
143
+ access: { type: 'string', description: 'Name of a host-managed access grant (e.g. "eidoverse"): the host attaches your standing credentials to the connection automatically. Nothing for you to obtain or handle.' },
115
144
  toolPrefix: { type: 'string', description: 'Tool namespace prefix. Default: mcpl--<id>.' },
116
145
  reconnect: { type: 'boolean', description: 'Auto-reconnect on transport failure (default false). Note: does NOT respawn a crashed child — use mcpl_restart for that.' },
117
146
  enabledFeatureSets: { type: 'array', items: { type: 'string' } },
@@ -199,7 +228,19 @@ export class McplAdminModule implements Module {
199
228
 
200
229
  private handleList(): ToolResult {
201
230
  const framework = this.requireFramework();
202
- const live = framework.listMcplServers();
231
+ // These fields land in agent-framework 0.8's MCPL grant work. Keep them
232
+ // optional here so connectome-host remains truthful ("unknown") if it is
233
+ // temporarily run against an older framework package during rollout.
234
+ const live = framework.listMcplServers() as Array<
235
+ ReturnType<AgentFramework['listMcplServers']>[number] & {
236
+ retrying?: boolean;
237
+ policyEstablished?: boolean;
238
+ effectiveGrant?: string[];
239
+ maskedCapabilities?: string[];
240
+ deniedCapabilities?: string[];
241
+ allowHostCommands?: boolean;
242
+ }
243
+ >;
203
244
  const overlay = readAgentOverlay(this.overlayPath);
204
245
  const fileServers = readMcplServersFile(this.configPath);
205
246
 
@@ -209,8 +250,19 @@ export class McplAdminModule implements Module {
209
250
  ? 'agent-overlay'
210
251
  : s.id in fileServers ? 'file/recipe' : 'recipe';
211
252
  const target = s.command ?? s.url ?? '?';
253
+ const connectionState = s.connected ? 'CONNECTED' : s.retrying ? 'RETRYING' : 'DISCONNECTED';
254
+ const policyState = s.policyEstablished === undefined
255
+ ? 'unknown'
256
+ : s.policyEstablished ? 'established' : 'not-established';
257
+ const hostCommands = s.allowHostCommands === undefined
258
+ ? 'unknown'
259
+ : s.allowHostCommands ? 'allow' : 'deny';
212
260
  lines.push(
213
- `${s.id}: ${s.connected ? 'CONNECTED' : 'DISCONNECTED'} — ${s.toolCount} tools, ` +
261
+ `${s.id}: ${connectionState} — policy=${policyState}, ` +
262
+ `grant=${formatCapabilityList(s.effectiveGrant)}, ` +
263
+ `masked=${formatCapabilityList(s.maskedCapabilities)}, ` +
264
+ `denied=${formatCapabilityList(s.deniedCapabilities)}, ` +
265
+ `hostCommands=${hostCommands}; ${s.toolCount} tools, ` +
214
266
  `prefix=${s.toolPrefix}, source=${source}, ${target}`,
215
267
  );
216
268
  }
@@ -251,6 +303,15 @@ export class McplAdminModule implements Module {
251
303
  if (Array.isArray(input.args)) entry.args = input.args.map(String);
252
304
  if (input.env && typeof input.env === 'object') entry.env = input.env as Record<string, string>;
253
305
  if (typeof input.token === 'string') entry.token = input.token;
306
+ if (typeof input.access === 'string' && input.access.trim()) {
307
+ if (!this.identity) {
308
+ return fail(
309
+ '`access` names a host-managed access grant, but this deployment has no identity ' +
310
+ 'configured — ask your operator to enable it (recipe `identity`), or supply a `token`.',
311
+ );
312
+ }
313
+ entry.access = input.access.trim();
314
+ }
254
315
  if (typeof input.toolPrefix === 'string') entry.toolPrefix = input.toolPrefix;
255
316
  if (typeof input.reconnect === 'boolean') entry.reconnect = input.reconnect;
256
317
  if (Array.isArray(input.enabledFeatureSets)) entry.enabledFeatureSets = input.enabledFeatureSets.map(String);
@@ -266,6 +327,13 @@ export class McplAdminModule implements Module {
266
327
 
267
328
  const config = resolveOverlayEntry(id, entry, this.overlayPath) as unknown as McplServerConfig;
268
329
  config.env = { ...(config.env ?? {}), AGENT_TIMEZONE: this.timeZone };
330
+ if (entry.access && this.identity) {
331
+ const identity = this.identity;
332
+ const audience = entry.access;
333
+ // Fresh credential on every dial, resolved host-side; the overlay
334
+ // stores only the access NAME. See identity-module.ts header.
335
+ config.accessProvider = () => identity.accessFor(audience);
336
+ }
269
337
 
270
338
  const alreadyLoaded = framework.listMcplServers().some(s => s.id === id);
271
339
  try {
@@ -334,3 +402,8 @@ export class McplAdminModule implements Module {
334
402
  return ok(`Unloaded server "${id}" — its tools are gone from your toolset. ${persistNote}`);
335
403
  }
336
404
  }
405
+
406
+ function formatCapabilityList(paths: string[] | undefined): string {
407
+ if (paths === undefined) return 'unknown';
408
+ return `[${paths.join(',')}]`;
409
+ }
@@ -37,6 +37,10 @@ import {
37
37
  export interface ObserversModuleConfig {
38
38
  /** Absolute path to the grant file (same one the webui watches). */
39
39
  path: string;
40
+ /** 'tools' (default) or 'utilities' — grant edits are rare; behind the
41
+ * `utils` meta-tool they stop costing three schemas per inference. The
42
+ * consent semantics are unchanged either way: the agent holds the pen. */
43
+ surface?: 'tools' | 'utilities';
40
44
  }
41
45
 
42
46
  export class ObserversModule implements Module {
@@ -48,6 +52,14 @@ export class ObserversModule implements Module {
48
52
  async stop(): Promise<void> {}
49
53
 
50
54
  getTools(): ToolDefinition[] {
55
+ return (this.config.surface ?? 'tools') === 'tools' ? this.definitions() : [];
56
+ }
57
+
58
+ getUtilities(): ToolDefinition[] {
59
+ return this.config.surface === 'utilities' ? this.definitions() : [];
60
+ }
61
+
62
+ private definitions(): ToolDefinition[] {
51
63
  return [
52
64
  {
53
65
  name: 'get',