antechamber 1.0.15 → 1.0.16

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,19 @@ and this project uses [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.0.16] - 2026-10-23
11
+
12
+ ### Fixed
13
+
14
+ - Stop OmO Work's status poll from killing the agent. Its 10-second `eval_cells.list` lost the race with a large
15
+ session's cold start, the timeout stopped the agent, and the user's prompt failed with
16
+ `pi command "eval_cells.list" timed out` while the resend could not start a reply. OmO Work's eval cell requests
17
+ now fail alone on timeout; conversation operations still stop the agent, which keeps their order.
18
+ - Say why the agent stopped whenever it stops on its own: a command timeout (which command, which deadline), a
19
+ broken pipe, an oversized frame, or a crash (exit code or signal). The transcript gets a notice card and the
20
+ interrupted reply carries the same error, instead of a bare `pi process exited (code=null)` toast or
21
+ "OpenCode did not start a reply". The notice survives an adapter restart until the next prompt.
22
+
10
23
  ## [1.0.15] - 2026-10-23
11
24
 
12
25
  ### Fixed
package/dist/mcp.js CHANGED
@@ -74,7 +74,7 @@ function liveSession(sessions, directory) {
74
74
  return s?.pi === undefined ? undefined : { s, pi: s.pi };
75
75
  }
