antechamber 0.2.13 → 0.2.15

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,6 +7,29 @@ and this project uses [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.15] - 2026-10-17
11
+
12
+ ### Fixed
13
+
14
+ - Show saved child-task errors when no transcript is available, and label missing runtime, token and cost statistics
15
+ as unknown instead of zero.
16
+
17
+ ## [0.2.14] - 2026-10-16
18
+
19
+ ### Fixed
20
+
21
+ - Retry a manual compaction that OmO cancelled without a user abort. Compacting a dormant session failed with
22
+ "Compaction cancelled" when its MCP servers finished connecting right after the agent started.
23
+ - List MCP servers for a directory whose sessions are all dormant, from the most recently used live session
24
+ elsewhere, instead of showing none.
25
+ - Keep OpenChamber 1.24's mobile transcript from opening blank after a long agent turn: a v1 message page with no
26
+ user message now reaches back to the prompt that opened its turn.
27
+
28
+ ### Added
29
+
30
+ - `antechamber doctor` reports the installed, latest released and running adapter versions, and warns when the running
31
+ server is older, since a released fix only goes live after OpenChamber relaunches the adapter.
32
+
10
33
  ## [0.2.13] - 2026-10-16
11
34
 
12
35
  ### Fixed
package/README.md CHANGED
@@ -142,7 +142,7 @@ antechamber serve --hostname 127.0.0.1 --port 4096
142
142
  |---|---|
143
143
  | `antechamber serve` | OpenCode-compatible server; `--hostname`/`--host`, `--port`, `--directory`. Bare `antechamber` also starts the server. |
144
144
  | `antechamber setup` | Configure OpenChamber; `--settings <file>`, `--profile <dir>`, `--binary <path>`, `--dry-run`, `--systemd [unit]`, `--pi-binary <path>`, `--restart`, `--auto-update`. |
145
- | `antechamber doctor` | Read-only Node, backend, agent/state directories, teardown, OpenChamber and auth checks; `--json`, `--settings <file>`, `--profile <dir>`. Exits 1 on any FAIL; warnings do not fail. |
145
+ | `antechamber doctor` | Read-only Node, backend, agent/state directories, teardown, OpenChamber and auth checks, plus the installed, latest released and running adapter versions (a running server older than the install needs an OpenChamber restart); `--json`, `--settings <file>`, `--profile <dir>`. Exits 1 on any FAIL; warnings do not fail. |
146
146
  | `antechamber upgrade` | Install the latest release over a global npm or bun install, with the package manager that owns it. Refused for source checkouts and `npx`. |
147
147
  | `antechamber version` | Package version plus OpenCode API contract version. `--version`/`-v` deliberately print exactly `opencode v2.0.20` for OpenChamber. |
148
148
  | `antechamber help` | Usage and options; also `--help`/`-h`. |
package/dist/cli.js CHANGED
@@ -61,10 +61,13 @@ try {
61
61
  }
62
62
  else if (command === "doctor") {
63
63
  const { doctor, formatDoctor } = await import("./commands/doctor.js");
64
+ const { createContext } = await import("./commands/context.js");
65
+ const { fetchLatestVersion } = await import("./self-update.js");
66
+ const latest = await fetchLatestVersion().catch((error) => error instanceof Error ? error : new Error(String(error)));
64
67
  const report = doctor({
65
68
  ...(values.settings !== undefined ? { settings: values.settings } : {}),
66
69
  ...(values.profile !== undefined ? { profile: values.profile } : {}),
67
- });
70
+ }, createContext(), latest);
68
71
  console.log(formatDoctor(report, values.json ?? false));
69
72
  process.exitCode = report.exitCode;
70
73
  }
@@ -9,6 +9,6 @@ interface DoctorReport {
9
9
  readonly exitCode: 0 | 1;
10
10
  }
11
11
  /** Checks capabilities with access/stat only, never creating state or config files. */
12
- export declare function doctor(options?: SettingsOptions, ctx?: CommandContext): DoctorReport;
12
+ export declare function doctor(options?: SettingsOptions, ctx?: CommandContext, latestVersion?: string | Error): DoctorReport;
13
13
  export declare function formatDoctor(report: DoctorReport, json: boolean): string;
14
14
  export {};
@@ -3,9 +3,12 @@ import { piAgentDir } from "../agent-dir.js";
3
3
  import { detectAgentName } from "../agent-name.js";
4
4
  import { agentBinary, defaultStateDir, envSetting, isPlainPi } from "../config.js";
5
5
  import { findPidfdPython } from "../pidfd.js";
