antechamber 0.2.8 → 0.2.10

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,27 @@ and this project uses [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.10] - 2026-10-16
11
+
12
+ ### Fixed
13
+
14
+ - `/btw` on an idle session no longer fails with "Invalid entry ID for
15
+ forking". Forking before an assistant message (the fork point `/btw` sends)
16
+ now keeps everything before that reply, matching OpenCode's exclusive fork
17
+ boundary. Forks are still refused while the session is busy.
18
+ - Running tool cards no longer flash a red "Interrupted" error until they
19
+ finish. An assistant message is now marked completed only once its tools
20
+ (including subagent task cards) have settled, as OpenCode does.
21
+
22
+ ## [0.2.9] - 2026-10-16
23
+
24
+ ### Fixed
25
+
26
+ - Mobile: sessions whose newest 30 messages contain no user prompt no longer
27
+ open blank on OpenChamber's mobile surface. v1 `GET /session/{id}/message`
28
+ now returns `x-next-cursor` and honors `before`, so the client pages back
29
+ until it finds the start of a turn.
30
+
10
31
  ## [0.2.8] - 2026-10-15
11
32
 
12
33
  - fix(session): resend bare /<skill> commands as /skill:<name>
package/dist/catalog.js CHANGED
@@ -32,7 +32,7 @@ function toOCModel(m) {
32
32
  toolcall: true,
33
33
  input: { text: modality("text"), audio: false, image: modality("image"), video: false, pdf: false },
34
34
  output: { text: true, audio: false, image: false, video: false, pdf: false },
35
- interleaved: m.reasoning ? true : false,
35
+ interleaved: !!m.reasoning,
36
36
  },
