@code-yeongyu/senpi 2026.10.2 → 2026.10.3

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 (54) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/bundle/chunks/{app-server-command-SZJAJX5J.js → app-server-command-VSOBULUS.js} +1 -1
  3. package/dist/bundle/chunks/{chunk-PA7DXEUM.js → chunk-2EYJJDES.js} +13 -30
  4. package/dist/bundle/chunks/{chunk-LACVPFMM.js → chunk-ATACOV2Q.js} +3 -3
  5. package/dist/bundle/chunks/{chunk-2NAN2JVY.js → chunk-E6TL3R3I.js} +1 -1
  6. package/dist/bundle/chunks/chunk-Q23IMPRD.js +6 -0
  7. package/dist/bundle/chunks/{chunk-GIK2N57G.js → chunk-WIBTUARS.js} +20 -2
  8. package/dist/bundle/chunks/chunk-XOUTBUAV.js +24 -0
  9. package/dist/bundle/chunks/{cli-main-B66BJ6WS.js → cli-main-RH4BAWCJ.js} +1 -1
  10. package/dist/bundle/chunks/{host-command-ESRPUXI2.js → host-command-2ZP7B3DA.js} +1 -1
  11. package/dist/bundle/chunks/{host-lifecycle-P65B4FWL.js → host-lifecycle-3ZZLFX55.js} +1 -1
  12. package/dist/bundle/chunks/host-lifecycle.js +2 -2
  13. package/dist/bundle/chunks/{interactive-mode-LBS4DGTM.js → interactive-mode-LBBIPYPZ.js} +1 -1
  14. package/dist/bundle/chunks/{multi-session-host-5C2TC4BN.js → multi-session-host-GFWO2JUC.js} +1 -1
  15. package/dist/bundle/chunks/{package-manager-cli-K3JGGB65.js → package-manager-cli-VHCS5NXO.js} +1 -1
  16. package/dist/bundle/chunks/{rpc-mode-FJGYYLVG.js → rpc-mode-KAZWRS2K.js} +1 -1
  17. package/dist/bundle/chunks/{schedule-command-BT36FE6F.js → schedule-command-OUALJDGD.js} +1 -1
  18. package/dist/bundle/chunks/{session-picker-MMTSHPR5.js → session-picker-UYF62GAF.js} +1 -1
  19. package/dist/bundle/chunks/session-worker.js +35 -34
  20. package/dist/bundle/cli.js +1 -1
  21. package/dist/bundle/index.js +1 -1
  22. package/dist/bundle/rpc-entry.js +1 -1
  23. package/dist/bundle/runtime-manifest.json +1 -1
  24. package/dist/core/agent-session.d.ts +4 -0
  25. package/dist/core/agent-session.js +60 -27
  26. package/dist/core/extensions/builtin/mcp/index.js +19 -10
  27. package/dist/core/extensions/builtin/mcp/service-register.d.ts +1 -0
  28. package/dist/core/extensions/builtin/mcp/service-register.js +2 -4
  29. package/dist/core/extensions/builtin/mcp/service-tools-changed.d.ts +11 -2
  30. package/dist/core/extensions/builtin/mcp/service-tools-changed.js +33 -6
  31. package/dist/core/extensions/builtin/mcp/service-types.d.ts +0 -2
  32. package/dist/core/extensions/builtin/mcp/service.d.ts +18 -8
  33. package/dist/core/extensions/builtin/mcp/service.js +231 -76
  34. package/dist/core/settings-manager.d.ts +2 -0
  35. package/dist/core/settings-manager.js +7 -0
  36. package/dist/core/skill-invocation.d.ts +10 -2
  37. package/dist/core/skill-invocation.js +13 -3
  38. package/dist/core/webview/native-webview.d.ts +1 -0
  39. package/dist/core/webview/webview-client.d.ts +5 -2
  40. package/dist/core/webview/webview-client.js +1 -1
  41. package/dist/core/webview/webview-readiness.d.ts +41 -0
  42. package/dist/core/webview/webview-readiness.js +48 -0
  43. package/dist/core/webview/webview-service.d.ts +8 -1
  44. package/dist/core/webview/webview-service.js +56 -16
  45. package/dist/modes/rpc/host-exec-argv.d.ts +7 -0
  46. package/dist/modes/rpc/host-exec-argv.js +45 -0
  47. package/dist/modes/rpc/host-launch.js +3 -2
  48. package/dist/modes/rpc/host-lifecycle.js +2 -1
  49. package/docs/rpc.md +2 -0
  50. package/docs/settings.md +1 -0
  51. package/docs/skills.md +4 -2
  52. package/package.json +6 -6
  53. package/dist/bundle/chunks/chunk-UB2RTUY7.js +0 -24
  54. package/dist/bundle/chunks/chunk-XOZFAFCS.js +0 -6
@@ -1,5 +1,5 @@
1
1
  import { getAgentDir } from "../../../../config.js";
2
- import { getToolSearchService, getToolSearchServiceForExtension, resetToolSearchServiceForTests, } from "../tool-search/service.js";
2
+ import { getToolSearchService, getToolSearchServiceForExtension, resetToolSearchServiceForTests, ToolSearchService, } from "../tool-search/service.js";
3
3
  import { resolveAuthMode } from "./auth/context.js";
