antechamber 1.0.14 → 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,27 @@ 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
+
23
+ ## [1.0.15] - 2026-10-23
24
+
25
+ ### Fixed
26
+
27
+ - Stop reaping a session while detached eval cells are still running: idle eviction killed the agent process and
28
+ every cell in its kernels once the session had been quiet for `ANTECHAMBER_IDLE_TIMEOUT_MS`. A session now stays
29
+ live while senpi-codemode reports detached cells, and its idle window restarts when the last one settles.
30
+
10
31
  ## [1.0.14] - 2026-10-22
11
32
 
12
33
  ### Fixed
package/dist/lifecycle.js CHANGED
@@ -56,6 +56,7 @@ export class SessionLifecycle {
56
56
  !s.hasPendingInteraction() &&
57
57
  !s.hasRunningChildren() &&
58
58
  !s.hasBackgroundWork?.() &&
59
+ !s.hasWakeSources?.() &&
59
60
  s.piSessionId !== undefined &&
60
61
  s.piPath !== undefined);
61
62
  }
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
  /**
@@ -73,6 +73,13 @@ export interface ManagedSession extends ThinkingState {
73
73
  hasRunningChildren: () => boolean;
74
74
  /** Live OmO background work (monitors) that keeps the agent process from idle eviction. */
75
75
  hasBackgroundWork?: () => boolean;
76
+ /** Live wake sources OmO publishes over RPC (detached eval cells), which also block idle eviction. */
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
+ };
76
83
  }
77
84
  type EventSink = (directory: string, event: OCEvent | TodoUpdated) => void;