76
76
  async function loadedServers(pi) {
77
- const response = await pi.command({ type: "get_loaded_surfaces" }, undefined, AbortSignal.timeout(MCP_READ_WAIT_MS));
77
+ const response = await pi.probe({ type: "get_loaded_surfaces" }, undefined, AbortSignal.timeout(MCP_READ_WAIT_MS));
78
78
  // Older OmO builds have no loaded-surfaces RPC: report no servers rather than a failure.
79
79
  if (!response.success || !isRecord(response.data) || !Array.isArray(response.data.mcpServers))
80
80
  return [];
@@ -96,7 +96,7 @@ async function directoryServers(sessions, directory) {
96
96
  return live === undefined ? [] : loadedServers(live.pi).catch(() => []);
97
97
  }
98
98
  async function hasMcpCommand(pi) {
99
- const response = await pi.command({ type: "get_commands" }, undefined, AbortSignal.timeout(MCP_READ_WAIT_MS));
99
+ const response = await pi.probe({ type: "get_commands" }, undefined, AbortSignal.timeout(MCP_READ_WAIT_MS));
100
100
  if (!response.success || !isRecord(response.data) || !Array.isArray(response.data.commands))
101
101
  return false;
102
102
  return response.data.commands.some((command) => isRecord(command) && command.name === "mcp" && command.source === "extension");
package/dist/pi-rpc.d.ts CHANGED
@@ -35,6 +35,31 @@ export interface ProcessTable {
35
35
  identity(pid: number): ProcessIdentity | undefined;
36
36
  groupMembers(group: number): ProcessIdentity[];
37
37
  }
38
+ /** Why the agent stopped without antechamber disposing it on purpose (eviction, delete, shutdown). */
39
+ export type PiStopReason = {
40
+ readonly kind: "timeout";
41
+ readonly command: string;
42
+ readonly ms: number;
43
+ } | {
44
+ readonly kind: "frame-overflow";
45
+ } | {
46
+ readonly kind: "spawn";
47
+ readonly detail: string;
48
+ } | {
49
+ readonly kind: "transport";
50
+ readonly detail: string;
51
+ } | {
52
+ readonly kind: "exit";
53
+ readonly code: number | null;
54
+ readonly signal: string | null;
55
+ };
56
+ /** The user-facing explanation OpenChamber shows when the agent stops. */
57
+ export declare function describeStop(reason: PiStopReason): string;
58
+ /** Rejects every command pending when the agent stopped; the exit handler is what shows it to the user. */
59
+ export declare class PiStoppedError extends Error {
60
+ readonly reason: PiStopReason;
61
+ constructor(reason: PiStopReason, cause?: Error);
62
+ }
38
63
  export type PiRpcResponse<T = unknown> = {
39
64
  readonly id: string;
40
65
  readonly type: "response";
@@ -69,18 +94,25 @@ export declare class PiRpcClient extends EventEmitter {
69
94
  private closed;
70
95
  private stderrTail;
71
96
  private disposing;
97
+ private disposeRequested;
98
+ private stopped;
99
+ private outputClosed;
100
+ private sentSignals;
72
101
  private agentActive;
73
102
  private compacting;
74
103
  private sessionPath;
75
104
  get pid(): number | undefined;
76
105
  get isBusy(): boolean;
77
106
  get sessionFile(): string | undefined;
107
+ /** Set once the agent stopped for a reason other than a requested dispose; read it from exit listeners. */
108
+ get stopReason(): PiStopReason | undefined;
78
109
  onEvent(listener: (payload: Record<string, unknown>) => void): this;
79
110
  onExit(listener: (code: number | null, signal: string | null) => void): this;
80
111
  onExtensionUI(listener: (request: Record<string, unknown>) => void): this;
81
112
  constructor(options: PiRpcClientOptions);
82
113
  private drain;
83
114
  private frameOverflow;
115
+ private ownSignal;
84
116
  /** Node has reaped the leader, so its PID (and group ID) may already belong to another process. */
85
117
  private reaped;
86
118
  private closeForTransportFailure;
@@ -89,8 +121,14 @@ export declare class PiRpcClient extends EventEmitter {
89
121
  respondExtensionUI(response: Record<string, unknown>): void;
90
122
  private write;
91
123
  private failAll;
92
- /** Send a command and wait for its correlated response. */
93
- command<T = unknown>(cmd: Omit<PiRpcCommand, "id">, timeoutMs?: number, signal?: AbortSignal, onDispatch?: () => void): Promise<PiRpcResponse<T>>;
124
+ /**
125
+ * A read whose late answer changes nothing (status polls, listings): its timeout rejects only itself and
126
+ * the agent keeps running. OmO handles RPC lines concurrently, so anything that orders the conversation
127
+ * must use `command`, whose timeout stops the agent rather than let a late reply overlap the next operation.
128
+ */
129
+ probe<T = unknown>(cmd: Omit<PiRpcCommand, "id">, timeoutMs?: number, signal?: AbortSignal): Promise<PiRpcResponse<T>>;
130
+ /** Send a command and wait for its correlated response. A timeout stops the agent unless `stopOnTimeout` is false. */
131
+ command<T = unknown>(cmd: Omit<PiRpcCommand, "id">, timeoutMs?: number, signal?: AbortSignal, onDispatch?: () => void, stopOnTimeout?: boolean): Promise<PiRpcResponse<T>>;
94
132
  /** Fire-and-forget command (no response correlation). */
95
133
  notify(cmd: Omit<PiRpcCommand, "id">): void;
96
134
  dispose(): Promise<void>;
package/dist/pi-rpc.js CHANGED
@@ -12,6 +12,7 @@ import { StringDecoder } from "node:string_decoder";
12
12
  import { EventEmitter } from "node:events";
13
13
  import { randomUUID } from "node:crypto";
14
14
  import { readdirSync, readFileSync } from "node:fs";
15
+ import { constants as osConstants } from "node:os";
15
16
  import { systemClock } from "./lifecycle.js";
16
17
  import { agentBinary, envSetting } from "./config.js";
17
18
  export const RPC_COMMAND_TIMEOUT_MS = 60_000;
@@ -58,6 +59,44 @@ const procTable = {
58
59
  .filter((member) => member.group === group);
59
60
  },
60
61
  };
62
+ function duration(ms) {
63
+ if (ms % 60_000 === 0)
64
+ return `${ms / 60_000} min`;
65
+ if (ms % 1_000 === 0)
66
+ return `${ms / 1_000} s`;
67
+ return `${ms} ms`;
68
+ }
69
+ /** The user-facing explanation OpenChamber shows when the agent stops. */
70
+ export function describeStop(reason) {
71
+ const restart = "Anything in progress did not finish; send a message to start the agent again.";
72
+ switch (reason.kind) {
73
+ case "timeout":
74
+ return `antechamber stopped the agent: its "${reason.command}" command got no reply within ${duration(reason.ms)}. ${restart}`;
75
+ case "frame-overflow":
76
+ return `antechamber stopped the agent: it sent a JSONL frame over the ${MAX_JSONL_FRAME_BYTES}-byte limit. ${restart}`;
77
+ case "spawn":
78
+ return `antechamber could not run the agent (${reason.detail}). ${restart}`;
79
+ case "transport":
80
+ return `antechamber lost its connection to the agent (${reason.detail}) and stopped it. ${restart}`;
81
+ case "exit": {
82
+ const status = reason.code !== null
83
+ ? `exit code ${reason.code}`
84
+ : reason.signal !== null
85
+ ? `signal ${reason.signal}`
86
+ : "no status";
87
+ return `The agent exited unexpectedly (${status}). ${restart}`;
88
+ }
89
+ }
90
+ }
91
+ /** Rejects every command pending when the agent stopped; the exit handler is what shows it to the user. */
92
+ export class PiStoppedError extends Error {
93
+ reason;
94
+ constructor(reason, cause) {
95
+ super(describeStop(reason), cause === undefined ? undefined : { cause });
96
+ this.reason = reason;
97
+ this.name = "AgentStoppedError";
98
+ }
99
+ }
61
100
  export class PiRpcClient extends EventEmitter {
62
101
  options;
63
102
  proc;
@@ -68,6 +107,10 @@ export class PiRpcClient extends EventEmitter {
68
107
  closed = false;
69
108
  stderrTail = "";
70
109
  disposing;
110
+ disposeRequested = false;
111
+ stopped;
112
+ outputClosed = false;
113
+ sentSignals = new Set();
71
114
  agentActive = false;
72
115
  compacting = false;
73
116
  sessionPath;
@@ -80,6 +123,10 @@ export class PiRpcClient extends EventEmitter {
80
123
  get sessionFile() {
81
124
  return this.sessionPath;
82
125
  }
126
+ /** Set once the agent stopped for a reason other than a requested dispose; read it from exit listeners. */
127
+ get stopReason() {
128
+ return this.stopped;
129
+ }
83
130
  onEvent(listener) {
84
131
  return super.on("event", listener);
85
132
  }
@@ -120,7 +167,7 @@ export class PiRpcClient extends EventEmitter {
120
167
  this.stderrTail = (this.stderrTail + chunk.toString()).slice(-8192);
121
168
  });
122
169
  this.proc.stdin?.on("error", (error) => {
123
- this.closeForTransportFailure(error);
170
+ this.closeForTransportFailure({ kind: "transport", detail: error.message }, error);
124
171
  });
125
172
  this.proc.stdout.on("data", (chunk) => {
126
173
  const text = typeof chunk === "string" ? chunk : this.decoder.write(chunk);
@@ -133,16 +180,26 @@ export class PiRpcClient extends EventEmitter {
133
180
  this.buffer += text;
134
181
  this.bufferBytes += Buffer.byteLength(text);
135
182
  this.drain();
136
- this.closeForTransportFailure(new Error("pi process closed stdout"));
183
+ this.outputClosed = true;
184
+ this.closeForTransportFailure({ kind: "transport", detail: "the agent closed its output" });
137
185
  });
186
+ // ChildProcess "error": the binary could not be spawned, or signalling it failed.
138
187
  this.proc.on("error", (err) => {
139
- this.closeForTransportFailure(err);
188
+ this.closeForTransportFailure({ kind: "spawn", detail: err.message }, err);
140
189
  });
141
190
  this.proc.on("exit", (code, signal) => {
142
191
  this.closed = true;
192
+ if (this.stopped === undefined && !this.disposeRequested)
193
+ this.stopped = { kind: "exit", code, signal };
194
+ // A crashing agent closes stdout just before it exits. Name the crash, not the closed pipe, unless the
195
+ // status comes from a signal antechamber sent while closing: OmO traps SIGTERM and exits 128 + 15.
196
+ else if (this.stopped?.kind === "transport" && this.outputClosed && !this.ownSignal(code, signal))
197
+ this.stopped = { kind: "exit", code, signal };
143
198
  console.error(`[pi-rpc] process exited code=${code} signal=${signal}` +
144
199
  (envSetting("DEBUG") && this.stderrTail ? `\n[pi-rpc] stderr tail:\n${this.stderrTail}` : ""));
145
- this.failAll(new Error(`pi exited (code=${code} signal=${signal})`));
200
+ this.failAll(this.stopped !== undefined
201
+ ? new PiStoppedError(this.stopped)
202
+ : new Error(`pi exited (code=${code} signal=${signal})`));
146
203
  this.emit("exit", code, signal);
147
204
  });
148
205
  }
@@ -170,26 +227,34 @@ export class PiRpcClient extends EventEmitter {
170
227
  }
171
228
  }
172
229
  frameOverflow() {
173
- const error = new Error(`pi JSONL frame exceeded ${MAX_JSONL_FRAME_BYTES} bytes`);
230
+ this.stopped ??= { kind: "frame-overflow" };
174
231
  this.closed = true;
175
232
  this.buffer = "";
176
233
  this.bufferBytes = 0;
177
- this.failAll(error);
234
+ this.failAll(new PiStoppedError(this.stopped));
178
235
  this.proc.stdout?.destroy();
179
236
  if (this.proc.pid === undefined)
180
237
  this.proc.kill("SIGKILL");
181
238
  else if (!this.reaped())
182
239
  process.kill(-this.proc.pid, "SIGKILL");
183
240
  }
241
+ ownSignal(code, signal) {
242
+ if (signal !== null)
243
+ return [...this.sentSignals].includes(signal);
244
+ if (code === null)
245
+ return true;
246
+ return [...this.sentSignals].some((sent) => code === 128 + osConstants.signals[sent]);
247
+ }
184
248
  /** Node has reaped the leader, so its PID (and group ID) may already belong to another process. */
185
249
  reaped() {
186
250
  return this.proc.exitCode !== null || this.proc.signalCode !== null;
187
251
  }
188
- closeForTransportFailure(error) {
252
+ closeForTransportFailure(reason, cause) {
189
253
  if (this.closed)
190
254
  return;
191
255
  this.closed = true;
192
- this.failAll(error);
256
+ this.stopped ??= reason;
257
+ this.failAll(new PiStoppedError(reason, cause));
193
258
  void this.dispose().catch((disposeError) => {
194
259
  const message = disposeError instanceof Error ? disposeError.message : String(disposeError);
195
260
  console.error(`[pi-rpc] failed to dispose after transport failure: ${message}`);
@@ -297,8 +362,16 @@ export class PiRpcClient extends EventEmitter {
297
362
  p.reject(err);
298
363
  this.pending.clear();
299
364
  }
300
- /** Send a command and wait for its correlated response. */
301
- command(cmd, timeoutMs, signal, onDispatch) {
365
+ /**
366
+ * A read whose late answer changes nothing (status polls, listings): its timeout rejects only itself and
367
+ * the agent keeps running. OmO handles RPC lines concurrently, so anything that orders the conversation
368
+ * must use `command`, whose timeout stops the agent rather than let a late reply overlap the next operation.
369
+ */
370
+ probe(cmd, timeoutMs, signal) {
371
+ return this.command(cmd, timeoutMs, signal, undefined, false);
372
+ }
373
+ /** Send a command and wait for its correlated response. A timeout stops the agent unless `stopOnTimeout` is false. */
374
+ command(cmd, timeoutMs, signal, onDispatch, stopOnTimeout = true) {
302
375
  if (this.closed)
303
376
  return Promise.reject(new Error("pi process is not running"));
304
377
  if (signal?.aborted)
@@ -323,8 +396,12 @@ export class PiRpcClient extends EventEmitter {
323
396
  const onAbort = () => {
324
397
  rejectCommand(signal?.reason);
325
398
  };
326
- const expire = (duration) => {
327
- this.closeForTransportFailure(new Error(`pi command "${expectedCommand}" timed out after ${duration}ms`));
399
+ // A probe's late response finds no pending entry and is dropped.
400
+ const expire = (ms) => {
401
+ if (stopOnTimeout)
402
+ this.closeForTransportFailure({ kind: "timeout", command: expectedCommand, ms });
403
+ else
404
+ rejectCommand(new Error(`pi command "${expectedCommand}" timed out after ${ms}ms`));
328
405
  };
329
406
  const armDeadline = () => {
330
407
  clearTimeout(timer);
@@ -356,6 +433,7 @@ export class PiRpcClient extends EventEmitter {
356
433
  async dispose() {
357
434
  if (this.disposing !== undefined)
358
435
  return this.disposing;
436
+ this.disposeRequested = true;
359
437
  this.closed = true;
360
438
  this.failAll(new Error("pi process disposed"));
361
439
  const pid = this.proc.pid;
@@ -366,6 +444,7 @@ export class PiRpcClient extends EventEmitter {
366
444
  const signal = (target, sig) => {
367
445
  try {
368
446
  process.kill(target, sig);
447
+ this.sentSignals.add(sig);
369
448
  }
370
449
  catch (error) {
371
450
  const code = error instanceof Error && "code" in error ? error.code : undefined;
@@ -5,6 +5,7 @@ import { getAgentName, getExposedAgentNames } from "./agent-name.js";
5
5
  import { agentDefaults } from "./catalog.js";
6
6
  import { announceSessionError, parseShellCommand, parseSlashCommand, runShellCommand, runSlashCommand, } from "./session-commands.js";
7
7
  import { envSetting } from "./config.js";
8
+ import { PiStoppedError } from "./pi-rpc.js";
8
9
  // This connector is not opencode — it only implements an OpenCode API subset.
9
10
  // OpenChamber reads /global/health's `version` as the installed opencode version
10
11
  // and offers an update when it trails the latest release. Advertising the SDK
@@ -55,6 +56,9 @@ export function promptAsync(services, session, text, model, noReply = false, mes
55
56
  void services.sessions
56
57
  .prompt(session.info.id, text, model, noReply, messageID, delivery, parts, metadata)
57
58
  .catch((error) => {
59
+ // The agent stopped: the session's exit handler already shows why, once.
60
+ if (error instanceof PiStoppedError)
61
+ return;
58
62
  announceSessionError({ sessions: services.sessions, session, emit: services.broadcast }, error instanceof Error ? error.name : "UnknownError", error instanceof Error ? error.message : String(error));
59
63
  });
60
64
  }
@@ -26,6 +26,10 @@ type ShellUserMessage = OCMessageWithParts & {
26
26
  info: OCUserMessage;
27
27
  parts: [OCTextPart];
28
28
  };
29
+ type ShareNoticeMessage = OCMessageWithParts & {
30
+ info: OCAssistantMessage;
31
+ parts: [OCTextPart];
32
+ };
29
33
  type ShellExecution = {
30
34
  kind: "completed";
31
35
  message: ShellMessage;
@@ -79,6 +83,8 @@ export declare function runSlashCommand(sessions: SessionManager, sessionId: str
79
83
  export declare function parseShellCommand(value: unknown): string | undefined;
80
84
  export declare function createShellMessage(session: ManagedSession, command: string): ShellMessage;
81
85
  export declare function shellUserMessage(message: ShellMessage): ShellUserMessage;
86
+ /** An adapter-authored notice card; `created` places a replayed notice at its original time. */
87
+ export declare function noticeMessage(session: ManagedSession, messageText: string, error?: OCAssistantMessage["error"], created?: number): ShareNoticeMessage;
82
88
  export declare function announceSessionNotice(context: ShareNoticeContext, messageText: string, error?: OCAssistantMessage["error"]): void;
83
89
  export declare function announceSessionError(context: ShareNoticeContext, errorName: string, messageText: string): void;
84
90
  export declare function completeShellMessage(message: ShellMessage, command: string, result: ShellResult): void;
@@ -225,13 +225,12 @@ export function shellUserMessage(message) {
225
225
  ],
226
226
  };
227
227
  }
228
- export function announceSessionNotice(context, messageText, error) {
229
- const { session, sessions, emit } = context;
230
- if (!sessions.messages(session.info.id))
231
- return;
228
+ /** An adapter-authored notice card; `created` places a replayed notice at its original time. */
229
+ export function noticeMessage(session, messageText, error, created) {
232
230
  const info = assistantMessage(session);
233
- const created = info.time.created;
234
- info.time.completed = created;
231
+ if (created !== undefined)
232
+ info.time.created = created;
233
+ info.time.completed = info.time.created;
235
234
  if (error)
236
235
  info.error = error;
237
236
  const part = {
@@ -240,9 +239,17 @@ export function announceSessionNotice(context, messageText, error) {
240
239
  messageID: info.id,
241
240
  type: "text",
242
241
  text: messageText,
243
- time: { start: created, end: created },
242
+ time: { start: info.time.created, end: info.time.created },
244
243
  };
245
- const message = { info, parts: [part] };
244
+ return { info, parts: [part] };
245
+ }
246
+ export function announceSessionNotice(context, messageText, error) {
247
+ const { session, sessions, emit } = context;
248
+ if (!sessions.messages(session.info.id))
249
+ return;
250
+ const message = noticeMessage(session, messageText, error);
251
+ const [part] = message.parts;
252
+ const { info } = message;
246
253
  sessions.appendMessage(session.info.id, message);
247
254
  emit(session.info.directory, { type: "message.updated", properties: { info } });
248
255
  emit(session.info.directory, { type: "message.part.updated", properties: { part } });
@@ -68,6 +68,11 @@ export interface SessionRegistryEntry {
68
68
  modelID: string;
69
69
  variant?: string;
70
70
  };
71
+ /** Why the agent last stopped on its own, shown again after a restart until the session runs again. */
72
+ lastStop?: {
73
+ message: string;
74
+ at: number;
75
+ };
71
76
  created: number;
72
77
  updated: number;
73
78
  }
@@ -459,6 +459,12 @@ function registryEntry(value) {
459
459
  if (value.model !== undefined &&
460
460
  (!record(value.model) || typeof value.model.providerID !== "string" || typeof value.model.modelID !== "string"))
461
461
  return false;
462
+ if (value.lastStop !== undefined &&
463
+ (!record(value.lastStop) ||
464
+ typeof value.lastStop.message !== "string" ||
465
+ typeof value.lastStop.at !== "number" ||
466
+ !Number.isFinite(value.lastStop.at)))
467
+ return false;
462
468
  return true;
463
469
  }
464
470
  /**
@@ -75,6 +75,11 @@ export interface ManagedSession extends ThinkingState {
75
75
  hasBackgroundWork?: () => boolean;
76
76
  /** Live wake sources OmO publishes over RPC (detached eval cells), which also block idle eviction. */
77
77
  hasWakeSources?: () => boolean;
78
+ /** Why the agent last stopped on its own; persisted and replayed as a notice until the next prompt. */
79
+ lastStop?: {
80
+ message: string;
81
+ at: number;
82
+ };
78
83
  }
79
84
  type EventSink = (directory: string, event: OCEvent | TodoUpdated) => void;
80
85
  export declare class SessionManager {
@@ -85,6 +90,8 @@ export declare class SessionManager {
85
90
  private operations;
86
91
  /** Connector-created session trees, including persisted ownership after restart. */
87
92
  private createdSessions;
93
+ /** Clients whose stop onExit already showed, so a failing revival does not show it again. */
94
+ private stopShown;
88
95
  private hostShutdown;
89
96
  private sessions;
90
97
  private dirtyDiffs;
@@ -203,7 +210,17 @@ export declare class SessionManager {
203
210
  /** Spawn pi against the session's JSONL file and adopt it as live. */
204
211
  private revive;
205
212
  private reviveLive;
213
+ /** Record, persist and show a stop that no exit handler will report. */
214
+ private reportStop;
206
215
  private ensureMessages;
216
+ /**
217
+ * The stop notice card. Its id derives from the stop time, so the live card and every replay after a
218
+ * history re-parse are one message, never duplicates.
219
+ */
220
+ private stopNotice;
221
+ private showStop;
222
+ /** Show the last unexplained stop again after a history re-parse or adapter restart, where it happened. */
223
+ private replayStop;
207
224
  private refreshMessageCache;
208
225
  /** pi paths/ids already represented in memory — shared by both scans. */
209
226
  private knownSessionKeys;
package/dist/sessions.js CHANGED
@@ -7,7 +7,7 @@ import { parsePiSessionFile, parsePiSessionText } from "./session-history.js";
7
7
  import { CONTEXT_MESSAGE_TYPE, nativePromptInput, v2PromptAttachments } from "./prompt-parts.js";
8
8
  import { existsSync, readdirSync, statSync, unlinkSync } from "node:fs";
9
9
  import { join } from "node:path";
10
- import { COMPACT_RPC_TIMEOUT_MS, PiRpcClient, RPC_COMMAND_TIMEOUT_MS, } from "./pi-rpc.js";
10
+ import { COMPACT_RPC_TIMEOUT_MS, describeStop, PiRpcClient, PiStoppedError, RPC_COMMAND_TIMEOUT_MS, } from "./pi-rpc.js";
11
11
  import { stopSessionHosts } from "./session-hosts.js";
12
12
  import { setThinkingLevel, userMessageModel } from "./thinking-levels.js";
13
13
  import { SessionLifecycle } from "./lifecycle.js";
@@ -19,7 +19,7 @@ import { SubagentBridge } from "./subagents.js";
19
19
  import { MonitorSessions } from "./monitor-sessions.js";
20
20
  import { WorkStatusBridge } from "./work-status.js";
21
21
  import { sessionDiff } from "./session-diff.js";
22
- import { announceSessionNotice, skillCommandText, slashTitlePrompt } from "./session-commands.js";
22
+ import { announceSessionNotice, noticeMessage, skillCommandText, slashTitlePrompt } from "./session-commands.js";
23
23
  import { ocIdFor, piSessionDir, piSessionsRootMtime, scanAllPiSessions, scanPiSessions, SessionStore, readSessionFileMeta, } from "./session-store.js";
24
24
  import { ADAPTER_VERSION } from "./version.js";
25
25
  export { ADAPTER_VERSION } from "./version.js";
@@ -53,6 +53,8 @@ export class SessionManager {
53
53
  operations = new Map();
54
54
  /** Connector-created session trees, including persisted ownership after restart. */
55
55
  createdSessions = new Map();
56
+ /** Clients whose stop onExit already showed, so a failing revival does not show it again. */
57
+ stopShown = new WeakSet();
56
58
  hostShutdown;
57
59
  sessions = new Map();
58
60
  dirtyDiffs = new Set();
@@ -263,13 +265,19 @@ export class SessionManager {
263
265
  if (extensionRequest !== undefined)
264
266
  s.approvals?.handle(extensionRequest);
265
267
  };
266
- const onExit = (code) => {
268
+ const onExit = (code, signal) => {
267
269
  if (s.pi !== pi)
268
270
  return;
269
- this.events.finishInterruptedRun(s, {
270
- name: "UnknownError",
271
- data: { message: `pi process exited (code=${code})` },
272
- });
271
+ // An agent that stopped on its own (timeout, broken pipe, crash) must say why: a notice card in the
272
+ // transcript, the same error on the reply it interrupted, and a replay after an adapter restart.
273
+ // Pending commands reject with the same PiStoppedError; prompt admission leaves it to this handler.
274
+ // Without a stop reason antechamber disposed the process itself and keeps the plain notice.
275
+ const reason = pi.stopReason;
276
+ const message = reason === undefined ? `pi process exited (code=${code} signal=${signal})` : describeStop(reason);
277
+ const error = { name: reason === undefined ? "UnknownError" : "AgentStoppedError", data: { message } };
278
+ this.events.finishInterruptedRun(s, error);
279
+ if (reason !== undefined)
280
+ s.lastStop = { message, at: Date.now() };
273
281
  try {
274
282
  recordTurn(s);
275
283
  this.persistMeta(s);
@@ -286,13 +294,11 @@ export class SessionManager {
286
294
  void this.lifecycle
287
295
  .dormant(s)
288
296
  .catch((error) => console.error("[lifecycle] exit cleanup failed:", error));
289
- this.emit(s.info.directory, {
290
- type: "session.error",
291
- properties: {
292
- sessionID: s.info.id,
293
- error: { name: "UnknownError", data: { message: `pi process exited (code=${code})` } },
294
- },
295
- });
297
+ if (s.lastStop !== undefined && reason !== undefined) {
298
+ this.stopShown.add(pi);
299
+ this.showStop(s, s.lastStop);
300
+ }
301
+ this.emit(s.info.directory, { type: "session.error", properties: { sessionID: s.info.id, error } });
296
302
  this.events.setStatus(s, { type: "idle" });
297
303
  };
298
304
  pi.onEvent(onEvent);
@@ -978,6 +984,9 @@ export class SessionManager {
978
984
  };
979
985
  if (metadata !== undefined)
980
986
  (s.tree.promptMetadata ??= {})[userMsg.id] = metadata;
987
+ // The user moved on: the stop notice stays in this adapter's memory but is not replayed after a restart.
988
+ if (!context)
989
+ delete s.lastStop;
981
990
  const parts = (inputParts ?? [{ type: "text", text }]).map((part) => ({
982
991
  ...part,
983
992
  id: part.id ?? id("prt"),
@@ -1251,9 +1260,28 @@ export class SessionManager {
1251
1260
  s.pi = undefined;
1252
1261
  delete s.approvals;
1253
1262
  await pi.dispose();
1263
+ // Unwired above, so onExit cannot report a stop that ended the revival (crash, cold-start timeout).
1264
+ // Read the reason after dispose: a closed stdout is corrected to the exit status once the exit arrives.
1265
+ if (error instanceof PiStoppedError && !this.stopShown.has(pi))
1266
+ this.reportStop(s, describeStop(pi.stopReason ?? error.reason));
1254
1267
  throw error;
1255
1268
  }
1256
1269
  }
1270
+ /** Record, persist and show a stop that no exit handler will report. */
1271
+ reportStop(s, message) {
1272
+ s.lastStop = { message, at: Date.now() };
1273
+ try {
1274
+ this.persistMeta(s);
1275
+ }
1276
+ catch (error) {
1277
+ this.reportPersistenceError(s, error);
1278
+ }
1279
+ this.showStop(s, s.lastStop);
1280
+ this.emit(s.info.directory, {
1281
+ type: "session.error",
1282
+ properties: { sessionID: s.info.id, error: { name: "AgentStoppedError", data: { message } } },
1283
+ });
1284
+ }
1257
1285
  ensureMessages(s) {
1258
1286
  if (this.subagents.owns(s.info.id))
1259
1287
  return this.subagents.messages(s.info.id);
@@ -1272,6 +1300,8 @@ export class SessionManager {
1272
1300
  followExternalAppends(s, s.restored.piPath);
1273
1301
  const admissions = s.tree.localShellTurns?.length;
1274
1302
  s.messages = parsePiSessionFile(s, getAgentName());
1303
+ if (s.lastStop !== undefined)
1304
+ this.replayStop(s, s.messages, s.lastStop);
1275
1305
  if (fileSize !== undefined)
1276
1306
  s.historyFileSize = fileSize;
1277
1307
  this.subagents.discover(s, s.messages);
@@ -1282,6 +1312,43 @@ export class SessionManager {
1282
1312
  }
1283
1313
  return s.messages;
1284
1314
  }
1315
+ /**
1316
+ * The stop notice card. Its id derives from the stop time, so the live card and every replay after a
1317
+ * history re-parse are one message, never duplicates.
1318
+ */
1319
+ stopNotice(s, stop) {
1320
+ const notice = noticeMessage(s, stop.message, { name: "AgentStoppedError", data: { message: stop.message } }, stop.at);
1321
+ notice.info.id = `msg_agentstop_${stop.at}`;
1322
+ for (const part of notice.parts) {
1323
+ part.id = `prt_agentstop_${stop.at}`;
1324
+ part.messageID = notice.info.id;
1325
+ }
1326
+ return notice;
1327
+ }
1328
+ showStop(s, stop) {
1329
+ const messages = this.ensureMessages(s);
1330
+ let notice = messages.find((message) => message.info.id === `msg_agentstop_${stop.at}`);
1331
+ if (notice === undefined) {
1332
+ notice = this.stopNotice(s, stop);
1333
+ this.appendMessage(s.info.id, notice);
1334
+ }
1335
+ this.emit(s.info.directory, { type: "message.updated", properties: { info: notice.info } });
1336
+ for (const part of notice.parts)
1337
+ this.events.emitPart(s, part);
1338
+ }
1339
+ /** Show the last unexplained stop again after a history re-parse or adapter restart, where it happened. */
1340
+ replayStop(s, messages, stop) {
1341
+ if (messages.some((message) => message.info.id === `msg_agentstop_${stop.at}`))
1342
+ return;
1343
+ // Like appendMessage: never inside the hidden redo branch while reverted.
1344
+ const boundary = messages.findIndex((message) => message.info.id === s.tree.revert?.messageID);
1345
+ const later = messages.findIndex((message) => message.info.time.created > stop.at);
1346
+ const at = Math.min(later < 0 ? messages.length : later, boundary < 0 ? messages.length : boundary);
1347
+ const notice = this.stopNotice(s, stop);
1348
+ notice.info.parentID = messages.slice(0, at).findLast((message) => message.info.role === "user")?.info.id ?? "";
1349
+ (s.localMessageIds ??= new Set()).add(notice.info.id);
1350
+ messages.splice(at, 0, notice);
1351
+ }
1285
1352
  refreshMessageCache(s, replaceNative = false) {
1286
1353
  const previous = s.messages ?? [];
1287
1354
  s.messages = undefined;
@@ -1409,6 +1476,7 @@ export class SessionManager {
1409
1476
  restored: { piSessionId: sc.piSessionId, piPath: sc.piPath },
1410
1477
  hasPendingInteraction: () => false,
1411
1478
  hasRunningChildren: () => false,
1479
+ ...(reg?.lastStop !== undefined ? { lastStop: reg.lastStop } : {}),
1412
1480
  };
1413
1481
  this.sessions.set(ocId, managed);
1414
1482
  if (reg?.connectorOwned === true)
@@ -1479,6 +1547,7 @@ export class SessionManager {
1479
1547
  ...(s.info.parentID !== undefined ? { parentID: s.info.parentID } : {}),
1480
1548
  ...(s.info.parentMessageID !== undefined ? { parentMessageID: s.info.parentMessageID } : {}),
1481
1549
  ...(s.model !== undefined ? { model: { providerID: s.model.providerID, modelID: s.model.modelID } } : {}),
1550
+ ...(s.lastStop !== undefined ? { lastStop: s.lastStop } : {}),
1482
1551
  created: s.info.time.created,
1483
1552
  updated: s.info.time.updated,
1484
1553
  });
@@ -247,7 +247,8 @@ export class WorkStatusBridge {
247
247
  const pi = s.pi;
248
248
  if (pi === undefined)
249
249
  throw new WorkStatusError(409, "The agent is not running");
250
- const reply = await pi.command(command, EVAL_RPC_TIMEOUT_MS);
250
+ // A slow cold start must not cost the session: an eval_cells timeout fails only the request.
251
+ const reply = await pi.probe(command, EVAL_RPC_TIMEOUT_MS);
251
252
  if (reply.success)
252
253
  return reply.data;
253
254
  const code = reply.errorCode;
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "antechamber-omo-compat",
3
- "version": "1.0.15",
3
+ "version": "1.0.16",
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.15",
3
+ "version": "1.0.16",
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.15",
3
+ "version": "1.0.16",
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.15",
3
+ "version": "1.0.16",
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",