4
4
  import { getValidCachedServer, readMcpCatalogCache } from "./catalog-cache.js";
5
5
  import { loadMcpConfig, mergeExtensionMcpServers, visitSpawnableMcpServers } from "./config.js";
@@ -7,6 +7,7 @@ import { collectAllPages } from "./expose/pagination.js";
7
7
  import { cleanupMcpOutputArtifacts, McpOutputArtifacts } from "./guard/output-guard.js";
8
8
  import { HostMcpRegistry } from "./host-registry.js";
9
9
  import { refreshMcpInstructionsForSession } from "./instructions.js";
10
+ import { createMcpLogger } from "./log.js";
10
11
  import { reconnectMcpNow } from "./reconnect.js";
11
12
  import { createMcpSessionConnection, disposeEntryConnection } from "./service-connection.js";
12
13
  import { getMcpServiceExposureStatus } from "./service-exposure.js";
@@ -15,6 +16,7 @@ import { buildMcpServerSnapshot } from "./service-snapshot.js";
15
16
  import { refreshMcpToolsOnListChanged, subscribeMcpToolsChanged } from "./service-tools-changed.js";
16
17
  import { resolveSkillMcpServer } from "./skill-server.js";
17
18
  import { MCP_ATTACH_SETTLE_TIMEOUT_MS, McpDeferredAttach, raceMcpStartupConnect, resolveMcpStartupTimeoutMs, shouldRaceMcpStartup, } from "./startup-race.js";
19
+ import { safeTimer } from "./wrap.js";
18
20
  export { registerToolsPreservingActiveSet } from "./active-set.js";
