@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.
- package/CHANGELOG.md +22 -0
- package/dist/bundle/chunks/{app-server-command-SZJAJX5J.js → app-server-command-VSOBULUS.js} +1 -1
- package/dist/bundle/chunks/{chunk-PA7DXEUM.js → chunk-2EYJJDES.js} +13 -30
- package/dist/bundle/chunks/{chunk-LACVPFMM.js → chunk-ATACOV2Q.js} +3 -3
- package/dist/bundle/chunks/{chunk-2NAN2JVY.js → chunk-E6TL3R3I.js} +1 -1
- package/dist/bundle/chunks/chunk-Q23IMPRD.js +6 -0
- package/dist/bundle/chunks/{chunk-GIK2N57G.js → chunk-WIBTUARS.js} +20 -2
- package/dist/bundle/chunks/chunk-XOUTBUAV.js +24 -0
- package/dist/bundle/chunks/{cli-main-B66BJ6WS.js → cli-main-RH4BAWCJ.js} +1 -1
- package/dist/bundle/chunks/{host-command-ESRPUXI2.js → host-command-2ZP7B3DA.js} +1 -1
- package/dist/bundle/chunks/{host-lifecycle-P65B4FWL.js → host-lifecycle-3ZZLFX55.js} +1 -1
- package/dist/bundle/chunks/host-lifecycle.js +2 -2
- package/dist/bundle/chunks/{interactive-mode-LBS4DGTM.js → interactive-mode-LBBIPYPZ.js} +1 -1
- package/dist/bundle/chunks/{multi-session-host-5C2TC4BN.js → multi-session-host-GFWO2JUC.js} +1 -1
- package/dist/bundle/chunks/{package-manager-cli-K3JGGB65.js → package-manager-cli-VHCS5NXO.js} +1 -1
- package/dist/bundle/chunks/{rpc-mode-FJGYYLVG.js → rpc-mode-KAZWRS2K.js} +1 -1
- package/dist/bundle/chunks/{schedule-command-BT36FE6F.js → schedule-command-OUALJDGD.js} +1 -1
- package/dist/bundle/chunks/{session-picker-MMTSHPR5.js → session-picker-UYF62GAF.js} +1 -1
- package/dist/bundle/chunks/session-worker.js +35 -34
- package/dist/bundle/cli.js +1 -1
- package/dist/bundle/index.js +1 -1
- package/dist/bundle/rpc-entry.js +1 -1
- package/dist/bundle/runtime-manifest.json +1 -1
- package/dist/core/agent-session.d.ts +4 -0
- package/dist/core/agent-session.js +60 -27
- package/dist/core/extensions/builtin/mcp/index.js +19 -10
- package/dist/core/extensions/builtin/mcp/service-register.d.ts +1 -0
- package/dist/core/extensions/builtin/mcp/service-register.js +2 -4
- package/dist/core/extensions/builtin/mcp/service-tools-changed.d.ts +11 -2
- package/dist/core/extensions/builtin/mcp/service-tools-changed.js +33 -6
- package/dist/core/extensions/builtin/mcp/service-types.d.ts +0 -2
- package/dist/core/extensions/builtin/mcp/service.d.ts +18 -8
- package/dist/core/extensions/builtin/mcp/service.js +231 -76
- package/dist/core/settings-manager.d.ts +2 -0
- package/dist/core/settings-manager.js +7 -0
- package/dist/core/skill-invocation.d.ts +10 -2
- package/dist/core/skill-invocation.js +13 -3
- package/dist/core/webview/native-webview.d.ts +1 -0
- package/dist/core/webview/webview-client.d.ts +5 -2
- package/dist/core/webview/webview-client.js +1 -1
- package/dist/core/webview/webview-readiness.d.ts +41 -0
- package/dist/core/webview/webview-readiness.js +48 -0
- package/dist/core/webview/webview-service.d.ts +8 -1
- package/dist/core/webview/webview-service.js +56 -16
- package/dist/modes/rpc/host-exec-argv.d.ts +7 -0
- package/dist/modes/rpc/host-exec-argv.js +45 -0
- package/dist/modes/rpc/host-launch.js +3 -2
- package/dist/modes/rpc/host-lifecycle.js +2 -1
- package/docs/rpc.md +2 -0
- package/docs/settings.md +1 -0
- package/docs/skills.md +4 -2
- package/package.json +6 -6
- package/dist/bundle/chunks/chunk-UB2RTUY7.js +0 -24
- 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
|
-
#
|
|
40
|
-
#
|
|
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.#
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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 (
|
|
111
|
-
this.#rehydrateFromSessionHistory(
|
|
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
|
-
|
|
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
|
|
129
|
-
if (config === null ||
|
|
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
|
-
|
|
158
|
-
this.#
|
|
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.#
|
|
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.#
|
|
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.#
|
|
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.#
|
|
330
|
+
activateSkillMcpTools(names, pi) {
|
|
331
|
+
this.#bindingFor(pi)?.registration?.activate(names);
|
|
213
332
|
}
|
|
214
|
-
#rehydrateFromSessionHistory(
|
|
215
|
-
const entries =
|
|
333
|
+
#rehydrateFromSessionHistory(binding) {
|
|
334
|
+
const entries = binding.context.sessionManager?.getEntries() ?? [];
|
|
216
335
|
if (entries.length === 0)
|
|
217
336
|
return;
|
|
218
|
-
this
|
|
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,
|
|
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 (
|
|
365
|
-
|
|
366
|
-
//
|
|
367
|
-
//
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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
|
-
|
|
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 (
|
|
535
|
+
if (config === null || this.#liveBindings().length === 0)
|
|
402
536
|
return;
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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.#
|
|
550
|
+
const toolSearchService = this.#toolSearchFor(binding);
|
|
410
551
|
if (toolSearchService === undefined)
|
|
411
552
|
return;
|
|
412
|
-
|
|
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 =
|
|
416
|
-
if (ctx
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
48
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
5
|
-
|
|
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
|