@namzu/sdk 48.3.0 → 49.0.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 (153) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/dist/config/registry.d.ts +2 -0
  3. package/dist/config/registry.d.ts.map +1 -1
  4. package/dist/config/registry.js +23 -3
  5. package/dist/config/registry.js.map +1 -1
  6. package/dist/connector/mcp/discovery.d.ts +9 -5
  7. package/dist/connector/mcp/discovery.d.ts.map +1 -1
  8. package/dist/connector/mcp/discovery.js +16 -8
  9. package/dist/connector/mcp/discovery.js.map +1 -1
  10. package/dist/connector/mcp/mcp-toolset.d.ts +3 -0
  11. package/dist/connector/mcp/mcp-toolset.d.ts.map +1 -1
  12. package/dist/connector/mcp/mcp-toolset.js +20 -2
  13. package/dist/connector/mcp/mcp-toolset.js.map +1 -1
  14. package/dist/connector/mcp/reconnect.d.ts +2 -0
  15. package/dist/connector/mcp/reconnect.d.ts.map +1 -1
  16. package/dist/connector/mcp/reconnect.js +36 -5
  17. package/dist/connector/mcp/reconnect.js.map +1 -1
  18. package/dist/plugin/lifecycle.d.ts +14 -10
  19. package/dist/plugin/lifecycle.d.ts.map +1 -1
  20. package/dist/plugin/lifecycle.js +178 -41
  21. package/dist/plugin/lifecycle.js.map +1 -1
  22. package/dist/prompt/resident-learning.js +2 -2
  23. package/dist/prompt/resident-learning.js.map +1 -1
  24. package/dist/prompt/resident-step.d.ts +2 -2
  25. package/dist/prompt/resident-step.js +4 -4
  26. package/dist/prompt/resident-step.js.map +1 -1
  27. package/dist/public-runtime.d.ts +2 -2
  28. package/dist/public-runtime.d.ts.map +1 -1
  29. package/dist/public-runtime.js +2 -2
  30. package/dist/public-runtime.js.map +1 -1
  31. package/dist/registry/tool/screen.d.ts.map +1 -1
  32. package/dist/registry/tool/screen.js +33 -2
  33. package/dist/registry/tool/screen.js.map +1 -1
  34. package/dist/runtime/jobs/registry.d.ts.map +1 -1
  35. package/dist/runtime/jobs/registry.js +41 -12
  36. package/dist/runtime/jobs/registry.js.map +1 -1
  37. package/dist/runtime/query/index.d.ts +6 -0
  38. package/dist/runtime/query/index.d.ts.map +1 -1
  39. package/dist/runtime/query/index.js +20 -1
  40. package/dist/runtime/query/index.js.map +1 -1
  41. package/dist/runtime/query/iteration/index.d.ts.map +1 -1
  42. package/dist/runtime/query/iteration/index.js +32 -8
  43. package/dist/runtime/query/iteration/index.js.map +1 -1
  44. package/dist/runtime/query/iteration/outstanding-work.d.ts.map +1 -1
  45. package/dist/runtime/query/iteration/outstanding-work.js +2 -0
  46. package/dist/runtime/query/iteration/outstanding-work.js.map +1 -1
  47. package/dist/runtime/query/iteration/phases/context.d.ts +4 -0
  48. package/dist/runtime/query/iteration/phases/context.d.ts.map +1 -1
  49. package/dist/runtime/query/iteration/phases/context.js.map +1 -1
  50. package/dist/runtime/query/iteration/phases/tool-review.d.ts.map +1 -1
  51. package/dist/runtime/query/iteration/phases/tool-review.js +7 -1
  52. package/dist/runtime/query/iteration/phases/tool-review.js.map +1 -1
  53. package/dist/runtime/query/iteration/step-shaping.d.ts +2 -0
  54. package/dist/runtime/query/iteration/step-shaping.d.ts.map +1 -1
  55. package/dist/runtime/query/iteration/step-shaping.js +5 -1
  56. package/dist/runtime/query/iteration/step-shaping.js.map +1 -1
  57. package/dist/store/index.d.ts +1 -0
  58. package/dist/store/index.d.ts.map +1 -1
  59. package/dist/store/index.js +1 -0
  60. package/dist/store/index.js.map +1 -1
  61. package/dist/store/memory/disk.d.ts +7 -2
  62. package/dist/store/memory/disk.d.ts.map +1 -1
  63. package/dist/store/memory/disk.js +58 -5
  64. package/dist/store/memory/disk.js.map +1 -1
  65. package/dist/store/memory/index.d.ts +9 -0
  66. package/dist/store/memory/index.d.ts.map +1 -1
  67. package/dist/store/memory/index.js +43 -5
  68. package/dist/store/memory/index.js.map +1 -1
  69. package/dist/store/memory/markdown.d.ts +7 -2
  70. package/dist/store/memory/markdown.d.ts.map +1 -1
  71. package/dist/store/memory/markdown.js +33 -0
  72. package/dist/store/memory/markdown.js.map +1 -1
  73. package/dist/store/memory/memory.d.ts +7 -2
  74. package/dist/store/memory/memory.d.ts.map +1 -1
  75. package/dist/store/memory/memory.js +34 -0
  76. package/dist/store/memory/memory.js.map +1 -1
  77. package/dist/store/memory/revision.d.ts +16 -0
  78. package/dist/store/memory/revision.d.ts.map +1 -0
  79. package/dist/store/memory/revision.js +144 -0
  80. package/dist/store/memory/revision.js.map +1 -0
  81. package/dist/tools/builtins/wait-for-job-bounds.d.ts +6 -4
  82. package/dist/tools/builtins/wait-for-job-bounds.d.ts.map +1 -1
  83. package/dist/tools/builtins/wait-for-job-bounds.js +46 -8
  84. package/dist/tools/builtins/wait-for-job-bounds.js.map +1 -1
  85. package/dist/tools/builtins/wait-for-job.d.ts +1 -0
  86. package/dist/tools/builtins/wait-for-job.d.ts.map +1 -1
  87. package/dist/tools/builtins/wait-for-job.js +24 -10
  88. package/dist/tools/builtins/wait-for-job.js.map +1 -1
  89. package/dist/tools/coordinator/wait-with-idle-bound.d.ts.map +1 -1
  90. package/dist/tools/coordinator/wait-with-idle-bound.js +4 -0
  91. package/dist/tools/coordinator/wait-with-idle-bound.js.map +1 -1
  92. package/dist/tools/memory/delete.d.ts.map +1 -1
  93. package/dist/tools/memory/delete.js +38 -3
  94. package/dist/tools/memory/delete.js.map +1 -1
  95. package/dist/tools/memory/read.d.ts.map +1 -1
  96. package/dist/tools/memory/read.js +38 -2
  97. package/dist/tools/memory/read.js.map +1 -1
  98. package/dist/tools/memory/search.d.ts.map +1 -1
  99. package/dist/tools/memory/search.js +38 -7
  100. package/dist/tools/memory/search.js.map +1 -1
  101. package/dist/tools/memory/update.d.ts.map +1 -1
  102. package/dist/tools/memory/update.js +30 -3
  103. package/dist/tools/memory/update.js.map +1 -1
  104. package/dist/toolsets/manager.d.ts +13 -0
  105. package/dist/toolsets/manager.d.ts.map +1 -1
  106. package/dist/toolsets/manager.js +60 -1
  107. package/dist/toolsets/manager.js.map +1 -1
  108. package/dist/turn/memory-recall.d.ts +2 -0
  109. package/dist/turn/memory-recall.d.ts.map +1 -1
  110. package/dist/turn/memory-recall.js +3 -1
  111. package/dist/turn/memory-recall.js.map +1 -1
  112. package/dist/types/memory/index.d.ts +29 -0
  113. package/dist/types/memory/index.d.ts.map +1 -1
  114. package/dist/types/memory/index.js +6 -0
  115. package/dist/types/memory/index.js.map +1 -1
  116. package/dist/types/plugin/index.d.ts +12 -2
  117. package/dist/types/plugin/index.d.ts.map +1 -1
  118. package/dist/types/plugin/index.js +5 -2
  119. package/dist/types/plugin/index.js.map +1 -1
  120. package/package.json +1 -1
  121. package/src/config/registry.ts +24 -3
  122. package/src/connector/mcp/discovery.ts +29 -8
  123. package/src/connector/mcp/mcp-toolset.ts +25 -4
  124. package/src/connector/mcp/reconnect.ts +34 -4
  125. package/src/plugin/lifecycle.ts +217 -46
  126. package/src/prompt/resident-learning.ts +2 -2
  127. package/src/prompt/resident-step.ts +4 -4
  128. package/src/public-runtime.ts +2 -0
  129. package/src/registry/tool/screen.ts +34 -2
  130. package/src/runtime/jobs/registry.ts +39 -15
  131. package/src/runtime/query/index.ts +24 -1
  132. package/src/runtime/query/iteration/index.ts +35 -10
  133. package/src/runtime/query/iteration/outstanding-work.ts +2 -0
  134. package/src/runtime/query/iteration/phases/context.ts +4 -0
  135. package/src/runtime/query/iteration/phases/tool-review.ts +6 -1
  136. package/src/runtime/query/iteration/step-shaping.ts +9 -1
  137. package/src/store/index.ts +1 -0
  138. package/src/store/memory/disk.ts +82 -7
  139. package/src/store/memory/index.ts +66 -7
  140. package/src/store/memory/markdown.ts +54 -2
  141. package/src/store/memory/memory.ts +52 -2
  142. package/src/store/memory/revision.ts +158 -0
  143. package/src/tools/builtins/wait-for-job-bounds.ts +45 -7
  144. package/src/tools/builtins/wait-for-job.ts +25 -11
  145. package/src/tools/coordinator/wait-with-idle-bound.ts +3 -0
  146. package/src/tools/memory/delete.ts +33 -3
  147. package/src/tools/memory/read.ts +35 -2
  148. package/src/tools/memory/search.ts +38 -7
  149. package/src/tools/memory/update.ts +28 -3
  150. package/src/toolsets/manager.ts +74 -1
  151. package/src/turn/memory-recall.ts +5 -1
  152. package/src/types/memory/index.ts +43 -0
  153. package/src/types/plugin/index.ts +11 -6
