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 +25 -0
- package/dist/background-results.d.ts +3 -3
- package/dist/background-results.js +39 -7
- package/dist/lifecycle.js +2 -0
- package/dist/monitor-sessions.d.ts +7 -0
- package/dist/monitor-sessions.js +15 -1
- package/dist/omo-shells.d.ts +80 -0
- package/dist/omo-shells.js +333 -0
- package/dist/pi-event-translation.d.ts +2 -0
- package/dist/pi-event-translation.js +24 -2
- package/dist/routes-v2.js +56 -4
- package/dist/session-history.js +13 -6
- package/dist/sessions.d.ts +6 -0
- package/dist/sessions.js +16 -0
- package/dist/tool-map.d.ts +2 -0
- package/dist/tool-map.js +3 -0
- package/dist/types.d.ts +30 -0
- package/dist/v2.js +47 -1
- package/dist/work-status.d.ts +2 -0
- package/dist/work-status.js +4 -0
- package/extensions/omo-compat/package.json +1 -1
- package/extensions/omo-dag/package.json +1 -1
- package/extensions/omo-work/README.md +8 -15
- package/extensions/omo-work/docs/DESIGN.md +24 -28
- package/extensions/omo-work/package.json +1 -1
- package/extensions/omo-work/service/main.js +0 -10
- package/extensions/omo-work/status/main.js +92 -93
- package/package.json +1 -1
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: {
|
|
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[];
|
package/dist/monitor-sessions.js
CHANGED
|
@@ -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[];
|