antechamber 1.0.23 → 1.0.25

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,31 @@ and this project uses [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.0.25] - 2026-10-27
11
+
12
+ ### Added
13
+
14
+ - Show OmO's background work as background commands in OpenChamber 2.x. While a terminal monitor, a detached eval
15
+ cell or a background terminal session runs, the session reads as working ("Background command") after its turn
16
+ ends, the turn timer keeps running, and the commands are listed above the composer with their output (work no card
17
+ started, such as a watch a revived agent restored, is listed after a reload). Stop works
18
+ for eval cells; OmO offers no way to stop a monitor or a background terminal session, so their Stop fails without
19
+ telling the agent anything. A card that left such work running now settles at once and follows the command, as
20
+ OpenCode's own background calls do.
21
+
22
+ ### Changed
23
+
24
+ - The OmO Work section no longer lists monitors, eval cells or background terminal sessions, which OpenChamber now
25
+ shows itself. It keeps the todo list, the goal timer and other activity.
26
+
27
+ ## [1.0.24] - 2026-10-27
28
+
29
+ ### Fixed
30
+
31
+ - Keep each reply in its own turn in OpenChamber 2.x after history is reloaded. A reply's request can start a few
32
+ milliseconds before OmO writes its prompt, so a restored reply sorted ahead of its prompt: the first turn's replies
33
+ vanished and each later turn's first reply was drawn in the turn before.
34
+
10
35
  ## [1.0.23] - 2026-10-27
11
36
 
12
37
  ### Fixed
@@ -27,14 +27,14 @@ interface Final {
27
27
  failed: boolean;
28
28
  }
29
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;
30
+ export declare function holdForBackground(part: OCToolPart, waits: string[], final: Final, agentStartedAtMs?: number): void;
31
31
  /** `id` finished at `at`: drop it from every card waiting on it; cards left with nothing to wait on end. */
32
32
  export declare function releaseBackground(messages: readonly OCMessageWithParts[], id: string, at: number): OCToolPart[];
33
33
  /** Nothing can still be running (the agent exited, or history is rebuilt): end every waiting card. */
34
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;
35
+ export declare function completeBackgroundEval(messages: readonly OCMessageWithParts[], result: BackgroundResult, completed: number, agentStartedAtMs?: number): OCToolPart | undefined;
36
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;
37
+ export declare function finishToolPart(part: OCToolPart, result: ToolResult, callID: string, at: number, agentStartedAtMs?: number): void;
38
38
  /**
39
39
  * OmO persists these notifications but sends no entry_appended frame for them, so the live
40
40
  * path never learns the native entry ID (raw RPC capture of installed omo-ai 5.1.33 / senpi
@@ -1,5 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { resultMetadata, resultText, toolFailed } from "./tool-map.js";
3
+ import { evalShellId, terminalShellId } from "./omo-shells.js";
3
4
  /** Raw RPC capture of installed omo-ai 5.1.33 / senpi runtime, 2026-10-17. */
