@animalabs/agent-framework 0.28.0 → 0.29.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 (240) hide show
  1. package/README.md +88 -25
  2. package/dist/src/agent.d.ts.map +1 -1
  3. package/dist/src/agent.js +4 -1
  4. package/dist/src/agent.js.map +1 -1
  5. package/dist/src/api/mcp-server-core.d.ts.map +1 -1
  6. package/dist/src/api/mcp-server-core.js +3 -12
  7. package/dist/src/api/mcp-server-core.js.map +1 -1
  8. package/dist/src/api/server.d.ts.map +1 -1
  9. package/dist/src/api/server.js +7 -3
  10. package/dist/src/api/server.js.map +1 -1
  11. package/dist/src/auto-continue.d.ts +2 -0
  12. package/dist/src/auto-continue.d.ts.map +1 -1
  13. package/dist/src/auto-continue.js +5 -2
  14. package/dist/src/auto-continue.js.map +1 -1
  15. package/dist/src/code-execution/isolate/runner.d.ts +7 -5
  16. package/dist/src/code-execution/isolate/runner.d.ts.map +1 -1
  17. package/dist/src/code-execution/isolate/runner.js +7 -5
  18. package/dist/src/code-execution/isolate/runner.js.map +1 -1
  19. package/dist/src/code-execution/isolate/scripts.d.ts.map +1 -1
  20. package/dist/src/code-execution/isolate/scripts.js +3 -2
  21. package/dist/src/code-execution/isolate/scripts.js.map +1 -1
  22. package/dist/src/framework.d.ts +164 -36
  23. package/dist/src/framework.d.ts.map +1 -1
  24. package/dist/src/framework.js +1120 -479
  25. package/dist/src/framework.js.map +1 -1
  26. package/dist/src/gate/event-gate.d.ts +34 -10
  27. package/dist/src/gate/event-gate.d.ts.map +1 -1
  28. package/dist/src/gate/event-gate.js +118 -48
  29. package/dist/src/gate/event-gate.js.map +1 -1
  30. package/dist/src/gate/index.d.ts +1 -1
  31. package/dist/src/gate/index.d.ts.map +1 -1
  32. package/dist/src/gate/index.js +1 -1
  33. package/dist/src/gate/index.js.map +1 -1
  34. package/dist/src/gate/types.d.ts +12 -2
  35. package/dist/src/gate/types.d.ts.map +1 -1
  36. package/dist/src/heartbeat/heartbeat.d.ts +18 -0
  37. package/dist/src/heartbeat/heartbeat.d.ts.map +1 -1
  38. package/dist/src/heartbeat/heartbeat.js +34 -8
  39. package/dist/src/heartbeat/heartbeat.js.map +1 -1
  40. package/dist/src/held-writes.d.ts +3 -3
  41. package/dist/src/held-writes.js +3 -3
  42. package/dist/src/host-presentation.d.ts +170 -0
  43. package/dist/src/host-presentation.d.ts.map +1 -0
  44. package/dist/src/host-presentation.js +561 -0
  45. package/dist/src/host-presentation.js.map +1 -0
  46. package/dist/src/index.d.ts +2 -2
  47. package/dist/src/index.d.ts.map +1 -1
  48. package/dist/src/index.js +1 -1
  49. package/dist/src/index.js.map +1 -1
  50. package/dist/src/line-reader.d.ts +4 -0
  51. package/dist/src/line-reader.d.ts.map +1 -0
  52. package/dist/src/line-reader.js +59 -0
  53. package/dist/src/line-reader.js.map +1 -0
  54. package/dist/src/mcpl/channel-registry.d.ts +34 -6
  55. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  56. package/dist/src/mcpl/channel-registry.js +131 -53
  57. package/dist/src/mcpl/channel-registry.js.map +1 -1
  58. package/dist/src/mcpl/conversation-router.d.ts.map +1 -1
  59. package/dist/src/mcpl/conversation-router.js +2 -1
  60. package/dist/src/mcpl/conversation-router.js.map +1 -1
  61. package/dist/src/mcpl/convert-block.d.ts +1 -0
  62. package/dist/src/mcpl/convert-block.d.ts.map +1 -1
  63. package/dist/src/mcpl/convert-block.js +16 -4
  64. package/dist/src/mcpl/convert-block.js.map +1 -1
  65. package/dist/src/mcpl/hook-orchestrator.d.ts.map +1 -1
  66. package/dist/src/mcpl/hook-orchestrator.js +4 -3
  67. package/dist/src/mcpl/hook-orchestrator.js.map +1 -1
  68. package/dist/src/mcpl/inbound-source.d.ts +3 -1
  69. package/dist/src/mcpl/inbound-source.d.ts.map +1 -1
  70. package/dist/src/mcpl/inbound-source.js +3 -1
  71. package/dist/src/mcpl/inbound-source.js.map +1 -1
  72. package/dist/src/mcpl/modern-connection.d.ts.map +1 -1
  73. package/dist/src/mcpl/modern-connection.js +11 -7
  74. package/dist/src/mcpl/modern-connection.js.map +1 -1
  75. package/dist/src/mcpl/protocol-family.d.ts +3 -2
  76. package/dist/src/mcpl/protocol-family.d.ts.map +1 -1
  77. package/dist/src/mcpl/protocol-family.js +25 -3
  78. package/dist/src/mcpl/protocol-family.js.map +1 -1
  79. package/dist/src/mcpl/push-handler.js +2 -2
  80. package/dist/src/mcpl/push-handler.js.map +1 -1
  81. package/dist/src/mcpl/references.d.ts +12 -40
  82. package/dist/src/mcpl/references.d.ts.map +1 -1
  83. package/dist/src/mcpl/references.js +29 -23
  84. package/dist/src/mcpl/references.js.map +1 -1
  85. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  86. package/dist/src/mcpl/server-connection.js +20 -20
  87. package/dist/src/mcpl/server-connection.js.map +1 -1
  88. package/dist/src/mcpl/skip-reply-wake.d.ts +25 -0
  89. package/dist/src/mcpl/skip-reply-wake.d.ts.map +1 -1
  90. package/dist/src/mcpl/skip-reply-wake.js +25 -0
  91. package/dist/src/mcpl/skip-reply-wake.js.map +1 -1
  92. package/dist/src/mcpl/tool-presence.d.ts.map +1 -1
  93. package/dist/src/mcpl/tool-presence.js +18 -17
  94. package/dist/src/mcpl/tool-presence.js.map +1 -1
  95. package/dist/src/mcpl/tool-signature.d.ts.map +1 -1
  96. package/dist/src/mcpl/tool-signature.js +17 -16
  97. package/dist/src/mcpl/tool-signature.js.map +1 -1
  98. package/dist/src/mcpl/toolset-host.d.ts +8 -26
  99. package/dist/src/mcpl/toolset-host.d.ts.map +1 -1
  100. package/dist/src/mcpl/toolset-host.js +16 -6
  101. package/dist/src/mcpl/toolset-host.js.map +1 -1
  102. package/dist/src/mcpl/toolsets-module.d.ts +14 -25
  103. package/dist/src/mcpl/toolsets-module.d.ts.map +1 -1
  104. package/dist/src/mcpl/toolsets-module.js +180 -50
  105. package/dist/src/mcpl/toolsets-module.js.map +1 -1
  106. package/dist/src/mcpl/transport.d.ts +1 -1
  107. package/dist/src/mcpl/transport.d.ts.map +1 -1
  108. package/dist/src/mcpl/transport.js +16 -16
  109. package/dist/src/mcpl/transport.js.map +1 -1
  110. package/dist/src/mcpl/types.d.ts +28 -12
  111. package/dist/src/mcpl/types.d.ts.map +1 -1
  112. package/dist/src/mcpl/types.js.map +1 -1
  113. package/dist/src/module-registry.d.ts +6 -7
  114. package/dist/src/module-registry.d.ts.map +1 -1
  115. package/dist/src/module-registry.js +6 -7
  116. package/dist/src/module-registry.js.map +1 -1
  117. package/dist/src/modules/api/index.d.ts.map +1 -1
  118. package/dist/src/modules/api/index.js +4 -2
  119. package/dist/src/modules/api/index.js.map +1 -1
  120. package/dist/src/modules/discord/index.d.ts.map +1 -1
  121. package/dist/src/modules/discord/index.js +33 -25
  122. package/dist/src/modules/discord/index.js.map +1 -1
  123. package/dist/src/modules/history/index.js +8 -6
  124. package/dist/src/modules/history/index.js.map +1 -1
  125. package/dist/src/modules/sender-name.d.ts +7 -0
  126. package/dist/src/modules/sender-name.d.ts.map +1 -0
  127. package/dist/src/modules/sender-name.js +10 -0
  128. package/dist/src/modules/sender-name.js.map +1 -0
  129. package/dist/src/modules/workspace/answers.d.ts +5 -1
  130. package/dist/src/modules/workspace/answers.d.ts.map +1 -1
  131. package/dist/src/modules/workspace/answers.js +16 -1
  132. package/dist/src/modules/workspace/answers.js.map +1 -1
  133. package/dist/src/modules/workspace/direct-write.d.ts.map +1 -1
  134. package/dist/src/modules/workspace/direct-write.js +6 -4
  135. package/dist/src/modules/workspace/direct-write.js.map +1 -1
  136. package/dist/src/modules/workspace/effects.d.ts +4 -2
  137. package/dist/src/modules/workspace/effects.d.ts.map +1 -1
  138. package/dist/src/modules/workspace/effects.js +50 -40
  139. package/dist/src/modules/workspace/effects.js.map +1 -1
  140. package/dist/src/modules/workspace/index.d.ts +1 -0
  141. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  142. package/dist/src/modules/workspace/index.js +66 -36
  143. package/dist/src/modules/workspace/index.js.map +1 -1
  144. package/dist/src/modules/workspace/observe.d.ts +51 -20
  145. package/dist/src/modules/workspace/observe.d.ts.map +1 -1
  146. package/dist/src/modules/workspace/observe.js +120 -82
  147. package/dist/src/modules/workspace/observe.js.map +1 -1
  148. package/dist/src/modules/workspace/reconcile.d.ts +7 -1
  149. package/dist/src/modules/workspace/reconcile.d.ts.map +1 -1
  150. package/dist/src/modules/workspace/reconcile.js +133 -37
  151. package/dist/src/modules/workspace/reconcile.js.map +1 -1
  152. package/dist/src/notice-tags.d.ts +79 -0
  153. package/dist/src/notice-tags.d.ts.map +1 -0
  154. package/dist/src/notice-tags.js +83 -0
  155. package/dist/src/notice-tags.js.map +1 -0
  156. package/dist/src/reasoning-loss-notice.d.ts.map +1 -1
  157. package/dist/src/reasoning-loss-notice.js +5 -3
  158. package/dist/src/reasoning-loss-notice.js.map +1 -1
  159. package/dist/src/silent-heartbeat.d.ts +1 -53
  160. package/dist/src/silent-heartbeat.d.ts.map +1 -1
  161. package/dist/src/silent-heartbeat.js +56 -3
  162. package/dist/src/silent-heartbeat.js.map +1 -1
  163. package/dist/src/supplied-text.d.ts +35 -0
  164. package/dist/src/supplied-text.d.ts.map +1 -0
  165. package/dist/src/supplied-text.js +141 -0
  166. package/dist/src/supplied-text.js.map +1 -0
  167. package/dist/src/tool-args.d.ts.map +1 -1
  168. package/dist/src/tool-args.js +9 -8
  169. package/dist/src/tool-args.js.map +1 -1
  170. package/dist/src/tool-image-ledger.d.ts.map +1 -1
  171. package/dist/src/tool-image-ledger.js +2 -1
  172. package/dist/src/tool-image-ledger.js.map +1 -1
  173. package/dist/src/tool-presentation.d.ts.map +1 -1
  174. package/dist/src/tool-presentation.js +5 -4
  175. package/dist/src/tool-presentation.js.map +1 -1
  176. package/dist/src/tune-out/coordinator.d.ts +3 -2
  177. package/dist/src/tune-out/coordinator.d.ts.map +1 -1
  178. package/dist/src/tune-out/coordinator.js +24 -26
  179. package/dist/src/tune-out/coordinator.js.map +1 -1
  180. package/dist/src/types/agent.d.ts +23 -9
  181. package/dist/src/types/agent.d.ts.map +1 -1
  182. package/dist/src/types/framework.d.ts +43 -0
  183. package/dist/src/types/framework.d.ts.map +1 -1
  184. package/dist/src/types/module.d.ts +22 -11
  185. package/dist/src/types/module.d.ts.map +1 -1
  186. package/package.json +2 -2
  187. package/src/agent.ts +4 -1
  188. package/src/api/mcp-server-core.ts +3 -14
  189. package/src/api/server.ts +7 -3
  190. package/src/auto-continue.ts +8 -2
  191. package/src/code-execution/isolate/runner.ts +7 -5
  192. package/src/code-execution/isolate/scripts.ts +3 -2
  193. package/src/framework.ts +1132 -492
  194. package/src/gate/event-gate.ts +128 -47
  195. package/src/gate/index.ts +1 -1
  196. package/src/gate/types.ts +12 -2
  197. package/src/heartbeat/heartbeat.ts +48 -9
  198. package/src/held-writes.ts +3 -3
  199. package/src/host-presentation.ts +605 -0
  200. package/src/index.ts +2 -2
  201. package/src/line-reader.ts +58 -0
  202. package/src/mcpl/channel-registry.ts +137 -52
  203. package/src/mcpl/conversation-router.ts +2 -1
  204. package/src/mcpl/convert-block.ts +17 -4
  205. package/src/mcpl/hook-orchestrator.ts +4 -3
  206. package/src/mcpl/inbound-source.ts +3 -1
  207. package/src/mcpl/modern-connection.ts +11 -7
  208. package/src/mcpl/protocol-family.ts +24 -3
  209. package/src/mcpl/push-handler.ts +2 -2
  210. package/src/mcpl/references.ts +31 -23
  211. package/src/mcpl/server-connection.ts +20 -18
  212. package/src/mcpl/skip-reply-wake.ts +35 -0
  213. package/src/mcpl/tool-presence.ts +18 -17
  214. package/src/mcpl/tool-signature.ts +17 -16
  215. package/src/mcpl/toolset-host.ts +18 -6
  216. package/src/mcpl/toolsets-module.ts +142 -50
  217. package/src/mcpl/transport.ts +18 -14
  218. package/src/mcpl/types.ts +29 -12
  219. package/src/module-registry.ts +6 -7
  220. package/src/modules/api/index.ts +4 -2
  221. package/src/modules/discord/index.ts +37 -25
  222. package/src/modules/history/index.ts +8 -6
  223. package/src/modules/sender-name.ts +9 -0
  224. package/src/modules/workspace/answers.ts +20 -2
  225. package/src/modules/workspace/direct-write.ts +4 -3
  226. package/src/modules/workspace/effects.ts +50 -40
  227. package/src/modules/workspace/index.ts +58 -34
  228. package/src/modules/workspace/observe.ts +139 -88
  229. package/src/modules/workspace/reconcile.ts +159 -36
  230. package/src/notice-tags.ts +84 -0
  231. package/src/reasoning-loss-notice.ts +5 -3
  232. package/src/silent-heartbeat.ts +4 -3
  233. package/src/supplied-text.ts +154 -0
  234. package/src/tool-args.ts +9 -8
  235. package/src/tool-image-ledger.ts +2 -1
  236. package/src/tool-presentation.ts +5 -4
  237. package/src/tune-out/coordinator.ts +25 -27
  238. package/src/types/agent.ts +23 -9
  239. package/src/types/framework.ts +44 -0
  240. package/src/types/module.ts +23 -11