6
+ import { readRunningServers } from "../running-servers.js";
7
+ import { isNewerVersion } from "../self-update.js";
8
+ import { ADAPTER_VERSION } from "../version.js";
6
9
  import { autoUpdateLauncherPath, autoUpdateLauncherTarget, createContext, isPackageBinary, readSettings, resolveBinary, settingsPath, } from "./context.js";
7
10
  /** Checks capabilities with access/stat only, never creating state or config files. */
8
- export function doctor(options = {}, ctx = createContext()) {
11
+ export function doctor(options = {}, ctx = createContext(), latestVersion) {
9
12
  const checks = [];
10
13
  const add = (name, status, message) => {
11
14
  checks.push({ name, status, message });
@@ -42,6 +45,22 @@ export function doctor(options = {}, ctx = createContext()) {
42
45
  catch (error) {
43
46
  add("state-dir", "FAIL", `${stateDir}: ${error instanceof Error ? error.message : String(error)}`);
44
47
  }
48
+ const latest = typeof latestVersion === "string" ? latestVersion : undefined;
49
+ if (latestVersion instanceof Error)
50
+ add("version", "INFO", `${ADAPTER_VERSION}; latest unknown (${latestVersion.message})`);
51
+ else if (latest !== undefined && isNewerVersion(latest, ADAPTER_VERSION))
52
+ add("version", "WARN", `${ADAPTER_VERSION} installed; ${latest} is released: run antechamber upgrade`);
53
+ else
54
+ add("version", "PASS", ADAPTER_VERSION);
55
+ // A released fix is live only once OpenChamber relaunches the adapter.
56
+ const running = readRunningServers(stateDir, ctx.fs, (pid) => ctx.platform === "linux" ? ctx.fs.existsSync(`/proc/${pid}`) : true);
57
+ if (running.length === 0)
58
+ add("running", "INFO", `no running antechamber server recorded in ${stateDir}`);
59
+ for (const server of running) {
60
+ const target = latest !== undefined && isNewerVersion(latest, ADAPTER_VERSION) ? latest : ADAPTER_VERSION;
61
+ const stale = isNewerVersion(target, server.version);
62
+ add("running", stale ? "WARN" : "PASS", `pid ${server.pid} at ${server.url} runs ${server.version}${stale ? `; restart OpenChamber to run ${target}` : ""}`);
63
+ }
45
64
  const python = ctx.platform === "linux"
46
65
  ? findPidfdPython(ctx.env, (candidate, args) => {
47
66
  try {
@@ -1,6 +1,10 @@
1
- import { envSetting } from "../config.js";
1
+ import * as fs from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { defaultStateDir, envSetting } from "../config.js";
4
+ import { recordRunningServer } from "../running-servers.js";
2
5
  import { selfUpdate } from "../self-update.js";
3
6
  import { AdapterServer, isLoopbackHost, serverPassword } from "../server.js";
7
+ import { ADAPTER_VERSION } from "../version.js";
4
8
  export async function serve(options) {
5
9
  const { port, host, directory, insecureNoAuth, compatible } = options;
6
10
  if (!isLoopbackHost(host) &&
@@ -10,7 +14,9 @@ export async function serve(options) {
10
14
  throw new Error(`refusing to bind ${host} without authentication. Set OPENCODE_SERVER_PASSWORD or ANTECHAMBER_SERVER_PASSWORD, or explicitly use --insecure-no-auth (ANTECHAMBER_ALLOW_INSECURE=1).`);
11
15
  }
12
16
  const server = new AdapterServer({ port, host, defaultDirectory: directory });
17
+ let forget = () => { };
13
18
  const shutdown = () => {
19
+ forget();
14
20
  void server.stop().then(() => process.exit(0), (error) => {
15
21
  console.error("antechamber: shutdown failed:", error);
16
22
  process.exit(1);
@@ -21,6 +27,13 @@ export async function serve(options) {
21
27
  process.on("SIGINT", shutdown);
22
28
  process.on("SIGTERM", shutdown);
23
29
  await server.start();
30
+ try {
31
+ forget = recordRunningServer(envSetting("STATE_DIR") ?? defaultStateDir(homedir(), fs.existsSync), { pid: process.pid, version: ADAPTER_VERSION, url: server.url, started: new Date().toISOString() }, fs);
32
+ }
33
+ catch (error) {
34
+ // Only doctor reads the record; serving must not depend on it.
35
+ console.error(`antechamber: cannot record the running version: ${error instanceof Error ? error.message : String(error)}`);
36
+ }
24
37
  if (compatible)
25
38
  console.log(`opencode server listening on ${server.url}`);
26
39
  else {
package/dist/mcp.d.ts CHANGED
@@ -3,7 +3,9 @@
3
3
  *
4
4
  * OpenChamber scopes MCP calls by directory only, while OmO owns MCP per session process.
5
5
  * Everything therefore targets the most recently used LIVE session in the directory; a
6
- * dormant session is never revived just to answer an MCP request.
6
+ * dormant session is never revived just to answer an MCP request. Status reads for a
7
+ * directory without one fall back to the most recently used live session elsewhere.
8
+ * Authorization stays scoped to the directory.
7
9
  *
8
10
  * Status comes from the read-only `get_loaded_surfaces` RPC. Authorization drives OmO's
9
11
  * documented `/mcp` subcommands (docs/mcp.md "Auth"):
package/dist/mcp.js CHANGED
@@ -3,7 +3,9 @@
3
3
  *
4
4
  * OpenChamber scopes MCP calls by directory only, while OmO owns MCP per session process.
5
5
  * Everything therefore targets the most recently used LIVE session in the directory; a
6
- * dormant session is never revived just to answer an MCP request.
6
+ * dormant session is never revived just to answer an MCP request. Status reads for a
7
+ * directory without one fall back to the most recently used live session elsewhere.
8
+ * Authorization stays scoped to the directory.
7
9
  *
8
10
  * Status comes from the read-only `get_loaded_surfaces` RPC. Authorization drives OmO's
9
11
  * documented `/mcp` subcommands (docs/mcp.md "Auth"):
@@ -87,7 +89,10 @@ async function loadedServers(pi) {
87
89
  : []);
88
90
  }
89
91
  async function directoryServers(sessions, directory) {
90
- const live = liveSession(sessions, directory);
92
+ // A directory whose sessions are all dormant would list nothing. Its servers are mostly
93
+ // the user's global and built-in ones, so any running session gives a better answer.
94
+ const fallback = sessions.liveSessions()[0]?.pi;
95
+ const live = liveSession(sessions, directory) ?? (fallback === undefined ? undefined : { pi: fallback });
91
96
  return live === undefined ? [] : loadedServers(live.pi).catch(() => []);
92
97
  }
93
98
  async function hasMcpCommand(pi) {
package/dist/routes-v1.js CHANGED
@@ -147,7 +147,15 @@ export async function handleV1(services, req, res, url, path, method, directory)
147
147
  json(res, 200, []);
148
148
  return;
149
149
  }
150
- const start = limit === undefined ? 0 : Math.max(0, end - limit);
150
+ let start = limit === undefined ? 0 : Math.max(0, end - limit);
151
+ // OpenChamber 1.24 widens a page with no user message only up to 120 messages
152
+ // (150 on desktop), then renders it with no turn to anchor on: blank. A long agent
153
+ // turn outgrows that, so a page reaches back to the prompt that opened its turn.
154
+ if (!messages.slice(start, end).some((message) => message.info.role === "user")) {
155
+ const prompt = messages.findLastIndex((message, index) => index < start && message.info.role === "user");
156
+ if (prompt >= 0)
157
+ start = prompt;
158
+ }
151
159
  const page = messages.slice(start, end);
152
160
  if (start > 0 && page[0] !== undefined)
153
161
  res.setHeader("x-next-cursor", page[0].info.id);
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Records each `serve` process under `<stateDir>/run/<pid>.json` so `antechamber doctor`
3
+ * can tell which version is actually running. A merged and released fix does nothing until
4
+ * OpenChamber relaunches the adapter, and auto-update only installs it for the next launch.
5
+ */
6
+ import type * as fs from "node:fs";
7
+ export interface RunningServer {
8
+ readonly pid: number;
9
+ readonly version: string;
10
+ readonly url: string;
11
+ readonly started: string;
12
+ }
13
+ type Fs = Pick<typeof fs, "existsSync" | "mkdirSync" | "readdirSync" | "readFileSync" | "rmSync" | "writeFileSync">;
14
+ /** Writes the record and returns its remover; a crash leaves a stale record that readers skip by PID. */
15
+ export declare function recordRunningServer(stateDir: string, server: RunningServer, files: Fs): () => void;
16
+ /** Records whose process is still alive, newest first. */
17
+ export declare function readRunningServers(stateDir: string, files: Fs, alive: (pid: number) => boolean): RunningServer[];
18
+ export {};
@@ -0,0 +1,35 @@
1
+ import { join } from "node:path";
2
+ const runDir = (stateDir) => join(stateDir, "run");
3
+ /** Writes the record and returns its remover; a crash leaves a stale record that readers skip by PID. */
4
+ export function recordRunningServer(stateDir, server, files) {
5
+ const path = join(runDir(stateDir), `${server.pid}.json`);
6
+ files.mkdirSync(runDir(stateDir), { recursive: true, mode: 0o700 });
7
+ files.writeFileSync(path, `${JSON.stringify(server)}\n`, { mode: 0o600 });
8
+ return () => files.rmSync(path, { force: true });
9
+ }
10
+ /** Records whose process is still alive, newest first. */
11
+ export function readRunningServers(stateDir, files, alive) {
12
+ const dir = runDir(stateDir);
13
+ if (!files.existsSync(dir))
14
+ return [];
15
+ return files
16
+ .readdirSync(dir)
17
+ .filter((name) => /^\d+\.json$/.test(name))
18
+ .flatMap((name) => {
19
+ try {
20
+ const record = JSON.parse(files.readFileSync(join(dir, name), "utf8"));
21
+ if (typeof record !== "object" || record === null)
22
+ return [];
23
+ const { pid, version, url, started } = record;
24
+ if (typeof pid !== "number" || typeof version !== "string" || typeof url !== "string")
25
+ return [];
26
+ if (typeof started !== "string" || !alive(pid))
27
+ return [];
28
+ return [{ pid, version, url, started }];
29
+ }
30
+ catch {
31
+ return []; // A record being written or removed concurrently.
32
+ }
33
+ })
34
+ .sort((a, b) => b.started.localeCompare(a.started));
35
+ }
@@ -20,6 +20,8 @@ interface StreamingState {
20
20
  }
21
21
  export interface ManagedSession extends ThinkingState {
22
22
  interruptRequested?: boolean;
23
+ /** Counts abort requests, so a compaction cancelled by the user is not retried. */
24
+ abortRequests?: number;
23
25
  titleSource?: TitleSource;
24
26
  activeUserMessageID?: string;
25
27
  inputs?: Map<string, {
@@ -104,8 +106,8 @@ export declare class SessionManager {
104
106
  private wirePi;
105
107
  /** pi writes `<ts>_<sessionId>.jsonl` at startup — poll briefly for it. */
106
108
  private resolvePiFile;
107
- /** Sessions with a running agent in `directory`, most recently used first; never revives a dormant one. */
108
- liveSessions(directory: string): ManagedSession[];
109
+ /** Sessions with a running agent (in `directory`, if given), most recently used first; never revives a dormant one. */
110
+ liveSessions(directory?: string): ManagedSession[];
109
111
  list(directory?: string): OCSession[];
110
112
  /**
111
113
  * Every restorable session on disk (all directories) plus live ones. Backs the
package/dist/sessions.js CHANGED
@@ -26,6 +26,9 @@ const SESSION_SCAN_CACHE_TTL_MS = 5_000;
26
26
  const PI_FILE_RESOLVE_ATTEMPTS = 10;
27
27
  const PI_FILE_RESOLVE_RETRY_MS = 200;
28
28
  const SESSION_TITLE_MAX_CHARS = 60;
29
+ /** OmO's reply when a compaction is aborted (`CompactionCancelledError`). */
30
+ const OMO_COMPACTION_CANCELLED = "Compaction cancelled";
31
+ const COMPACT_CANCEL_RETRIES = 2;
29
32
  export class SessionManager {
30
33
  emit;
31
34
  defaultDirectory;
@@ -289,12 +292,12 @@ export class SessionManager {
289
292
  }
290
293
  return undefined;
291
294
  }
292
- /** Sessions with a running agent in `directory`, most recently used first; never revives a dormant one. */
295
+ /** Sessions with a running agent (in `directory`, if given), most recently used first; never revives a dormant one. */
293
296
  liveSessions(directory) {
294
297
  if (this.disposed)
295
298
  return [];
296
299
  return [...this.sessions.values()]
297
- .filter((s) => s.pi !== undefined && s.info.directory === directory)
300
+ .filter((s) => s.pi !== undefined && (directory === undefined || s.info.directory === directory))
298
301
  .sort((a, b) => this.lifecycle.lastUsed(b) - this.lifecycle.lastUsed(a));
299
302
  }
300
303
  list(directory) {
@@ -688,6 +691,7 @@ export class SessionManager {
688
691
  const s = this.get(sessionId);
689
692
  if (!s)
690
693
  return false;
694
+ s.abortRequests = (s.abortRequests ?? 0) + 1;
691
695
  if (s.pi !== undefined) {
692
696
  if (s.status.type !== "idle")
693
697
  s.interruptRequested = true;
@@ -755,7 +759,19 @@ export class SessionManager {
755
759
  this.lifecycle.touch(s);
756
760
  const timeout = command.type === "compact" ? COMPACT_RPC_TIMEOUT_MS : RPC_COMMAND_TIMEOUT_MS;
757
761
  signal?.throwIfAborted();
758
- const response = await pi.command(command, timeout, undefined, onDispatch);
762
+ const abortsBefore = s.abortRequests ?? 0;
763
+ let response = await pi.command(command, timeout, undefined, onDispatch);
764
+ // OmO cancels a running compaction whenever its active tool set changes. On a session
765
+ // just revived for this request, MCP servers finish connecting a moment after startup
766
+ // and cancel it although nobody asked. Retry unless the user sent an abort meanwhile.
767
+ for (let retry = 0; retry < COMPACT_CANCEL_RETRIES &&
768
+ command.type === "compact" &&
769
+ !response.success &&
770
+ response.error === OMO_COMPACTION_CANCELLED &&
771
+ (s.abortRequests ?? 0) === abortsBefore &&
772
+ !signal?.aborted &&
773
+ s.pi === pi; retry++)
774
+ response = await pi.command(command, timeout);
759
775
  if (!signal?.aborted && response.success && (command.type === "compact" || command.type === "bash"))
760
776
  await this.reloadMessagesLive(s, signal);
761
777
  return response;
package/dist/subagents.js CHANGED
@@ -639,10 +639,16 @@ export class SubagentBridge {
639
639
  const model = typeof data.model === "string"
640
640
  ? data.model
641
641
  : `${resolved.provider ?? "unknown"}/${resolved.model_id ?? "unknown"}`;
642
+ const finalResponse = stripTaskSuffix(typeof data.final_response === "string" ? data.final_response : "");
643
+ const error = terminal(data.status) && data.status !== "completed" && typeof data.error_message === "string"
644
+ ? data.error_message
645
+ : undefined;
646
+ const runtime = stats.runtime_ms ?? data.duration_ms;
642
647
  const text = [
643
- stripTaskSuffix(typeof data.final_response === "string" ? data.final_response : ""),
644
- `Model: ${model}\nRuntime: ${stats.runtime_ms ?? data.duration_ms ?? 0} ms\nTokens: ${stats.total_tokens ?? 0}\nCost: $${stats.cost_usd ?? 0}`,
645
- "Full transcript was not retained by OmO.",
648
+ finalResponse,
649
+ error && !finalResponse.includes(error) ? error : "",
650
+ `Model: ${model}\nRuntime: ${runtime === undefined ? "unknown" : `${runtime} ms`}\nTokens: ${stats.total_tokens ?? "unknown"}\nCost: ${stats.cost_usd === undefined ? "unknown" : `$${stats.cost_usd}`}`,
651
+ "Full transcript is unavailable.",
646
652
  ]
647
653
  .filter(Boolean)
648
654
  .join("\n\n");
@@ -666,6 +672,7 @@ export class SubagentBridge {
666
672
  role: "assistant",
667
673
  provider: resolved.provider ?? model.split("/")[0],
668
674
  model: resolved.model_id ?? model.split("/").slice(1).join("/"),
675
+ ...(error ? { stopReason: "error", errorMessage: error } : {}),
669
676
  // run_stats tokens are totals across the whole run (every model call),
670
677
  // not one request's context, so only the cost is carried as usage; the
671
678
  // totals stay in the text above. Token counts here read as context fill.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "antechamber-omo-graph",
3
- "version": "0.2.13",
3
+ "version": "0.2.15",
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",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "antechamber",
3
- "version": "0.2.13",
3
+ "version": "0.2.15",
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",
@@ -58,7 +58,8 @@
58
58
  "smoke": "tsx contrib/smoke.ts",
59
59
  "omo-smoke": "tsx contrib/omo/omo-smoke.ts",
60
60
  "model-smoke": "tsx contrib/model-consistency.ts",
61
- "v2-smoke": "tsx contrib/v2-smoke.ts"
61
+ "v2-smoke": "tsx contrib/v2-smoke.ts",
62
+ "replay": "tsx contrib/replay/transcripts.ts"
62
63
  },
63
64
  "allowScripts": {
64
65
  "esbuild@0.28.2": true