4
5
  export function backgroundResult(customType, content) {
5
6
  if (customType !== "senpi-terminal:notification" && customType !== "senpi-codemode:notification")
@@ -77,17 +78,34 @@ function waitsOf(part) {
77
78
  const waits = part.state.status === "running" ? part.state.metadata?.backgroundWaits : undefined;
78
79
  return Array.isArray(waits) ? waits.filter((id) => typeof id === "string") : [];
79
80
  }
81
+ /**
82
+ * The OpenCode shell (omo-shells.ts) a card stands for while its work runs: the detached cell itself, else the
83
+ * first background session it launched. OpenChamber 2.x ties one shell to a card.
84
+ */
85
+ function backgroundShellID(callID, waits, agentStartedAtMs) {
86
+ if (waits.includes(callID))
87
+ return evalShellId(callID);
88
+ const shell = waits.find((id) => /^bash_\d+$/.test(id));
89
+ return shell !== undefined && agentStartedAtMs !== undefined ? terminalShellId(agentStartedAtMs, shell) : undefined;
90
+ }
80
91
  /** Keep a finished card running, with its result already shown, until `waits` report finishing. */
81
- export function holdForBackground(part, waits, final) {
92
+ export function holdForBackground(part, waits, final, agentStartedAtMs) {
82
93
  const state = part.state;
83
94
  if (state.status === "pending" || waits.length === 0)
84
95
  return;
85
96
  const title = state.status === "running" || state.status === "completed" ? state.title : undefined;
97
+ const shellID = backgroundShellID(part.callID, waits, agentStartedAtMs);
86
98
  part.state = {
87
99
  status: "running",
88
100
  input: state.input,
89
101
  ...(title !== undefined ? { title } : {}),
90
- metadata: { ...state.metadata, output: final.output, backgroundWaits: waits, backgroundFailed: final.failed },
102
+ metadata: {
103
+ ...state.metadata,
104
+ output: final.output,
105
+ backgroundWaits: waits,
106
+ backgroundFailed: final.failed,
107
+ ...(shellID !== undefined ? { backgroundShellID: shellID } : {}),
108
+ },
91
109
  time: { start: state.time.start },
92
110
  };
93
111
  }
@@ -95,7 +113,7 @@ function finish(part, final, at) {
95
113
  const state = part.state;
96
114
  if (state.status === "pending")
97
115
  return;
98
- const { backgroundWaits: _waits, backgroundFailed: _failed, output: _output, ...metadata } = state.metadata ?? {};
116
+ const { backgroundWaits: _waits, backgroundFailed: _failed, backgroundShellID: _shell, output: _output, ...metadata } = state.metadata ?? {};
99
117
  const time = { start: state.time.start, end: Math.max(at, state.time.start) };
100
118
  const title = state.status === "running" || state.status === "completed" ? (state.title ?? part.tool) : part.tool;
101
119
  part.state = final.failed
@@ -145,7 +163,7 @@ export function settleBackground(messages, at) {
145
163
  }
146
164
  return changed;
147
165
  }
148
- export function completeBackgroundEval(messages, result, completed) {
166
+ export function completeBackgroundEval(messages, result, completed, agentStartedAtMs) {
149
167
  if (result.kind !== "eval")
150
168
  return undefined;
151
169
  for (const message of messages) {
@@ -160,7 +178,7 @@ export function completeBackgroundEval(messages, result, completed) {
160
178
  ];
161
179
  const remaining = [...new Set(waits)];
162
180
  if (part.state.status !== "pending" && remaining.length > 0) {
163
- holdForBackground(part, remaining, final);
181
+ holdForBackground(part, remaining, final, agentStartedAtMs);
164
182
  }
165
183
  else
166
184
  finish(part, final, completed);
@@ -169,14 +187,16 @@ export function completeBackgroundEval(messages, result, completed) {
169
187
  return undefined;
170
188
  }
171
189
  /** 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) {
190
+ export function finishToolPart(part, result, callID, at, agentStartedAtMs) {
173
191
  const state = part.state;
174
192
  const text = resultText(result.content);
175
193
  const commands = backgroundCommands(result);
194
+ const monitor = monitorShellID(part, result, agentStartedAtMs);
176
195
  const metadata = {
177
196
  ...(state.status !== "pending" ? state.metadata : {}),
178
197
  ...resultMetadata(part.tool, result),
179
198
  ...commands,
199
+ ...(monitor !== undefined ? { backgroundShellID: monitor } : {}),
180
200
  };
181
201
  const time = { start: state.status !== "pending" ? state.time.start : at, end: at };
182
202
  const title = state.status === "running" || state.status === "completed" ? (state.title ?? part.tool) : part.tool;
@@ -186,7 +206,19 @@ export function finishToolPart(part, result, callID, at) {
186
206
  : { status: "completed", input: state.input ?? {}, output: text, title, metadata, time };
187
207
  const waits = backgroundWaits(callID, text, commands.backgroundCommands);
188
208
  if (waits.length > 0)
189
- holdForBackground(part, waits, { output: text, failed });
209
+ holdForBackground(part, waits, { output: text, failed }, agentStartedAtMs);
210
+ }
211
+ /**
212
+ * A `monitor` call returns at once with the watch's terminal id: `details: {monitor_id, bash_id, monitor: true}`
213
+ * (senpi core/extensions/builtin/terminal/tools/monitor.js, installed 2026-10-27).
214
+ */
215
+ function monitorShellID(part, result, agentStartedAtMs) {
216
+ if (part.tool !== "monitor" || toolFailed(result) || agentStartedAtMs === undefined)
217
+ return undefined;
218
+ const details = result.details;
219
+ if (details === null || typeof details !== "object" || !("bash_id" in details))
220
+ return undefined;
221
+ return typeof details.bash_id === "string" ? terminalShellId(agentStartedAtMs, details.bash_id) : undefined;
190
222
  }
191
223
  /**
192
224
  * OmO persists these notifications but sends no entry_appended frame for them, so the live
package/dist/lifecycle.js CHANGED
@@ -109,6 +109,8 @@ export class SessionLifecycle {
109
109
  }
110
110
  s.unwirePi?.();
111
111
  s.pi = undefined; // Old exit listeners must not mutate a revived session.
112
+ // Unwired, the exit handler will not run: end what died with the process here.
113
+ s.onDormant?.();
112
114
  const disposing = pi.dispose();
113
115
  this.disposing.add(disposing);
114
116
  try {
@@ -41,6 +41,13 @@ export declare class MonitorSessions {
41
41
  constructor(sessions: Map<string, ManagedSession>, emit: (directory: string, event: OCEvent) => void);
42
42
  wire(parent: ManagedSession): void;
43
43
  owns(id: string): boolean;
44
+ /** Matched lines of a parent's watch received after the first `seen`, as far as they are still kept. */
45
+ linesSince(parentID: string, terminalId: string, seen: number): {
46
+ lines: string[];
47
+ total: number;
48
+ } | undefined;
49
+ /** OmO's terminal id (`bash_N`) of a child's watch while it runs. */
50
+ terminalId(id: string): string | undefined;
44
51
  /** A parent's watches, live first then the newest ended ones, with their latest matched lines. */
45
52
  list(parentID: string, maxEnded: number, maxLines: number): MonitorView[];
46
53
  messages(id: string): OCMessageWithParts[];
@@ -88,6 +88,19 @@ export class MonitorSessions {
88
88
  owns(id) {
89
89
  return this.watches.has(id);
90
90
  }
91
+ /** Matched lines of a parent's watch received after the first `seen`, as far as they are still kept. */
92
+ linesSince(parentID, terminalId, seen) {
93
+ const watch = this.watches.get(ocIdFor(`${parentID}/monitor/${terminalId}`));
94
+ if (watch === undefined)
95
+ return undefined;
96
+ const fresh = Math.max(0, watch.lineCount - seen);
97
+ return { lines: fresh === 0 ? [] : watch.lines.slice(-fresh), total: watch.lineCount };
98
+ }
99
+ /** OmO's terminal id (`bash_N`) of a child's watch while it runs. */
100
+ terminalId(id) {
101
+ const watch = this.watches.get(id);
102
+ return watch !== undefined && watch.ending === undefined ? watch.monitor.id : undefined;
103
+ }
91
104
  /** A parent's watches, live first then the newest ended ones, with their latest matched lines. */
92
105
  list(parentID, maxEnded, maxLines) {
93
106
  const watches = [...this.watches.values()].filter((watch) => watch.session.info.parentID === parentID);
@@ -131,6 +144,7 @@ export class MonitorSessions {
131
144
  if (!watch)
132
145
  continue;
133
146
  watch.lines = [...watch.lines, ...lines].slice(-MAX_EVENT_LINES);
147
+ watch.lineCount += lines.length;
134
148
  this.publish(watch);
135
149
  }
136
150
  }
@@ -251,7 +265,7 @@ export class MonitorSessions {
251
265
  hasPendingInteraction: () => false,
252
266
  hasRunningChildren: () => false,
253
267
  };
254
- const watch = { session, monitor, lines: [] };
268
+ const watch = { session, monitor, lines: [], lineCount: 0 };
255
269
  this.watches.set(id, watch);
256
270
  this.sessions.set(id, session);
257
271
  this.emit(session.info.directory, { type: "session.created", properties: { info: session.info } });
@@ -0,0 +1,80 @@
1
+ /**
2
+ * OmO background work as OpenCode 2.x shells, so OpenChamber 2.x shows it the way it shows OpenCode's
3
+ * own background commands: the session reads as working ("Background command") while its turn is idle,
4
+ * the turn timer holds, the chat lists the commands with their output, and a turn's card follows its
5
+ * command. OpenChamber derives all of it from `GET /api/shell` and the `shell.created` / `shell.exited`
6
+ * events, keyed by `metadata.sessionID` (packages/ui/src/sync/background-shells.ts and
7
+ * global-session-status.ts, OpenChamber 2.5.2).
8
+ *
9
+ * Sources, all already followed for idle eviction and the OmO Work section:
10
+ * - terminal monitors (`terminal_monitor_state`, monitor-sessions.ts);
11
+ * - background terminal sessions (the terminal manifest, work-status.ts `readBackgroundSessions`);
12
+ * - detached eval cells (the codemode wake source, work-status.ts).
13
+ *
14
+ * Monitors and background sessions share OmO's `bash_N` numbering, which restarts with each agent
15
+ * process, so their shell id carries the process start: `sh_t<start base36>_<bash_N>`. Eval cells are
16
+ * keyed by their cell id, which is the eval tool call's id: `sh_e_<cellId>`. OpenCode's schema requires
17
+ * the `sh_` prefix (Shell.ID).
18
+ */
19
+ import type { MonitorSessions } from "./monitor-sessions.js";
20
+ import type { ManagedSession } from "./sessions.js";
21
+ import type { BackgroundResult } from "./background-results.js";
22
+ import type { OCEvent, OCShellInfo } from "./types.js";
23
+ import { type WorkStatusBridge } from "./work-status.js";
24
+ /** Metadata key OpenChamber puts on the note it sends before stopping commands (background-shell.ts). */
25
+ export declare const SHELL_CANCELLATION_KEY = "openchamberShellCancellation";
26
+ export type OmoShellKind = "monitor" | "background" | "eval";
27
+ export declare function terminalShellId(agentStartedAtMs: number, bashId: string): string;
28
+ export declare function evalShellId(cellId: string): string;
29
+ export interface ShellOutputPage {
30
+ output: string;
31
+ cursor: number;
32
+ size: number;
33
+ truncated: boolean;
34
+ }
35
+ /**
36
+ * Turns successive windows of a growing text (OmO only ever exposes a tail) into the append-only byte
37
+ * stream OpenCode's `cursor` reads. A window continues the known output where the known output's end
38
+ * matches the window's start; a window that shares nothing with it is appended whole.
39
+ */
40
+ export declare class OutputLog {
41
+ private bytes;
42
+ /** Absolute offset of `bytes[0]`. */
43
+ private start;
44
+ get size(): number;
45
+ update(window: string): void;
46
+ /** Text known to follow what is already logged (a source that reports increments, not tails). */
47
+ append(text: string): void;
48
+ private add;
49
+ /** Bytes at the start of `next` that the known output already ends with: the longest such overlap. */
50
+ private overlap;
51
+ /** Pages end on a character boundary, so a client that decodes each page alone never splits one. */
52
+ read(cursor: number, limit: number): ShellOutputPage;
53
+ }
54
+ export declare class OmoShells {
55
+ private readonly sessions;
56
+ private readonly monitors;
57
+ private readonly work;
58
+ private readonly emit;
59
+ private readonly live;
60
+ private readonly ended;
61
+ constructor(sessions: Map<string, ManagedSession>, monitors: MonitorSessions, work: WorkStatusBridge, emit: (directory: string, event: OCEvent) => void);
62
+ sync(s: ManagedSession): void;
63
+ /** OmO's finished notification for a background session or a cell: keep its status for the shell's end. */
64
+ noteEnding(s: ManagedSession, result: BackgroundResult): void;
65
+ /** The agent process exited, or the session was removed: everything it ran is gone. */
66
+ stopParent(sessionID: string): void;
67
+ list(directory: string): OCShellInfo[];
68
+ get(id: string): OCShellInfo | undefined;
69
+ /** Only eval cells can be stopped: OmO registers no RPC request to stop a monitor or a background session. */
70
+ stoppable(id: string): boolean;
71
+ output(id: string, cursor: number | undefined, limit: number | undefined): Promise<ShellOutputPage | undefined>;
72
+ /** Stop a running eval cell through codemode; the cell's own completion then ends the shell. */
73
+ stop(id: string): Promise<void>;
74
+ private desired;
75
+ private start;
76
+ private end;
77
+ /** Lines matched since the last read, counted, so a sliding window of matched lines never repeats. */
78
+ private appendLines;
79
+ private refreshOutput;
80
+ }
@@ -0,0 +1,333 @@
1
+ import { readBackgroundSessions, WorkStatusError } from "./work-status.js";
2
+ /** Metadata key OpenChamber puts on the note it sends before stopping commands (background-shell.ts). */
3
+ export const SHELL_CANCELLATION_KEY = "openchamberShellCancellation";
4
+ /** Ended shells kept for `GET /api/shell/:id` and their output; OpenCode keeps 25 per directory. */
5
+ const MAX_ENDED = 25;
6
+ /** Output kept per shell; older bytes are dropped and reported as skipped. */
7
+ const MAX_OUTPUT_BYTES = 256 * 1024;
8
+ /** codemode's own peek cap (senpi-codemode src/extension/eval-cells-rpc.ts MAX_TAIL_BYTES). */
9
+ const EVAL_PEEK_BYTES = 64 * 1024;
10
+ /** Leading bytes of a new window searched for in the known output to find where it continues it. */
11
+ const PROBE_BYTES = 32;
12
+ const MAX_MONITOR_LINES = 200;
13
+ const BACKGROUND_NOTICE = "OmO does not expose a background terminal session's output to antechamber; the agent reads it with bash_output.\n";
14
+ export function terminalShellId(agentStartedAtMs, bashId) {
15
+ return `sh_t${Math.trunc(agentStartedAtMs).toString(36)}_${bashId}`;
16
+ }
17
+ export function evalShellId(cellId) {
18
+ return `sh_e_${cellId}`;
19
+ }
20
+ /**
21
+ * Turns successive windows of a growing text (OmO only ever exposes a tail) into the append-only byte
22
+ * stream OpenCode's `cursor` reads. A window continues the known output where the known output's end
23
+ * matches the window's start; a window that shares nothing with it is appended whole.
24
+ */
25
+ export class OutputLog {
26
+ bytes = Buffer.alloc(0);
27
+ /** Absolute offset of `bytes[0]`. */
28
+ start = 0;
29
+ get size() {
30
+ return this.start + this.bytes.length;
31
+ }
32
+ update(window) {
33
+ const next = Buffer.from(window, "utf8");
34
+ this.add(next.subarray(this.overlap(next)));
35
+ }
36
+ /** Text known to follow what is already logged (a source that reports increments, not tails). */
37
+ append(text) {
38
+ this.add(Buffer.from(text, "utf8"));
39
+ }
40
+ add(added) {
41
+ if (added.length === 0)
42
+ return;
43
+ this.bytes = Buffer.concat([this.bytes, added]);
44
+ const excess = this.bytes.length - MAX_OUTPUT_BYTES;
45
+ if (excess > 0) {
46
+ this.bytes = this.bytes.subarray(excess);
47
+ this.start += excess;
48
+ }
49
+ }
50
+ /** Bytes at the start of `next` that the known output already ends with: the longest such overlap. */
51
+ overlap(next) {
52
+ const known = this.bytes;
53
+ const from = Math.max(0, known.length - next.length);
54
+ const probe = next.subarray(0, Math.min(PROBE_BYTES, next.length));
55
+ const matches = (at) => known.subarray(at).equals(next.subarray(0, known.length - at));
56
+ if (probe.length === 0)
57
+ return 0;
58
+ for (let at = known.indexOf(probe, from); at !== -1; at = known.indexOf(probe, at + 1)) {
59
+ if (matches(at))
60
+ return known.length - at;
61
+ }
62
+ // Overlaps shorter than the probe cannot be found by indexOf.
63
+ for (let at = Math.max(from, known.length - probe.length + 1); at < known.length; at++) {
64
+ if (matches(at))
65
+ return known.length - at;
66
+ }
67
+ return 0;
68
+ }
69
+ /** Pages end on a character boundary, so a client that decodes each page alone never splits one. */
70
+ read(cursor, limit) {
71
+ const size = this.size;
72
+ const continuation = (at) => at < size && ((this.bytes[at - this.start] ?? 0) & 0xc0) === 0x80;
73
+ let from = Math.min(Math.max(cursor, this.start), size);
74
+ while (continuation(from))
75
+ from++;
76
+ let to = Math.min(size, from + limit);
77
+ while (to > from && continuation(to))
78
+ to--;
79
+ // A limit smaller than the next character still returns that whole character.
80
+ if (to === from && from < size) {
81
+ to = from + 1;
82
+ while (continuation(to))
83
+ to++;
84
+ }
85
+ return {
86
+ output: this.bytes.subarray(from - this.start, to - this.start).toString("utf8"),
87
+ cursor: to,
88
+ size,
89
+ truncated: cursor < this.start,
90
+ };
91
+ }
92
+ }
93
+ function monitorHeader(view) {
94
+ const { monitor } = view;
95
+ const filter = monitor.filter !== null ? `filter: ${monitor.filter}\n` : "";
96
+ return `$ ${monitor.command ?? monitor.description}\n${filter}\n`;
97
+ }
98
+ /**
99
+ * How OmO says a background session or a detached cell finished (background-results.ts `backgroundResult`):
100
+ * "bash_2 finished: killed (exit code 1)". A failed cell has no exit code; it reads as exit 1.
101
+ */
102
+ function resultEnd(result) {
103
+ if (result.kind === "eval")
104
+ return result.failed ? { status: "exited", exit: 1 } : { status: "exited", exit: 0 };
105
+ const status = result.title.slice(result.title.indexOf("finished:") + "finished:".length);
106
+ const exit = /exit code (-?\d+)/.exec(status)?.[1];
107
+ const code = exit !== undefined ? { exit: Number(exit) } : {};
108
+ if (/\bkill|signal/i.test(status))
109
+ return { status: "killed", ...code };
110
+ if (/time(d)? ?out/i.test(status))
111
+ return { status: "timeout", ...code };
112
+ return { status: "exited", ...code };
113
+ }
114
+ function endStatus(view) {
115
+ switch (view?.ending?.reason) {
116
+ case "exit":
117
+ return view.ending.exitCode === null ? { status: "exited" } : { status: "exited", exit: view.ending.exitCode };
118
+ case "timeout":
119
+ return { status: "timeout" };
120
+ case "killed":
121
+ case "agent-exit":
122
+ return { status: "killed" };
123
+ default:
124
+ return { status: "exited" };
125
+ }
126
+ }
127
+ export class OmoShells {
128
+ sessions;
129
+ monitors;
130
+ work;
131
+ emit;
132
+ live = new Map();
133
+ ended = new Map();
134
+ constructor(sessions, monitors, work, emit) {
135
+ this.sessions = sessions;
136
+ this.monitors = monitors;
137
+ this.work = work;
138
+ this.emit = emit;
139
+ }
140
+ sync(s) {
141
+ const desired = this.desired(s);
142
+ const now = Date.now();
143
+ for (const [id, tracked] of this.live) {
144
+ if (tracked.info.metadata.sessionID !== s.info.id || desired.has(id))
145
+ continue;
146
+ const view = tracked.kind === "monitor"
147
+ ? this.monitors.list(s.info.id, Number.MAX_SAFE_INTEGER, 0).find((item) => item.monitor.id === tracked.source)
148
+ : undefined;
149
+ this.end(id, tracked, tracked.ending ?? endStatus(view), now);
150
+ }
151
+ for (const [id, want] of desired) {
152
+ const tracked = this.live.get(id);
153
+ if (tracked === undefined)
154
+ this.start(s, id, want);
155
+ else if (tracked.kind === "monitor")
156
+ this.appendLines(s.info.id, tracked);
157
+ }
158
+ }
159
+ /** OmO's finished notification for a background session or a cell: keep its status for the shell's end. */
160
+ noteEnding(s, result) {
161
+ const epoch = s.pi?.startedAtMs;
162
+ const id = result.kind === "eval"
163
+ ? evalShellId(result.callID)
164
+ : epoch !== undefined
165
+ ? terminalShellId(epoch, result.callID)
166
+ : undefined;
167
+ const tracked = id !== undefined ? this.live.get(id) : undefined;
168
+ if (tracked !== undefined)
169
+ tracked.ending ??= resultEnd(result);
170
+ }
171
+ /** The agent process exited, or the session was removed: everything it ran is gone. */
172
+ stopParent(sessionID) {
173
+ const now = Date.now();
174
+ for (const [id, tracked] of this.live) {
175
+ if (tracked.info.metadata.sessionID === sessionID)
176
+ this.end(id, tracked, { status: "killed" }, now);
177
+ }
178
+ }
179
+ list(directory) {
180
+ for (const s of this.sessions.values())
181
+ if (s.info.directory === directory && s.pi !== undefined)
182
+ this.sync(s);
183
+ return [...this.live.values()]
184
+ .filter((tracked) => tracked.directory === directory)
185
+ .map((tracked) => tracked.info)
186
+ .sort((a, b) => a.time.started - b.time.started || a.id.localeCompare(b.id));
187
+ }
188
+ get(id) {
189
+ return (this.live.get(id) ?? this.ended.get(id))?.info;
190
+ }
191
+ /** Only eval cells can be stopped: OmO registers no RPC request to stop a monitor or a background session. */
192
+ stoppable(id) {
193
+ return this.live.get(id)?.kind === "eval";
194
+ }
195
+ async output(id, cursor, limit) {
196
+ const tracked = this.live.get(id) ?? this.ended.get(id);
197
+ if (tracked === undefined)
198
+ return undefined;
199
+ if (this.live.has(id))
200
+ await this.refreshOutput(tracked);
201
+ return tracked.output.read(cursor ?? 0, limit ?? Number.MAX_SAFE_INTEGER);
202
+ }
203
+ /** Stop a running eval cell through codemode; the cell's own completion then ends the shell. */
204
+ async stop(id) {
205
+ const tracked = this.live.get(id);
206
+ const s = tracked && this.sessions.get(tracked.info.metadata.sessionID);
207
+ if (tracked === undefined || s === undefined)
208
+ throw new WorkStatusError(404, `Unknown shell: ${id}`);
209
+ if (tracked.kind !== "eval")
210
+ throw new WorkStatusError(400, "OmO cannot stop a monitor or a background terminal session over RPC");
211
+ await this.work.stopEvalCell(s, tracked.source);
212
+ tracked.ending = { status: "killed" };
213
+ this.sync(s);
214
+ }
215
+ desired(s) {
216
+ const out = new Map();
217
+ const epoch = s.pi?.startedAtMs;
218
+ if (s.pi === undefined || epoch === undefined)
219
+ return out;
220
+ for (const view of this.monitors.list(s.info.id, 0, MAX_MONITOR_LINES)) {
221
+ if (view.ending !== undefined)
222
+ continue;
223
+ const { monitor } = view;
224
+ out.set(terminalShellId(epoch, monitor.id), {
225
+ kind: "monitor",
226
+ source: monitor.id,
227
+ command: monitor.command ?? monitor.description,
228
+ startedAtMs: monitor.startedAtMs,
229
+ monitor: view,
230
+ });
231
+ }
232
+ if (s.piPath !== undefined && s.piSessionId !== undefined) {
233
+ // Same rule as idle eviction (work-status.ts `wire`): a leftover entry of an earlier process is not ours.
234
+ for (const shell of readBackgroundSessions(s.piPath, s.piSessionId)) {
235
+ if (shell.pid === undefined || shell.startedAtMs < epoch)
236
+ continue;
237
+ out.set(terminalShellId(epoch, shell.id), {
238
+ kind: "background",
239
+ source: shell.id,
240
+ command: shell.command,
241
+ startedAtMs: shell.startedAtMs,
242
+ });
243
+ }
244
+ }
245
+ for (const cell of this.work.liveCells(s.info.id)) {
246
+ out.set(evalShellId(cell.id), {
247
+ kind: "eval",
248
+ source: cell.id,
249
+ command: `eval: ${cell.description}`,
250
+ startedAtMs: cell.startedAtMs,
251
+ });
252
+ }
253
+ return out;
254
+ }
255
+ start(s, id, want) {
256
+ const info = {
257
+ id,
258
+ status: "running",
259
+ command: want.command,
260
+ cwd: s.info.directory,
261
+ shell: want.kind === "eval" ? "eval" : "bash",
262
+ file: "",
263
+ metadata: { sessionID: s.info.id, omo: { kind: want.kind, id: want.source } },
264
+ time: { started: want.startedAtMs },
265
+ };
266
+ const tracked = {
267
+ info,
268
+ directory: s.info.directory,
269
+ kind: want.kind,
270
+ source: want.source,
271
+ output: new OutputLog(),
272
+ seenLines: 0,
273
+ };
274
+ if (want.monitor !== undefined) {
275
+ tracked.output.append(monitorHeader(want.monitor));
276
+ this.appendLines(s.info.id, tracked);
277
+ }
278
+ else if (want.kind === "background")
279
+ tracked.output.append(BACKGROUND_NOTICE);
280
+ this.live.set(id, tracked);
281
+ this.ended.delete(id);
282
+ this.emit(tracked.directory, { type: "shell.created", properties: { info } });
283
+ }
284
+ end(id, tracked, end, at) {
285
+ this.live.delete(id);
286
+ tracked.info = {
287
+ ...tracked.info,
288
+ status: end.status,
289
+ ...(end.exit !== undefined ? { exit: end.exit } : {}),
290
+ time: { ...tracked.info.time, completed: at },
291
+ };
292
+ this.ended.set(id, tracked);
293
+ for (const key of this.ended.keys()) {
294
+ if (this.ended.size <= MAX_ENDED)
295
+ break;
296
+ this.ended.delete(key);
297
+ }
298
+ this.emit(tracked.directory, {
299
+ type: "shell.exited",
300
+ properties: { id, status: end.status, ...(end.exit !== undefined ? { exit: end.exit } : {}) },
301
+ });
302
+ }
303
+ /** Lines matched since the last read, counted, so a sliding window of matched lines never repeats. */
304
+ appendLines(parentID, tracked) {
305
+ const next = this.monitors.linesSince(parentID, tracked.source, tracked.seenLines);
306
+ if (next === undefined)
307
+ return;
308
+ if (next.lines.length > 0)
309
+ tracked.output.append(`${next.lines.join("\n")}\n`);
310
+ tracked.seenLines = next.total;
311
+ }
312
+ async refreshOutput(tracked) {
313
+ const s = this.sessions.get(tracked.info.metadata.sessionID);
314
+ if (s === undefined)
315
+ return;
316
+ if (tracked.kind === "monitor") {
317
+ this.appendLines(s.info.id, tracked);
318
+ return;
319
+ }
320
+ if (tracked.kind !== "eval")
321
+ return;
322
+ try {
323
+ const peek = await this.work.peekEvalCell(s, tracked.source, EVAL_PEEK_BYTES);
324
+ if (typeof peek.outputTail === "string")
325
+ tracked.output.update(peek.outputTail);
326
+ }
327
+ catch (error) {
328
+ // A cell that just settled, or a stopped agent: what is already known stays readable.
329
+ if (!(error instanceof WorkStatusError))
330
+ throw error;
331
+ }
332
+ }
333
+ }
@@ -5,6 +5,7 @@ import type { SessionStore } from "./session-store.js";
5
5
  import { type SubagentBridge } from "./subagents.js";
6
6
  import { type MonitorSessions } from "./monitor-sessions.js";
7
7
  import type { WorkStatusBridge } from "./work-status.js";
8
+ import { type OmoShells } from "./omo-shells.js";
8
9
  import { type TodoUpdated } from "./todo-bridge.js";
9
10
  export interface PiUsage {
10
11
  input?: number;
@@ -42,6 +43,7 @@ interface PiEventServices {
42
43
  readonly subagents: SubagentBridge;
43
44
  readonly monitors: MonitorSessions;
44
45
  readonly workStatus: Pick<WorkStatusBridge, "observe">;
46
+ readonly shells: Pick<OmoShells, "sync" | "noteEnding">;
45
47
  readonly store: Pick<SessionStore, "update">;
46
48
  readonly dirtyDiffs: Set<string>;
47
49
  readonly ensureMessages: (s: ManagedSession) => OCMessageWithParts[];