19
21
  export class McpService {
20
22
  #disposed = false;
@@ -23,7 +25,6 @@ export class McpService {
23
25
  #sessionContext = null;
24
26
  #sessionStartCount = 0;
25
27
  #lastSessionStartReason = null;
26
- #toolRefreshGeneration = 0;
27
28
  #config = null;
28
29
  #authAgentDir;
29
30
  #authEnv;
@@ -36,11 +37,15 @@ export class McpService {
36
37
  #wireStatusListeners = new Set();
37
38
  #wireStatusRefreshQueue = Promise.resolve();
38
39
  #refreshActiveSetWhenNoTools = false;
39
- #tierBRegistration;
40
- #toolSearchService;
40
+ #bindings = new Map();
41
+ #pendingAttaches = 0;
42
+ // Sessions that quit while their own attach was still queued: the attach must not bind them (#2524 review).
43
+ #releasedSessions = new WeakSet();
44
+ #deferredDisposeReason;
45
+ // Releases waiting for the attaches that deferred their dispose: they settle once the last one does.
46
+ #deferredDisposeWaiters = [];
41
47
  #sessionOptions = {};
42
48
  #skillServerWarnings = new Set();
43
- #pi;
44
49
  #attachQueue = Promise.resolve();
45
50
  #deferredAttach = new McpDeferredAttach();
46
51
  #latestWireStatus = { servers: [] };
@@ -50,12 +55,21 @@ export class McpService {
50
55
  #outputArtifacts = new McpOutputArtifacts();
51
56
  #registry;
52
57
  #shareConnections;
58
+ #servesManySessions;
59
+ /** `servesManySessions` marks the process-wide service every classic session attaches to. */
53
60
  constructor(options = {}) {
54
61
  this.#registry = options.mcpRegistry ?? new HostMcpRegistry();
55
62
  this.#shareConnections = options.mcpRegistry !== undefined;
63
+ this.#servesManySessions = options.servesManySessions === true;
56
64
  }
57
65
  async attachSession(event, ctx, _pi, options = {}) {
66
+ // Counted from the moment it queues, so a session's release cannot dispose the service
67
+ // under an attach that has not bound yet.
68
+ this.#pendingAttaches += 1;
58
69
  const attach = this.#attachQueue.then(async () => {
70
+ if (this.#disposed) {
71
+ throw new Error("The MCP service is disposed; attach the session to a live service instead.");
72
+ }
59
73
  this.#sessionContext = ctx;
60
74
  this.#skillServerWarnings.clear();
61
75
  this.#sessionStartCount += 1;
@@ -73,47 +87,154 @@ export class McpService {
73
87
  });
74
88
  mergeExtensionMcpServers(config, ctx.getRegisteredMcpServers?.() ?? []);
75
89
  this.#config = config;
76
- this.#pi = _pi;
77
- if (_pi !== undefined) {
78
- const activationRuntime = {
79
- getActiveTools: () => _pi.getActiveTools(),
80
- setActiveTools: (names) => _pi.setActiveTools([...names]),
81
- };
82
- const sessionToolSearch = getToolSearchServiceForExtension(_pi);
83
- if (sessionToolSearch !== undefined) {
84
- this.#toolSearchService = sessionToolSearch;
85
- sessionToolSearch.bindActivationRuntime(activationRuntime);
86
- }
87
- else {
88
- try {
89
- this.#toolSearchService = getToolSearchService();
90
- this.#toolSearchService.bindActivationRuntime(activationRuntime);
91
- }
92
- catch {
93
- this.#toolSearchService = getToolSearchService({ getAllTools: () => [], ...activationRuntime });
94
- }
95
- }
96
- }
90
+ const binding = _pi === undefined || this.#releasedSessions.has(_pi) ? undefined : this.#bind(_pi, ctx);
97
91
  this.#authAgentDir = sessionOptions.agentDir;
98
92
  this.#authEnv = sessionOptions.env;
99
93
  this.#sessionOptions = sessionOptions;
100
- const toolRefreshGeneration = this.#toolRefreshGeneration + 1;
101
- this.#toolRefreshGeneration = toolRefreshGeneration;
102
- await this.#syncFromConfig(config, sessionOptions, event.reason !== "reload", _pi, toolRefreshGeneration);
103
- if (_pi !== undefined)
104
- await this.#registerDirectTools(_pi);
94
+ await this.#syncFromConfig(config, sessionOptions, event.reason !== "reload", binding);
95
+ if (binding !== undefined)
96
+ await this.#registerDirectTools(binding);
105
97
  // Replay promotion markers from the (possibly resumed) session history
106
98
  // BEFORE the first turn: the request tool snapshot is taken before the
107
99
  // per-turn context event fires, so the context-event replay alone lands
108
100
  // one turn late. Doing it here puts restored tools on the very first
109
101
  // wire payload after a --continue/resume.
110
- if (_pi !== undefined)
111
- this.#rehydrateFromSessionHistory(ctx);
102
+ if (binding !== undefined)
103
+ this.#rehydrateFromSessionHistory(binding);
112
104
  if (shouldCaptureWireStatus(ctx))
113
105
  await this.refreshWireStatusSnapshot(ctx.sessionManager?.getSessionId?.());
114
106
  });
115
107
  this.#attachQueue = attach.then(() => undefined, () => undefined);
116
- await attach;
108
+ try {
109
+ await attach;
110
+ }
111
+ finally {
112
+ this.#pendingAttaches -= 1;
113
+ await this.#disposeIfDeferredAndIdle();
114
+ }
115
+ }
116
+ /** A release that found attaches still pending leaves the dispose to the last of them, once no session is bound. */
117
+ async #disposeIfDeferredAndIdle() {
118
+ const reason = this.#deferredDisposeReason;
119
+ if (reason === undefined || this.#pendingAttaches > 0)
120
+ return;
121
+ this.#deferredDisposeReason = undefined;
122
+ // A session that attached meanwhile keeps the service; otherwise the deferred dispose runs now.
123
+ if (this.#liveBindings().length === 0)
124
+ await this.dispose(reason);
125
+ for (const settle of this.#deferredDisposeWaiters.splice(0))
126
+ settle();
127
+ }
128
+ #bind(pi, ctx) {
129
+ const activationRuntime = {
130
+ getActiveTools: () => pi.getActiveTools(),
131
+ setActiveTools: (names) => pi.setActiveTools([...names]),
132
+ };
133
+ const previous = this.#bindings.get(pi);
134
+ // A session-owned service serves exactly one session: a new attach takes over its binding.
135
+ if (!this.#servesManySessions)
136
+ this.#bindings.clear();
137
+ let fallbackToolSearch;
138
+ let holdsProcessFallback = false;
139
+ const sessionToolSearch = getToolSearchServiceForExtension(pi);
140
+ if (sessionToolSearch !== undefined) {
141
+ sessionToolSearch.bindActivationRuntime(activationRuntime);
142
+ }
143
+ else if (this.#liveBindings().some((other) => other.pi !== pi && other.holdsProcessFallback)) {
144
+ // The process-wide fallback already serves another live session; rebinding it would move that
145
+ // session's activation runtime and catalog hook here, so this session gets its own (#2514).
146
+ fallbackToolSearch = new ToolSearchService({ getAllTools: () => [], ...activationRuntime });
147
+ }
148
+ else {
149
+ holdsProcessFallback = true;
150
+ try {
151
+ fallbackToolSearch = getToolSearchService();
152
+ fallbackToolSearch.bindActivationRuntime(activationRuntime);
153
+ }
154
+ catch {
155
+ fallbackToolSearch = getToolSearchService({ getAllTools: () => [], ...activationRuntime });
156
+ }
157
+ }
158
+ const binding = {
159
+ pi,
160
+ fallbackToolSearch,
161
+ holdsProcessFallback,
162
+ context: ctx,
163
+ registeredIdentities: previous?.registeredIdentities ?? new Map(),
164
+ registration: previous?.registration,
165
+ };
166
+ // Re-inserting keeps the most recent attach last, which is what a caller naming no session gets.
167
+ this.#bindings.delete(pi);
168
+ this.#bindings.set(pi, binding);
169
+ return binding;
170
+ }
171
+ #toolSearchFor(binding) {
172
+ return getToolSearchServiceForExtension(binding.pi) ?? binding.fallbackToolSearch;
173
+ }
174
+ #liveBindings() {
175
+ for (const [pi, binding] of this.#bindings) {
176
+ // A disposed session retires its own tool-search service; its binding goes with it.
177
+ if (getToolSearchServiceForExtension(binding.pi)?.isDisposed === true)
178
+ this.#bindings.delete(pi);
179
+ }
180
+ return [...this.#bindings.values()];
181
+ }
182
+ /** The binding of the session that owns `pi`; without one, the most recently attached live session. */
183
+ #bindingFor(pi) {
184
+ return pi === undefined ? this.#liveBindings().at(-1) : this.#bindings.get(pi);
185
+ }
186
+ /**
187
+ * Release the binding of the session that owns `pi` (#2514). The shared connections keep
188
+ * serving every other live session; once none is left, `disposeReason` disposes the service
189
+ * the way that last session's own exit would.
190
+ */
191
+ async releaseSession(pi, disposeReason) {
192
+ const released = this.#bindings.get(pi);
193
+ this.#bindings.delete(pi);
194
+ if (disposeReason !== undefined)
195
+ this.#releasedSessions.add(pi);
196
+ const live = this.#liveBindings();
197
+ const latest = live.at(-1);
198
+ if (latest !== undefined) {
199
+ if (released !== undefined && this.#sessionContext === released.context)
200
+ this.#sessionContext = latest.context;
201
+ return;
202
+ }
203
+ // A session whose attach is still queued has not bound yet but will use this service.
204
+ if (disposeReason === undefined)
205
+ return;
206
+ if (this.#pendingAttaches === 0) {
207
+ await this.dispose(disposeReason);
208
+ return;
209
+ }
210
+ // The release settles only once the pending attaches have, so a caller that awaits it
211
+ // (session shutdown, builtin removal) sees the service disposed, not a dispose scheduled later.
212
+ this.#deferredDisposeReason = disposeReason;
213
+ const settled = new Promise((settle) => this.#deferredDisposeWaiters.push(() => settle("settled")));
214
+ const deadlineMs = resolveMcpDeferredDisposeTimeoutMs();
215
+ let timer;
216
+ const outcome = await Promise.race([
217
+ settled,
218
+ new Promise((expire) => {
219
+ timer = safeTimer("deferred-dispose", deadlineMs, () => expire("timeout"), {
220
+ logger: createMcpLogger("service"),
221
+ });
222
+ }),
223
+ ]);
224
+ clearTimeout(timer);
225
+ if (outcome === "settled" || this.#deferredDisposeReason === undefined)
226
+ return;
227
+ // A hung attach must not hold a reload or a quit forever: dispose anyway once the
228
+ // deadline passes, unless a session bound in the meantime, and say so.
229
+ this.#deferredDisposeReason = undefined;
230
+ createMcpLogger("service").warn("MCP attach still pending at the dispose deadline; disposing anyway", {
231
+ timeoutMs: deadlineMs,
232
+ reason: disposeReason,
233
+ });
234
+ if (this.#liveBindings().length === 0)
235
+ await this.dispose(disposeReason);
236
+ for (const settle of this.#deferredDisposeWaiters.splice(0))
237
+ settle();
117
238
  }
118
239
  /**
119
240
  * Register skill-declared MCP servers (todo 37). Skill servers are forced
@@ -123,10 +244,10 @@ export class McpService {
123
244
  * returns a warning (system wins). `${VAR}` expansion follows the declaring
124
245
  * skill's trust (skill-server.ts); each trust warning is returned once per session.
125
246
  */
126
- async attachSkillMcpServers(declared) {
247
+ async attachSkillMcpServers(declared, pi) {
127
248
  const config = this.#config;
128
- const pi = this.#pi;
129
- if (config === null || pi === undefined)
249
+ const binding = this.#bindingFor(pi);
250
+ if (config === null || binding === undefined)
130
251
  return [];
131
252
  const warnings = [];
132
253
  const projectTrusted = this.#sessionOptions.projectTrusted ?? this.#sessionContext?.isProjectTrusted() ?? false;
@@ -154,10 +275,8 @@ export class McpService {
154
275
  added += 1;
155
276
  }
156
277
  if (added > 0) {
157
- const toolRefreshGeneration = this.#toolRefreshGeneration + 1;
158
- this.#toolRefreshGeneration = toolRefreshGeneration;
159
- await this.#syncFromConfig(config, this.#sessionOptions, false, pi, toolRefreshGeneration);
160
- await this.#registerDirectTools(pi);
278
+ await this.#syncFromConfig(config, this.#sessionOptions, false, binding);
279
+ await this.#registerDirectTools(binding);
161
280
  if (this.#sessionContext !== null && shouldCaptureWireStatus(this.#sessionContext)) {
162
281
  await this.refreshWireStatusSnapshot(this.#sessionContext.sessionManager?.getSessionId?.());
163
282
  }
@@ -166,16 +285,16 @@ export class McpService {
166
285
  }
167
286
  /** Registered searchable catalog (mapped name + server-side tool name),
168
287
  * used by the skills loader to compute activation targets. */
169
- getTierBSearchable() {
170
- return this.#tierBRegistration?.searchable ?? [];
288
+ getTierBSearchable(pi) {
289
+ return this.#bindingFor(pi)?.registration?.searchable ?? [];
171
290
  }
172
291
  /** Connected servers that list prompts (todo 40), for slash registration. */
173
- getMcpPromptServers() {
174
- return this.#tierBRegistration?.promptServers ?? [];
292
+ getMcpPromptServers(pi) {
293
+ return this.#bindingFor(pi)?.registration?.promptServers ?? [];
175
294
  }
176
295
  /** Connected servers that list resources (todo 39), for mention expansion. */
177
- getMcpResourceServers() {
178
- return this.#tierBRegistration?.resourceServers ?? [];
296
+ getMcpResourceServers(pi) {
297
+ return this.#bindingFor(pi)?.registration?.resourceServers ?? [];
179
298
  }
180
299
  /** Subscribe to completed catalog registrations. Consumers must inspect the
181
300
  * resulting snapshot because tool, resource, and prompt catalogs can become
@@ -208,14 +327,14 @@ export class McpService {
208
327
  }
209
328
  /** Reveal skill-owned tools (todo 37): activation is effective the next
210
329
  * turn, exactly like an tool_search promotion. Unknown names are ignored. */
211
- activateSkillMcpTools(names) {
212
- this.#tierBRegistration?.activate(names);
330
+ activateSkillMcpTools(names, pi) {
331
+ this.#bindingFor(pi)?.registration?.activate(names);
213
332
  }
214
- #rehydrateFromSessionHistory(ctx) {
215
- const entries = ctx.sessionManager?.getEntries() ?? [];
333
+ #rehydrateFromSessionHistory(binding) {
334
+ const entries = binding.context.sessionManager?.getEntries() ?? [];
216
335
  if (entries.length === 0)
217
336
  return;
218
- this.rehydrateActiveToolsFromHistory(entries);
337
+ this.#toolSearchFor(binding)?.maybeRehydrateFromHistory(entries);
219
338
  }
220
339
  async handleSessionShutdown(event) {
221
340
  if (shouldDisposeMcpService(event.reason))
@@ -227,6 +346,7 @@ export class McpService {
227
346
  this.#disposed = true;
228
347
  this.#disposeCount += 1;
229
348
  this.#lastDisposeReason = reason;
349
+ this.#bindings.clear();
230
350
  this.#sessionContext = null;
231
351
  this.#config = null;
232
352
  this.#elicitationUiProvider = undefined;
@@ -306,7 +426,7 @@ export class McpService {
306
426
  ? this.#latestWireStatus
307
427
  : (this.#wireStatusBySession.get(sessionId) ?? { servers: [] });
308
428
  }
309
- async #syncFromConfig(config, options, useCache, pi, toolRefreshGeneration) {
429
+ async #syncFromConfig(config, options, useCache, binding) {
310
430
  const cache = await readMcpCatalogCache(options.agentDir);
311
431
  const hadConnectionsBeforeSync = this.#connections.size > 0;
312
432
  this.#refreshActiveSetWhenNoTools = Object.keys(config.servers).length > 0 || hadConnectionsBeforeSync;
@@ -360,21 +480,36 @@ export class McpService {
360
480
  if (shouldRaceMcpStartup(server.config.lifecycle) || cachedCatalog === undefined) {
361
481
  connects.push(raceMcpStartupConnect({
362
482
  entry,
363
- pi,
364
- registerDirectTools: async (targetPi) => {
365
- await this.#registerDirectTools(targetPi);
366
- // A raced attach ran its history replay before this catalog
367
- // existed; replay now so restored tools still land on the
368
- // first turn's payload (idempotent: already-active names skip).
369
- if (this.#sessionContext !== null)
370
- this.#rehydrateFromSessionHistory(this.#sessionContext);
483
+ pi: binding?.pi,
484
+ registerDirectTools: async () => {
485
+ // The catalog is shared, so it lands in every live session, not only the one that
486
+ // started the connect (#2514). One session's failure must not starve the others:
487
+ // each registers on its own, and the failures are reported together afterwards.
488
+ const failures = [];
489
+ for (const live of this.#liveBindings()) {
490
+ try {
491
+ await this.#registerDirectTools(live);
492
+ // A raced attach ran its history replay before this catalog
493
+ // existed; replay now so restored tools still land on the
494
+ // first turn's payload (idempotent: already-active names skip).
495
+ this.#rehydrateFromSessionHistory(live);
496
+ }
497
+ catch (error) {
498
+ failures.push(error);
499
+ }
500
+ }
371
501
  // The session instructions block was likewise captured at attach
372
502
  // time, before this server connected; rebuild it so the first
373
503
  // turn carries this server's instructions after a raced connect.
374
504
  refreshMcpInstructionsForSession(this);
505
+ if (failures.length > 0) {
506
+ throw new AggregateError(failures, `MCP ${name} catalog failed to register in ${failures.length} session(s)`);
507
+ }
375
508
  },
376
509
  serverConfig: server.config,
377
- shouldRefreshTools: () => !this.#disposed && this.#toolRefreshGeneration === toolRefreshGeneration,
510
+ // A later attach does not supersede this catalog: it registers in every live session. Skip it only
511
+ // once the service is gone or this connection was replaced (#2524 review).
512
+ shouldRefreshTools: () => !this.#disposed && this.#entryForName(name) === entry,
378
513
  deadlineMs: resolveMcpStartupTimeoutMs(server.config.startupTimeoutMs),
379
514
  onDeferred: (settled) => this.#deferredAttach.track(settled),
380
515
  }));
@@ -396,36 +531,44 @@ export class McpService {
396
531
  entry.disposeWireStatus = unsubscribeState;
397
532
  }
398
533
  async #handleServerToolsChanged(entry, connectOnly) {
399
- const pi = this.#pi;
400
534
  const config = this.#config;
401
- if (pi === undefined || config === null)
535
+ if (config === null || this.#liveBindings().length === 0)
402
536
  return;
403
- await refreshMcpToolsOnListChanged(entry, pi, config, (target) => this.#registerDirectTools(target), connectOnly);
404
- }
405
- async #registerDirectTools(pi) {
537
+ // Resolved when the refresh registers, not when it starts: a session that attaches while the
538
+ // refresh is still listing tools must receive the refreshed catalog too.
539
+ const targets = () => this.#liveBindings().map((binding) => ({
540
+ pi: binding.pi,
541
+ registeredIdentity: () => binding.registeredIdentities.get(entry.key),
542
+ register: () => this.#registerDirectTools(binding),
543
+ }));
544
+ await refreshMcpToolsOnListChanged(entry, targets, config, connectOnly);
545
+ }
546
+ async #registerDirectTools(binding) {
406
547
  const config = this.#config;
407
548
  if (config === null)
408
549
  return;
409
- const toolSearchService = this.#toolSearchService;
550
+ const toolSearchService = this.#toolSearchFor(binding);
410
551
  if (toolSearchService === undefined)
411
552
  return;
412
- this.#tierBRegistration = await registerMcpServiceDirectTools(pi, config, this.#connections.values(), toolSearchService, {
553
+ binding.registration = await registerMcpServiceDirectTools(binding.pi, config, this.#connections.values(), toolSearchService, {
413
554
  refreshActiveSetWhenEmpty: this.#refreshActiveSetWhenNoTools,
555
+ onRegistered: (entry, identity) => binding.registeredIdentities.set(entry.key, identity),
414
556
  });
415
- const ctx = this.#sessionContext;
416
- if (ctx?.mode === "rpc") {
557
+ const ctx = binding.context;
558
+ if (ctx.mode === "rpc") {
417
559
  await this.refreshWireStatusSnapshot(ctx.sessionManager?.getSessionId?.());
418
560
  }
419
561
  for (const listener of this.#registrationListeners)
420
562
  listener();
421
563
  }
422
564
  /** Route compatibility callers through the shared ownership-aware scanner. */
423
- rehydrateActiveToolsFromHistory(messages) {
424
- return this.#toolSearchService?.maybeRehydrateFromHistory(messages) ?? [];
565
+ rehydrateActiveToolsFromHistory(messages, pi) {
566
+ return this.maybeRehydrateFromHistory(messages, pi);
425
567
  }
426
568
  /** Shared service memoization keeps this scan once-per-catalog-generation. */
427
- maybeRehydrateFromHistory(messages) {
428
- return this.#toolSearchService?.maybeRehydrateFromHistory(messages) ?? [];
569
+ maybeRehydrateFromHistory(messages, pi) {
570
+ const binding = this.#bindingFor(pi);
571
+ return ((binding === undefined ? undefined : this.#toolSearchFor(binding)?.maybeRehydrateFromHistory(messages)) ?? []);
429
572
  }
430
573
  #serverSnapshot(name) {
431
574
  const connection = this.getConnection(name);
@@ -640,7 +783,7 @@ function assertNever(value) {
640
783
  let service = null;
641
784
  export function getMcpService() {
642
785
  if (service === null || service.isDisposed()) {
643
- service = new McpService();
786
+ service = new McpService({ servesManySessions: true });
644
787
  }
645
788
  return service;
646
789
  }
@@ -651,4 +794,16 @@ export function resetMcpServiceForTests() {
651
794
  service = null;
652
795
  resetToolSearchServiceForTests();
653
796
  }
797
+ export const MCP_DEFERRED_DISPOSE_TIMEOUT_ENV = "SENPI_MCP_DEFERRED_DISPOSE_TIMEOUT_MS";
798
+ const MCP_DEFERRED_DISPOSE_TIMEOUT_MS = 15_000;
799
+ /** How long a release waits for pending attaches before it disposes anyway. */
800
+ function resolveMcpDeferredDisposeTimeoutMs() {
801
+ const raw = process.env[MCP_DEFERRED_DISPOSE_TIMEOUT_ENV]?.trim();
802
+ if (raw !== undefined && raw.length > 0) {
803
+ const value = Number(raw);
804
+ if (Number.isFinite(value) && value >= 0)
805
+ return value;
806
+ }
807
+ return MCP_DEFERRED_DISPOSE_TIMEOUT_MS;
808
+ }
654
809
  //# sourceMappingURL=service.js.map
@@ -97,6 +97,7 @@ export interface Settings {
97
97
  themes?: string[];
98
98
  hooks?: string[];
99
99
  enableSkillCommands?: boolean;
100
+ maxSkillExpansionsPerPrompt?: number;
100
101
  terminal?: TerminalSettings;
101
102
  promptCache?: PromptCacheSettings;
102
103
  images?: ImageSettings;
@@ -483,6 +484,7 @@ export declare class SettingsManager {
483
484
  setThemePaths(paths: string[]): void;
484
485
  setProjectThemePaths(paths: string[]): void;
485
486
  getEnableSkillCommands(): boolean;
487
+ getMaxSkillExpansionsPerPrompt(): number;
486
488
  setEnableSkillCommands(enabled: boolean): void;
487
489
  getThinkingBudgets(): ThinkingBudgetsSettings | undefined;
488
490
  getTerminalCapabilityOverrides(): Partial<TerminalCapabilities>;
@@ -21,6 +21,7 @@ import { parseSettingsJson } from "./settings-json.js";
21
21
  import { withoutOverride } from "./settings-overrides.js";
22
22
  import { removeRetiredSettingsKeys, writeRawScopedSettings } from "./settings-retired-keys.js";
23
23
  import { ASK_USER_DEFAULT_TIMEOUT_MINUTES, ASK_USER_MAX_TIMEOUT_MINUTES, ASK_USER_MIN_TIMEOUT_MINUTES, } from "./settings-shapes.js";
24
+ import { MAX_SKILL_EXPANSIONS_PER_PROMPT } from "./skill-invocation.js";
24
25
  import { isTerminalMouseMode, } from "./terminal-settings.js";
25
26
  export const DEFAULT_STREAM_START_TIMEOUT_MS = 300_000;
26
27
  export const DEFAULT_PROVIDER_STREAM_RETRY_TIMEOUT_MS = 30_000;
@@ -1605,6 +1606,12 @@ export class SettingsManager {
1605
1606
  getEnableSkillCommands() {
1606
1607
  return this.settings.enableSkillCommands ?? true;
1607
1608
  }
1609
+ getMaxSkillExpansionsPerPrompt() {
1610
+ const value = this.settings.maxSkillExpansionsPerPrompt;
1611
+ return typeof value === "number" && Number.isInteger(value) && value > 0
1612
+ ? value
1613
+ : MAX_SKILL_EXPANSIONS_PER_PROMPT;
1614
+ }
1608
1615
  setEnableSkillCommands(enabled) {
1609
1616
  this.globalSettings.enableSkillCommands = enabled;
1610
1617
  this.markModified("enableSkillCommands");
@@ -44,7 +44,15 @@ export interface ParseSkillInvocationOptions {
44
44
  * `$name` is executable when it names a loaded skill.
45
45
  */
46
46
  export declare function parseSkillInvocationTokens(text: string, options?: ParseSkillInvocationOptions): SkillInvocationToken[];
47
- export declare function removeSkillInvocationTokens(text: string, tokens: readonly SkillInvocationToken[]): string;
48
- /** Caps explicit skill expansion so one prompt cannot consume unbounded context. */
47
+ /**
48
+ * Remove expanded invocation tokens, leaving `[skill: name]` where an inline one stood. Tokens in
49
+ * `unloaded` were not expanded (per-prompt cap) and leave `[skill not loaded: name]` in either position,
50
+ * so the model is told it does not have that skill instead of seeing a bare command.
51
+ */
52
+ export declare function removeSkillInvocationTokens(text: string, tokens: readonly SkillInvocationToken[], unloaded?: ReadonlySet<SkillInvocationToken>): string;
53
+ /**
54
+ * Default cap on explicit skill expansion so one prompt cannot consume unbounded context.
55
+ * Users who compose more skills raise it with the `maxSkillExpansionsPerPrompt` setting.
56
+ */
49
57
  export declare const MAX_SKILL_EXPANSIONS_PER_PROMPT = 5;
50
58
  //# sourceMappingURL=skill-invocation.d.ts.map
@@ -121,12 +121,19 @@ function stripLeadingInvocationSeparators(text) {
121
121
  }
122
122
  return text.slice(cursor);
123
123
  }
124
- export function removeSkillInvocationTokens(text, tokens) {
124
+ /**
125
+ * Remove expanded invocation tokens, leaving `[skill: name]` where an inline one stood. Tokens in
126
+ * `unloaded` were not expanded (per-prompt cap) and leave `[skill not loaded: name]` in either position,
127
+ * so the model is told it does not have that skill instead of seeing a bare command.
128
+ */
129
+ export function removeSkillInvocationTokens(text, tokens, unloaded = new Set()) {
125
130
  let cursor = 0;
126
131
  let result = "";
127
132
  for (const token of tokens) {
128
133
  result += text.slice(cursor, token.start);
129
- if (token.position === "inline")
134
+ if (unloaded.has(token))
135
+ result += `[skill not loaded: ${token.name}]`;
136
+ else if (token.position === "inline")
130
137
  result += `[skill: ${token.name}]`;
131
138
  cursor = token.end;
132
139
  if (token.position === "inline" &&
@@ -138,6 +145,9 @@ export function removeSkillInvocationTokens(text, tokens) {
138
145
  result += text.slice(cursor);
139
146
  return tokens.some((token) => token.position === "leading") ? stripLeadingInvocationSeparators(result) : result;
140
147
  }
141
- /** Caps explicit skill expansion so one prompt cannot consume unbounded context. */
148
+ /**
149
+ * Default cap on explicit skill expansion so one prompt cannot consume unbounded context.
150
+ * Users who compose more skills raise it with the `maxSkillExpansionsPerPrompt` setting.
151
+ */
142
152
  export const MAX_SKILL_EXPANSIONS_PER_PROMPT = 5;
143
153
  //# sourceMappingURL=skill-invocation.js.map
@@ -5,6 +5,7 @@ export interface NativeWebView extends EventTarget {
5
5
  readonly loading: boolean;
6
6
  onNavigated: ((url: string, title: string) => void) | null;
7
7
  onNavigationFailed: ((error: Error) => void) | null;
8
+ navigate(url: string): Promise<void>;
8
9
  close(): void;
9
10
  }
10
11
  export interface NativeWebViewClass {
@@ -1,8 +1,11 @@
1
1
  import type { MessagePort } from "node:worker_threads";
2
2
  import type { NativeWebView } from "./native-webview.ts";
3
3
  export interface WebViewClientHost {
4
- /** Launches a view; `adopt` runs in the launch's own turn and returns false once the client is released. */
5
- createView(options: Readonly<Record<string, unknown>>, onConsole: ((...args: unknown[]) => void) | undefined, adopt: (view: NativeWebView) => boolean): Promise<NativeWebView>;
4
+ /**
5
+ * Launches a view; `adopt` runs in the launch's own turn and returns false once the client is released,
6
+ * and `wanted` tells a launch about to be retried whether anyone still waits for it.
7
+ */
8
+ createView(options: Readonly<Record<string, unknown>>, onConsole: ((...args: unknown[]) => void) | undefined, adopt: (view: NativeWebView) => boolean, wanted: () => boolean): Promise<NativeWebView>;
6
9
  onClientClosed(client: WebViewServiceClient): void;
7
10
  }
8
11
  export declare function closeQuietly(view: NativeWebView): void;
@@ -94,7 +94,7 @@ export class WebViewServiceClient {
94
94
  const onConsole = captureConsole
95
95
  ? (...args) => this.#emit(viewId, { type: "console", args })
96
96
  : undefined;
97
- const launch = this.#host.createView(options, onConsole, (view) => this.#adopt(viewId, view));
97
+ const launch = this.#host.createView(options, onConsole, (view) => this.#adopt(viewId, view), () => !this.#released);
98
98
  this.#launches.add(launch);
99
99
  try {
100
100
  return await launch;
@@ -0,0 +1,41 @@
1
+ import type { NativeWebView } from "./native-webview.ts";
2
+ /**
3
+ * A Chrome-backed `Bun.WebView` is usable only once a navigation has attached a CDP session to its
4
+ * target, and Bun reports that attach through nothing but the navigation settling. On Windows a
5
+ * freshly launched Chrome sometimes never finishes it (senpi#2353), so the service drives each view's
6
+ * first navigation itself, to `about:blank` (no network, nothing but the launch and the attach), and
7
+ * replies to `create` only once it settled.
8
+ */
9
+ export interface WebViewReadinessPolicy {
10
+ /** How long the readiness navigation may take before the launch counts as stalled. */
11
+ readonly attachBoundMs: number;
12
+ /** Launches per view, the first included, before the stall is reported to the cell. */
13
+ readonly launchAttempts: number;
14
+ }
15
+ export declare const DEFAULT_READINESS: WebViewReadinessPolicy;
16
+ export declare const READINESS_URL = "about:blank";
17
+ export type ReadinessEvent = {
18
+ readonly type: "ready";
19
+ readonly launch: number;
20
+ readonly attachMs: number;
21
+ } | {
22
+ readonly type: "stalled";
23
+ readonly phase: "cdp-target-attach";
24
+ readonly launch: number;
25
+ };
26
+ /** Appends one line per launch outcome to the file `SENPI_WEBVIEW_READINESS_LOG` names (CI diagnostics). */
27
+ export declare function readinessLogFromEnvironment(): ((event: ReadinessEvent) => void) | undefined;
28
+ export declare class WebViewNotReadyError extends Error {
29
+ readonly code = "ERR_WEBVIEW_NOT_READY";
30
+ readonly phase = "cdp-target-attach";
31
+ constructor(launches: number, policy: WebViewReadinessPolicy);
32
+ }
33
+ /** Resolves once the readiness bound of one launch is reached; `cancel` stops it when the launch settled first. */
34
+ export interface AttachDeadline {
35
+ readonly reached: Promise<void>;
36
+ cancel(): void;
37
+ }
38
+ export declare function timedDeadline(boundMs: number): AttachDeadline;
39
+ /** Resolves the readiness navigation's duration once it settled, undefined when the deadline came first. */
40
+ export declare function attachedWithin(view: NativeWebView, deadline: AttachDeadline): Promise<number | undefined>;
41
+ //# sourceMappingURL=webview-readiness.d.ts.map