antechamber 0.2.22 → 0.2.24

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,17 @@ and this project uses [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.24] - 2026-10-17
11
+
12
+ ### Fixed
13
+
14
+ - Show completed background bash output and detached eval results in OpenChamber, including after reloading history.
15
+ A card that started background work keeps running until that work finishes.
16
+
17
+ ## [0.2.23] - 2026-10-17
18
+
19
+ - feat(monitors): show matched lines in monitor sessions (#32)
20
+
10
21
  ## [0.2.22] - 2026-10-17
11
22
 
12
23
  - fix(tools): stream live bash and eval output into OpenChamber cards (#29)
@@ -41,6 +52,10 @@ and this project uses [Semantic Versioning](https://semver.org/).
41
52
  - OmO Compatibility, a second bundled OpenChamber extension (1.24 or newer) with no code or permissions. It heads
42
53
  eval cards with `JS Eval` or `Python Eval` and the cell's summary.
43
54
 
55
+ ### Added
56
+
57
+ - Show the lines an OmO monitor matched in its monitor session, as they fire.
58
+
44
59
  ## [0.2.18] - 2026-10-17
45
60
 
46
61
  - test(endpoints): await the abort_bash dispatch instead of reading a flag (#27)
@@ -0,0 +1,51 @@
1
+ import type { ManagedSession } from "./sessions.js";
2
+ import type { OCMessageWithParts, OCModelRef, OCToolPart } from "./types.js";
3
+ import { type ToolResult } from "./tool-map.js";
4
+ export interface BackgroundResult {
5
+ /** Content hash; identical notifications are told apart by `backgroundID`'s occurrence index. */
6
+ readonly key: string;
7
+ readonly kind: "terminal" | "eval";
8
+ readonly callID: string;
9
+ readonly title: string;
10
+ readonly output: string;
11
+ readonly failed: boolean;
12
+ }
13
+ /** Raw RPC capture of installed omo-ai 5.1.33 / senpi runtime, 2026-10-17. */
14
+ export declare function backgroundResult(customType: unknown, content: unknown): BackgroundResult | undefined;
15
+ /** Retain only the command needed to label a later terminal completion. */
16
+ export declare function backgroundCommands(result: ToolResult): {
17
+ backgroundCommands?: Record<string, string>;
18
+ };
19
+ export declare function launchedShells(text: string): string[];
20
+ /**
21
+ * What a tool result leaves running: the background shells it started, and the eval cell
22
+ * itself when it detached. The card keeps running until each one reports finishing.
23
+ */
24
+ export declare function backgroundWaits(callID: string, text: string, commands?: Record<string, unknown>): string[];
25
+ interface Final {
26
+ output: string;
27
+ failed: boolean;
28
+ }
29
+ /** Keep a finished card running, with its result already shown, until `waits` report finishing. */
30
+ export declare function holdForBackground(part: OCToolPart, waits: string[], final: Final): void;
31
+ /** `id` finished at `at`: drop it from every card waiting on it; cards left with nothing to wait on end. */
32
+ export declare function releaseBackground(messages: readonly OCMessageWithParts[], id: string, at: number): OCToolPart[];
33
+ /** Nothing can still be running (the agent exited, or history is rebuilt): end every waiting card. */
34
+ export declare function settleBackground(messages: readonly OCMessageWithParts[], at: number): OCToolPart[];
35
+ export declare function completeBackgroundEval(messages: readonly OCMessageWithParts[], result: BackgroundResult, completed: number): OCToolPart | undefined;
36
+ /** A tool result finishes its card at `at`; a result that left work running keeps it running. */
37
+ export declare function finishToolPart(part: OCToolPart, result: ToolResult, callID: string, at: number): void;
38
+ /**
39
+ * OmO persists these notifications but sends no entry_appended frame for them, so the live
40
+ * path never learns the native entry ID (raw RPC capture of installed omo-ai 5.1.33 / senpi
41
+ * runtime, 2026-10-17). Live, history and entry binding all derive the same ID from the
42
+ * content and its occurrence count in conversation order instead.
43
+ */
44
+ export declare function backgroundID(messages: readonly OCMessageWithParts[], result: BackgroundResult): string;
45
+ export declare function backgroundMessage(s: Pick<ManagedSession, "info" | "messages">, result: BackgroundResult, entry: {
46
+ id: string;
47
+ created: number;
48
+ model: OCModelRef;
49
+ agent: string;
50
+ }): OCMessageWithParts;
51
+ export {};
@@ -0,0 +1,277 @@
1
+ import { createHash } from "node:crypto";
2
+ import { resultMetadata, resultText, toolFailed } from "./tool-map.js";
3
+ /** Raw RPC capture of installed omo-ai 5.1.33 / senpi runtime, 2026-10-17. */
4
+ export function backgroundResult(customType, content) {
5
+ if (customType !== "senpi-terminal:notification" && customType !== "senpi-codemode:notification")
6
+ return undefined;
7
+ const raw = typeof content === "string" ? content : resultText(content);
8
+ const key = createHash("sha256").update(`${customType}\n${raw}`).digest("hex").slice(0, 16);
9
+ const text = raw.replaceAll(/<\/?system-reminder>/g, "").replace(/^\n/, "");
10
+ const newline = text.indexOf("\n");
11
+ const header = newline < 0 ? text : text.slice(0, newline);
12
+ const body = newline < 0 ? "" : text.slice(newline + 1);
13
+ if (customType === "senpi-terminal:notification") {
14
+ const match = /^Background terminal session (\S+) finished: (.+?)\.?$/.exec(header);
15
+ if (match?.[1] === undefined || match[2] === undefined)
16
+ return undefined;
17
+ return {
18
+ key,
19
+ kind: "terminal",
20
+ callID: match[1],
21
+ title: `${match[1]} finished: ${match[2]}`,
22
+ output: body.replace(/^Final output:\r?\n?/, ""),
23
+ failed: false,
24
+ };
25
+ }
26
+ const match = /^Detached eval cell (\S+)(?: \([^)]+\))? (completed|failed|error)\b.*$/i.exec(header);
27
+ if (match?.[1] === undefined || match[2] === undefined)
28
+ return undefined;
29
+ return {
30
+ key,
31
+ kind: "eval",
32
+ callID: match[1],
33
+ title: header.replace(/^Detached eval cell /, "").replace(/\.$/, ""),
34
+ output: body,
35
+ failed: match[2].toLowerCase() !== "completed",
36
+ };
37
+ }
38
+ /** Retain only the command needed to label a later terminal completion. */
39
+ export function backgroundCommands(result) {
40
+ const details = result.details;
41
+ if (details === null || typeof details !== "object" || !("toolCalls" in details))
42
+ return {};
43
+ if (!Array.isArray(details.toolCalls))
44
+ return {};
45
+ const commands = {};
46
+ for (const call of details.toolCalls) {
47
+ if (call?.name !== "bash" || typeof call.resultPreview !== "string" || typeof call.args?.command !== "string")
48
+ continue;
49
+ for (const id of launchedShells(call.resultPreview))
50
+ commands[id] = call.args.command;
51
+ }
52
+ return Object.keys(commands).length === 0 ? {} : { backgroundCommands: commands };
53
+ }
54
+ /**
55
+ * Background shells a result says it left running (raw RPC captures, 2026-10-17). A cell's output is
56
+ * trimmed to its tail, which can cut the "with ID" line and leave only the bash_output hint.
57
+ */
58
+ const LAUNCHED = /\b(?:running in background|auto-detached to background) with ID:\s*(bash_\d+)\b|Use bash_output\(\{ bash_id: "(bash_\d+)" \}\) only to peek/g;
59
+ export function launchedShells(text) {
60
+ return [...new Set([...text.matchAll(LAUNCHED)].map((match) => match[1] ?? match[2] ?? "").filter(Boolean))];
61
+ }
62
+ /**
63
+ * What a tool result leaves running: the background shells it started, and the eval cell
64
+ * itself when it detached. The card keeps running until each one reports finishing.
65
+ */
66
+ export function backgroundWaits(callID, text, commands = {}) {
67
+ const waits = new Set();
68
+ for (const id of launchedShells(text))
69
+ waits.add(id);
70
+ for (const id of Object.keys(commands))
71
+ waits.add(id);
72
+ if (/^Eval cell \S+ detached and is running/.test(text))
73
+ waits.add(callID);
74
+ return [...waits];
75
+ }
76
+ function waitsOf(part) {
77
+ const waits = part.state.status === "running" ? part.state.metadata?.backgroundWaits : undefined;
78
+ return Array.isArray(waits) ? waits.filter((id) => typeof id === "string") : [];
79
+ }
80
+ /** Keep a finished card running, with its result already shown, until `waits` report finishing. */
81
+ export function holdForBackground(part, waits, final) {
82
+ const state = part.state;
83
+ if (state.status === "pending" || waits.length === 0)
84
+ return;
85
+ const title = state.status === "running" || state.status === "completed" ? state.title : undefined;
86
+ part.state = {
87
+ status: "running",
88
+ input: state.input,
89
+ ...(title !== undefined ? { title } : {}),
90
+ metadata: { ...state.metadata, output: final.output, backgroundWaits: waits, backgroundFailed: final.failed },
91
+ time: { start: state.time.start },
92
+ };
93
+ }
94
+ function finish(part, final, at) {
95
+ const state = part.state;
96
+ if (state.status === "pending")
97
+ return;
98
+ const { backgroundWaits: _waits, backgroundFailed: _failed, output: _output, ...metadata } = state.metadata ?? {};
99
+ const time = { start: state.time.start, end: Math.max(at, state.time.start) };
100
+ const title = state.status === "running" || state.status === "completed" ? (state.title ?? part.tool) : part.tool;
101
+ part.state = final.failed
102
+ ? { status: "error", input: state.input, error: final.output || `tool ${part.tool} failed`, metadata, time }
103
+ : { status: "completed", input: state.input, output: final.output, title, metadata, time };
104
+ }
105
+ function finalOf(part) {
106
+ const metadata = part.state.status === "running" ? part.state.metadata : undefined;
107
+ if (typeof metadata?.backgroundFailed !== "boolean")
108
+ return undefined;
109
+ return { output: typeof metadata.output === "string" ? metadata.output : "", failed: metadata.backgroundFailed };
110
+ }
111
+ /** `id` finished at `at`: drop it from every card waiting on it; cards left with nothing to wait on end. */
112
+ export function releaseBackground(messages, id, at) {
113
+ const changed = [];
114
+ for (const message of messages) {
115
+ for (const part of message.parts) {
116
+ if (part.type !== "tool" || part.state.status !== "running")
117
+ continue;
118
+ const waits = waitsOf(part);
119
+ if (!waits.includes(id))
120
+ continue;
121
+ const left = waits.filter((wait) => wait !== id);
122
+ const final = finalOf(part);
123
+ if (left.length > 0 || final === undefined)
124
+ part.state.metadata = { ...part.state.metadata, backgroundWaits: left };
125
+ else
126
+ finish(part, final, at);
127
+ changed.push(part);
128
+ }
129
+ }
130
+ return changed;
131
+ }
132
+ /** Nothing can still be running (the agent exited, or history is rebuilt): end every waiting card. */
133
+ export function settleBackground(messages, at) {
134
+ const changed = [];
135
+ for (const message of messages) {
136
+ for (const part of message.parts) {
137
+ if (part.type !== "tool")
138
+ continue;
139
+ const final = finalOf(part);
140
+ if (final === undefined)
141
+ continue;
142
+ finish(part, final, at);
143
+ changed.push(part);
144
+ }
145
+ }
146
+ return changed;
147
+ }
148
+ export function completeBackgroundEval(messages, result, completed) {
149
+ if (result.kind !== "eval")
150
+ return undefined;
151
+ for (const message of messages) {
152
+ const part = message.parts.find((part) => part.type === "tool" && part.callID === result.callID);
153
+ if (part === undefined)
154
+ continue;
155
+ const final = { output: result.output, failed: result.failed };
156
+ // The cell is done; shells it moved to the background may still run.
157
+ const waits = [
158
+ ...waitsOf(part).filter((wait) => wait !== result.callID),
159
+ ...backgroundWaits(result.callID, result.output),
160
+ ];
161
+ const remaining = [...new Set(waits)];
162
+ if (part.state.status !== "pending" && remaining.length > 0) {
163
+ holdForBackground(part, remaining, final);
164
+ }
165
+ else
166
+ finish(part, final, completed);
167
+ return part;
168
+ }
169
+ return undefined;
170
+ }
171
+ /** A tool result finishes its card at `at`; a result that left work running keeps it running. */
172
+ export function finishToolPart(part, result, callID, at) {
173
+ const state = part.state;
174
+ const text = resultText(result.content);
175
+ const commands = backgroundCommands(result);
176
+ const metadata = {
177
+ ...(state.status !== "pending" ? state.metadata : {}),
178
+ ...resultMetadata(part.tool, result),
179
+ ...commands,
180
+ };
181
+ const time = { start: state.status !== "pending" ? state.time.start : at, end: at };
182
+ const title = state.status === "running" || state.status === "completed" ? (state.title ?? part.tool) : part.tool;
183
+ const failed = toolFailed(result);
184
+ part.state = failed
185
+ ? { status: "error", input: state.input ?? {}, error: text || `tool ${part.tool} failed`, metadata, time }
186
+ : { status: "completed", input: state.input ?? {}, output: text, title, metadata, time };
187
+ const waits = backgroundWaits(callID, text, commands.backgroundCommands);
188
+ if (waits.length > 0)
189
+ holdForBackground(part, waits, { output: text, failed });
190
+ }
191
+ /**
192
+ * OmO persists these notifications but sends no entry_appended frame for them, so the live
193
+ * path never learns the native entry ID (raw RPC capture of installed omo-ai 5.1.33 / senpi
194
+ * runtime, 2026-10-17). Live, history and entry binding all derive the same ID from the
195
+ * content and its occurrence count in conversation order instead.
196
+ */
197
+ export function backgroundID(messages, result) {
198
+ const prefix = `msg_background_${result.key}_`;
199
+ return `${result.key}_${messages.filter((message) => message.info.id.startsWith(prefix)).length}`;
200
+ }
201
+ export function backgroundMessage(s, result, entry) {
202
+ const messages = s.messages ?? [];
203
+ let command = result.callID;
204
+ // The card that launched the command started it, so the finished card shows the real run time.
205
+ let started = entry.created;
206
+ if (result.kind === "terminal") {
207
+ // Newest first, stopping at an earlier finish of the same id: OmO numbers shells from bash_1 again
208
+ // after its process restarts, so an older launch of that id is not this command's.
209
+ search: for (const message of messages.toReversed()) {
210
+ for (const part of message.parts.toReversed()) {
211
+ if (part.type !== "tool" || part.state.status === "pending")
212
+ continue;
213
+ if (part.callID.startsWith("call_background_") &&
214
+ part.state.input.command?.toString().split("\n")[0] === result.callID)
215
+ break search;
216
+ const commands = part.state.metadata?.backgroundCommands;
217
+ const nested = commands !== null && typeof commands === "object" && result.callID in commands
218
+ ? Reflect.get(commands, result.callID)
219
+ : undefined;
220
+ const held = part.state.metadata?.output;
221
+ const output = part.state.status === "completed"
222
+ ? part.state.output
223
+ : part.state.status === "error"
224
+ ? part.state.error
225
+ : typeof held === "string"
226
+ ? held
227
+ : "";
228
+ const original = typeof nested === "string"
229
+ ? nested
230
+ : launchedShells(output).includes(result.callID)
231
+ ? (part.state.input.command ?? part.state.input.code)
232
+ : undefined;
233
+ if (typeof original === "string") {
234
+ command += `\n${original}`;
235
+ started = Math.min(part.state.time.start, entry.created);
236
+ break search;
237
+ }
238
+ }
239
+ }
240
+ }
241
+ const sessionID = s.info.id;
242
+ const messageID = `msg_background_${entry.id}`;
243
+ return {
244
+ info: {
245
+ id: messageID,
246
+ sessionID,
247
+ role: "assistant",
248
+ parentID: messages.findLast((message) => message.info.role === "user")?.info.id ?? "",
249
+ time: { created: entry.created, completed: entry.created },
250
+ modelID: entry.model.modelID,
251
+ providerID: entry.model.providerID,
252
+ mode: entry.agent,
253
+ path: { cwd: s.info.directory, root: s.info.directory },
254
+ cost: 0,
255
+ tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
256
+ finish: "stop",
257
+ },
258
+ parts: [
259
+ {
260
+ id: `prt_background_${entry.id}`,
261
+ sessionID,
262
+ messageID,
263
+ type: "tool",
264
+ tool: "bash",
265
+ callID: `call_background_${entry.id}`,
266
+ state: {
267
+ status: "completed",
268
+ input: { command },
269
+ output: result.output,
270
+ title: result.title,
271
+ metadata: {},
272
+ time: { start: started, end: entry.created },
273
+ },
274
+ },
275
+ ],
276
+ };
277
+ }
@@ -3,6 +3,8 @@ import type { OCEvent, OCMessageWithParts } from "./types.js";
3
3
  /** Names OmO's terminal extension passes to `pi.rpc.emit` (core/extensions/builtin/monitor-state-event.js). */
4
4
  export declare const MONITOR_STATE_EVENT = "terminal_monitor_state";
5
5
  export declare const MONITOR_ENDED_EVENT = "terminal_monitor_ended";
6
+ /** customType of the custom message OmO sends with a watch's matched lines (raw RPC capture of installed omo-ai 5.1.33, 2026-10-17). */
7
+ export declare const MONITOR_NOTIFICATION = "senpi-monitor:notification";
6
8
  export interface MonitorSnapshot {
7
9
  id: string;
8
10
  description: string;
@@ -33,6 +35,8 @@ export declare class MonitorSessions {
33
35
  owns(id: string): boolean;
34
36
  messages(id: string): OCMessageWithParts[];
35
37
  observe(parent: ManagedSession, ev: Record<string, unknown>): void;
38
+ /** A hidden `senpi-monitor:notification` custom message: append its lines to each watch it names. */
39
+ notify(parent: ManagedSession, message: Record<string, unknown>): void;
36
40
  /** The agent process is gone: OmO's in-process watches died with it. */
37
41
  stopParent(parentID: string): void;
38
42
  /** Forget a deleted child, or every child of a deleted parent. */
@@ -8,6 +8,10 @@ import { ocIdFor } from "./session-store.js";
8
8
  /** Names OmO's terminal extension passes to `pi.rpc.emit` (core/extensions/builtin/monitor-state-event.js). */
9
9
  export const MONITOR_STATE_EVENT = "terminal_monitor_state";
10
10
  export const MONITOR_ENDED_EVENT = "terminal_monitor_ended";
11
+ /** customType of the custom message OmO sends with a watch's matched lines (raw RPC capture of installed omo-ai 5.1.33, 2026-10-17). */
12
+ export const MONITOR_NOTIFICATION = "senpi-monitor:notification";
13
+ /** Matched lines kept per watch; older lines are dropped. */
14
+ const MAX_EVENT_LINES = 200;
11
15
  function record(value) {
12
16
  return value !== null && typeof value === "object" && !Array.isArray(value);
13
17
  }
@@ -95,6 +99,27 @@ export class MonitorSessions {
95
99
  else if (ev.name === MONITOR_ENDED_EVENT)
96
100
  this.applyEnded(parent, ev.data);
97
101
  }
102
+ /** A hidden `senpi-monitor:notification` custom message: append its lines to each watch it names. */
103
+ notify(parent, message) {
104
+ if (typeof message.content !== "string" || !record(message.details) || !Array.isArray(message.details.monitors))
105
+ return;
106
+ const lines = message.content
107
+ .replaceAll(/<\/?system-reminder>/g, "")
108
+ .replace(/^Monitor event\([^\n]*?\):\s?/, "")
109
+ .split("\n")
110
+ .filter((line) => line.trim() !== "");
111
+ if (lines.length === 0)
112
+ return;
113
+ for (const entry of message.details.monitors) {
114
+ if (!record(entry) || typeof entry.id !== "string")
115
+ continue;
116
+ const watch = this.watches.get(this.childID(parent, entry.id));
117
+ if (!watch)
118
+ continue;
119
+ watch.lines = [...watch.lines, ...lines].slice(-MAX_EVENT_LINES);
120
+ this.publish(watch);
121
+ }
122
+ }
98
123
  /** The agent process is gone: OmO's in-process watches died with it. */
99
124
  stopParent(parentID) {
100
125
  for (const watch of this.watches.values()) {
@@ -193,14 +218,14 @@ export class MonitorSessions {
193
218
  hasPendingInteraction: () => false,
194
219
  hasRunningChildren: () => false,
195
220
  };
196
- const watch = { session, monitor };
221
+ const watch = { session, monitor, lines: [] };
197
222
  this.watches.set(id, watch);
198
223
  this.sessions.set(id, session);
199
224
  this.emit(session.info.directory, { type: "session.created", properties: { info: session.info } });
200
225
  this.publish(watch);
201
226
  }
202
227
  publish(watch) {
203
- const { session, monitor, ending } = watch;
228
+ const { session, monitor, ending, lines } = watch;
204
229
  const directory = session.info.directory;
205
230
  const sessionID = session.info.id;
206
231
  const base = sessionID.replace(/^ses_/, "");
@@ -208,7 +233,8 @@ export class MonitorSessions {
208
233
  const started = monitor.startedAtMs;
209
234
  const command = monitor.command ?? monitor.description;
210
235
  const input = { command, description: monitor.description };
211
- const output = monitorSummary(monitor, ending);
236
+ const summary = monitorSummary(monitor, ending);
237
+ const output = lines.length > 0 ? `${summary}\n\n${lines.join("\n")}` : summary;
212
238
  const tool = {
213
239
  id: `prt_${base}_tool`,
214
240
  sessionID,
@@ -3,7 +3,7 @@ import type { OCAssistantMessage, OCMessageWithParts, OCModelRef, OCEvent, OCSes
3
3
  import type { ManagedSession } from "./sessions.js";
4
4
  import type { SessionStore } from "./session-store.js";
5
5
  import type { SubagentBridge } from "./subagents.js";
6
- import type { MonitorSessions } from "./monitor-sessions.js";
6
+ import { type MonitorSessions } from "./monitor-sessions.js";
7
7
  import { type TodoUpdated } from "./todo-bridge.js";
8
8
  export interface PiUsage {
9
9
  input?: number;
@@ -62,6 +62,7 @@ export declare class PiEventTranslator {
62
62
  private reconcileUser;
63
63
  onPiEvent(s: ManagedSession, ev: Record<string, unknown>): void;
64
64
  private onTodoEntry;
65
+ private onBackgroundEntry;
65
66
  private onLifecycleEvent;
66
67
  private onToolEvent;
67
68
  private onMessageEvent;
@@ -81,6 +82,8 @@ export declare class PiEventTranslator {
81
82
  private onToolcallEvent;
82
83
  /** turn_end.toolResults → finalize tool parts (completed / error). */
83
84
  private applyToolResults;
85
+ /** The agent process is gone, and its background shells with it: end cards still waiting on them. */
86
+ settleBackground(s: ManagedSession): void;
84
87
  /** Complete an ended assistant message whose completion waited on its tools; true when it changed. */
85
88
  private completeSettled;
86
89
  /** Tool parts that never received a result (abort, crash) must not spin forever. */
@@ -1,6 +1,7 @@
1
1
  /** Pi message/part translation and the live event bridge, with explicit manager services. */
2
2
  import { randomUUID } from "node:crypto";
3
- import { applyProviderToolResults, finishProviderToolParts, mapTool, providerToolParts, resultMetadata, resultText, toolFailed, } from "./tool-map.js";
3
+ import { MONITOR_NOTIFICATION } from "./monitor-sessions.js";
4
+ import { applyProviderToolResults, finishProviderToolParts, mapTool, providerToolParts, resultText, toolFailed, } from "./tool-map.js";
4
5
  import { TodoBridge } from "./todo-bridge.js";
5
6
  import { readSessionTree, recordTurn } from "./session-tree.js";
6
7
  import { touchedFiles } from "./session-diff.js";
@@ -8,6 +9,7 @@ import { getAgentName } from "./agent-name.js";
8
9
  import { userMessageModel } from "./thinking-levels.js";
9
10
  import { debugLog } from "./misc-routes.js";
10
11
  import { envSetting } from "./config.js";
12
+ import { backgroundID, backgroundMessage, backgroundResult, completeBackgroundEval, finishToolPart, releaseBackground, settleBackground, } from "./background-results.js";
11
13
  export function id(prefix) {
12
14
  return `${prefix}_${randomUUID().replaceAll("-", "")}`;
13
15
  }
@@ -190,7 +192,7 @@ export class PiEventTranslator {
190
192
  });
191
193
  if (status.type === "idle") {
192
194
  s.info.time.idle = Date.now();
193
- const last = s.messages?.findLast((message) => message.info.role === "assistant");
195
+ const last = s.messages?.findLast((message) => message.info.role === "assistant" && !message.info.id.startsWith("msg_background_"));
194
196
  s.info.outcome =
195
197
  s.interruptRequested || (last?.info.role === "assistant" && last.info.error?.name === "MessageAbortedError")
196
198
  ? "interrupted"
@@ -268,6 +270,7 @@ export class PiEventTranslator {
268
270
  switch (ev.type) {
269
271
  case "entry_appended":
270
272
  this.onTodoEntry(s, ev);
273
+ this.onBackgroundEntry(s, ev.entry, "entry");
271
274
  break;
272
275
  case "session_info_changed":
273
276
  this.services.applyNativeTitle(s, ev.name);
@@ -307,6 +310,54 @@ export class PiEventTranslator {
307
310
  if (event !== undefined)
308
311
  this.services.emit(s.info.directory, event);
309
312
  }
313
+ onBackgroundEntry(s, value, source = "message") {
314
+ if (value === null || typeof value !== "object")
315
+ return;
316
+ if (!("customType" in value) || !("content" in value))
317
+ return;
318
+ const result = backgroundResult(value.customType, value.content);
319
+ if (result === undefined)
320
+ return;
321
+ const messages = this.services.ensureMessages(s);
322
+ const timestamp = "timestamp" in value ? value.timestamp : undefined;
323
+ const parsed = typeof timestamp === "string" ? Date.parse(timestamp) : timestamp;
324
+ const created = typeof parsed === "number" && Number.isFinite(parsed) ? parsed : Date.now();
325
+ const part = completeBackgroundEval(messages, result, created);
326
+ const prefix = `msg_background_${result.key}_`;
327
+ const unbound = messages.findLast((message) => message.info.id.startsWith(prefix) && s.tree.messageEntries[message.info.id] === undefined);
328
+ // A finished shell also ends the wait of the card that launched it (once, from the live message).
329
+ if (result.kind === "terminal" && source === "message")
330
+ for (const waiting of releaseBackground(messages, result.callID, created))
331
+ this.emitPart(s, waiting);
332
+ if (part !== undefined)
333
+ this.emitPart(s, part);
334
+ else if (source === "entry" && unbound !== undefined) {
335
+ // The live custom message already created this card; the entry only supplies its native ID.
336
+ if ("id" in value && typeof value.id === "string")
337
+ s.tree.messageEntries[unbound.info.id] = value.id;
338
+ }
339
+ else {
340
+ const message = backgroundMessage(s, result, {
341
+ id: backgroundID(messages, result),
342
+ created,
343
+ model: s.model ?? { providerID: "unknown", modelID: "unknown" },
344
+ agent: getAgentName(),
345
+ });
346
+ if (messages.some((existing) => existing.info.id === message.info.id))
347
+ return;
348
+ if ("id" in value && typeof value.id === "string")
349
+ s.tree.messageEntries[message.info.id] = value.id;
350
+ const boundary = messages.findIndex((item) => item.info.id === s.tree.revert?.messageID);
351
+ messages.splice(boundary < 0 ? messages.length : boundary, 0, message);
352
+ this.services.emit(s.info.directory, { type: "message.updated", properties: { info: message.info } });
353
+ for (const part of message.parts)
354
+ this.emitPart(s, part);
355
+ }
356
+ if ("id" in value && typeof value.id === "string") {
357
+ recordTurn(s);
358
+ this.services.persistMeta(s);
359
+ }
360
+ }
310
361
  onLifecycleEvent(s, ev) {
311
362
  switch (ev.type) {
312
363
  case "agent_start":
@@ -443,6 +494,18 @@ export class PiEventTranslator {
443
494
  case "message_start": {
444
495
  if (this.reconcileUser(s, ev))
445
496
  return;
497
+ const custom = ev.message;
498
+ if (custom?.role === "custom" && custom.customType === MONITOR_NOTIFICATION) {
499
+ this.services.monitors.notify(s, custom);
500
+ return;
501
+ }
502
+ if (ev.message !== null &&
503
+ typeof ev.message === "object" &&
504
+ "role" in ev.message &&
505
+ ev.message.role === "custom") {
506
+ this.onBackgroundEntry(s, ev.message);
507
+ return;
508
+ }
446
509
  const msg = ev.message;
447
510
  if (msg?.role !== "assistant")
448
511
  return; // user message already recorded at prompt()
@@ -791,35 +854,7 @@ export class PiEventTranslator {
791
854
  continue;
792
855
  s.toolByCallId.delete(callID);
793
856
  s.todoBridge?.finish(part, toolFailed(tr));
794
- const text = resultText(tr.content);
795
- const startedAt = part.state.status !== "pending" ? part.state.time.start : Date.now();
796
- const input = part.state.input ?? {};
797
- const metadata = {
798
- ...(part.state.status !== "pending" ? part.state.metadata : {}),
799
- ...resultMetadata(part.tool, tr),
800
- };
801
- const title = part.state.status === "running" || part.state.status === "completed"
802
- ? (part.state.title ?? part.tool)
803
- : part.tool;
804
- if (toolFailed(tr)) {
805
- part.state = {
806
- status: "error",
807
- input,
808
- error: text || `tool ${part.tool} failed`,
809
- metadata,
810
- time: { start: startedAt, end: Date.now() },
811
- };
812
- }
813
- else {
814
- part.state = {
815
- status: "completed",
816
- input,
817
- output: text,
818
- title,
819
- metadata,
820
- time: { start: startedAt, end: Date.now() },
821
- };
822
- }
857
+ finishToolPart(part, tr, callID, Date.now());
823
858
  if (touchedFiles([part]).length > 0)
824
859
  this.services.dirtyDiffs.add(s.info.id);
825
860
  this.emitPart(s, part);
@@ -830,6 +865,11 @@ export class PiEventTranslator {
830
865
  this.services.emit(s.info.directory, { type: "message.updated", properties: { info: owner.info } });
831
866
  }
832
867
  }
868
+ /** The agent process is gone, and its background shells with it: end cards still waiting on them. */
869
+ settleBackground(s) {
870
+ for (const part of settleBackground(s.messages ?? [], Date.now()))
871
+ this.emitPart(s, part);
872
+ }
833
873
  /** Complete an ended assistant message whose completion waited on its tools; true when it changed. */
834
874
  completeSettled(s, info) {
835
875
  if (!this.awaitingTools.has(info) || info.role !== "assistant" || hasActiveTools(s, info.id))
@@ -1,6 +1,6 @@
1
1
  /** Reconstruct OpenCode history from pi JSONL session files. */
2
2
  import { readFileSync } from "node:fs";
3
- import { applyProviderToolResults, finishProviderToolParts, mapTool, providerToolParts, resultMetadata, resultText, toolFailed, } from "./tool-map.js";
3
+ import { applyProviderToolResults, finishProviderToolParts, mapTool, providerToolParts } from "./tool-map.js";
4
4
  import { TodoBridge } from "./todo-bridge.js";
5
5
  import { userMessageModel } from "./thinking-levels.js";
6
6
  import { contextMessageID } from "./prompt-parts.js";
@@ -8,6 +8,7 @@ import { bindMessageEntries } from "./session-tree.js";
8
8
  import { taskParts, updateTaskParts, taskCompletions } from "./subagents.js";
9
9
  import { shellTitle, shellUserMessage } from "./session-commands.js";
10
10
  import { compactionMessages, piModelId, piStopReason, textOf } from "./pi-event-translation.js";
11
+ import { backgroundID, backgroundMessage, backgroundResult, completeBackgroundEval, finishToolPart, releaseBackground, settleBackground, } from "./background-results.js";
11
12
  function entryTime(e) {
12
13
  if (typeof e.timestamp === "number")
13
14
  return e.timestamp;
@@ -139,7 +140,9 @@ export function parsePiSessionText(s, agentName, raw) {
139
140
  finishProviderToolParts([...toolParts.values()].flat(), entryTime(runAssistant), runAssistant.message?.stopReason === "aborted" ? "Cancelled" : "No result");
140
141
  runAssistant = undefined;
141
142
  };
143
+ let lastAt = 0;
142
144
  for (const e of chain) {
145
+ lastAt = Math.max(lastAt, entryTime(e));
143
146
  if (e.type === "model_change" && typeof e.provider === "string" && typeof e.modelId === "string") {
144
147
  currentModel = { providerID: e.provider, modelID: e.modelId };
145
148
  continue;
@@ -153,6 +156,22 @@ export function parsePiSessionText(s, agentName, raw) {
153
156
  out.push(...compactionMessages(s, e.id ?? `r${indexOf.get(e) ?? 0}`, e.summary, entryTime(e), currentModel, currentThinkingLevel));
154
157
  continue;
155
158
  }
159
+ if (e.type === "custom_message") {
160
+ const result = backgroundResult(e.customType, e.content);
161
+ if (result?.kind === "terminal")
162
+ releaseBackground(out, result.callID, entryTime(e));
163
+ if (result !== undefined && completeBackgroundEval(out, result, entryTime(e)) === undefined) {
164
+ const message = backgroundMessage({ info: s.info, messages: out }, result, {
165
+ id: backgroundID(out, result),
166
+ created: entryTime(e),
167
+ model: currentModel,
168
+ agent: agentName,
169
+ });
170
+ if (e.id !== undefined)
171
+ s.tree.messageEntries[message.info.id] = e.id;
172
+ out.push(message);
173
+ }
174
+ }
156
175
  const contextID = contextMessageID(e);
157
176
  if (contextID !== undefined) {
158
177
  endRun();
@@ -386,32 +405,8 @@ export function parsePiSessionText(s, agentName, raw) {
386
405
  updateTaskParts(matching, m, true, created);
387
406
  continue;
388
407
  }
389
- const text = resultText(m.content) ?? "";
390
- const startedAt = part.state.status !== "pending" ? part.state.time.start : created;
391
- const input = part.state.input ?? {};
392
- const metadata = {
393
- ...(part.state.status !== "pending" ? part.state.metadata : {}),
394
- ...resultMetadata(part.tool, m),
395
- };
396
- const title = part.state.status === "running" || part.state.status === "completed"
397
- ? (part.state.title ?? part.tool)
398
- : part.tool;
399
- part.state = toolFailed(m)
400
- ? {
401
- status: "error",
402
- input,
403
- error: text !== "" ? text : `tool ${part.tool} failed`,
404
- metadata,
405
- time: { start: startedAt, end: created },
406
- }
407
- : {
408
- status: "completed",
409
- input,
410
- output: text,
411
- title,
412
- metadata,
413
- time: { start: startedAt, end: created },
414
- };
408
+ if (callID !== undefined)
409
+ finishToolPart(part, m, callID, created);
415
410
  }
416
411
  // system / custom entries are not rendered
417
412
  }
@@ -487,5 +482,7 @@ export function parsePiSessionText(s, agentName, raw) {
487
482
  part.id = identity;
488
483
  }
489
484
  }
485
+ // The agent that ran these shells is gone by the time history is rebuilt.
486
+ settleBackground(out, lastAt);
490
487
  return out;
491
488
  }
@@ -31,6 +31,7 @@ export interface TreeEntry {
31
31
  id: string;
32
32
  parentId: string | null;
33
33
  customType?: string;
34
+ content?: unknown;
34
35
  details?: unknown;
35
36
  message?: {
36
37
  role: string;
@@ -1,6 +1,7 @@
1
1
  import { RPC_COMMAND_TIMEOUT_MS } from "./pi-rpc.js";
2
2
  import { readFileSync, statSync } from "node:fs";
3
3
  import { contextMessageID, nativePromptInput } from "./prompt-parts.js";
4
+ import { backgroundResult } from "./background-results.js";
4
5
  export class SessionTreeError extends Error {
5
6
  }
6
7
  export function durableShellTree(tree) {
@@ -132,6 +133,7 @@ function bindOrdinaryEntry(s, entry, messages, cursor) {
132
133
  .join("");
133
134
  const index = messages.findIndex((message, position) => position >= cursor &&
134
135
  !s.localMessageIds?.has(message.info.id) &&
136
+ !message.info.id.startsWith("msg_background_") &&
135
137
  s.tree.messageEntries[message.info.id] === undefined &&
136
138
  message.info.role === entry.message?.role &&
137
139
  (message.info.role !== "user" ||
@@ -158,6 +160,20 @@ export function bindMessageEntries(s, tree) {
158
160
  const branch = branchEntries(tree, s.tree.revert ? s.tree.revert.previousLeafId : leafId);
159
161
  const positions = new Map(branch.map((entry, index) => [entry.id, index]));
160
162
  pruneStaleShellTurns(s, positions);
163
+ // Background completion cards carry no native ID live; recompute their content key in branch order.
164
+ const seen = new Map();
165
+ for (const entry of branch) {
166
+ if (entry.type !== "custom_message")
167
+ continue;
168
+ const result = backgroundResult(entry.customType, entry.content);
169
+ if (result === undefined)
170
+ continue;
171
+ const occurrence = seen.get(result.key) ?? 0;
172
+ seen.set(result.key, occurrence + 1);
173
+ const messageID = `msg_background_${result.key}_${occurrence}`;
174
+ if (s.messages?.some((message) => message.info.id === messageID))
175
+ s.tree.messageEntries[messageID] = entry.id;
176
+ }
161
177
  // Admitted context carries its OpenChamber id in the native custom message.
162
178
  for (const entry of branch) {
163
179
  const messageID = contextMessageID(entry);
package/dist/sessions.js CHANGED
@@ -250,6 +250,7 @@ export class SessionManager {
250
250
  this.reportPersistenceError(s, error);
251
251
  }
252
252
  delete s.interruptRequested;
253
+ this.events.settleBackground(s);
253
254
  this.subagents.stopParent(s.info.id);
254
255
  this.monitors.stopParent(s.info.id);
255
256
  void this.lifecycle
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "antechamber-omo-compat",
3
- "version": "0.2.22",
3
+ "version": "0.2.24",
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": "0.2.22",
3
+ "version": "0.2.24",
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.22",
3
+ "version": "0.2.24",
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",