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