@@ -0,0 +1,605 @@
1
+ /**
2
+ * The host's own toolsets (MCPL RFC-014 §8.5 host-made toolsets, §8.1 surface
3
+ * presentation): how tools outside an agent's tool list are presented, for
4
+ * the framework's built-in tools and for MCPL servers alike. It's host
5
+ * presentation, not an MCPL wire change: it works for every server, with or
6
+ * without RFC-014.
7
+ *
8
+ * - **The built-in toolset** (`settings`): the host tools an agent reaches
9
+ * for rarely (its settings, wake rules, observers, MCPL administration,
10
+ * status reads, modules' utilities, and every tool the agent hid from its
11
+ * own list). None of them is in the tool list; `toolsets--open` shows them,
12
+ * `toolsets--act` calls them while it's open.
13
+ * - **MCPL server tabs**: each server is presented to each agent one of three
14
+ * ways. `full`: its tools in the tool list, as before. `tab` (a hidden
15
+ * tab): connected, its events still arrive, but none of its tools is in the
16
+ * tool list; `toolsets--open {server}` shows them and lets `toolsets--act`
17
+ * call them, `toolsets--close {server}` hides them again. `closed-tab`:
18
+ * disconnected until `toolsets--open {server}` reconnects it.
19
+ *
20
+ * The tool list (the request's header) changes only when a server switches
21
+ * between full and a tab, or a tool is hidden or shown: opening, acting in and
22
+ * closing a toolset never change it, so the prompt cache stays.
23
+ *
24
+ * State lives in one JSON file per store (`config/toolsets.json`): per agent,
25
+ * which host toolsets are open and each server's presentation where it
26
+ * differs from the configured default; per server (host-wide, since a
27
+ * connection is the host's), whether it is disconnected as a closed tab.
28
+ */
29
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
30
+ import { dirname } from 'node:path';
31
+ import type { ToolDefinition } from './types/events.js';
32
+ import { checkArgs, firstSentence, renderToolDefinition, signature } from './mcpl/tool-signature.js';
33
+ import { META_TOOL_NAMES } from './mcpl/toolset-host.js';
34
+
35
+ /** The built-in toolset's name, as agents address it (`toolsets--open {toolset: "settings"}`). */
36
+ export const BUILTIN_TOOLSET = 'settings';
37
+
38
+ /** How a server is presented: in the tool list, as a tab (connected, hidden), or as a closed tab (disconnected). */
39
+ export type ServerPresentation = 'full' | 'tab' | 'closed-tab';
40
+
41
+ export const SERVER_PRESENTATIONS: readonly ServerPresentation[] = ['full', 'tab', 'closed-tab'];
42
+
43
+ /** A server as the host's presentation knows it. */
44
+ export interface PresentedServer {
45
+ id: string;
46
+ /** The operator's default for it (McplServerConfig.presentation), full when unset. */
47
+ configured: ServerPresentation;
48
+ /** Whether it's connected now. */
49
+ connected: boolean;
50
+ }
51
+
52
+ export interface HostPresentationDeps {
53
+ /** Every tool the agent may call now, before the tool list is trimmed (the "available" surface). */
54
+ available(agentName: string): ToolDefinition[];
55
+ /** Whether a tool belongs to the built-in toolset by the framework's or its module's placement. */
56
+ placedInBuiltin(toolName: string): boolean;
57
+ /** The tools the agent has hidden from its own list (tool presentation), by name. */
58
+ hidden(agentName: string): ReadonlySet<string>;
59
+ /** The MCPL server a tool name reaches, or null for a host tool. */
60
+ serverOf(toolName: string): string | null;
61
+ /** Every server the host has configured: connected, or kept as a closed tab. */
62
+ servers(): PresentedServer[];
63
+ /** Whether the agent can call the meta-tools: without them the host presents nothing out of the tool list. */
64
+ canUseMeta(agentName: string): boolean;
65
+ /** Disconnect a server, keeping its configuration to connect it again. */
66
+ disconnect(serverId: string): Promise<void>;
67
+ /** Connect a server kept as a closed tab, with its configuration and credentials. */
68
+ reconnect(serverId: string): Promise<void>;
69
+ /** Where a change of the tool list is said: logs. */
70
+ log(line: string): void;
71
+ /** Whether a host tool is a module utility: never in the tool list, whatever else. */
72
+ isUtility(toolName: string): boolean;
73
+ /** The resident agents of this host (no forks, ephemeral runs or the subconscious). */
74
+ residents(): string[];
75
+ }
76
+
77
+ interface AgentState {
78
+ /** Host toolsets this agent has open: `settings`, or a server's tab as `server:<id>`. */
79
+ open: string[];
80
+ /** Server presentations the agent chose, where they differ from the configured default. */
81
+ servers: Record<string, 'full' | 'tab'>;
82
+ }
83
+
84
+ interface StateDoc {
85
+ version: 1;
86
+ agents: Record<string, AgentState>;
87
+ /** Servers disconnected as closed tabs (true), or connected against a closed-tab default (false). */
88
+ closed: Record<string, boolean>;
89
+ }
90
+
91
+ const emptyDoc = (): StateDoc => ({ version: 1, agents: {}, closed: {} });
92
+ const isObj = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v);
93
+
94
+ function parseDoc(raw: string): StateDoc {
95
+ const parsed = JSON.parse(raw) as unknown;
96
+ if (!isObj(parsed) || parsed.version !== 1) throw new Error('not a version 1 toolsets state');
97
+ const doc = emptyDoc();
98
+ if (isObj(parsed.agents)) {
99
+ for (const [name, value] of Object.entries(parsed.agents)) {
100
+ if (!isObj(value)) continue;
101
+ const open = Array.isArray(value.open) ? value.open.filter((x): x is string => typeof x === 'string') : [];
102
+ const servers: Record<string, 'full' | 'tab'> = {};
103
+ if (isObj(value.servers)) {
104
+ for (const [id, mode] of Object.entries(value.servers)) if (mode === 'full' || mode === 'tab') servers[id] = mode;
105
+ }
106
+ doc.agents[name] = { open, servers };
107
+ }
108
+ }
109
+ if (isObj(parsed.closed)) {
110
+ for (const [id, closed] of Object.entries(parsed.closed)) if (typeof closed === 'boolean') doc.closed[id] = closed;
111
+ }
112
+ return doc;
113
+ }
114
+
115
+ const tabKey = (serverId: string) => `server:${serverId}`;
116
+
117
+ /** What a call through `toolsets--act` reaches when it's a host toolset's tool. */
118
+ export type HostActTarget =
119
+ | { kind: 'call'; name: string; args: Record<string, unknown> }
120
+ | { kind: 'refused'; error: string };
121
+
122
+ export class HostPresentation {
123
+ private doc: StateDoc;
124
+
125
+ constructor(private readonly deps: HostPresentationDeps, private readonly statePath: string | null) {
126
+ this.doc = emptyDoc();
127
+ if (statePath && existsSync(statePath)) {
128
+ try {
129
+ this.doc = parseDoc(readFileSync(statePath, 'utf8'));
130
+ } catch (error) {
131
+ // Kept, not written over: moved aside, so what an agent chose can be
132
+ // read back by whoever runs the host.
133
+ const aside = `${statePath}.unreadable-${new Date().toISOString().replace(/[:.]/g, '-')}`;
134
+ let moved = false;
135
+ try { renameSync(statePath, aside); moved = true; } catch { /* left where it is */ }
136
+ deps.log(`[toolsets] ${statePath} can't be read (${error instanceof Error ? error.message : String(error)}); `
137
+ + (moved ? `moved aside to ${aside}; ` : 'left as it is, and not written to; ')
138
+ + 'starting from the configured presentation');
139
+ if (!moved) this.unwritable = true;
140
+ }
141
+ }
142
+ }
143
+
144
+ /** An unreadable state file that couldn't be moved aside is never written over. */
145
+ private unwritable = false;
146
+ /** Reconnects of closed tabs under way, so two opens make one connect. */
147
+ private reconnecting = new Map<string, Promise<void>>();
148
+ /** Each server's opens and closes, one at a time: a disconnect's guard holds until it's done. */
149
+ private serverOps = new Map<string, Promise<unknown>>();
150
+
151
+ private serially<T>(serverId: string, op: () => Promise<T>): Promise<T> {
152
+ const before = this.serverOps.get(serverId) ?? Promise.resolve();
153
+ const run = before.then(op, op);
154
+ const settled = run.catch(() => undefined);
155
+ this.serverOps.set(serverId, settled);
156
+ void settled.then(() => { if (this.serverOps.get(serverId) === settled) this.serverOps.delete(serverId); });
157
+ return run;
158
+ }
159
+
160
+ private save(): void {
161
+ if (!this.statePath || this.unwritable) return;
162
+ // An agent with nothing open and nothing chosen has no state to keep.
163
+ for (const [name, a] of Object.entries(this.doc.agents)) {
164
+ if (a.open.length === 0 && Object.keys(a.servers).length === 0) delete this.doc.agents[name];
165
+ }
166
+ try {
167
+ mkdirSync(dirname(this.statePath), { recursive: true });
168
+ const tmp = `${this.statePath}.tmp`;
169
+ writeFileSync(tmp, JSON.stringify(this.doc, null, 2) + '\n');
170
+ renameSync(tmp, this.statePath);
171
+ } catch (error) {
172
+ this.deps.log(`[toolsets] couldn't save ${this.statePath}: ${error instanceof Error ? error.message : String(error)}`);
173
+ }
174
+ }
175
+
176
+ private agent(name: string): AgentState {
177
+ return (this.doc.agents[name] ??= { open: [], servers: {} });
178
+ }
179
+
180
+ // ── what's known ──
181
+
182
+ /** Whether the host presents anything outside this agent's tool list. */
183
+ active(agentName: string): boolean {
184
+ return this.deps.canUseMeta(agentName);
185
+ }
186
+
187
+ private server(id: string): PresentedServer | undefined {
188
+ return this.deps.servers().find((s) => s.id === id);
189
+ }
190
+
191
+ /** Whether a server is kept disconnected as a closed tab (host-wide). */
192
+ isClosedTab(serverId: string): boolean {
193
+ const set = this.doc.closed[serverId];
194
+ if (set !== undefined) return set;
195
+ // A configured closed tab is closed until it's connected (by an open, or
196
+ // a runtime connect that didn't ask).
197
+ const server = this.server(serverId);
198
+ return server?.configured === 'closed-tab' && !server.connected;
199
+ }
200
+
201
+ /** How a server is presented to an agent now. */
202
+ presentation(agentName: string, serverId: string): ServerPresentation {
203
+ if (this.isClosedTab(serverId)) return 'closed-tab';
204
+ const chosen = this.doc.agents[agentName]?.servers[serverId];
205
+ if (chosen) return chosen;
206
+ const configured = this.server(serverId)?.configured ?? 'full';
207
+ return configured === 'closed-tab' ? 'tab' : configured;
208
+ }
209
+
210
+ /** Servers to leave unconnected at startup: closed tabs, by state or by configured default. */
211
+ startClosed(configs: ReadonlyArray<{ id: string; presentation?: ServerPresentation }>): Set<string> {
212
+ const out = new Set<string>();
213
+ for (const c of configs) {
214
+ const set = this.doc.closed[c.id];
215
+ if (set === true || (set === undefined && c.presentation === 'closed-tab')) out.add(c.id);
216
+ }
217
+ return out;
218
+ }
219
+
220
+ isOpen(agentName: string, key: string): boolean {
221
+ return this.doc.agents[agentName]?.open.includes(key) ?? false;
222
+ }
223
+
224
+ private setOpen(agentName: string, key: string, open: boolean): void {
225
+ const a = this.agent(agentName);
226
+ const has = a.open.includes(key);
227
+ if (open && !has) a.open.push(key);
228
+ if (!open && has) a.open = a.open.filter((k) => k !== key);
229
+ this.save();
230
+ }
231
+
232
+ // ── the tool list ──
233
+
234
+ /** The tool list an agent is shown: what it may call, less the built-in toolset and its tabs. */
235
+ header(agentName: string, tools: ToolDefinition[], active: boolean = this.active(agentName)): ToolDefinition[] {
236
+ // Without the meta-tools nothing is presented out of the tool list, but a
237
+ // utility still isn't in it: it was never a tool-list tool.
238
+ if (!active) return tools.filter((t) => isMetaTool(t.name) || !this.deps.isUtility(t.name));
239
+ return tools.filter((t) => !this.outOfHeader(agentName, t.name));
240
+ }
241
+
242
+ private outOfHeader(agentName: string, name: string): boolean {
243
+ if (isMetaTool(name)) return false;
244
+ // A utility is never a tool-list tool, presented here or not.
245
+ if (this.deps.isUtility(name)) return true;
246
+ const server = this.deps.serverOf(name);
247
+ if (server) return this.presentation(agentName, server) !== 'full';
248
+ return this.deps.placedInBuiltin(name);
249
+ }
250
+
251
+ /** The built-in toolset's tools for an agent: its placed tools, and the host tools it hid. */
252
+ builtinTools(agentName: string): ToolDefinition[] {
253
+ const hidden = this.deps.hidden(agentName);
254
+ return this.deps.available(agentName).filter((t) =>
255
+ !isMetaTool(t.name) && this.deps.serverOf(t.name) === null && (this.deps.placedInBuiltin(t.name) || hidden.has(t.name)));
256
+ }
257
+
258
+ /** A server's tab's tools for an agent: all of them in a tab, its hidden ones while it's full. */
259
+ tabTools(agentName: string, serverId: string): ToolDefinition[] {
260
+ const full = this.presentation(agentName, serverId) === 'full';
261
+ const hidden = this.deps.hidden(agentName);
262
+ return this.deps.available(agentName).filter((t) => this.deps.serverOf(t.name) === serverId && (!full || hidden.has(t.name)));
263
+ }
264
+
265
+ // ── what an agent is told ──
266
+
267
+ private announce(label: string, tools: ToolDefinition[], closeHint: string): string {
268
+ const lines = [`${label} open: call these with ${META_TOOL_NAMES.act} {tool, args}; they aren't in your tool list.`];
269
+ if (!tools.length) lines.push(' (none right now)');
270
+ for (const t of tools) {
271
+ const sentence = firstSentence(t.description);
272
+ lines.push(` ${signature(t)}${sentence ? ` — ${sentence}` : ''}`);
273
+ }
274
+ lines.push(`${closeHint} closes it.`);
275
+ return lines.join('\n');
276
+ }
277
+
278
+ private builtinLabel = `[${BUILTIN_TOOLSET}]`;
279
+ private tabLabel = (serverId: string) => `[tab ${serverId}]`;
280
+
281
+ /** The catalog lines `toolsets--look` begins with. */
282
+ catalog(agentName: string): string[] {
283
+ const builtin = this.builtinTools(agentName);
284
+ const lines = [
285
+ `${this.builtinLabel} the host's settings and controls (${builtin.length} tool${builtin.length === 1 ? '' : 's'}): ` +
286
+ (this.isOpen(agentName, BUILTIN_TOOLSET)
287
+ ? `open — ${META_TOOL_NAMES.look} {toolset:"${BUILTIN_TOOLSET}"} lists them`
288
+ : `closed — ${META_TOOL_NAMES.open} {toolset:"${BUILTIN_TOOLSET}"} opens it`),
289
+ ];
290
+ for (const s of this.deps.servers()) {
291
+ const mode = this.presentation(agentName, s.id);
292
+ const tabTools = this.tabTools(agentName, s.id);
293
+ if (mode === 'full') {
294
+ if (tabTools.length) lines.push(`${this.tabLabel(s.id)} in your tool list; ${tabTools.length} hidden tool${tabTools.length === 1 ? '' : 's'} in its tab (${this.isOpen(agentName, tabKey(s.id)) ? 'open' : `closed — ${META_TOOL_NAMES.open} {server:"${s.id}"} opens it`})`);
295
+ else lines.push(`${this.tabLabel(s.id)} in your tool list — ${META_TOOL_NAMES.close} {server:"${s.id}"} moves it to a tab`);
296
+ } else if (mode === 'tab') {
297
+ lines.push(`${this.tabLabel(s.id)} a tab, connected (${tabTools.length} tool${tabTools.length === 1 ? '' : 's'}): ` +
298
+ (this.isOpen(agentName, tabKey(s.id)) ? 'open' : `hidden — ${META_TOOL_NAMES.open} {server:"${s.id}"} opens it`) +
299
+ `; ${META_TOOL_NAMES.open} {server:"${s.id}", full:true} puts it back in your tool list`);
300
+ } else {
301
+ lines.push(`${this.tabLabel(s.id)} a closed tab, disconnected: ${META_TOOL_NAMES.open} {server:"${s.id}"} connects and opens it`);
302
+ }
303
+ }
304
+ return lines;
305
+ }
306
+
307
+ /** `toolsets--look {toolset:"settings"}` or `{server}`: one host toolset's tools. */
308
+ lookToolset(agentName: string, which: { builtin: true } | { serverId: string }): string {
309
+ if ('builtin' in which) {
310
+ const tools = this.builtinTools(agentName);
311
+ const open = this.isOpen(agentName, BUILTIN_TOOLSET);
312
+ return [
313
+ `${this.builtinLabel} ${open ? 'open' : 'closed'}${open ? '' : ` — ${META_TOOL_NAMES.open} {toolset:"${BUILTIN_TOOLSET}"} opens it`}`,
314
+ ...tools.map((t) => ` ${signature(t)}${firstSentence(t.description) ? ` — ${firstSentence(t.description)}` : ''}`),
315
+ ].join('\n');
316
+ }
317
+ const { serverId } = which;
318
+ const mode = this.presentation(agentName, serverId);
319
+ if (mode === 'closed-tab') return `${this.tabLabel(serverId)} a closed tab, disconnected: ${META_TOOL_NAMES.open} {server:"${serverId}"} connects and opens it.`;
320
+ const tools = this.tabTools(agentName, serverId);
321
+ const open = this.isOpen(agentName, tabKey(serverId));
322
+ const head = mode === 'full'
323
+ ? `${this.tabLabel(serverId)} in your tool list${tools.length ? `; its hidden tools (${open ? 'open' : 'closed'}):` : '.'}`
324
+ : `${this.tabLabel(serverId)} a tab, connected, ${open ? 'open' : `hidden — ${META_TOOL_NAMES.open} {server:"${serverId}"} opens it`}:`;
325
+ return [head, ...tools.map((t) => ` ${signature(t)}${firstSentence(t.description) ? ` — ${firstSentence(t.description)}` : ''}`)].join('\n');
326
+ }
327
+
328
+ /** `toolsets--look {tool}` for a host toolset's tool, or null when no host toolset has it. */
329
+ lookTool(agentName: string, tool: string, serverId?: string): string | null {
330
+ const def = this.listedTool(agentName, tool, serverId);
331
+ if (!def) return null;
332
+ return `${renderToolDefinition(def)}\n${this.homeOf(agentName, def.name)}`;
333
+ }
334
+
335
+ /**
336
+ * A host toolset's tool as the agent has it listed, by its name, or with
337
+ * its server named by the name the server gave it (as act and look take it).
338
+ */
339
+ private listedTool(agentName: string, tool: string, server?: string): ToolDefinition | undefined {
340
+ const inTabs = this.deps.servers().filter((s) => !server || s.id === server).flatMap((s) => this.tabTools(agentName, s.id));
341
+ return (!server ? this.builtinTools(agentName).find((t) => t.name === tool) : undefined)
342
+ ?? inTabs.find((t) => t.name === tool)
343
+ ?? (server ? inTabs.find((t) => t.name.endsWith(`--${tool}`) && this.deps.serverOf(t.name) === server) : undefined);
344
+ }
345
+
346
+ private homeOf(agentName: string, name: string): string {
347
+ const server = this.deps.serverOf(name);
348
+ if (server) return `in ${this.tabLabel(server)} (${this.isOpen(agentName, tabKey(server)) ? 'open' : 'closed'})`;
349
+ return `in ${this.builtinLabel} (${this.isOpen(agentName, BUILTIN_TOOLSET) ? 'open' : 'closed'})`;
350
+ }
351
+
352
+ // ── open and close ──
353
+
354
+ /** `toolsets--open {toolset:"settings"}`. */
355
+ openBuiltin(agentName: string): string {
356
+ this.setOpen(agentName, BUILTIN_TOOLSET, true);
357
+ return this.announce(this.builtinLabel, this.builtinTools(agentName), `${META_TOOL_NAMES.close} {toolset:"${BUILTIN_TOOLSET}"}`);
358
+ }
359
+
360
+ /** `toolsets--close {toolset:"settings"}`. */
361
+ closeBuiltin(agentName: string): string {
362
+ const was = this.isOpen(agentName, BUILTIN_TOOLSET);
363
+ this.setOpen(agentName, BUILTIN_TOOLSET, false);
364
+ return was ? `${this.builtinLabel} closed.` : `${this.builtinLabel} wasn't open.`;
365
+ }
366
+
367
+ /** Whether a server id names a server the host presents (connected, or a closed tab). */
368
+ knows(serverId: string): boolean {
369
+ return this.server(serverId) !== undefined;
370
+ }
371
+
372
+ /** Connect a closed tab again: one connect, whoever else asks meanwhile. */
373
+ private async reconnect(serverId: string): Promise<void> {
374
+ let pending = this.reconnecting.get(serverId);
375
+ if (!pending) {
376
+ pending = (async () => {
377
+ await this.deps.reconnect(serverId);
378
+ this.doc.closed[serverId] = false;
379
+ })().finally(() => this.reconnecting.delete(serverId));
380
+ this.reconnecting.set(serverId, pending);
381
+ }
382
+ await pending;
383
+ }
384
+
385
+ /**
386
+ * `toolsets--open {server}`: a closed tab is connected again; a tab's tools
387
+ * are shown and callable through act; with `full`, the server goes back
388
+ * into the tool list (one change of the tool list).
389
+ */
390
+ openServer(agentName: string, serverId: string, opts: { full?: boolean } = {}): Promise<string> {
391
+ return this.serially(serverId, () => this.runOpenServer(agentName, serverId, opts));
392
+ }
393
+
394
+ private async runOpenServer(agentName: string, serverId: string, opts: { full?: boolean }): Promise<string> {
395
+ const said: string[] = [];
396
+ if (this.isClosedTab(serverId) || this.reconnecting.has(serverId)) {
397
+ await this.reconnect(serverId);
398
+ said.push(`${serverId} connected.`);
399
+ }
400
+ const a = this.agent(agentName);
401
+ try {
402
+ if (opts.full) {
403
+ const was = this.presentation(agentName, serverId);
404
+ a.servers[serverId] = 'full';
405
+ a.open = a.open.filter((k) => k !== tabKey(serverId));
406
+ said.push(was === 'full'
407
+ ? `${serverId} is in your tool list already.`
408
+ : `${serverId}'s tools are back in your tool list from your next request (your tool list changed, so that request writes its prompt cache again).`);
409
+ return said.join('\n');
410
+ }
411
+ if (this.presentation(agentName, serverId) === 'full' && !this.tabTools(agentName, serverId).length) {
412
+ said.push(`${serverId}'s tools are in your tool list: call them by name. ${META_TOOL_NAMES.close} {server:"${serverId}"} moves it to a tab.`);
413
+ return said.join('\n');
414
+ }
415
+ if (!a.open.includes(tabKey(serverId))) a.open.push(tabKey(serverId));
416
+ said.push(this.announce(this.tabLabel(serverId), this.tabTools(agentName, serverId), `${META_TOOL_NAMES.close} {server:"${serverId}"}`));
417
+ return said.join('\n');
418
+ } finally {
419
+ this.save();
420
+ }
421
+ }
422
+
423
+ /**
424
+ * The other resident agents a server's disconnect would take it from. One
425
+ * the host presents things to out of its tool list uses the server while
426
+ * it's in that list or its tab is open; one that can't use the meta-tools
427
+ * has every server in its list and couldn't open a closed tab again, so it
428
+ * always does. A connection is the host's, so one agent's closed tab is
429
+ * every agent's.
430
+ */
431
+ private otherUsers(agentName: string, serverId: string): string[] {
432
+ return this.deps.residents().filter((other) => other !== agentName
433
+ && (!this.active(other) || this.presentation(other, serverId) === 'full' || this.isOpen(other, tabKey(serverId))));
434
+ }
435
+
436
+ /**
437
+ * `toolsets--close {server}`: a server in the tool list moves to a tab (one
438
+ * change of the tool list); an open tab is hidden; with `disconnect`, the
439
+ * server is disconnected as a closed tab until it's opened again, unless
440
+ * another resident agent still uses it. The disconnect comes first: one
441
+ * that fails changes nothing.
442
+ */
443
+ closeServer(agentName: string, serverId: string, opts: { disconnect?: boolean } = {}): Promise<string> {
444
+ return this.serially(serverId, () => this.runCloseServer(agentName, serverId, opts));
445
+ }
446
+
447
+ private async runCloseServer(agentName: string, serverId: string, opts: { disconnect?: boolean }): Promise<string> {
448
+ const was = this.presentation(agentName, serverId);
449
+ const wasOpen = this.isOpen(agentName, tabKey(serverId));
450
+ let disconnected = false;
451
+ if (opts.disconnect && !this.isClosedTab(serverId)) {
452
+ const users = this.otherUsers(agentName, serverId);
453
+ if (users.length) {
454
+ return `${serverId} wasn't disconnected: ${users.join(', ')} still ${users.length === 1 ? 'uses' : 'use'} it, ` +
455
+ `and a connection is the host's, every agent's. Nothing was changed. ` +
456
+ `${META_TOOL_NAMES.close} {server:"${serverId}"} without disconnect moves it out of your own tool list.`;
457
+ }
458
+ await this.deps.disconnect(serverId);
459
+ this.doc.closed[serverId] = true;
460
+ disconnected = true;
461
+ }
462
+ const said: string[] = [];
463
+ const a = this.agent(agentName);
464
+ try {
465
+ if (was === 'full') {
466
+ a.servers[serverId] = 'tab';
467
+ said.push(`${serverId}'s tools leave your tool list from your next request (your tool list changed, so that request writes its prompt cache again); its tab: ${META_TOOL_NAMES.open} {server:"${serverId}"}.`);
468
+ } else if (wasOpen) {
469
+ said.push(disconnected ? `${this.tabLabel(serverId)} hidden.` : `${this.tabLabel(serverId)} hidden; it stays connected.`);
470
+ }
471
+ a.open = a.open.filter((k) => k !== tabKey(serverId));
472
+ if (disconnected) said.push(`${serverId} disconnected: none of its events reach you until ${META_TOOL_NAMES.open} {server:"${serverId}"} connects it again.`);
473
+ else if (opts.disconnect) said.push(`${serverId} is disconnected already.`);
474
+ } finally {
475
+ this.save();
476
+ }
477
+ if (!said.length) said.push(`${this.tabLabel(serverId)} is hidden already.`);
478
+ return said.join('\n');
479
+ }
480
+
481
+ /** A server connected by other means (an operator's connect, a deploy): no longer a closed tab. */
482
+ noteConnected(serverId: string, wasClosedTab = false): void {
483
+ // Closed by an agent, or by its configured default (the caller says,
484
+ // from the configuration it kept): connected against it now, which is
485
+ // kept across restarts. A server that wasn't a closed tab writes
486
+ // nothing, so a configured closed-tab default still applies at startup.
487
+ if (wasClosedTab || this.doc.closed[serverId] === true) {
488
+ this.doc.closed[serverId] = false;
489
+ this.save();
490
+ }
491
+ }
492
+
493
+ /** A server's saved closed state: true closed, false connected against a closed-tab default, undefined neither. */
494
+ closedState(serverId: string): boolean | undefined {
495
+ return this.doc.closed[serverId];
496
+ }
497
+
498
+ /** A connect refused before anything changed: its closed tab is put back as it was. */
499
+ restoreClosed(serverId: string, was: boolean | undefined): void {
500
+ if (was === undefined) delete this.doc.closed[serverId];
501
+ else this.doc.closed[serverId] = was;
502
+ this.save();
503
+ }
504
+
505
+ /** A server removed for good (an operator's disconnect): nothing of its tab is kept. */
506
+ noteRemoved(serverId: string): void {
507
+ let changed = false;
508
+ if (serverId in this.doc.closed) { delete this.doc.closed[serverId]; changed = true; }
509
+ for (const a of Object.values(this.doc.agents)) {
510
+ if (serverId in a.servers) { delete a.servers[serverId]; changed = true; }
511
+ const open = a.open.filter((k) => k !== tabKey(serverId));
512
+ if (open.length !== a.open.length) { a.open = open; changed = true; }
513
+ }
514
+ if (changed) this.save();
515
+ }
516
+
517
+ // ── act ──
518
+
519
+ /**
520
+ * `toolsets--act` for a host toolset's tool: the call it makes, under the
521
+ * tool's own name; a refusal when its toolset isn't open or its arguments
522
+ * don't fit its definition; null when no host toolset has the tool (an
523
+ * RFC-014 dynamic tool's call goes on to the server's toolsets). A
524
+ * server's tool may be named in full, or bare with `server`.
525
+ */
526
+ resolveAct(agentName: string, input: unknown): HostActTarget | null {
527
+ if (!this.active(agentName)) return null;
528
+ const args = isObj(input) ? input : {};
529
+ const tool = typeof args.tool === 'string' ? args.tool.trim() : '';
530
+ if (!tool) return null;
531
+ const server = typeof args.server === 'string' && args.server.trim() ? args.server.trim() : undefined;
532
+ const listed = this.listedTool(agentName, tool, server);
533
+ // A host toolset's tool the agent may not call isn't listed to it; its
534
+ // call still goes on, by name, for dispatch to refuse as it would a
535
+ // direct call, rather than reading as no such tool.
536
+ const def: { name: string; inputSchema?: unknown } | undefined = listed
537
+ ?? (!server && this.deps.serverOf(tool) === null && this.deps.placedInBuiltin(tool) ? { name: tool } : undefined);
538
+ if (!def) {
539
+ if (server && this.isClosedTab(server)) return { kind: 'refused', error: `${server} is a closed tab, disconnected: ${META_TOOL_NAMES.open} {server:"${server}"} connects it. Nothing was sent.` };
540
+ return null;
541
+ }
542
+ if (args.args !== undefined && !isObj(args.args)) return { kind: 'refused', error: 'args must be an object of the tool\'s arguments; nothing was sent' };
543
+ const home = this.deps.serverOf(def.name);
544
+ const key = home ? tabKey(home) : BUILTIN_TOOLSET;
545
+ if (!this.isOpen(agentName, key)) {
546
+ const opener = home ? `${META_TOOL_NAMES.open} {server:"${home}"}` : `${META_TOOL_NAMES.open} {toolset:"${BUILTIN_TOOLSET}"}`;
547
+ return { kind: 'refused', error: `${def.name} is in ${home ? this.tabLabel(home) : this.builtinLabel}, which isn't open: ${opener} opens it. Nothing was sent.` };
548
+ }
549
+ const callArgs = (args.args ?? {}) as Record<string, unknown>;
550
+ // As a server's dynamic tool is checked before it's sent (RFC §6.1): the
551
+ // arguments against the definition the agent was shown.
552
+ if (def.inputSchema !== undefined) {
553
+ const problem = checkArgs(def.inputSchema, callArgs);
554
+ if (problem) {
555
+ // A key the definition doesn't have is named too, since it's often
556
+ // the cause of a missing one (a misspelling).
557
+ const props = isObj(def.inputSchema) && isObj((def.inputSchema as Record<string, unknown>).properties)
558
+ ? Object.keys((def.inputSchema as Record<string, unknown>).properties as Record<string, unknown>) : null;
559
+ const unknown = props ? Object.keys(callArgs).filter((k) => !props.includes(k)) : [];
560
+ const extra = unknown.length
561
+ ? `; ${unknown.map((k) => JSON.stringify(k)).join(', ')} ${unknown.length === 1 ? "isn't one of its parameters" : "aren't among its parameters"}`
562
+ : '';
563
+ return { kind: 'refused', error: `Invalid arguments for ${def.name}: ${problem}${extra}. Nothing was sent; ${META_TOOL_NAMES.look} {tool:"${def.name}"} shows its definition.` };
564
+ }
565
+ }
566
+ return { kind: 'call', name: def.name, args: callArgs };
567
+ }
568
+
569
+ // ── reminders (RFC-014 §8.3) ──
570
+
571
+ /**
572
+ * The open host toolsets of an agent: each one's marker (its announcement's
573
+ * first words, which a reminder repeats) and its reminder, worked out only
574
+ * when one is due. `dueWhenAbsent`: such a toolset is due whenever a
575
+ * request doesn't carry its marker, even one never seen since the host
576
+ * started (open across a restart).
577
+ */
578
+ openMarkers(agentName: string): Array<{ marker: string; reminder: () => string; dueWhenAbsent: true }> {
579
+ if (!this.active(agentName)) return [];
580
+ const out: Array<{ marker: string; reminder: () => string; dueWhenAbsent: true }> = [];
581
+ for (const key of this.doc.agents[agentName]?.open ?? []) {
582
+ if (key === BUILTIN_TOOLSET) {
583
+ out.push({
584
+ marker: `${this.builtinLabel} open`,
585
+ dueWhenAbsent: true,
586
+ reminder: () => `${this.builtinLabel} open: ${this.builtinTools(agentName).map((t) => t.name).join(', ') || '(none)'} — through ${META_TOOL_NAMES.act}; ${META_TOOL_NAMES.close} {toolset:"${BUILTIN_TOOLSET}"} closes it.`,
587
+ });
588
+ } else if (key.startsWith('server:')) {
589
+ const id = key.slice('server:'.length);
590
+ if (!this.knows(id) || this.isClosedTab(id)) continue;
591
+ out.push({
592
+ marker: `${this.tabLabel(id)} open`,
593
+ dueWhenAbsent: true,
594
+ reminder: () => `${this.tabLabel(id)} open: ${this.tabTools(agentName, id).map((t) => t.name).join(', ') || '(none)'} — through ${META_TOOL_NAMES.act}; ${META_TOOL_NAMES.close} {server:"${id}"} hides it.`,
595
+ });
596
+ }
597
+ }
598
+ return out;
599
+ }
600
+ }
601
+
602
+ /** The meta-tools themselves never leave the tool list: they're how anything else is reached. */
603
+ export function isMetaTool(name: string): boolean {
604
+ return name === META_TOOL_NAMES.open || name === META_TOOL_NAMES.act || name === META_TOOL_NAMES.look || name === META_TOOL_NAMES.close;
605
+ }
package/src/index.ts CHANGED
@@ -68,12 +68,12 @@ export { buildCodeExecutionToolDefinition, CODE_EXECUTION_TOOL_NAME } from './co
68
68
  export type { SessionUsage, AgentUsage, SessionUsageSnapshot, UsageUpdatedEvent } from './usage/index.js';
