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 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 stays scoped to the directory.
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 stays scoped to the directory.
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
- return sessions.lifecycle
185
- .use(s, () => pi.command({ type: "prompt", message: `/${commandName} ${args}`, expandPromptTemplates: true }, MCP_AUTH_COMMAND_TIMEOUT_MS))
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.s.pi !== flow.pi || now - flow.created > MCP_AUTH_ATTEMPT_TTL_MS))
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.pi === pi)
324
+ if (existing?.running && agentCurrent(existing))
239
325
  return existing.command === command ? { flow: existing } : inProgress(name);
240
- const servers = await loadedServers(pi);
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 { error: { status: 404, body: errorBody("McpServerNotFoundError", `MCP server not found: ${name}`) } };
243
- const commandName = await mcpCommandName(pi);
347
+ return refuse(404, "McpServerNotFoundError", `MCP server not found: ${name}`);
244
348
  if (commandName === undefined)
245
- return { error: { status: 400, body: errorBody("UnsupportedError", "This OmO build has no /mcp command") } };
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
- announce(services, directory, name);
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 || flow.s.pi !== flow.pi || Date.now() - flow.created > MCP_AUTH_ATTEMPT_TTL_MS)
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 currentStatus(flow.pi, name) };
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
- announce(services, directory, name);
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
- if (!flow.authorized)
351
- return fail(400, "McpAuthError", flow.error ?? `MCP server ${name} did not complete authorization`);
352
- return { server: await currentStatus(flow.pi, name) };
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 currentStatus(flow.pi, name);
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.
@@ -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
- const COMPACT_CANCEL_RETRIES = 2;
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
- let response = await pi.command(command, timeout, undefined, onDispatch);
835
- // OmO cancels a running compaction whenever its active tool set changes. On a session
836
- // just revived for this request, MCP servers finish connecting a moment after startup
837
- // and cancel it although nobody asked. Retry unless the user sent an abort meanwhile.
838
- for (let retry = 0; retry < COMPACT_CANCEL_RETRIES &&
839
- command.type === "compact" &&
840
- !response.success &&
841
- response.error === OMO_COMPACTION_CANCELLED &&
842
- (s.abortRequests ?? 0) === abortsBefore &&
843
- !signal?.aborted &&
844
- s.pi === pi; retry++)
845
- response = await pi.command(command, timeout);
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;
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "antechamber-omo-compat",
3
- "version": "1.0.27",
3
+ "version": "1.0.29",
4
4
  "description": "OpenChamber extension: displays OmO tool calls the way OmO means them when OpenChamber runs on antechamber",
5
5
  "private": true,
6
6
  "type": "module",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "antechamber-omo-graph",
3
- "version": "1.0.27",
3
+ "version": "1.0.29",
4
4
  "description": "OpenChamber extension: live, interactive graph of the OmO sub-agents behind the current session",
5
5
  "private": true,
6
6
  "type": "module",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "antechamber-omo-work",
3
- "version": "1.0.27",
3
+ "version": "1.0.29",
4
4
  "description": "OpenChamber 2.x Work Status section: OmO todo phases, monitors, eval cells and background work of the current session",
5
5
  "private": true,
6
6
  "type": "module",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "antechamber",
3
- "version": "1.0.27",
3
+ "version": "1.0.29",
4
4
  "description": "OpenCode-compatible HTTP/SSE server that lets OpenChamber drive the pi coding agent (and OmO) over RPC",
5
5
  "type": "module",
6
6
  "license": "MIT",