37
37
  cost: {
38
38
  input: m.cost?.input ?? 0,
@@ -15,7 +15,7 @@ export function debugLog(line) {
15
15
  if (!envSetting("DEBUG") || loggingDisabled)
16
16
  return;
17
17
  try {
18
- appendFileSync(envSetting("DEBUG_LOG") ?? join(tmpdir(), `antechamber-debug-${process.getuid?.() ?? "user"}.log`), line + "\n", { flag: "a", mode: 0o600 });
18
+ appendFileSync(envSetting("DEBUG_LOG") ?? join(tmpdir(), `antechamber-debug-${process.getuid?.() ?? "user"}.log`), `${line}\n`, { flag: "a", mode: 0o600 });
19
19
  }
20
20
  catch (error) {
21
21
  loggingDisabled = true;
@@ -51,6 +51,8 @@ interface PiEventServices {
51
51
  /** Translates per-session events; registry and lifecycle operations stay with SessionManager. */
52
52
  export declare class PiEventTranslator {
53
53
  private services;
54
+ /** Ended assistant messages whose completion waits on their tools. */
55
+ private awaitingTools;
54
56
  constructor(services: PiEventServices);
55
57
  /** Close dead-runtime UI state before an error or idle event can reach the client. */
56
58
  finishInterruptedRun(s: ManagedSession, error: NonNullable<OCAssistantMessage["error"]>): void;
@@ -79,7 +81,11 @@ export declare class PiEventTranslator {
79
81
  private onToolcallEvent;
80
82
  /** turn_end.toolResults → finalize tool parts (completed / error). */
81
83
  private applyToolResults;
84
+ /** Complete an ended assistant message whose completion waited on its tools; true when it changed. */
85
+ private completeSettled;
82
86
  /** Tool parts that never received a result (abort, crash) must not spin forever. */
83
87
  private finalizeDanglingTools;
88
+ /** Complete every ended message of this session still waiting on its tools. */
89
+ private completeAwaitingTools;
84
90
  }
85
91
  export {};
@@ -142,6 +142,8 @@ export function compactionMessages(s, key, summary, created, model) {
142
142
  /** Translates per-session events; registry and lifecycle operations stay with SessionManager. */
143
143
  export class PiEventTranslator {
144
144
  services;
145
+ /** Ended assistant messages whose completion waits on their tools. */
146
+ awaitingTools = new WeakSet();
145
147
  constructor(services) {
146
148
  this.services = services;
147
149
  }
@@ -420,6 +422,10 @@ export class PiEventTranslator {
420
422
  const msg = ev.message;
421
423
  if (msg?.role !== "assistant")
422
424
  return; // user message already recorded at prompt()
425
+ // pi starts the next step only after the previous step's tool results, so
426
+ // close any earlier step still waiting on cards (e.g. a background task)
427
+ // before this one opens; v2 clients must never see an ended step reopen.
428
+ this.completeAwaitingTools(s);
423
429
  // pi stamps the assistant message with the model really serving it.
424
430
  // Trust that over our snapshot, which goes stale whenever pi switches on
425
431
  // its own side (subagent routing, an in-session /model, a resumed file).
@@ -468,7 +474,15 @@ export class PiEventTranslator {
468
474
  }
469
475
  applyUsage(s.streaming.message, ended.usage);
470
476
  Object.assign(s.streaming.message, piStopReason(ended.stopReason, ended.errorMessage));
471
- s.streaming.message.time.completed = Date.now();
477
+ // OpenCode completes an assistant message only after its tools settle, and
478
+ // OpenChamber marks active tools under a completed message as Interrupted.
479
+ // pi ends a toolUse message before executing its tool calls, so defer its
480
+ // completion to the last tool result (applyToolResults), the next step, or
481
+ // the run end. Errored/aborted messages never run their tools.
482
+ if (ended.stopReason === "toolUse" && hasActiveTools(s, s.streaming.message.id))
483
+ this.awaitingTools.add(s.streaming.message);
484
+ else
485
+ s.streaming.message.time.completed = Date.now();
472
486
  this.services.emit(s.info.directory, { type: "message.updated", properties: { info: s.streaming.message } });
473
487
  this.finishStreaming(s);
474
488
  if (syncSessionCost(s))
@@ -482,6 +496,8 @@ export class PiEventTranslator {
482
496
  onCompactionEvent(s, ev) {
483
497
  switch (ev.type) {
484
498
  case "compaction_start":
499
+ // An auto-compaction can fall between steps; its summary is a new step.
500
+ this.completeAwaitingTools(s);
485
501
  s.compactionID = id("compaction");
486
502
  this.services.emit(s.info.directory, {
487
503
  type: "session.compaction.started",
@@ -738,8 +754,12 @@ export class PiEventTranslator {
738
754
  applyToolResults(s, results) {
739
755
  for (const tr of results) {
740
756
  const callID = typeof tr.toolCallId === "string" ? tr.toolCallId : undefined;
741
- if (callID !== undefined && this.services.subagents.result(s, callID, tr, true))
757
+ if (callID !== undefined && this.services.subagents.result(s, callID, tr, true)) {
758
+ const owner = s.messages?.find((m) => m.parts.some((p) => p.type === "tool" && p.callID === callID));
759
+ if (owner && this.completeSettled(s, owner.info))
760
+ this.services.emit(s.info.directory, { type: "message.updated", properties: { info: owner.info } });
742
761
  continue;
762
+ }
743
763
  const part = callID !== undefined ? s.toolByCallId.get(callID) : undefined;
744
764
  if (!part || callID === undefined)
745
765
  continue;
@@ -778,10 +798,20 @@ export class PiEventTranslator {
778
798
  this.services.dirtyDiffs.add(s.info.id);
779
799
  this.emitPart(s, part);
780
800
  const owner = s.messages?.find((m) => m.info.id === part.messageID);
781
- if (owner)
782
- this.services.emit(s.info.directory, { type: "message.updated", properties: { info: owner.info } });
801
+ if (!owner)
802
+ continue;
803
+ this.completeSettled(s, owner.info);
804
+ this.services.emit(s.info.directory, { type: "message.updated", properties: { info: owner.info } });
783
805
  }
784
806
  }
807
+ /** Complete an ended assistant message whose completion waited on its tools; true when it changed. */
808
+ completeSettled(s, info) {
809
+ if (!this.awaitingTools.has(info) || info.role !== "assistant" || hasActiveTools(s, info.id))
810
+ return false;
811
+ this.awaitingTools.delete(info);
812
+ info.time.completed = Date.now();
813
+ return true;
814
+ }
785
815
  /** Tool parts that never received a result (abort, crash) must not spin forever. */
786
816
  finalizeDanglingTools(s) {
787
817
  for (const part of finishProviderToolParts(s.toolByCallId.values(), Date.now()))
@@ -799,5 +829,26 @@ export class PiEventTranslator {
799
829
  this.emitPart(s, part);
800
830
  }
801
831
  s.toolByCallId.clear();
832
+ this.completeAwaitingTools(s); // the run is over
802
833
  }
834
+ /** Complete every ended message of this session still waiting on its tools. */
835
+ completeAwaitingTools(s) {
836
+ const now = Date.now();
837
+ for (const { info } of s.messages ?? []) {
838
+ if (!this.awaitingTools.has(info) || info.role !== "assistant")
839
+ continue;
840
+ this.awaitingTools.delete(info);
841
+ info.time.completed = now;
842
+ this.services.emit(s.info.directory, { type: "message.updated", properties: { info } });
843
+ }
844
+ }
845
+ }
846
+ /** A tool part of this message (including subagent task cards) still waiting for its result. */
847
+ function hasActiveTools(s, messageID) {
848
+ const active = (part) => part.type === "tool" &&
849
+ part.messageID === messageID &&
850
+ (part.state.status === "pending" || part.state.status === "running");
851
+ if ([...s.toolByCallId.values()].some(active))
852
+ return true;
853
+ return s.messages?.find((m) => m.info.id === messageID)?.parts.some(active) ?? false;
803
854
  }
package/dist/pi-rpc.js CHANGED
@@ -253,7 +253,7 @@ export class PiRpcClient extends EventEmitter {
253
253
  write(obj) {
254
254
  if (this.closed)
255
255
  return;
256
- this.proc.stdin.write(JSON.stringify(obj) + "\n");
256
+ this.proc.stdin.write(`${JSON.stringify(obj)}\n`);
257
257
  }
258
258
  failAll(err) {
259
259
  for (const p of this.pending.values())
package/dist/pidfd.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import { delimiter, resolve } from "node:path";
3
+ // biome-ignore lint/complexity/noUselessStringRaw: embedded Python must keep any backslashes verbatim.
3
4
  const pidfdProbe = String.raw `
4
5
  import os, selectors, signal
5
6
  fd = os.pidfd_open(os.getpid())
package/dist/routes-v1.js CHANGED
@@ -134,9 +134,24 @@ export async function handleV1(services, req, res, url, path, method, directory)
134
134
  json(res, 200, await services.sessions.remove(sessionId));
135
135
  return;
136
136
  case "GET /message": {
137
+ // OpenCode pages backwards from the newest message: `before` is the
138
+ // x-next-cursor of the previous page (here, its oldest message ID).
139
+ // Without the cursor, OpenChamber's loader treats a partial page as the
140
+ // whole history; mobile's 30-message first page then has no user
141
+ // message to anchor a turn on, and the transcript renders blank.
137
142
  const messages = services.sessions.messages(sessionId) ?? [];
138
143
  const limit = numberParam(url.searchParams.get("limit"));
139
- json(res, 200, limit === undefined ? messages : limit === 0 ? [] : messages.slice(-limit));
144
+ const before = url.searchParams.get("before");
145
+ const end = before === null ? messages.length : messages.findIndex((message) => message.info.id === before);
146
+ if (end < 0 || limit === 0) {
147
+ json(res, 200, []);
148
+ return;
149
+ }
150
+ const start = limit === undefined ? 0 : Math.max(0, end - limit);
151
+ const page = messages.slice(start, end);
152
+ if (start > 0 && page[0] !== undefined)
153
+ res.setHeader("x-next-cursor", page[0].info.id);
154
+ json(res, 200, page);
140
155
  return;
141
156
  }
142
157
  case "GET /children":
@@ -407,7 +407,6 @@ export function parsePiSessionText(s, agentName, raw) {
407
407
  metadata,
408
408
  time: { start: startedAt, end: created },
409
409
  };
410
- continue;
411
410
  }
412
411
  // system / custom entries are not rendered
413
412
  }
@@ -50,6 +50,7 @@ function processInfo(pid) {
50
50
  // Node 22 has no pidfd API. This bounded, stdlib-only helper holds all pidfds
51
51
  // across TERM, readiness-based death observation, and KILL. -I ignores Python
52
52
  // environment customizations. No numeric-PID signal fallback is permitted.
53
+ // biome-ignore lint/complexity/noUselessStringRaw: embedded Python must keep any backslashes verbatim.
53
54
  const stopBoundHosts = String.raw `
54
55
  import contextlib, json, os, selectors, signal, sys, time
55
56
 
@@ -235,13 +235,18 @@ export async function forkConversation(s, pi, tree, messageID) {
235
235
  throw new SessionTreeError("Cannot fork an empty conversation");
236
236
  const shellUser = entry.message?.role === "bashExecution" &&
237
237
  s.messages?.find((message) => message.info.id === messageID)?.info.role === "user";
238
- const forkEntryId = shellUser ? entry.parentId : entry.id;
239
- if (forkEntryId === null)
240
- throw new SessionTreeError("Cannot fork before a root shell entry");
238
+ // OpenCode forks strictly before any selected message. OmO's default "before"
239
+ // position only accepts user entries, so other boundaries (an assistant reply,
240
+ // as /btw sends) fork "at" the entry preceding them.
241
+ const beforeOther = messageID !== undefined && entry.message?.role !== "user" && entry.message?.role !== "bashExecution";
242
+ const forkEntryId = shellUser || beforeOther ? entry.parentId : entry.id;
243
+ if (forkEntryId === null) {
244
+ throw new SessionTreeError(shellUser ? "Cannot fork before a root shell entry" : "Cannot fork before the first entry");
245
+ }
241
246
  const response = await pi.command({
242
247
  type: "fork",
243
248
  entryId: forkEntryId,
244
- ...(messageID === undefined || entry.message?.role === "bashExecution" ? { position: "at" } : {}),
249
+ ...(messageID === undefined || beforeOther || entry.message?.role === "bashExecution" ? { position: "at" } : {}),
245
250
  });
246
251
  if (!response.success)
247
252
  throw new SessionTreeError(response.error ?? "OmO fork failed");
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "antechamber-omo-graph",
3
- "version": "0.2.8",
3
+ "version": "0.2.10",
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.8",
3
+ "version": "0.2.10",
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",
@@ -40,7 +40,7 @@
40
40
  "CHANGELOG.md"
41
41
  ],
42
42
  "bin": {
43
- "antechamber": "./dist/cli.js"
43
+ "antechamber": "dist/cli.js"
44
44
  },
45
45
  "scripts": {
46
46
  "build": "tsc -p tsconfig.json && node --input-type=module -e \"import { chmodSync } from 'node:fs'; chmodSync('dist/cli.js', 0o755)\" && npm run build:extension",
@@ -60,6 +60,9 @@
60
60
  "model-smoke": "tsx contrib/model-consistency.ts",
61
61
  "v2-smoke": "tsx contrib/v2-smoke.ts"
62
62
  },
63
+ "allowScripts": {
64
+ "esbuild@0.28.2": true
65
+ },
63
66
  "engines": {
64
67
  "node": ">=22"
65
68
  },