antechamber 0.2.9 → 0.2.11

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,25 @@ and this project uses [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.11] - 2026-10-16
11
+
12
+ ### Fixed
13
+
14
+ - Keep pending prompts alive while the agent is running or compacting instead of terminating useful work at the
15
+ ordinary RPC deadline. Silent requests still time out, and prompt admission has a bounded overall wait.
16
+
17
+ ## [0.2.10] - 2026-10-16
18
+
19
+ ### Fixed
20
+
21
+ - `/btw` on an idle session no longer fails with "Invalid entry ID for
22
+ forking". Forking before an assistant message (the fork point `/btw` sends)
23
+ now keeps everything before that reply, matching OpenCode's exclusive fork
24
+ boundary. Forks are still refused while the session is busy.
25
+ - Running tool cards no longer flash a red "Interrupted" error until they
26
+ finish. An assistant message is now marked completed only once its tools
27
+ (including subagent task cards) have settled, as OpenCode does.
28
+
10
29
  ## [0.2.9] - 2026-10-16
11
30
 
12
31
  ### Fixed
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.d.ts CHANGED
@@ -69,6 +69,8 @@ export declare class PiRpcClient extends EventEmitter {
69
69
  private closed;
70
70
  private stderrTail;
71
71
  private disposing;
72
+ private agentActive;
73
+ private compacting;
72
74
  get pid(): number | undefined;
73
75
  get isBusy(): boolean;
74
76
  onEvent(listener: (payload: Record<string, unknown>) => void): this;
package/dist/pi-rpc.js CHANGED
@@ -21,6 +21,7 @@ export const RPC_COMMAND_TIMEOUT_MS = 60_000;
21
21
  * long before a large session finished, leaving the run orphaned.
22
22
  */
23
23
  export const COMPACT_RPC_TIMEOUT_MS = 31 * 60_000;
24
+ const PROMPT_ADMISSION_TIMEOUT_MS = COMPACT_RPC_TIMEOUT_MS + RPC_COMMAND_TIMEOUT_MS;
24
25
  const MAX_JSONL_FRAME_BYTES = 64 * 1024 * 1024;
25
26
  export class UpstreamProtocolError extends Error {
26
27
  constructor(message) {
@@ -67,6 +68,8 @@ export class PiRpcClient extends EventEmitter {
67
68
  closed = false;
68
69
  stderrTail = "";
69
70
  disposing;
71
+ agentActive = false;
72
+ compacting = false;
70
73
  get pid() {
71
74
  return this.proc.pid;
72
75
  }
@@ -235,6 +238,29 @@ export class PiRpcClient extends EventEmitter {
235
238
  this.handleExtensionUI(msg);
236
239
  return;
237
240
  }
241
+ const wasWorking = this.agentActive || this.compacting;
242
+ switch (msg.type) {
243
+ case "agent_start":
244
+ case "auto_retry_start":
245
+ this.agentActive = true;
246
+ break;
247
+ case "agent_end":
248
+ if (msg.willRetry !== true)
249
+ this.agentActive = false;
250
+ break;
251
+ case "agent_idle":
252
+ this.agentActive = false;
253
+ break;
254
+ case "compaction_start":
255
+ this.compacting = true;
256
+ break;
257
+ case "compaction_end":
258
+ this.compacting = false;
259
+ break;
260
+ }
261
+ if (wasWorking !== (this.agentActive || this.compacting))
262
+ for (const request of this.pending.values())
263
+ request.onWorkState?.();
238
264
  this.emit("event", msg);
239
265
  }
240
266
  handleExtensionUI(msg) {
@@ -253,7 +279,7 @@ export class PiRpcClient extends EventEmitter {
253
279
  write(obj) {
254
280
  if (this.closed)
255
281
  return;
256
- this.proc.stdin.write(JSON.stringify(obj) + "\n");
282
+ this.proc.stdin.write(`${JSON.stringify(obj)}\n`);
257
283
  }
258
284
  failAll(err) {
259
285
  for (const p of this.pending.values())
@@ -261,16 +287,21 @@ export class PiRpcClient extends EventEmitter {
261
287
  this.pending.clear();
262
288
  }
263
289
  /** Send a command and wait for its correlated response. */
264
- command(cmd, timeoutMs = RPC_COMMAND_TIMEOUT_MS, signal, onDispatch) {
290
+ command(cmd, timeoutMs, signal, onDispatch) {
265
291
  if (this.closed)
266
292
  return Promise.reject(new Error("pi process is not running"));
267
293
  if (signal?.aborted)
268
294
  return Promise.reject(signal.reason);
269
295
  const id = randomUUID();
270
296
  const expectedCommand = String(cmd.type);
297
+ const deadlineMs = timeoutMs ?? RPC_COMMAND_TIMEOUT_MS;
298
+ const waitsForWork = expectedCommand === "prompt" && timeoutMs === undefined;
271
299
  return new Promise((resolve, reject) => {
300
+ let timer;
301
+ let admissionTimer;
272
302
  const cleanup = () => {
273
303
  clearTimeout(timer);
304
+ clearTimeout(admissionTimer);
274
305
  signal?.removeEventListener("abort", onAbort);
275
306
  this.pending.delete(id);
276
307
  };
@@ -281,9 +312,17 @@ export class PiRpcClient extends EventEmitter {
281
312
  const onAbort = () => {
282
313
  rejectCommand(signal?.reason);
283
314
  };
284
- const timer = setTimeout(() => {
285
- this.closeForTransportFailure(new Error(`pi command "${expectedCommand}" timed out after ${timeoutMs}ms`));
286
- }, timeoutMs);
315
+ const expire = (duration) => {
316
+ this.closeForTransportFailure(new Error(`pi command "${expectedCommand}" timed out after ${duration}ms`));
317
+ };
318
+ const armDeadline = () => {
319
+ clearTimeout(timer);
320
+ if (!waitsForWork || !(this.agentActive || this.compacting))
321
+ timer = setTimeout(() => expire(deadlineMs), deadlineMs);
322
+ };
323
+ armDeadline();
324
+ if (waitsForWork)
325
+ admissionTimer = setTimeout(() => expire(PROMPT_ADMISSION_TIMEOUT_MS), PROMPT_ADMISSION_TIMEOUT_MS);
287
326
  this.pending.set(id, {
288
327
  command: expectedCommand,
289
328
  resolve: (r) => {
@@ -292,6 +331,7 @@ export class PiRpcClient extends EventEmitter {
292
331
  resolve(r.success ? { ...r, data: r.data } : r);
293
332
  },
294
333
  reject: rejectCommand,
334
+ ...(waitsForWork ? { onWorkState: armDeadline } : {}),
295
335
  });
296
336
  signal?.addEventListener("abort", onAbort, { once: true });
297
337
  this.write({ ...cmd, id });
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())
@@ -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.9",
3
+ "version": "0.2.11",
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.9",
3
+ "version": "0.2.11",
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
  },