antechamber 1.0.27 → 1.0.29
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 +18 -0
- package/dist/mcp.d.ts +14 -1
- package/dist/mcp.js +168 -29
- package/dist/pi-event-translation.d.ts +2 -0
- package/dist/pi-event-translation.js +18 -0
- package/dist/server.js +2 -1
- package/dist/sessions.d.ts +7 -0
- package/dist/sessions.js +37 -13
- package/extensions/omo-compat/package.json +1 -1
- package/extensions/omo-dag/package.json +1 -1
- package/extensions/omo-work/package.json +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,10 +7,28 @@ and this project uses [Semantic Versioning](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [1.0.29] - 2026-10-28
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- Stop OmO from cancelling a `/compact` that revives a session. OmO cancels a running compaction whenever an MCP
|
|
15
|
+
server's tools register, which a freshly started agent does for about a second, so the chat showed
|
|
16
|
+
"Compaction failed" twice (OpenChamber 2.5 draws these events as transcript rows) above a compaction that then
|
|
17
|
+
succeeded. A compaction now waits, at most 10 s, until OmO reports no MCP server still connecting. Cancellations
|
|
18
|
+
nobody asked for are retried up to four times instead of two, and a retried attempt is no longer reported as a
|
|
19
|
+
failure; a compaction the user stops still is.
|
|
20
|
+
|
|
21
|
+
## [1.0.28] - 2026-10-28
|
|
22
|
+
|
|
23
|
+
- fix(mcp): sign in to MCP servers without a running OmO session (#79)
|
|
24
|
+
|
|
10
25
|
## [1.0.27] - 2026-10-28
|
|
11
26
|
|
|
12
27
|
### Fixed
|
|
13
28
|
|
|
29
|
+
- Sign in to an MCP server from OpenChamber without a running OmO session in that directory. The sign-in now runs in
|
|
30
|
+
an agent started for it alone, which stops once the sign-in completes, is cancelled or expires, and every running
|
|
31
|
+
session reconnects the server afterwards so it picks up the new token.
|
|
14
32
|
- Find OmO's builtin `/mcp` command when an extension such as `pi-mcp-adapter` also registers `mcp` and OmO renames
|
|
15
33
|
the builtin to `mcp:1`. MCP sign-in used to fail there with "Unknown command /mcp".
|
|
16
34
|
|
package/dist/mcp.d.ts
CHANGED
|
@@ -5,7 +5,10 @@
|
|
|
5
5
|
* Everything therefore targets the most recently used LIVE session in the directory; a
|
|
6
6
|
* dormant session is never revived just to answer an MCP request. Status reads for a
|
|
7
7
|
* directory without one fall back to the most recently used live session elsewhere.
|
|
8
|
-
* Authorization
|
|
8
|
+
* Authorization in a directory without one runs in an agent spawned for that flow alone
|
|
9
|
+
* (`--no-session`), disposed once the flow completes, fails, is cancelled or expires.
|
|
10
|
+
* OmO stores tokens globally, so a completed sign-in reconnects the server in every other
|
|
11
|
+
* live session.
|
|
9
12
|
*
|
|
10
13
|
* Status comes from the read-only `get_loaded_surfaces` RPC. Authorization drives OmO's
|
|
11
14
|
* documented `/mcp` subcommands (docs/mcp.md "Auth"):
|
|
@@ -20,6 +23,7 @@
|
|
|
20
23
|
* An extension that registers its own `mcp` command makes OmO rename the builtin (`mcp:1`),
|
|
21
24
|
* so the builtin is found by its source, not by name.
|
|
22
25
|
*/
|
|
26
|
+
import { PiRpcClient } from "./pi-rpc.js";
|
|
23
27
|
import type { RouteServices } from "./route-services.js";
|
|
24
28
|
type OCMcpStatus = {
|
|
25
29
|
status: "connected";
|
|
@@ -61,6 +65,13 @@ export declare const MCP_AUTH_COMMAND_TIMEOUT_MS: number;
|
|
|
61
65
|
/** OmO's runtime states fold onto OpenCode's; lazy servers are usable, so they read as connected. */
|
|
62
66
|
export declare function openCodeMcpStatus(server: OmoMcpServer): OCMcpStatus;
|
|
63
67
|
export declare function v2McpStatus(server: OmoMcpServer): V2McpStatus;
|
|
68
|
+
/**
|
|
69
|
+
* Resolves once no MCP server is still connecting, after `budgetMs`, or on abort. OmO cancels a running
|
|
70
|
+
* compaction whenever a server's tools register (setActiveTools -> abortCompaction, senpi
|
|
71
|
+
* chunks/chunk-NNQ7DPUD.js). A probe on 2026-10-28 found three servers connecting when get_state answered,
|
|
72
|
+
* all connected within about a second, each change announced by `loaded_surfaces_changed`.
|
|
73
|
+
*/
|
|
74
|
+
export declare function waitForMcpServers(pi: PiRpcClient, budgetMs: number, signal?: AbortSignal): Promise<void>;
|
|
64
75
|
/** The URL OmO prints on its own line after naming the server (`auth` and `auth-start` both do). */
|
|
65
76
|
export declare function authorizationUrl(text: string, name: string): string | undefined;
|
|
66
77
|
/**
|
|
@@ -69,6 +80,8 @@ export declare function authorizationUrl(text: string, name: string): string | u
|
|
|
69
80
|
* authorization request's own `redirect_uri` and `state`.
|
|
70
81
|
*/
|
|
71
82
|
export declare function redirectInput(authorization: string, input: string): string | undefined;
|
|
83
|
+
/** Stops the agents spawned for sign-ins; live sessions are stopped by their own manager. */
|
|
84
|
+
export declare function disposeMcpAgents(): Promise<void>;
|
|
72
85
|
export declare function mcpRoute(services: RouteServices, method: string, path: string, directory: string, body: () => Promise<unknown>): Promise<RouteResult | undefined>;
|
|
73
86
|
export declare function mcpRouteV2(services: RouteServices, method: string, path: string, directory: string, body: () => Promise<unknown>): Promise<RouteResult | undefined>;
|
|
74
87
|
export {};
|
package/dist/mcp.js
CHANGED
|
@@ -5,7 +5,10 @@
|
|
|
5
5
|
* Everything therefore targets the most recently used LIVE session in the directory; a
|
|
6
6
|
* dormant session is never revived just to answer an MCP request. Status reads for a
|
|
7
7
|
* directory without one fall back to the most recently used live session elsewhere.
|
|
8
|
-
* Authorization
|
|
8
|
+
* Authorization in a directory without one runs in an agent spawned for that flow alone
|
|
9
|
+
* (`--no-session`), disposed once the flow completes, fails, is cancelled or expires.
|
|
10
|
+
* OmO stores tokens globally, so a completed sign-in reconnects the server in every other
|
|
11
|
+
* live session.
|
|
9
12
|
*
|
|
10
13
|
* Status comes from the read-only `get_loaded_surfaces` RPC. Authorization drives OmO's
|
|
11
14
|
* documented `/mcp` subcommands (docs/mcp.md "Auth"):
|
|
@@ -22,6 +25,7 @@
|
|
|
22
25
|
*/
|
|
23
26
|
import { randomUUID } from "node:crypto";
|
|
24
27
|
import { errorBody } from "./http.js";
|
|
28
|
+
import { PiRpcClient } from "./pi-rpc.js";
|
|
25
29
|
/** Bounds how long an HTTP caller waits on a read-only RPC; the transport keeps its own budget. */
|
|
26
30
|
const MCP_READ_WAIT_MS = 5_000;
|
|
27
31
|
/** OmO announces the authorization URL before it waits for the browser. */
|
|
@@ -91,6 +95,51 @@ async function loadedServers(pi) {
|
|
|
91
95
|
]
|
|
92
96
|
: []);
|
|
93
97
|
}
|
|
98
|
+
/**
|
|
99
|
+
* Resolves once no MCP server is still connecting, after `budgetMs`, or on abort. OmO cancels a running
|
|
100
|
+
* compaction whenever a server's tools register (setActiveTools -> abortCompaction, senpi
|
|
101
|
+
* chunks/chunk-NNQ7DPUD.js). A probe on 2026-10-28 found three servers connecting when get_state answered,
|
|
102
|
+
* all connected within about a second, each change announced by `loaded_surfaces_changed`.
|
|
103
|
+
*/
|
|
104
|
+
export async function waitForMcpServers(pi, budgetMs, signal) {
|
|
105
|
+
const deadline = Date.now() + budgetMs;
|
|
106
|
+
let changed = false;
|
|
107
|
+
let wake = () => { };
|
|
108
|
+
const listener = (ev) => {
|
|
109
|
+
if (ev.type !== "loaded_surfaces_changed")
|
|
110
|
+
return;
|
|
111
|
+
changed = true;
|
|
112
|
+
wake();
|
|
113
|
+
};
|
|
114
|
+
pi.onEvent(listener);
|
|
115
|
+
try {
|
|
116
|
+
while (!signal?.aborted) {
|
|
117
|
+
changed = false;
|
|
118
|
+
const servers = await loadedServers(pi).catch(() => []);
|
|
119
|
+
if (!servers.some((server) => server.status === "connecting"))
|
|
120
|
+
return;
|
|
121
|
+
if (changed)
|
|
122
|
+
continue;
|
|
123
|
+
const left = deadline - Date.now();
|
|
124
|
+
if (left <= 0)
|
|
125
|
+
return;
|
|
126
|
+
await new Promise((resolve) => {
|
|
127
|
+
const done = () => {
|
|
128
|
+
clearTimeout(timer);
|
|
129
|
+
signal?.removeEventListener("abort", done);
|
|
130
|
+
wake = () => { };
|
|
131
|
+
resolve();
|
|
132
|
+
};
|
|
133
|
+
const timer = setTimeout(done, left);
|
|
134
|
+
signal?.addEventListener("abort", done, { once: true });
|
|
135
|
+
wake = done;
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
finally {
|
|
140
|
+
pi.off("event", listener);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
94
143
|
async function directoryServers(sessions, directory) {
|
|
95
144
|
// A directory whose sessions are all dormant would list nothing. Its servers are mostly
|
|
96
145
|
// the user's global and built-in ones, so any running session gives a better answer.
|
|
@@ -181,8 +230,8 @@ function runMcpCommand(sessions, s, pi, name, commandName, args, observe = {}) {
|
|
|
181
230
|
};
|
|
182
231
|
pi.onEvent(onEvent);
|
|
183
232
|
pi.onExtensionUI(onNotify);
|
|
184
|
-
|
|
185
|
-
|
|
233
|
+
const send = () => pi.command({ type: "prompt", message: `/${commandName} ${args}`, expandPromptTemplates: true }, MCP_AUTH_COMMAND_TIMEOUT_MS);
|
|
234
|
+
return (s === undefined ? send() : sessions.lifecycle.use(s, send))
|
|
186
235
|
.then((response) => {
|
|
187
236
|
if (!response.success)
|
|
188
237
|
throw new Error(response.error ?? "OmO rejected the /mcp command");
|
|
@@ -200,11 +249,57 @@ function runMcpCommand(sessions, s, pi, name, commandName, args, observe = {}) {
|
|
|
200
249
|
function announce(services, directory, name) {
|
|
201
250
|
services.broadcast(directory, { type: "mcp.tools.changed", properties: { server: name } });
|
|
202
251
|
}
|
|
252
|
+
/** Other live sessions keep their own connection, which still lacks the new token until it reconnects. */
|
|
253
|
+
function authorized(services, directory, name, flow) {
|
|
254
|
+
announce(services, directory, name);
|
|
255
|
+
const reconnects = services.sessions.liveSessions().flatMap((s) => {
|
|
256
|
+
const pi = s.pi;
|
|
257
|
+
if (pi === undefined || pi === flow.pi)
|
|
258
|
+
return [];
|
|
259
|
+
return [
|
|
260
|
+
mcpCommandName(pi)
|
|
261
|
+
.then((commandName) => commandName === undefined
|
|
262
|
+
? undefined
|
|
263
|
+
: runMcpCommand(services.sessions, s, pi, name, commandName, `reconnect ${name}`))
|
|
264
|
+
.catch((error) => console.error(`[mcp] reconnecting ${name} in session ${s.info.id} failed: ${errorMessage(error)}`)),
|
|
265
|
+
];
|
|
266
|
+
});
|
|
267
|
+
if (reconnects.length > 0)
|
|
268
|
+
void Promise.all(reconnects).then(() => announce(services, directory, name));
|
|
269
|
+
}
|
|
270
|
+
function agentCurrent(flow) {
|
|
271
|
+
return flow.s === undefined ? !flow.released : flow.s.pi === flow.pi;
|
|
272
|
+
}
|
|
273
|
+
function release(flow) {
|
|
274
|
+
if (flow.s !== undefined)
|
|
275
|
+
return;
|
|
276
|
+
flow.released = true;
|
|
277
|
+
clearTimeout(flow.expiry);
|
|
278
|
+
void flow.pi
|
|
279
|
+
.dispose()
|
|
280
|
+
.catch((error) => console.error(`[mcp] stopping an auth agent failed: ${errorMessage(error)}`));
|
|
281
|
+
}
|
|
282
|
+
/** Stops the agents spawned for sign-ins; live sessions are stopped by their own manager. */
|
|
283
|
+
export async function disposeMcpAgents() {
|
|
284
|
+
const dedicated = [...flows.values()].filter((flow) => flow.s === undefined && !flow.released);
|
|
285
|
+
for (const flow of dedicated) {
|
|
286
|
+
flow.released = true;
|
|
287
|
+
clearTimeout(flow.expiry);
|
|
288
|
+
}
|
|
289
|
+
await Promise.all(dedicated.map((flow) => flow.pi.dispose()));
|
|
290
|
+
}
|
|
291
|
+
async function flowStatus(flow, name) {
|
|
292
|
+
if (flow.server !== undefined || !agentCurrent(flow))
|
|
293
|
+
return flow.server;
|
|
294
|
+
return currentStatus(flow.pi, name);
|
|
295
|
+
}
|
|
203
296
|
function pruneFlows() {
|
|
204
297
|
const now = Date.now();
|
|
205
298
|
for (const [key, flow] of flows)
|
|
206
|
-
if (!flow.running && (flow
|
|
299
|
+
if (!flow.running && (!agentCurrent(flow) || now - flow.created > MCP_AUTH_ATTEMPT_TTL_MS)) {
|
|
207
300
|
flows.delete(key);
|
|
301
|
+
release(flow);
|
|
302
|
+
}
|
|
208
303
|
}
|
|
209
304
|
/** Concurrent clicks share one admission, so a second `/mcp auth` never races the first. */
|
|
210
305
|
function admitFlow(services, directory, name, command) {
|
|
@@ -223,26 +318,35 @@ const inProgress = (name) => ({
|
|
|
223
318
|
async function startFlow(services, directory, name, command) {
|
|
224
319
|
if (!SERVER_NAME.test(name))
|
|
225
320
|
return { error: { status: 400, body: errorBody("InvalidRequestError", "invalid MCP server name") } };
|
|
226
|
-
const live = liveSession(services.sessions, directory);
|
|
227
|
-
if (live === undefined)
|
|
228
|
-
return {
|
|
229
|
-
error: {
|
|
230
|
-
status: 400,
|
|
231
|
-
body: errorBody("McpUnavailableError", "MCP authorization needs a running OmO session in this directory"),
|
|
232
|
-
},
|
|
233
|
-
};
|
|
234
|
-
const { s, pi } = live;
|
|
235
321
|
const key = flowKey(directory, name);
|
|
236
322
|
pruneFlows();
|
|
237
323
|
const existing = flows.get(key);
|
|
238
|
-
if (existing?.running && existing
|
|
324
|
+
if (existing?.running && agentCurrent(existing))
|
|
239
325
|
return existing.command === command ? { flow: existing } : inProgress(name);
|
|
240
|
-
const
|
|
326
|
+
const live = liveSession(services.sessions, directory);
|
|
327
|
+
const s = live?.s;
|
|
328
|
+
const pi = live?.pi ?? new PiRpcClient({ cwd: directory, args: ["--no-session"] });
|
|
329
|
+
const refuse = async (status, error, message) => {
|
|
330
|
+
if (live === undefined)
|
|
331
|
+
await pi.dispose();
|
|
332
|
+
return { error: { status, body: errorBody(error, message) } };
|
|
333
|
+
};
|
|
334
|
+
let servers;
|
|
335
|
+
let commandName;
|
|
336
|
+
try {
|
|
337
|
+
// A spawned agent answers its first RPC only once it has started.
|
|
338
|
+
if (live === undefined)
|
|
339
|
+
await pi.command({ type: "get_state" });
|
|
340
|
+
servers = await loadedServers(pi);
|
|
341
|
+
commandName = await mcpCommandName(pi);
|
|
342
|
+
}
|
|
343
|
+
catch (error) {
|
|
344
|
+
return refuse(400, "McpUnavailableError", `OmO could not start for MCP authorization: ${errorMessage(error)}`);
|
|
345
|
+
}
|
|
241
346
|
if (!servers.some((server) => server.name === name))
|
|
242
|
-
return
|
|
243
|
-
const commandName = await mcpCommandName(pi);
|
|
347
|
+
return refuse(404, "McpServerNotFoundError", `MCP server not found: ${name}`);
|
|
244
348
|
if (commandName === undefined)
|
|
245
|
-
return
|
|
349
|
+
return refuse(400, "UnsupportedError", "This OmO build has no /mcp command");
|
|
246
350
|
const url = Promise.withResolvers();
|
|
247
351
|
const flow = {
|
|
248
352
|
s,
|
|
@@ -255,7 +359,15 @@ async function startFlow(services, directory, name, command) {
|
|
|
255
359
|
done: Promise.resolve(),
|
|
256
360
|
running: true,
|
|
257
361
|
authorized: false,
|
|
362
|
+
released: false,
|
|
258
363
|
};
|
|
364
|
+
if (live === undefined) {
|
|
365
|
+
pi.onExit(() => {
|
|
366
|
+
flow.released = true;
|
|
367
|
+
});
|
|
368
|
+
flow.expiry = setTimeout(() => release(flow), MCP_AUTH_ATTEMPT_TTL_MS);
|
|
369
|
+
flow.expiry.unref();
|
|
370
|
+
}
|
|
259
371
|
flow.done = runMcpCommand(services.sessions, s, pi, name, commandName, `${command} ${name}`, {
|
|
260
372
|
url: (found) => {
|
|
261
373
|
flow.authorizationUrl ??= found;
|
|
@@ -265,12 +377,20 @@ async function startFlow(services, directory, name, command) {
|
|
|
265
377
|
flow.authorized = true;
|
|
266
378
|
},
|
|
267
379
|
})
|
|
268
|
-
.then(() => {
|
|
269
|
-
if (flow.authorized)
|
|
270
|
-
|
|
380
|
+
.then(async () => {
|
|
381
|
+
if (!flow.authorized)
|
|
382
|
+
return;
|
|
383
|
+
if (live === undefined) {
|
|
384
|
+
const server = await currentStatus(pi, name).catch(() => undefined);
|
|
385
|
+
if (server !== undefined)
|
|
386
|
+
flow.server = server;
|
|
387
|
+
release(flow);
|
|
388
|
+
}
|
|
389
|
+
authorized(services, directory, name, flow);
|
|
271
390
|
})
|
|
272
391
|
.catch((error) => {
|
|
273
392
|
flow.error = errorMessage(error);
|
|
393
|
+
release(flow);
|
|
274
394
|
throw error;
|
|
275
395
|
})
|
|
276
396
|
.finally(() => {
|
|
@@ -278,6 +398,8 @@ async function startFlow(services, directory, name, command) {
|
|
|
278
398
|
});
|
|
279
399
|
// The caller that started the flow may already have its URL; a later failure is still reported.
|
|
280
400
|
void flow.done.catch((error) => console.error(`[mcp] authorization of ${name} failed: ${errorMessage(error)}`));
|
|
401
|
+
if (existing !== undefined)
|
|
402
|
+
release(existing);
|
|
281
403
|
flows.set(key, flow);
|
|
282
404
|
return { flow };
|
|
283
405
|
}
|
|
@@ -310,10 +432,12 @@ async function completeFlow(services, directory, name, input) {
|
|
|
310
432
|
const fail = (status, name_, message) => ({
|
|
311
433
|
error: { status, body: errorBody(name_, message) },
|
|
312
434
|
});
|
|
313
|
-
if (flow === undefined ||
|
|
435
|
+
if (flow === undefined || Date.now() - flow.created > MCP_AUTH_ATTEMPT_TTL_MS)
|
|
314
436
|
return fail(400, "McpAuthError", `No pending authorization for ${name}; start it again`);
|
|
315
437
|
if (flow.authorized)
|
|
316
|
-
return { server: await
|
|
438
|
+
return { server: await flowStatus(flow, name) };
|
|
439
|
+
if (!agentCurrent(flow))
|
|
440
|
+
return fail(400, "McpAuthError", `No pending authorization for ${name}; start it again`);
|
|
317
441
|
if (flow.error !== undefined)
|
|
318
442
|
return fail(400, "McpAuthError", `Authorization of ${name} failed (${flow.error}); start it again`);
|
|
319
443
|
if (typeof input !== "string" || flow.authorizationUrl === undefined)
|
|
@@ -321,11 +445,15 @@ async function completeFlow(services, directory, name, input) {
|
|
|
321
445
|
const redirect = redirectInput(flow.authorizationUrl, input);
|
|
322
446
|
if (redirect === undefined)
|
|
323
447
|
return fail(400, "InvalidRequestError", "code must be an authorization code or redirect URL");
|
|
448
|
+
// OmO consumes the pending state on any answer, so a dedicated agent has nothing left to wait for.
|
|
449
|
+
let attempted = false;
|
|
450
|
+
let server;
|
|
324
451
|
try {
|
|
325
452
|
if (flow.running && flow.command === "auth") {
|
|
326
453
|
const target = new URL(redirect);
|
|
327
454
|
if (!sameCallback(target, flow.authorizationUrl))
|
|
328
455
|
return fail(400, "McpAuthError", `OmO is waiting for ${name} on its loopback callback; paste that redirect`);
|
|
456
|
+
attempted = true;
|
|
329
457
|
const delivered = await fetch(target, { redirect: "manual", signal: AbortSignal.timeout(MCP_READ_WAIT_MS) });
|
|
330
458
|
if (!delivered.ok) {
|
|
331
459
|
// OmO ends the flow on a rejected callback; settle it so a retry cannot join the dead flow.
|
|
@@ -335,21 +463,30 @@ async function completeFlow(services, directory, name, input) {
|
|
|
335
463
|
await flow.done;
|
|
336
464
|
}
|
|
337
465
|
else {
|
|
466
|
+
attempted = true;
|
|
338
467
|
await runMcpCommand(services.sessions, flow.s, flow.pi, name, flow.commandName, `auth-complete ${name} ${redirect}`, {
|
|
339
468
|
authorized: () => {
|
|
340
469
|
flow.authorized = true;
|
|
341
470
|
},
|
|
342
471
|
});
|
|
343
|
-
|
|
472
|
+
if (flow.authorized)
|
|
473
|
+
authorized(services, directory, name, flow);
|
|
474
|
+
else
|
|
475
|
+
announce(services, directory, name);
|
|
344
476
|
}
|
|
477
|
+
if (!flow.authorized)
|
|
478
|
+
return fail(400, "McpAuthError", flow.error ?? `MCP server ${name} did not complete authorization`);
|
|
479
|
+
server = await flowStatus(flow, name);
|
|
345
480
|
}
|
|
346
481
|
catch (error) {
|
|
347
482
|
flow.error = errorMessage(error);
|
|
348
483
|
return fail(400, "McpAuthError", flow.error);
|
|
349
484
|
}
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
485
|
+
finally {
|
|
486
|
+
if (attempted)
|
|
487
|
+
release(flow);
|
|
488
|
+
}
|
|
489
|
+
return { server };
|
|
353
490
|
}
|
|
354
491
|
function decodeName(raw) {
|
|
355
492
|
try {
|
|
@@ -397,7 +534,7 @@ export async function mcpRoute(services, method, path, directory, body) {
|
|
|
397
534
|
status: 400,
|
|
398
535
|
body: errorBody("McpAuthError", `MCP server ${name} is waiting for its authorization code; paste it to finish`),
|
|
399
536
|
};
|
|
400
|
-
const server = await
|
|
537
|
+
const server = await flowStatus(flow, name);
|
|
401
538
|
return { status: 200, body: server === undefined ? { status: "disabled" } : openCodeMcpStatus(server) };
|
|
402
539
|
}
|
|
403
540
|
const announced = await awaitAuthorizationUrl(flow, name);
|
|
@@ -489,8 +626,10 @@ export async function mcpRouteV2(services, method, path, directory, body) {
|
|
|
489
626
|
return "error" in result ? result.error : { status: 204 };
|
|
490
627
|
}
|
|
491
628
|
if (method === "DELETE") {
|
|
492
|
-
if (flow !== undefined)
|
|
629
|
+
if (flow !== undefined) {
|
|
493
630
|
flows.delete(flowKey(directory, name));
|
|
631
|
+
release(flow);
|
|
632
|
+
}
|
|
494
633
|
return { status: 204 };
|
|
495
634
|
}
|
|
496
635
|
if (method !== "GET")
|
|
@@ -76,6 +76,8 @@ export declare class PiEventTranslator {
|
|
|
76
76
|
private onToolEvent;
|
|
77
77
|
private onMessageEvent;
|
|
78
78
|
private onCompactionEvent;
|
|
79
|
+
/** Publishes a held compaction cancellation once sessionRpc will not retry it. */
|
|
80
|
+
releaseCompactionEnd(s: ManagedSession): void;
|
|
79
81
|
private finishStreaming;
|
|
80
82
|
private onMessageUpdate;
|
|
81
83
|
private onProviderTools;
|
|
@@ -634,6 +634,11 @@ export class PiEventTranslator {
|
|
|
634
634
|
onCompactionEvent(s, ev) {
|
|
635
635
|
switch (ev.type) {
|
|
636
636
|
case "compaction_start":
|
|
637
|
+
// sessionRpc retried a cancelled compaction: the UI still shows the first attempt running.
|
|
638
|
+
if (s.heldCompactionEnd !== undefined) {
|
|
639
|
+
delete s.heldCompactionEnd;
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
637
642
|
// An auto-compaction can fall between steps; its summary is a new step.
|
|
638
643
|
this.completeAwaitingTools(s);
|
|
639
644
|
s.compactionID = id("compaction");
|
|
@@ -654,6 +659,11 @@ export class PiEventTranslator {
|
|
|
654
659
|
}
|
|
655
660
|
return;
|
|
656
661
|
case "compaction_end": {
|
|
662
|
+
const retry = s.compactRetry;
|
|
663
|
+
if (ev.aborted === true && retry !== undefined && retry.left > 0 && (s.abortRequests ?? 0) === retry.aborts) {
|
|
664
|
+
s.heldCompactionEnd = ev;
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
657
667
|
delete s.info.time.compacting;
|
|
658
668
|
const result = ev.result;
|
|
659
669
|
const inputID = s.compactionID ?? id("compaction");
|
|
@@ -715,6 +725,14 @@ export class PiEventTranslator {
|
|
|
715
725
|
return;
|
|
716
726
|
}
|
|
717
727
|
}
|
|
728
|
+
/** Publishes a held compaction cancellation once sessionRpc will not retry it. */
|
|
729
|
+
releaseCompactionEnd(s) {
|
|
730
|
+
const ev = s.heldCompactionEnd;
|
|
731
|
+
if (ev === undefined)
|
|
732
|
+
return;
|
|
733
|
+
delete s.heldCompactionEnd;
|
|
734
|
+
this.onCompactionEvent(s, ev);
|
|
735
|
+
}
|
|
718
736
|
finishStreaming(s) {
|
|
719
737
|
if (s.streaming)
|
|
720
738
|
s.streaming = undefined;
|
package/dist/server.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/** HTTP/SSE composition root for the OpenCode API backed by pi. */
|
|
2
2
|
import { createHash, randomBytes } from "node:crypto";
|
|
3
3
|
import { createServer } from "node:http";
|
|
4
|
+
import { disposeMcpAgents } from "./mcp.js";
|
|
4
5
|
import { debugLog } from "./misc-routes.js";
|
|
5
6
|
import { SessionManager } from "./sessions.js";
|
|
6
7
|
import { loadCatalog } from "./catalog.js";
|
|
@@ -64,7 +65,7 @@ export class AdapterServer {
|
|
|
64
65
|
socket.destroy();
|
|
65
66
|
this.webSocketClients.clear();
|
|
66
67
|
try {
|
|
67
|
-
await this.sessions.disposeAll();
|
|
68
|
+
await Promise.all([this.sessions.disposeAll(), disposeMcpAgents()]);
|
|
68
69
|
}
|
|
69
70
|
finally {
|
|
70
71
|
// An in-flight HTTP request must not hold shutdown open after teardown.
|
package/dist/sessions.d.ts
CHANGED
|
@@ -51,6 +51,13 @@ export interface ManagedSession extends ThinkingState {
|
|
|
51
51
|
/** A manual compaction started while idle; its end returns the session to idle. */
|
|
52
52
|
compactingFromIdle?: boolean;
|
|
53
53
|
compactionID?: string;
|
|
54
|
+
/** Retries sessionRpc still owes a `compact` command that OmO cancels on its own. */
|
|
55
|
+
compactRetry?: {
|
|
56
|
+
left: number;
|
|
57
|
+
aborts: number;
|
|
58
|
+
};
|
|
59
|
+
/** A cancelled compaction_end held back while its retry may continue the same compaction. */
|
|
60
|
+
heldCompactionEnd?: Record<string, unknown>;
|
|
54
61
|
/** Cancels the adapter-owned user bash waiter and its OmO execution. */
|
|
55
62
|
cancelShell?: (interrupted?: boolean) => void;
|
|
56
63
|
/**
|
package/dist/sessions.js
CHANGED
|
@@ -20,6 +20,7 @@ import { MonitorSessions } from "./monitor-sessions.js";
|
|
|
20
20
|
import { WorkStatusBridge } from "./work-status.js";
|
|
21
21
|
import { OmoShells } from "./omo-shells.js";
|
|
22
22
|
import { sessionDiff } from "./session-diff.js";
|
|
23
|
+
import { waitForMcpServers } from "./mcp.js";
|
|
23
24
|
import { announceSessionNotice, noticeMessage, skillCommandText, slashTitlePrompt } from "./session-commands.js";
|
|
24
25
|
import { ocIdFor, piSessionDir, piSessionsRootMtime, scanAllPiSessions, scanPiSessions, SessionStore, readSessionFileMeta, } from "./session-store.js";
|
|
25
26
|
import { ADAPTER_VERSION } from "./version.js";
|
|
@@ -30,7 +31,11 @@ const PI_FILE_RESOLVE_RETRY_MS = 200;
|
|
|
30
31
|
const SESSION_TITLE_MAX_CHARS = 60;
|
|
31
32
|
/** OmO's reply when a compaction is aborted (`CompactionCancelledError`). */
|
|
32
33
|
const OMO_COMPACTION_CANCELLED = "Compaction cancelled";
|
|
33
|
-
|
|
34
|
+
// Real revivals took two cancels before the third attempt committed (OmO compaction.log, 2026-10-27),
|
|
35
|
+
// so two retries left no margin for one more MCP server connecting late.
|
|
36
|
+
const COMPACT_CANCEL_RETRIES = 4;
|
|
37
|
+
/** Longest a compaction waits for connecting MCP servers; a stuck server falls back to the retries. */
|
|
38
|
+
const MCP_SETTLE_WAIT_MS = 10_000;
|
|
34
39
|
/**
|
|
35
40
|
* A dormant session's record as OmO will resume it. OpenChamber 2.x restores a reopened session's effort
|
|
36
41
|
* from this record and resends it, so the model and level come together from the file; the registry only
|
|
@@ -829,20 +834,39 @@ export class SessionManager {
|
|
|
829
834
|
throw new Error(`session cannot be resumed: ${sessionId}`);
|
|
830
835
|
this.lifecycle.touch(s);
|
|
831
836
|
const timeout = command.type === "compact" ? COMPACT_RPC_TIMEOUT_MS : RPC_COMMAND_TIMEOUT_MS;
|
|
837
|
+
// OmO cancels a compaction whenever an MCP server's tools register, which a freshly revived agent
|
|
838
|
+
// does for about a second; start it once they have. The retries below remain the backstop.
|
|
839
|
+
if (command.type === "compact")
|
|
840
|
+
await waitForMcpServers(pi, MCP_SETTLE_WAIT_MS, signal);
|
|
832
841
|
signal?.throwIfAborted();
|
|
833
842
|
const abortsBefore = s.abortRequests ?? 0;
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
command
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
843
|
+
// The translator holds back a cancellation this loop may retry, so the UI shows one compaction.
|
|
844
|
+
if (command.type === "compact")
|
|
845
|
+
s.compactRetry = { left: COMPACT_CANCEL_RETRIES, aborts: abortsBefore };
|
|
846
|
+
let response;
|
|
847
|
+
try {
|
|
848
|
+
response = await pi.command(command, timeout, undefined, onDispatch);
|
|
849
|
+
// OmO cancels a running compaction whenever its active tool set changes. On a session
|
|
850
|
+
// just revived for this request, MCP servers finish connecting a moment after startup
|
|
851
|
+
// and cancel it although nobody asked. Retry unless the user sent an abort meanwhile.
|
|
852
|
+
for (let retry = 0; retry < COMPACT_CANCEL_RETRIES &&
|
|
853
|
+
command.type === "compact" &&
|
|
854
|
+
!response.success &&
|
|
855
|
+
response.error === OMO_COMPACTION_CANCELLED &&
|
|
856
|
+
(s.abortRequests ?? 0) === abortsBefore &&
|
|
857
|
+
!signal?.aborted &&
|
|
858
|
+
s.pi === pi; retry++) {
|
|
859
|
+
if (s.compactRetry)
|
|
860
|
+
s.compactRetry.left--;
|
|
861
|
+
response = await pi.command(command, timeout);
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
finally {
|
|
865
|
+
if (command.type === "compact") {
|
|
866
|
+
delete s.compactRetry;
|
|
867
|
+
this.events.releaseCompactionEnd(s);
|
|
868
|
+
}
|
|
869
|
+
}
|
|
846
870
|
if (!signal?.aborted && response.success && (command.type === "compact" || command.type === "bash"))
|
|
847
871
|
await this.reloadMessagesLive(s, signal);
|
|
848
872
|
return response;
|