69
69
 
70
70
  // EventGate
71
- export { EventGate, wakeLineText, type WakeProvenance, type WritePlacement } from './gate/index.js';
71
+ export { EventGate, MCPL_GATE_EVENT_TYPES, wakeLineText, type EventTypesFrom, type WakeProvenance, type WritePlacement } from './gate/index.js';
72
72
  export type { GateConfig, GateOptions, GatePolicy, GatePolicyMatch, GateBehavior, WakeLine } from './gate/index.js';
73
73
  export { ReplyJudge, DEFAULT_JUDGE_MODEL, DEFAULT_JUDGE_MAX_INPUT_TOKENS, MIN_JUDGE_INPUT_TOKENS } from './gate/index.js';
74
74
  export type { JudgeConfig, JudgeEventSummary, JudgeFn, JudgeRequest, JudgeVerdict, ReplyJudgeOptions, ReplyJudgeDeps, UpcomingWake } from './gate/index.js';
75
75
  export { Heartbeat, DEFAULT_HEARTBEAT, HEARTBEAT_TOOL_PREFIX } from './heartbeat/heartbeat.js';
76
- export type { HeartbeatOptions, HeartbeatSettings, HeartbeatWake, HeartbeatDeliveryMode, Reminder } from './heartbeat/heartbeat.js';
76
+ export type { HeartbeatOptions, HeartbeatSettings, HeartbeatWake, HeartbeatDeliveryMode, HeartbeatScheduledWake, Reminder } from './heartbeat/heartbeat.js';
77
77
 
78
78
  // MCPL channel registry (exposed for modules that need channel-level operations)
79
79
  export { ChannelRegistry } from './mcpl/index.js';