@@ -55,10 +55,10 @@ export class MCPToolDiscovery {
55
55
  private log: Logger
56
56
  private options: MCPToolDiscoveryOptions
57
57
  /**
58
- * Last admitted tool set per SERVER, for drift detection.
58
+ * Last admitted tool set per owner identity, for drift detection.
59
59
  *
60
- * Keyed by server name rather than client id, and the difference is the
61
- * whole point. A client id is minted per connection, so on the path a
60
+ * Plain discovery uses the server name; plugin discovery supplies its
61
+ * qualified source id. A client id is minted per connection, so on the path a
62
62
  * real MCP server actually takes — a plugin enabling, connecting, and
63
63
  * being disabled again — every discovery was the first one that id had
64
64
  * ever seen, and drift could not fire however many times the server
@@ -119,9 +119,15 @@ export class MCPToolDiscovery {
119
119
  return results
120
120
  }
121
121
 
122
- async discoverFrom(client: MCPClient): Promise<MCPDiscoveredTool[]> {
122
+ async discoverFrom(
123
+ client: MCPClient,
124
+ options?: { readonly isCurrent?: () => boolean },
125
+ ): Promise<MCPDiscoveredTool[]> {
123
126
  const state = client.getState()
124
127
  const advertised = await client.listTools()
128
+ // A newer listing can finish before this one. Do not let the older
129
+ // generation replace the baseline or emit a drift event after that.
130
+ if (options?.isCurrent?.() === false) return []
125
131
 
126
132
  // The boundary. Without it the REMOTE side decides what enters the
127
133
  // agent's registry, which inverts least privilege: a server could
@@ -144,7 +150,7 @@ export class MCPToolDiscovery {
144
150
  })
145
151
  }
146
152
 
147
- this.detectDrift(client.id, state.serverName, admitted)
153
+ this.detectDrift(state.serverName, client.id, state.serverName, admitted)
148
154
 
149
155
  return admitted.map((tool) => ({
150
156
  tool,
@@ -153,6 +159,16 @@ export class MCPToolDiscovery {
153
159
  }))
154
160
  }
155
161
 
162
+ /** Record the exact already-admitted snapshot another owner mounted. */
163
+ recordAdmittedTools(
164
+ identity: string,
165
+ clientId: string,
166
+ serverName: string,
167
+ admitted: readonly MCPToolDefinition[],
168
+ ): void {
169
+ this.detectDrift(identity, clientId, serverName, [...admitted])
170
+ }
171
+
156
172
  /**
157
173
  * The prompts a server publishes, through the same admission gate its
158
174
  * tools go through.
@@ -251,9 +267,14 @@ export class MCPToolDiscovery {
251
267
  })
252
268
  }
253
269
 
254
- private detectDrift(clientId: string, serverName: string, admitted: MCPToolDefinition[]): void {
255
- const previous = this.lastSeen.get(serverName)
256
- this.lastSeen.set(serverName, admitted)
270
+ private detectDrift(
271
+ identity: string,
272
+ clientId: string,
273
+ serverName: string,
274
+ admitted: MCPToolDefinition[],
275
+ ): void {
276
+ const previous = this.lastSeen.get(identity)
277
+ this.lastSeen.set(identity, admitted)
257
278
  if (!previous) return
258
279
 
259
280
  const drift = diffTools(previous, admitted)
@@ -5,7 +5,11 @@ import { NAMZU } from '../../constants/telemetry/index.js'
5
5
  import { ToolsetConflictError } from '../../toolsets/combine.js'
6
6
  import type { Toolset, ToolsetAvailability } from '../../toolsets/types.js'
7
7
  import { deferred } from '../../toolsets/wrappers.js'
8
- import type { MCPResource, MCPServerCapabilities } from '../../types/connector/index.js'
8
+ import type {
9
+ MCPResource,
10
+ MCPServerCapabilities,
11
+ MCPToolDefinition,
12
+ } from '../../types/connector/index.js'
9
13
  import type { ToolContext, ToolDefinition, ToolResult } from '../../types/tool/index.js'
10
14
  import type { ToolSource } from '../../types/toolset/index.js'
11
15
  import { toErrorMessage } from '../../utils/error.js'
@@ -103,6 +107,8 @@ export interface MCPToolsetOptions {
103
107
  * added and removed names apply at the next refresh boundary.
104
108
  */
105
109
  readonly onDrift?: (event: { serverName: string; clientId: string; drift: MCPToolDrift }) => void
110
+ /** The exact admitted server listing processed at construction and each committed refresh. */
111
+ readonly onToolsAdmitted?: (tools: readonly MCPToolDefinition[]) => void
106
112
  /** Current tool, prompt and resource names refused by this server's policy. */
107
113
  readonly onRefused?: (event: {
108
114
  serverName: string
@@ -320,14 +326,20 @@ export async function mcpToolset(
320
326
  let promptDefs: ToolDefinition[] = []
321
327
  let mainTools: ToolDefinition[] = []
322
328
  let closed = false
329
+ let toolRefreshGeneration = 0
330
+ let admittedToolSnapshot: readonly MCPToolDefinition[] = []
323
331
  const listeners = new Set<() => void>()
324
332
 
325
333
  function rebuildMain(): void {
326
334
  mainTools = [...toolDefs, ...promptDefs]
327
335
  }
328
336
 
329
- async function refreshTools(): Promise<void> {
330
- const discovered = await discovery.discoverFrom(client)
337
+ async function refreshTools(): Promise<boolean> {
338
+ const generation = ++toolRefreshGeneration
339
+ const discovered = await discovery.discoverFrom(client, {
340
+ isCurrent: () => !closed && generation === toolRefreshGeneration,
341
+ })
342
+ if (closed || generation !== toolRefreshGeneration) return false
331
343
  const previous = new Map(toolDefs.map((definition) => [definition.name, definition]))
332
344
  toolDefs = discovered.map((d) => {
333
345
  const name = mcpToolsetName(serverName, d.tool.name)
@@ -346,6 +358,8 @@ export async function mcpToolset(
346
358
  if (!discovered.some((entry) => entry.tool.name === name)) heldToolNames.delete(name)
347
359
  }
348
360
  rebuildMain()
361
+ admittedToolSnapshot = discovered.map((entry) => entry.tool)
362
+ return true
349
363
  }
350
364
 
351
365
  async function refreshPrompts(): Promise<void> {
@@ -414,9 +428,12 @@ export async function mcpToolset(
414
428
  }
415
429
  }
416
430
  assertUnique()
431
+ options.onToolsAdmitted?.(admittedToolSnapshot)
417
432
 
418
433
  const notify = (): void => {
434
+ if (closed) return
419
435
  assertUnique()
436
+ options.onToolsAdmitted?.(admittedToolSnapshot)
420
437
  for (const listener of listeners) listener()
421
438
  }
422
439
  const onChange = (listener: () => void): (() => void) => {
@@ -449,7 +466,11 @@ export async function mcpToolset(
449
466
  if (closed) return
450
467
  if (method === 'notifications/tools/list_changed') {
451
468
  if (!currentCapabilities()?.tools?.listChanged) return
452
- void refreshTools().then(notify).catch(onRefreshFailure('tools'))
469
+ void refreshTools()
470
+ .then((updated) => {
471
+ if (updated) notify()
472
+ })
473
+ .catch(onRefreshFailure('tools'))
453
474
  } else if (method === 'notifications/prompts/list_changed') {
454
475
  if (!currentCapabilities()?.prompts?.listChanged) return
455
476
  void refreshPrompts().then(notify).catch(onRefreshFailure('prompts'))
@@ -83,6 +83,7 @@ export class MCPReconnectSupervisor {
83
83
  private stopped = false
84
84
  private inFlight = false
85
85
  private timer?: ReturnType<typeof setTimeout>
86
+ private finishWait?: () => void
86
87
 
87
88
  constructor(
88
89
  private readonly client: MCPClient,
@@ -106,7 +107,7 @@ export class MCPReconnectSupervisor {
106
107
 
107
108
  /** Begin watching. Idempotent. */
108
109
  start(): void {
109
- if (!this.policy().enabled || this.unsubscribe || this.stopped) return
110
+ if (this.stopped || this.unsubscribe || !this.policy().enabled) return
110
111
  this.unsubscribe = this.client.onLifecycle((event) => {
111
112
  if (event.type !== 'mcp_client_disconnected' && event.type !== 'mcp_client_error') return
112
113
  void this.recover()
@@ -124,8 +125,9 @@ export class MCPReconnectSupervisor {
124
125
  this.stopped = true
125
126
  this.unsubscribe?.()
126
127
  this.unsubscribe = undefined
127
- if (this.timer) clearTimeout(this.timer)
128
- this.timer = undefined
128
+ // Clearing the timer alone strands recover() inside an unresolved
129
+ // promise. Settle it so the stopped check can retire the loop.
130
+ this.finishWait?.()
129
131
  }
130
132
 
131
133
  private async recover(): Promise<void> {
@@ -154,9 +156,22 @@ export class MCPReconnectSupervisor {
154
156
 
155
157
  try {
156
158
  await this.client.connect()
159
+ // A host may stop the supervisor and dispose its live policy
160
+ // while connect() is still settling. Do not read that policy or
161
+ // publish a reconnect callback after teardown. A successful
162
+ // late connect also needs a second close: the host may have
163
+ // finished its own disconnect before this connect settled.
164
+ if (this.stopped) {
165
+ this.closeLateConnection()
166
+ return
167
+ }
157
168
  await this.policy().onReconnected?.()
158
169
  return
159
170
  } catch {
171
+ if (this.stopped) {
172
+ this.closeLateConnection()
173
+ return
174
+ }
160
175
  // Deliberately swallowed: a failed attempt is the normal
161
176
  // case here and the client has already logged and emitted
162
177
  // its own error. Re-raising would surface a routine retry
@@ -164,15 +179,30 @@ export class MCPReconnectSupervisor {
164
179
  delay = Math.min(delay * 2, this.policy().maxDelayMs)
165
180
  }
166
181
  }
182
+ if (this.stopped) return
167
183
  this.policy().onGaveUp?.(this.policy().maxAttempts)
168
184
  } finally {
169
185
  this.inFlight = false
170
186
  }
171
187
  }
172
188
 
189
+ private closeLateConnection(): void {
190
+ // A failed handshake can leave an opened transport, too. Stop is
191
+ // synchronous and the host's disable path has its own bounded close;
192
+ // keep this late cleanup best-effort without delaying recovery exit.
193
+ void this.client.disconnect().catch(() => {})
194
+ }
195
+
173
196
  private wait(ms: number): Promise<void> {
174
197
  return new Promise((resolve) => {
175
- this.timer = setTimeout(resolve, ms)
198
+ const finish = () => {
199
+ if (this.timer) clearTimeout(this.timer)
200
+ this.timer = undefined
201
+ if (this.finishWait === finish) this.finishWait = undefined
202
+ resolve()
203
+ }
204
+ this.finishWait = finish
205
+ this.timer = setTimeout(finish, ms)
176
206
  })
177
207
  }
178
208
  }
@@ -49,6 +49,7 @@ interface PluginContributionRecord {
49
49
  mcpToolsets: Toolset[]
50
50
  mcpUnsubscribers: Array<() => void>
51
51
  mcpNamesBySource: Map<string, Set<string>>
52
+ configReleases: Array<() => void>
52
53
  /** Namespaced skill names, so rollback and disable can take them back. */
53
54
  skillNames: string[]
54
55
  }
@@ -62,6 +63,68 @@ interface PluginAdmission {
62
63
  readonly inCode?: DefinedPlugin
63
64
  }
64
65
 
66
+ const DEFAULT_PLUGIN_MCP_CONNECT_TIMEOUT_MS = 10_000
67
+ const PLUGIN_MCP_CLOSE_TIMEOUT_MS = 5_000
68
+
69
+ /** One deadline across handshake and all initial discovery requests. */
70
+ async function beforePluginMcpDeadline<T>(
71
+ work: () => Promise<T>,
72
+ deadlineAt: number,
73
+ timeoutMs: number,
74
+ serverName: string,
75
+ onLate?: (value: T) => void,
76
+ onTimeout?: () => void,
77
+ ): Promise<T> {
78
+ const expired = (): Error =>
79
+ new Error(`Plugin MCP server "${serverName}" did not answer within ${timeoutMs}ms`)
80
+ const remaining = deadlineAt - Date.now()
81
+ if (remaining <= 0) throw expired()
82
+ let timer: ReturnType<typeof setTimeout> | undefined
83
+ let timedOut = false
84
+ try {
85
+ const pending = work()
86
+ // A timed-out discovery can still finish after rollback. In particular,
87
+ // mcpToolset() starts a reconnect supervisor just before it returns.
88
+ // Close that late result without publishing any of its tools.
89
+ void pending.then(
90
+ (value) => {
91
+ if (timedOut) onLate?.(value)
92
+ },
93
+ () => {},
94
+ )
95
+ return await Promise.race([
96
+ pending,
97
+ new Promise<never>((_, reject) => {
98
+ timer = setTimeout(() => {
99
+ timedOut = true
100
+ onTimeout?.()
101
+ reject(expired())
102
+ }, remaining)
103
+ }),
104
+ ])
105
+ } finally {
106
+ if (timer) clearTimeout(timer)
107
+ }
108
+ }
109
+
110
+ /** Teardown is best-effort, but one stalled close must not block rollback or disable. */
111
+ async function closePluginMcpWithin(work: () => Promise<void>, label: string): Promise<void> {
112
+ let timer: ReturnType<typeof setTimeout> | undefined
113
+ try {
114
+ await Promise.race([
115
+ work(),
116
+ new Promise<never>((_, reject) => {
117
+ timer = setTimeout(
118
+ () => reject(new Error(`${label} did not close within ${PLUGIN_MCP_CLOSE_TIMEOUT_MS}ms`)),
119
+ PLUGIN_MCP_CLOSE_TIMEOUT_MS,
120
+ )
121
+ }),
122
+ ])
123
+ } finally {
124
+ if (timer) clearTimeout(timer)
125
+ }
126
+ }
127
+
65
128
  /** Dynamic imports bypass ToolDefinition's TypeScript contract. */
66
129
  function assertPluginTool(
67
130
  value: unknown,
@@ -100,6 +163,9 @@ function immutableManifest(manifest: PluginDefinition['manifest']): PluginDefini
100
163
  command: server.command,
101
164
  ...(server.args ? { args: Object.freeze([...server.args]) } : {}),
102
165
  ...(server.env ? { env: Object.freeze({ ...server.env }) } : {}),
166
+ ...(server.connectTimeoutMs !== undefined
167
+ ? { connectTimeoutMs: server.connectTimeoutMs }
168
+ : {}),
103
169
  }),
104
170
  )
105
171
  return Object.freeze({
@@ -199,6 +265,8 @@ export class PluginLifecycleManager {
199
265
  }>
200
266
  > = new Map()
201
267
  private pluginContributions: Map<PluginId, PluginContributionRecord> = new Map()
268
+ /** Reserve each id before async admission so lifecycle calls cannot overlap. */
269
+ private readonly lifecycleTails = new Map<PluginId, Promise<void>>()
202
270
  /** Immutable executable admissions owned by this manager, never by the mutable registry. */
203
271
  private pluginAdmissions: Map<PluginId, PluginAdmission> = new Map()
204
272
  private hookTimeoutMs: number
@@ -206,18 +274,16 @@ export class PluginLifecycleManager {
206
274
  private readonly scopeRoots: Readonly<Record<PluginScope, string>>
207
275
  private log: Logger
208
276
  /**
209
- * The admission boundary for everything a plugin's MCP servers advertise.
277
+ * The cross-enable drift history for each plugin-owned MCP source.
210
278
  *
211
- * One instance for the whole manager rather than one per plugin, and that
212
- * is the part that matters: the instance is what remembers each server's
213
- * previously admitted tool set, so a server that changes between one
214
- * plugin being disabled and the next being enabled is still noticed. A
215
- * per-plugin instance would forget on every teardown, which is precisely
216
- * the window a rug pull uses.
279
+ * The toolset applies policy and performs the only listing. It passes the
280
+ * admitted snapshot here after it commits, keyed by its full plugin source.
281
+ * Keeping this recorder for the manager lifetime detects a server that
282
+ * changes between disable and the next enable without conflating unrelated
283
+ * plugins that use the same local server label.
217
284
  *
218
- * Its client list is deliberately left empty — `discoverFrom` takes the
219
- * client directly, so registering it here would be bookkeeping nothing
220
- * reads.
285
+ * Its client list stays empty because this instance records snapshots;
286
+ * it never makes a second request to the server.
221
287
  */
222
288
  private mcpDiscovery: MCPToolDiscovery
223
289
  private readonly mcpToolPolicies?: Readonly<Record<string, MCPToolPolicy>>
@@ -235,8 +301,20 @@ export class PluginLifecycleManager {
235
301
  this.onMCPToolDrift = config.onMCPToolDrift
236
302
  this.log = config.log.child({ [SCOPE_ATTRIBUTE]: 'plugin/lifecycle' })
237
303
  this.mcpDiscovery = new MCPToolDiscovery([], {
238
- ...(config.mcpToolPolicies ? { policies: config.mcpToolPolicies } : {}),
239
- ...(config.onMCPToolDrift ? { onDrift: config.onMCPToolDrift } : {}),
304
+ ...(config.onMCPToolDrift
305
+ ? {
306
+ onDrift: (event) => {
307
+ try {
308
+ this.onMCPToolDrift?.(event)
309
+ } catch (error) {
310
+ this.log.warn('Plugin MCP drift listener failed', {
311
+ 'namzu.plugin.mcp_server': event.serverName,
312
+ 'exception.message': toErrorMessage(error),
313
+ })
314
+ }
315
+ },
316
+ }
317
+ : {}),
240
318
  logger: this.log,
241
319
  })
242
320
  }
@@ -453,6 +531,21 @@ export class PluginLifecycleManager {
453
531
  })
454
532
  }
455
533
 
534
+ private serializeLifecycle<T>(pluginId: PluginId, work: () => Promise<T>): Promise<T> {
535
+ const previous = this.lifecycleTails.get(pluginId) ?? Promise.resolve()
536
+ const running = previous.then(work)
537
+ // A failed enable must not poison later disable, retry or uninstall.
538
+ const tail = running.then(
539
+ () => undefined,
540
+ () => undefined,
541
+ )
542
+ this.lifecycleTails.set(pluginId, tail)
543
+ void tail.then(() => {
544
+ if (this.lifecycleTails.get(pluginId) === tail) this.lifecycleTails.delete(pluginId)
545
+ })
546
+ return running
547
+ }
548
+
456
549
  private async readLegacyAdmission(plugin: PluginDefinition): Promise<PluginAdmission> {
457
550
  const rootDir = await resolveWithinReal(this.scopeRoots[plugin.scope], plugin.rootDir)
458
551
  const manifest = immutableManifest(
@@ -482,7 +575,11 @@ export class PluginLifecycleManager {
482
575
  })
483
576
  }
484
577
 
485
- async enable(pluginId: PluginId): Promise<void> {
578
+ enable(pluginId: PluginId): Promise<void> {
579
+ return this.serializeLifecycle(pluginId, () => this.enableOwned(pluginId))
580
+ }
581
+
582
+ private async enableOwned(pluginId: PluginId): Promise<void> {
486
583
  const plugin = this.pluginRegistry.getOrThrow(pluginId)
487
584
  let admission = this.pluginAdmissions.get(pluginId)
488
585
 
@@ -543,6 +640,7 @@ export class PluginLifecycleManager {
543
640
  mcpToolsets: [],
544
641
  mcpUnsubscribers: [],
545
642
  mcpNamesBySource: new Map(),
643
+ configReleases: [],
546
644
  skillNames: [],
547
645
  }
548
646
 
@@ -703,30 +801,75 @@ export class PluginLifecycleManager {
703
801
  },
704
802
  logger: this.log,
705
803
  })
706
-
707
- await client.connect()
804
+ // A failed handshake still owns a transport (and possibly a child
805
+ // process). Put it under rollback ownership before the first await.
708
806
  contributions.mcpClients.push(client)
807
+ const timeoutMs = config.connectTimeoutMs ?? DEFAULT_PLUGIN_MCP_CONNECT_TIMEOUT_MS
808
+ const deadlineAt = Date.now() + timeoutMs
809
+ const closeLateClient = () => {
810
+ void closePluginMcpWithin(
811
+ () => client.disconnect(),
812
+ `Plugin MCP server "${config.name}" after late connection`,
813
+ ).catch((error) => {
814
+ this.log.warn('Late MCP disconnect failed', {
815
+ 'namzu.mcp.client_id': client.id,
816
+ 'exception.message': toErrorMessage(error),
817
+ })
818
+ })
819
+ }
820
+ await beforePluginMcpDeadline(
821
+ () => client.connect(),
822
+ deadlineAt,
823
+ timeoutMs,
824
+ config.name,
825
+ closeLateClient,
826
+ )
709
827
 
710
828
  // The toolset owns reconnection and reads this policy on each attempt.
711
829
  const policyScope = this.configRegistry?.register(
712
- `mcp.${config.name}`,
830
+ `mcp.plugin.${pluginName}.${config.name}`,
713
831
  MCPReconnectOptionsSchema,
714
832
  )
715
- // Preserve cross-disable drift detection. The live toolset below owns
716
- // changes during this enablement; this manager-owned discovery remembers
717
- // the server's earlier admission across clients and plugin lifetimes.
718
- await this.mcpDiscovery.discoverFrom(client)
833
+ if (policyScope) contributions.configReleases.push(() => policyScope.dispose())
834
+ // The toolset performs the only listing. Record precisely the admitted
835
+ // snapshot it mounts, including later refreshes, under the plugin source.
719
836
  const sourceId = `plugin:${pluginName}/mcp:${config.name}`
720
837
  const policy = this.mcpToolPolicies?.[config.name] ?? this.mcpToolPolicies?.['*']
721
- const entries = await mcpToolset(client, {
722
- id: sourceId,
723
- availability: 'deferred',
724
- allow: policy?.allow,
725
- deny: policy?.deny,
726
- reconnect: policyScope ? () => policyScope.get() : {},
727
- onDrift: this.onMCPToolDrift,
728
- logger: this.log,
729
- })
838
+ let acceptingSnapshot = true
839
+ const entries = await beforePluginMcpDeadline(
840
+ () =>
841
+ mcpToolset(client, {
842
+ id: sourceId,
843
+ availability: 'deferred',
844
+ allow: policy?.allow,
845
+ deny: policy?.deny,
846
+ reconnect: policyScope ? () => policyScope.get() : {},
847
+ onToolsAdmitted: (tools) => {
848
+ if (acceptingSnapshot) {
849
+ this.mcpDiscovery.recordAdmittedTools(sourceId, client.id, config.name, tools)
850
+ }
851
+ },
852
+ logger: this.log,
853
+ }),
854
+ deadlineAt,
855
+ timeoutMs,
856
+ config.name,
857
+ (lateEntries) => {
858
+ for (const entry of lateEntries) {
859
+ void closePluginMcpWithin(async () => {
860
+ await entry.close?.()
861
+ }, `Late MCP toolset ${entry.source.id}`).catch((error) => {
862
+ this.log.warn('Late MCP toolset close failed', {
863
+ 'namzu.toolset.source_id': entry.source.id,
864
+ 'exception.message': toErrorMessage(error),
865
+ })
866
+ })
867
+ }
868
+ },
869
+ () => {
870
+ acceptingSnapshot = false
871
+ },
872
+ )
730
873
  contributions.mcpToolsets.push(...entries)
731
874
  const wrapped = entries.map((entry) =>
732
875
  prefixed(entry, `${pluginName}${PLUGIN_NAMESPACE_SEPARATOR}`),
@@ -779,25 +922,49 @@ export class PluginLifecycleManager {
779
922
  contributions: PluginContributionRecord,
780
923
  operation: 'rollback' | 'disable',
781
924
  ): Promise<void> {
782
- for (const unsubscribe of contributions.mcpUnsubscribers) unsubscribe()
783
- for (const entry of contributions.mcpToolsets) await entry.close?.()
784
- for (const client of contributions.mcpClients) {
785
- try {
786
- await client.disconnect()
787
- } catch (error) {
788
- this.log.warn('MCP disconnect failed', {
789
- 'namzu.plugin.operation': operation,
790
- 'namzu.mcp.client_id': client.id,
791
- 'exception.message': toErrorMessage(error),
792
- })
925
+ try {
926
+ for (const unsubscribe of contributions.mcpUnsubscribers) unsubscribe()
927
+ await Promise.all(
928
+ contributions.mcpToolsets.map(async (entry) => {
929
+ try {
930
+ await closePluginMcpWithin(async () => {
931
+ await entry.close?.()
932
+ }, `MCP toolset ${entry.source.id}`)
933
+ } catch (error) {
934
+ this.log.warn('MCP toolset close failed', {
935
+ 'namzu.plugin.operation': operation,
936
+ 'namzu.toolset.source_id': entry.source.id,
937
+ 'exception.message': toErrorMessage(error),
938
+ })
939
+ }
940
+ }),
941
+ )
942
+ await Promise.all(
943
+ contributions.mcpClients.map(async (client) => {
944
+ try {
945
+ await closePluginMcpWithin(() => client.disconnect(), `MCP client ${client.id}`)
946
+ } catch (error) {
947
+ this.log.warn('MCP disconnect failed', {
948
+ 'namzu.plugin.operation': operation,
949
+ 'namzu.mcp.client_id': client.id,
950
+ 'exception.message': toErrorMessage(error),
951
+ })
952
+ }
953
+ }),
954
+ )
955
+ for (const names of contributions.mcpNamesBySource.values()) {
956
+ for (const name of names) this.removeContributedTool(name)
793
957
  }
958
+ } finally {
959
+ for (const release of contributions.configReleases) release()
794
960
  }
795
- for (const names of contributions.mcpNamesBySource.values()) {
796
- for (const name of names) this.removeContributedTool(name)
797
- }
798
961
  }
799
962
 
800
- async disable(pluginId: PluginId): Promise<void> {
963
+ disable(pluginId: PluginId): Promise<void> {
964
+ return this.serializeLifecycle(pluginId, () => this.disableOwned(pluginId))
965
+ }
966
+
967
+ private async disableOwned(pluginId: PluginId): Promise<void> {
801
968
  const plugin = this.pluginRegistry.getOrThrow(pluginId)
802
969
  const admission = this.pluginAdmissions.get(pluginId)
803
970
  const contributions = this.pluginContributions.get(pluginId)
@@ -856,12 +1023,16 @@ export class PluginLifecycleManager {
856
1023
  })
857
1024
  }
858
1025
 
859
- async uninstall(pluginId: PluginId): Promise<void> {
1026
+ uninstall(pluginId: PluginId): Promise<void> {
1027
+ return this.serializeLifecycle(pluginId, () => this.uninstallOwned(pluginId))
1028
+ }
1029
+
1030
+ private async uninstallOwned(pluginId: PluginId): Promise<void> {
860
1031
  const plugin = this.pluginRegistry.getOrThrow(pluginId)
861
1032
  const admission = this.pluginAdmissions.get(pluginId)
862
1033
 
863
1034
  if (this.pluginContributions.has(pluginId)) {
864
- await this.disable(pluginId)
1035
+ await this.disableOwned(pluginId)
865
1036
  }
866
1037
 
867
1038
  this.pluginRegistry.unregister(pluginId)
@@ -103,7 +103,7 @@ export function createResidentStepContext(
103
103
  ...contributions,
104
104
  Object.freeze({
105
105
  id: 'namzu.resident-step.learning-catalogue',
106
- placement: 'dynamic' as const,
106
+ placement: 'context' as const,
107
107
  render: () =>
108
108
  [
109
109
  '## Available evaluated resident skills',
@@ -113,7 +113,7 @@ export function createResidentStepContext(
113
113
  }),
114
114
  Object.freeze({
115
115
  id: 'namzu.resident-step.selected-learning',
116
- placement: 'turn' as const,
116
+ placement: 'context' as const,
117
117
  render: () => {
118
118
  if (!selected.size) return null
119
119
  try {
@@ -58,8 +58,8 @@ export interface ResidentStepPromptOptions {
58
58
  * contributions. Register them on the registry supplied to `query`.
59
59
  *
60
60
  * The static contribution carries stable work guidance and the host's output
61
- * contract. The dynamic contribution captures this invocation's state and
62
- * approved learning, outside the cached prefix. Both remain present on every
61
+ * contract. The context contribution captures this invocation's state and
62
+ * approved learning as request-only user-role context. Both remain present on every
63
63
  * iteration; build fresh contributions for the next admitted step.
64
64
  *
65
65
  * Project instructions and authorization remain on their existing host/runtime
@@ -128,14 +128,14 @@ export function createResidentStepContributions(
128
128
  }),
129
129
  Object.freeze({
130
130
  id: 'namzu.resident-step.continuation',
131
- placement: 'dynamic' as const,
131
+ placement: 'context' as const,
132
132
  render: () => snapshot,
133
133
  }),
134
134
  ...(boundNames.length
135
135
  ? [
136
136
  Object.freeze({
137
137
  id: 'namzu.resident-step.source-bound-learning',
138
- placement: 'turn' as const,
138
+ placement: 'context' as const,
139
139
  render: () => {
140
140
  let sources: readonly ResidentLearningSource[] = []
141
141
  try {
@@ -506,6 +506,7 @@ export {
506
506
  MEMORY_VERIFY_NOTICE,
507
507
  MemoryContentRejectedError,
508
508
  MemoryNameConflictError,
509
+ MemoryRevisionConflictError,
509
510
  describeMemoryAge,
510
511
  isMemoryName,
511
512
  memoryIndexLine,
@@ -1143,6 +1144,7 @@ export {
1143
1144
  MEMORY_TYPES,
1144
1145
  assertMemoryStatus,
1145
1146
  assertMemoryType,
1147
+ hasConditionalMemoryWrites,
1146
1148
  isMemoryType,
1147
1149
  } from './types/memory/index.js'
1148
1150
  export {