78
85
  export declare class SessionManager {
@@ -83,6 +90,8 @@ export declare class SessionManager {
83
90
  private operations;
84
91
  /** Connector-created session trees, including persisted ownership after restart. */
85
92
  private createdSessions;
93
+ /** Clients whose stop onExit already showed, so a failing revival does not show it again. */
94
+ private stopShown;
86
95
  private hostShutdown;
87
96
  private sessions;
88
97
  private dirtyDiffs;
@@ -201,7 +210,17 @@ export declare class SessionManager {
201
210
  /** Spawn pi against the session's JSONL file and adopt it as live. */
202
211
  private revive;
203
212
  private reviveLive;
213
+ /** Record, persist and show a stop that no exit handler will report. */
214
+ private reportStop;
204
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;
205
224
  private refreshMessageCache;
206
225
  /** pi paths/ids already represented in memory — shared by both scans. */
207
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();
@@ -149,6 +151,7 @@ export class SessionManager {
149
151
  published = true;
150
152
  this.subagents.wire(managed);
151
153
  this.monitors.wire(managed);
154
+ this.workStatus.wire(managed);
152
155
  this.lifecycle.touch(managed);
153
156
  this.wirePi(managed, pi);
154
157
  if (options.model !== undefined)
@@ -262,13 +265,19 @@ export class SessionManager {
262
265
  if (extensionRequest !== undefined)
263
266
  s.approvals?.handle(extensionRequest);
264
267
  };
265
- const onExit = (code) => {
268
+ const onExit = (code, signal) => {
266
269
  if (s.pi !== pi)
267
270
  return;
268
- this.events.finishInterruptedRun(s, {
269
- name: "UnknownError",
270
- data: { message: `pi process exited (code=${code})` },
271
- });
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() };
272
281
  try {
273
282
  recordTurn(s);
274
283
  this.persistMeta(s);
@@ -285,13 +294,11 @@ export class SessionManager {
285
294
  void this.lifecycle
286
295
  .dormant(s)
287
296
  .catch((error) => console.error("[lifecycle] exit cleanup failed:", error));
288
- this.emit(s.info.directory, {
289
- type: "session.error",
290
- properties: {
291
- sessionID: s.info.id,
292
- error: { name: "UnknownError", data: { message: `pi process exited (code=${code})` } },
293
- },
294
- });
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 } });
295
302
  this.events.setStatus(s, { type: "idle" });
296
303
  };
297
304
  pi.onEvent(onEvent);
@@ -977,6 +984,9 @@ export class SessionManager {
977
984
  };
978
985
  if (metadata !== undefined)
979
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;
980
990
  const parts = (inputParts ?? [{ type: "text", text }]).map((part) => ({
981
991
  ...part,
982
992
  id: part.id ?? id("prt"),
@@ -1250,9 +1260,28 @@ export class SessionManager {
1250
1260
  s.pi = undefined;
1251
1261
  delete s.approvals;
1252
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));
1253
1267
  throw error;
1254
1268
  }
1255
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
+ }
1256
1285
  ensureMessages(s) {
1257
1286
  if (this.subagents.owns(s.info.id))
1258
1287
  return this.subagents.messages(s.info.id);
@@ -1271,6 +1300,8 @@ export class SessionManager {
1271
1300
  followExternalAppends(s, s.restored.piPath);
1272
1301
  const admissions = s.tree.localShellTurns?.length;
1273
1302
  s.messages = parsePiSessionFile(s, getAgentName());
1303
+ if (s.lastStop !== undefined)
1304
+ this.replayStop(s, s.messages, s.lastStop);
1274
1305
  if (fileSize !== undefined)
1275
1306
  s.historyFileSize = fileSize;
1276
1307
  this.subagents.discover(s, s.messages);
@@ -1281,6 +1312,43 @@ export class SessionManager {
1281
1312
  }
1282
1313
  return s.messages;
1283
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
+ }
1284
1352
  refreshMessageCache(s, replaceNative = false) {
1285
1353
  const previous = s.messages ?? [];
1286
1354
  s.messages = undefined;
@@ -1408,12 +1476,14 @@ export class SessionManager {
1408
1476
  restored: { piSessionId: sc.piSessionId, piPath: sc.piPath },
1409
1477
  hasPendingInteraction: () => false,
1410
1478
  hasRunningChildren: () => false,
1479
+ ...(reg?.lastStop !== undefined ? { lastStop: reg.lastStop } : {}),
1411
1480
  };
1412
1481
  this.sessions.set(ocId, managed);
1413
1482
  if (reg?.connectorOwned === true)
1414
1483
  this.createdSessions.set(sc.piSessionId, sc.directory);
1415
1484
  this.subagents.wire(managed);
1416
1485
  this.monitors.wire(managed);
1486
+ this.workStatus.wire(managed);
1417
1487
  return managed;
1418
1488
  }
1419
1489
  /** Resolve a bare session id (e.g. deep link after restart) via known directories. */
@@ -1477,6 +1547,7 @@ export class SessionManager {
1477
1547
  ...(s.info.parentID !== undefined ? { parentID: s.info.parentID } : {}),
1478
1548
  ...(s.info.parentMessageID !== undefined ? { parentMessageID: s.info.parentMessageID } : {}),
1479
1549
  ...(s.model !== undefined ? { model: { providerID: s.model.providerID, modelID: s.model.modelID } } : {}),
1550
+ ...(s.lastStop !== undefined ? { lastStop: s.lastStop } : {}),
1480
1551
  created: s.info.time.created,
1481
1552
  updated: s.info.time.updated,
1482
1553
  });
@@ -70,6 +70,8 @@ export declare class WorkStatusBridge {
70
70
  private readonly work;
71
71
  constructor(monitors: MonitorSessions);
72
72
  private state;
73
+ /** Evicting the agent process would kill its detached eval cells, so a live wake source keeps it. */
74
+ wire(s: ManagedSession): void;
73
75
  observe(s: ManagedSession, ev: Record<string, unknown>): void;
74
76
  /** The agent process exited: its in-process wake sources and timers died with it. */
75
77
  stopParent(sessionID: string): void;
@@ -138,6 +138,10 @@ export class WorkStatusBridge {
138
138
  }
139
139
  return work;
140
140
  }
141
+ /** Evicting the agent process would kill its detached eval cells, so a live wake source keeps it. */
142
+ wire(s) {
143
+ s.hasWakeSources = () => (this.work.get(s.info.id)?.wakeSources.size ?? 0) > 0;
144
+ }
141
145
  observe(s, ev) {
142
146
  if (ev.name === WAKE_SOURCE_STATE_EVENT) {
143
147
  const source = parseWakeSource(ev.data);
@@ -243,7 +247,8 @@ export class WorkStatusBridge {
243
247
  const pi = s.pi;
244
248
  if (pi === undefined)
245
249
  throw new WorkStatusError(409, "The agent is not running");
246
- 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);
247
252
  if (reply.success)
248
253
  return reply.data;
249
254
  const code = reply.errorCode;
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "antechamber-omo-compat",
3
- "version": "1.0.14",
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.14",
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.14",
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.14",
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",