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