antechamber 0.1.0 → 0.2.0
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 +51 -0
- package/README.md +17 -14
- package/bin/opencode-antechamber +10 -1
- package/bin/opencode-pi +11 -1
- package/dist/approvals.d.ts +4 -1
- package/dist/approvals.js +51 -33
- package/dist/catalog.d.ts +1 -1
- package/dist/catalog.js +1 -1
- package/dist/commands/context.d.ts +0 -1
- package/dist/commands/context.js +1 -1
- package/dist/commands/doctor.d.ts +3 -2
- package/dist/commands/serve.d.ts +2 -1
- package/dist/commands/setup.d.ts +2 -3
- package/dist/commands/setup.js +16 -4
- package/dist/http.d.ts +8 -1
- package/dist/http.js +42 -19
- package/dist/pi-event-translation.d.ts +21 -3
- package/dist/pi-event-translation.js +317 -65
- package/dist/pi-rpc.d.ts +26 -2
- package/dist/pi-rpc.js +102 -29
- package/dist/prompt-parts.d.ts +40 -0
- package/dist/prompt-parts.js +157 -0
- package/dist/route-services.d.ts +45 -2
- package/dist/route-services.js +70 -3
- package/dist/routes-v1.js +110 -65
- package/dist/routes-v2.js +376 -73
- package/dist/server.d.ts +9 -1
- package/dist/server.js +109 -9
- package/dist/session-commands.d.ts +20 -15
- package/dist/session-commands.js +61 -11
- package/dist/session-diff.d.ts +1 -1
- package/dist/session-diff.js +10 -2
- package/dist/session-history.js +113 -42
- package/dist/session-hosts.js +2 -2
- package/dist/session-store.d.ts +8 -4
- package/dist/session-store.js +183 -33
- package/dist/session-tree.d.ts +9 -2
- package/dist/session-tree.js +51 -63
- package/dist/sessions.d.ts +34 -6
- package/dist/sessions.js +340 -146
- package/dist/sse.d.ts +16 -6
- package/dist/sse.js +77 -21
- package/dist/subagents.d.ts +5 -0
- package/dist/subagents.js +86 -69
- package/dist/tool-map.d.ts +3 -2
- package/dist/types.d.ts +164 -1
- package/dist/v2.d.ts +18 -4
- package/dist/v2.js +141 -50
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/extensions/omo-dag/README.md +2 -0
- package/extensions/omo-dag/docs/DESIGN.md +10 -1
- package/extensions/omo-dag/panel/index.html +1 -1
- package/extensions/omo-dag/panel/main.js +60 -60
- package/extensions/omo-dag/service/main.js +72 -8
- package/package.json +1 -1
package/dist/server.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
/** HTTP/SSE composition root for the OpenCode API backed by pi. */
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
2
3
|
import { createServer } from "node:http";
|
|
3
4
|
import { debugLog } from "./misc-routes.js";
|
|
4
5
|
import { SessionManager } from "./sessions.js";
|
|
@@ -14,17 +15,22 @@ export class AdapterServer {
|
|
|
14
15
|
sessions;
|
|
15
16
|
sse;
|
|
16
17
|
sseClients = new Map();
|
|
18
|
+
webSocketClients = new Set();
|
|
19
|
+
webSocketEvents = [];
|
|
20
|
+
nextWebSocketEventID = 0;
|
|
17
21
|
catalogPromise;
|
|
18
22
|
actualPort;
|
|
19
|
-
http = new Http();
|
|
23
|
+
http = new Http(() => this.url);
|
|
20
24
|
constructor(options) {
|
|
21
25
|
this.options = options;
|
|
22
26
|
this.sse = new SSE(options, this.sseClients);
|
|
23
27
|
this.sessions = new SessionManager((directory, event) => this.broadcast(directory, event), undefined, options.defaultDirectory);
|
|
24
28
|
this.server = createServer((req, res) => void this.route(req, res));
|
|
29
|
+
this.server.on("upgrade", (req, socket, head) => this.handleUpgrade(req, socket, head));
|
|
25
30
|
}
|
|
26
31
|
catalog() {
|
|
27
32
|
this.catalogPromise ??= loadCatalog(this.options.defaultDirectory).catch((err) => {
|
|
33
|
+
this.catalogPromise = undefined;
|
|
28
34
|
console.error("[catalog] failed to load models from pi:", err);
|
|
29
35
|
return { providers: [], defaults: {} };
|
|
30
36
|
});
|
|
@@ -50,6 +56,9 @@ export class AdapterServer {
|
|
|
50
56
|
// session RPC groups and private task hosts are still being disposed.
|
|
51
57
|
const closed = new Promise((resolve) => this.server.close(() => resolve()));
|
|
52
58
|
this.sse.close();
|
|
59
|
+
for (const socket of this.webSocketClients)
|
|
60
|
+
socket.destroy();
|
|
61
|
+
this.webSocketClients.clear();
|
|
53
62
|
try {
|
|
54
63
|
await this.sessions.disposeAll();
|
|
55
64
|
}
|
|
@@ -64,12 +73,91 @@ export class AdapterServer {
|
|
|
64
73
|
// ---------------------------------------------------------------
|
|
65
74
|
broadcast(directory, event) {
|
|
66
75
|
this.sse.broadcast(directory, event);
|
|
76
|
+
this.broadcastWebSocket(directory, event);
|
|
67
77
|
}
|
|
68
78
|
writeSSE(res, frame) {
|
|
69
79
|
return this.sse.writeSSE(res, frame);
|
|
70
80
|
}
|
|
71
|
-
handleSSE(res, flavor) {
|
|
72
|
-
|
|
81
|
+
handleSSE(req, res, flavor, directory, global) {
|
|
82
|
+
const lastEventID = req.headers["last-event-id"];
|
|
83
|
+
this.sse.handleSSE(res, { flavor, global, ...(global ? {} : { directory }) }, Array.isArray(lastEventID) ? lastEventID[0] : lastEventID, this.writeSSE.bind(this));
|
|
84
|
+
}
|
|
85
|
+
handleUpgrade(req, socket, _head) {
|
|
86
|
+
const url = new URL(req.url ?? "/", this.url);
|
|
87
|
+
if (url.pathname !== "/api/global/event/ws") {
|
|
88
|
+
this.rejectUpgrade(socket, 404, "Not Found");
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (!this.http.allowsUpgrade(req)) {
|
|
92
|
+
this.rejectUpgrade(socket, 403, "Forbidden");
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
const keyHeader = req.headers["sec-websocket-key"];
|
|
96
|
+
const key = Array.isArray(keyHeader) ? keyHeader[0] : keyHeader;
|
|
97
|
+
if (req.headers["sec-websocket-version"] !== "13" || typeof key !== "string") {
|
|
98
|
+
this.rejectUpgrade(socket, 400, "Bad Request");
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
const accept = createHash("sha1").update(`${key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`).digest("base64");
|
|
102
|
+
socket.write([
|
|
103
|
+
"HTTP/1.1 101 Switching Protocols",
|
|
104
|
+
"Upgrade: websocket",
|
|
105
|
+
"Connection: Upgrade",
|
|
106
|
+
`Sec-WebSocket-Accept: ${accept}`,
|
|
107
|
+
"",
|
|
108
|
+
"",
|
|
109
|
+
].join("\r\n"));
|
|
110
|
+
this.webSocketClients.add(socket);
|
|
111
|
+
socket.once("close", () => this.webSocketClients.delete(socket));
|
|
112
|
+
socket.once("error", () => this.webSocketClients.delete(socket));
|
|
113
|
+
const cursor = Number(url.searchParams.get("lastEventId"));
|
|
114
|
+
const replayReset = Number.isSafeInteger(cursor) && cursor >= 0 && cursor < this.nextWebSocketEventID - this.webSocketEvents.length;
|
|
115
|
+
this.writeWebSocket(socket, { type: "ready", ...(replayReset ? { replayReset: true } : {}) });
|
|
116
|
+
if (!Number.isSafeInteger(cursor) || cursor < 0)
|
|
117
|
+
return;
|
|
118
|
+
for (const event of this.webSocketEvents) {
|
|
119
|
+
if (event.id > cursor)
|
|
120
|
+
this.writeWebSocketEvent(socket, event);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
rejectUpgrade(socket, status, message) {
|
|
124
|
+
socket.end(`HTTP/1.1 ${status} ${message}\r\nConnection: close\r\n\r\n`);
|
|
125
|
+
}
|
|
126
|
+
broadcastWebSocket(directory, payload) {
|
|
127
|
+
const event = { id: ++this.nextWebSocketEventID, directory, payload };
|
|
128
|
+
this.webSocketEvents.push(event);
|
|
129
|
+
if (this.webSocketEvents.length > 1024)
|
|
130
|
+
this.webSocketEvents.shift();
|
|
131
|
+
for (const socket of this.webSocketClients)
|
|
132
|
+
this.writeWebSocketEvent(socket, event);
|
|
133
|
+
}
|
|
134
|
+
writeWebSocketEvent(socket, event) {
|
|
135
|
+
this.writeWebSocket(socket, {
|
|
136
|
+
type: "event",
|
|
137
|
+
eventId: String(event.id),
|
|
138
|
+
directory: event.directory,
|
|
139
|
+
scope: "global",
|
|
140
|
+
payload: event.payload,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
writeWebSocket(socket, frame) {
|
|
144
|
+
if (socket.destroyed || !socket.writable) {
|
|
145
|
+
this.webSocketClients.delete(socket);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
const payload = Buffer.from(JSON.stringify(frame));
|
|
149
|
+
const header = payload.length < 126
|
|
150
|
+
? Buffer.from([0x81, payload.length])
|
|
151
|
+
: payload.length <= 0xffff
|
|
152
|
+
? Buffer.from([0x81, 126, payload.length >> 8, payload.length & 0xff])
|
|
153
|
+
: (() => {
|
|
154
|
+
const value = Buffer.alloc(10);
|
|
155
|
+
value[0] = 0x81;
|
|
156
|
+
value[1] = 127;
|
|
157
|
+
value.writeBigUInt64BE(BigInt(payload.length), 2);
|
|
158
|
+
return value;
|
|
159
|
+
})();
|
|
160
|
+
socket.write(Buffer.concat([header, payload]));
|
|
73
161
|
}
|
|
74
162
|
// ---------------------------------------------------------------
|
|
75
163
|
// Router
|
|
@@ -82,18 +170,30 @@ export class AdapterServer {
|
|
|
82
170
|
const url = new URL(req.url ?? "/", this.url);
|
|
83
171
|
const path = url.pathname.replace(/\/+$/, "") || "/";
|
|
84
172
|
const method = req.method ?? "GET";
|
|
85
|
-
const
|
|
173
|
+
const directoryQuery = url.searchParams.get("directory") ??
|
|
86
174
|
url.searchParams.get("location[directory]") ??
|
|
87
|
-
url.searchParams.get("location")
|
|
88
|
-
|
|
89
|
-
|
|
175
|
+
url.searchParams.get("location");
|
|
176
|
+
const directoryHeader = req.headers["x-opencode-directory"];
|
|
177
|
+
const encodedDirectory = Array.isArray(directoryHeader) ? directoryHeader[0] : directoryHeader;
|
|
178
|
+
const directoryEncodingHeader = req.headers["x-opencode-directory-encoding"];
|
|
179
|
+
const directoryEncoding = Array.isArray(directoryEncodingHeader)
|
|
180
|
+
? directoryEncodingHeader[0]
|
|
181
|
+
: directoryEncodingHeader;
|
|
182
|
+
let headerDirectory = encodedDirectory;
|
|
183
|
+
if (encodedDirectory !== undefined &&
|
|
184
|
+
(directoryEncoding?.toLowerCase() === "uri" || /^(?:%|[a-z]%3a)/i.test(encodedDirectory))) {
|
|
185
|
+
headerDirectory = decodeURIComponent(encodedDirectory);
|
|
186
|
+
}
|
|
187
|
+
const directory = directoryQuery ?? headerDirectory ?? this.options.defaultDirectory;
|
|
90
188
|
// SSE stream (v2 clients subscribe at /api/event; v1 at /event)
|
|
91
189
|
if (method === "GET" && (path === "/event" || path === "/global/event")) {
|
|
92
|
-
this.handleSSE(res, "v1");
|
|
190
|
+
this.handleSSE(req, res, "v1", directory, path === "/global/event");
|
|
93
191
|
return;
|
|
94
192
|
}
|
|
95
193
|
if (method === "GET" && path === "/api/event") {
|
|
96
|
-
|
|
194
|
+
// OpenChamber 2.3.2 opens one unscoped /api/event as its global hub (captured);
|
|
195
|
+
// only an explicit directory selector scopes the stream.
|
|
196
|
+
this.handleSSE(req, res, "v2", directory, directoryQuery === null && headerDirectory === undefined);
|
|
97
197
|
return;
|
|
98
198
|
}
|
|
99
199
|
const services = {
|
|
@@ -1,16 +1,18 @@
|
|
|
1
|
+
import type { OCPromptPart } from "./types.js";
|
|
1
2
|
import type { ManagedSession, SessionManager } from "./sessions.js";
|
|
2
3
|
import type { OCAssistantMessage, OCEvent, OCMessageWithParts, OCToolPart, OCTextPart, OCUserMessage } from "./types.js";
|
|
3
|
-
|
|
4
|
+
type CommandDescriptor = {
|
|
4
5
|
name: string;
|
|
5
6
|
description: string;
|
|
6
7
|
template: "";
|
|
7
8
|
};
|
|
8
|
-
|
|
9
|
+
type SlashCommandInput = {
|
|
9
10
|
command: string;
|
|
10
11
|
arguments: string;
|
|
11
12
|
messageID?: string;
|
|
13
|
+
parts?: OCPromptPart[];
|
|
12
14
|
};
|
|
13
|
-
|
|
15
|
+
type ShellResult = {
|
|
14
16
|
output: string;
|
|
15
17
|
exitCode: number | null;
|
|
16
18
|
cancelled: boolean;
|
|
@@ -20,40 +22,36 @@ export type ShellMessage = OCMessageWithParts & {
|
|
|
20
22
|
info: OCAssistantMessage;
|
|
21
23
|
parts: [OCToolPart];
|
|
22
24
|
};
|
|
23
|
-
|
|
25
|
+
type ShellUserMessage = OCMessageWithParts & {
|
|
24
26
|
info: OCUserMessage;
|
|
25
27
|
parts: [OCTextPart];
|
|
26
28
|
};
|
|
27
|
-
|
|
28
|
-
info: OCAssistantMessage;
|
|
29
|
-
parts: [OCTextPart];
|
|
30
|
-
};
|
|
31
|
-
export type ShellExecution = {
|
|
29
|
+
type ShellExecution = {
|
|
32
30
|
kind: "completed";
|
|
33
31
|
message: ShellMessage;
|
|
34
32
|
} | {
|
|
35
33
|
kind: "error";
|
|
36
34
|
message: string;
|
|
37
35
|
};
|
|
38
|
-
|
|
36
|
+
interface ShellCommandContext {
|
|
39
37
|
sessions: SessionManager;
|
|
40
38
|
session: ManagedSession;
|
|
41
39
|
command: string;
|
|
42
40
|
emit: (directory: string, event: OCEvent) => void;
|
|
43
41
|
}
|
|
44
|
-
|
|
42
|
+
interface ShareNoticeContext {
|
|
45
43
|
sessions: SessionManager;
|
|
46
44
|
session: ManagedSession;
|
|
47
45
|
emit: (directory: string, event: OCEvent) => void;
|
|
48
46
|
}
|
|
49
|
-
|
|
47
|
+
type CompactResult = {
|
|
50
48
|
kind: "compacted";
|
|
51
49
|
} | {
|
|
52
50
|
kind: "error";
|
|
53
51
|
message: string;
|
|
54
52
|
};
|
|
55
53
|
export declare const unsupportedShare: {
|
|
56
|
-
readonly name: "
|
|
54
|
+
readonly name: "UnsupportedError";
|
|
57
55
|
readonly data: {
|
|
58
56
|
readonly message: "Sharing is not available with OmO";
|
|
59
57
|
};
|
|
@@ -61,10 +59,16 @@ export declare const unsupportedShare: {
|
|
|
61
59
|
export declare function shellTitle(command: string): string;
|
|
62
60
|
export declare function getCommands(directory: string): Promise<CommandDescriptor[]>;
|
|
63
61
|
export declare function parseSlashCommand(value: unknown): SlashCommandInput | undefined;
|
|
64
|
-
|
|
62
|
+
/**
|
|
63
|
+
* OmO never auto-titles a first prompt that starts with "/", so a session opened
|
|
64
|
+
* with `/skill:plan Build the importer` kept its first-message title. Title from
|
|
65
|
+
* the command's arguments instead; a bare command has nothing to title from.
|
|
66
|
+
*/
|
|
67
|
+
export declare function slashTitlePrompt(text: string): {
|
|
68
|
+
sessionTitlePrompt?: string;
|
|
69
|
+
};
|
|
65
70
|
export declare function runSlashCommand(sessions: SessionManager, sessionId: string, input: SlashCommandInput): Promise<OCMessageWithParts | undefined>;
|
|
66
71
|
export declare function parseShellCommand(value: unknown): string | undefined;
|
|
67
|
-
export declare function parseShellResult(value: unknown): ShellResult;
|
|
68
72
|
export declare function createShellMessage(session: ManagedSession, command: string): ShellMessage;
|
|
69
73
|
export declare function shellUserMessage(message: ShellMessage): ShellUserMessage;
|
|
70
74
|
export declare function announceSessionNotice(context: ShareNoticeContext, messageText: string, error?: OCAssistantMessage["error"]): void;
|
|
@@ -73,3 +77,4 @@ export declare function completeShellMessage(message: ShellMessage, command: str
|
|
|
73
77
|
export declare function failShellMessage(message: ShellMessage, command: string, error: string, metadata?: Record<string, unknown>): void;
|
|
74
78
|
export declare function compactSession(sessions: SessionManager, sessionId: string): Promise<CompactResult>;
|
|
75
79
|
export declare function runShellCommand(context: ShellCommandContext): Promise<ShellExecution>;
|
|
80
|
+
export {};
|
package/dist/session-commands.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { PiRpcClient } from "./pi-rpc.js";
|
|
2
|
+
import { PiRpcClient, UpstreamProtocolError } from "./pi-rpc.js";
|
|
3
3
|
import { getAgentName } from "./agent-name.js";
|
|
4
|
+
import { nativePromptInput, parsePromptParts } from "./prompt-parts.js";
|
|
4
5
|
// OpenChamber drops rejected Share actions, so emit a visible session notice too.
|
|
5
6
|
export const unsupportedShare = {
|
|
6
|
-
name: "
|
|
7
|
+
name: "UnsupportedError",
|
|
7
8
|
data: { message: "Sharing is not available with OmO" },
|
|
8
9
|
};
|
|
9
10
|
function isRecord(value) {
|
|
@@ -12,8 +13,10 @@ function isRecord(value) {
|
|
|
12
13
|
function id(prefix) {
|
|
13
14
|
return `${prefix}_${randomUUID().replaceAll("-", "")}`;
|
|
14
15
|
}
|
|
16
|
+
const SHELL_TITLE_MAX_CHARS = 60;
|
|
17
|
+
const SHELL_TITLE_TRUNCATED_CHARS = 57;
|
|
15
18
|
export function shellTitle(command) {
|
|
16
|
-
const short = command.length >
|
|
19
|
+
const short = command.length > SHELL_TITLE_MAX_CHARS ? `${command.slice(0, SHELL_TITLE_TRUNCATED_CHARS)}…` : command;
|
|
17
20
|
return `bash: ${short}`;
|
|
18
21
|
}
|
|
19
22
|
function assistantMessage(session) {
|
|
@@ -112,13 +115,30 @@ export function parseSlashCommand(value) {
|
|
|
112
115
|
return undefined;
|
|
113
116
|
// OpenChamber inserts the command's user message optimistically under this id.
|
|
114
117
|
const messageID = typeof value.messageID === "string" && value.messageID !== "" ? value.messageID : undefined;
|
|
115
|
-
|
|
118
|
+
const parts = parsePromptParts(value.parts);
|
|
119
|
+
nativePromptInput("", parts);
|
|
120
|
+
return {
|
|
121
|
+
command: value.command,
|
|
122
|
+
arguments: value.arguments,
|
|
123
|
+
...(messageID ? { messageID } : {}),
|
|
124
|
+
...(parts.length > 0 ? { parts } : {}),
|
|
125
|
+
};
|
|
116
126
|
}
|
|
117
|
-
|
|
127
|
+
/**
|
|
128
|
+
* OmO never auto-titles a first prompt that starts with "/", so a session opened
|
|
129
|
+
* with `/skill:plan Build the importer` kept its first-message title. Title from
|
|
130
|
+
* the command's arguments instead; a bare command has nothing to title from.
|
|
131
|
+
*/
|
|
132
|
+
export function slashTitlePrompt(text) {
|
|
133
|
+
const match = /^\s*\/\S+\s+([\s\S]*\S)/.exec(text);
|
|
134
|
+
return match?.[1] !== undefined && !match[1].startsWith("/") ? { sessionTitlePrompt: match[1] } : {};
|
|
135
|
+
}
|
|
136
|
+
function slashCommandPrompt(input) {
|
|
118
137
|
return input.arguments === "" ? `/${input.command}` : `/${input.command} ${input.arguments}`;
|
|
119
138
|
}
|
|
120
139
|
export async function runSlashCommand(sessions, sessionId, input) {
|
|
121
|
-
|
|
140
|
+
const text = slashCommandPrompt(input);
|
|
141
|
+
await sessions.prompt(sessionId, text, undefined, false, input.messageID, "steer", input.parts ? [{ type: "text", text }, ...input.parts] : undefined);
|
|
122
142
|
await sessions.reloadMessages(sessionId);
|
|
123
143
|
const messages = sessions.messages(sessionId) ?? [];
|
|
124
144
|
return [...messages].reverse().find((message) => message.info.role === "assistant") ?? messages[messages.length - 1];
|
|
@@ -128,7 +148,7 @@ export function parseShellCommand(value) {
|
|
|
128
148
|
return undefined;
|
|
129
149
|
return value.command;
|
|
130
150
|
}
|
|
131
|
-
|
|
151
|
+
function parseShellResult(value) {
|
|
132
152
|
if (!isRecord(value) ||
|
|
133
153
|
typeof value.output !== "string" ||
|
|
134
154
|
(typeof value.exitCode !== "number" &&
|
|
@@ -136,7 +156,7 @@ export function parseShellResult(value) {
|
|
|
136
156
|
!(value.cancelled === true && value.exitCode === undefined)) ||
|
|
137
157
|
typeof value.cancelled !== "boolean" ||
|
|
138
158
|
typeof value.truncated !== "boolean") {
|
|
139
|
-
throw new
|
|
159
|
+
throw new UpstreamProtocolError("OmO bash returned an invalid result");
|
|
140
160
|
}
|
|
141
161
|
return {
|
|
142
162
|
output: value.output,
|
|
@@ -256,6 +276,15 @@ export async function runShellCommand(context) {
|
|
|
256
276
|
sessions.messages(session.info.id);
|
|
257
277
|
const message = createShellMessage(session, command);
|
|
258
278
|
const user = shellUserMessage(message);
|
|
279
|
+
const shell = {
|
|
280
|
+
id: message.parts[0].callID,
|
|
281
|
+
command,
|
|
282
|
+
cwd: session.info.directory,
|
|
283
|
+
shell: "bash",
|
|
284
|
+
file: "",
|
|
285
|
+
metadata: {},
|
|
286
|
+
time: { started: message.info.time.created },
|
|
287
|
+
};
|
|
259
288
|
const dispatch = (session.tree.shellDispatch ??= {});
|
|
260
289
|
dispatch[message.info.id] = false;
|
|
261
290
|
const afterEntryId = session.tree.leafId ?? null;
|
|
@@ -276,8 +305,12 @@ export async function runShellCommand(context) {
|
|
|
276
305
|
shellEvent(context, { type: "message.updated", properties: { info: user.info } });
|
|
277
306
|
shellEvent(context, { type: "message.part.updated", properties: { part: user.parts[0] } });
|
|
278
307
|
emitShellMessage(context, message);
|
|
308
|
+
shellEvent(context, {
|
|
309
|
+
type: "session.shell.started",
|
|
310
|
+
properties: { sessionID: session.info.id, shell: { ...shell, status: "running" } },
|
|
311
|
+
});
|
|
279
312
|
try {
|
|
280
|
-
sessions.update(session.info.id, {});
|
|
313
|
+
await sessions.update(session.info.id, {});
|
|
281
314
|
const response = await sessions.sessionRpc(session.info.id, { type: "bash", command }, controller.signal, () => {
|
|
282
315
|
dispatch[message.info.id] = true;
|
|
283
316
|
// Only actual dispatch admissions are durable; queued attempts stay in the live cache.
|
|
@@ -285,7 +318,7 @@ export async function runShellCommand(context) {
|
|
|
285
318
|
// A native result can land beyond the last synchronized leaf.
|
|
286
319
|
if (!session.tree.revert)
|
|
287
320
|
delete session.tree.leafId;
|
|
288
|
-
sessions.update(session.info.id, {});
|
|
321
|
+
void sessions.update(session.info.id, {}).catch((error) => controller.abort(error));
|
|
289
322
|
});
|
|
290
323
|
if (controller.signal.aborted)
|
|
291
324
|
throw controller.signal.reason;
|
|
@@ -317,7 +350,24 @@ export async function runShellCommand(context) {
|
|
|
317
350
|
}
|
|
318
351
|
finally {
|
|
319
352
|
delete session.cancelShell;
|
|
353
|
+
const state = message.parts[0].state;
|
|
354
|
+
const metadata = state.status === "pending" ? undefined : state.metadata;
|
|
355
|
+
const output = state.status === "completed" ? state.output : state.status === "error" ? state.error : "";
|
|
356
|
+
const size = Buffer.byteLength(output);
|
|
357
|
+
shellEvent(context, {
|
|
358
|
+
type: "session.shell.ended",
|
|
359
|
+
properties: {
|
|
360
|
+
sessionID: session.info.id,
|
|
361
|
+
shell: {
|
|
362
|
+
...shell,
|
|
363
|
+
status: state.status === "completed" ? "exited" : "killed",
|
|
364
|
+
...(typeof metadata?.exitCode === "number" ? { exit: metadata.exitCode } : {}),
|
|
365
|
+
time: { ...shell.time, completed: message.info.time.completed ?? Date.now() },
|
|
366
|
+
},
|
|
367
|
+
output: { output, cursor: size, size, truncated: metadata?.truncated === true },
|
|
368
|
+
},
|
|
369
|
+
});
|
|
320
370
|
setShellStatus(context, "idle");
|
|
321
|
-
sessions.update(session.info.id, {});
|
|
371
|
+
await sessions.update(session.info.id, {});
|
|
322
372
|
}
|
|
323
373
|
}
|
package/dist/session-diff.d.ts
CHANGED
|
@@ -11,4 +11,4 @@ export interface SessionFileDiff {
|
|
|
11
11
|
status: "added" | "modified" | "deleted";
|
|
12
12
|
}
|
|
13
13
|
export declare function touchedFiles(parts: readonly OCPart[]): string[];
|
|
14
|
-
export declare function sessionDiff(directory: string, messages: readonly OCMessageWithParts[]): Promise<SessionFileDiff[]>;
|
|
14
|
+
export declare function sessionDiff(directory: string, messages: readonly OCMessageWithParts[], context?: number): Promise<SessionFileDiff[]>;
|
package/dist/session-diff.js
CHANGED
|
@@ -3,6 +3,8 @@ import { readFile, realpath, lstat } from "node:fs/promises";
|
|
|
3
3
|
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
4
4
|
import { promisify } from "node:util";
|
|
5
5
|
const exec = promisify(execFile);
|
|
6
|
+
const GIT_EXEC_TIMEOUT_MS = 5_000;
|
|
7
|
+
const GIT_EXEC_MAX_BUFFER_BYTES = 16 * 1024 * 1024;
|
|
6
8
|
export function touchedFiles(parts) {
|
|
7
9
|
return [
|
|
8
10
|
...new Set(parts.flatMap((part) => part.type === "tool" &&
|
|
@@ -14,7 +16,11 @@ export function touchedFiles(parts) {
|
|
|
14
16
|
];
|
|
15
17
|
}
|
|
16
18
|
async function git(directory, args) {
|
|
17
|
-
const result = await exec("git", args, {
|
|
19
|
+
const result = await exec("git", args, {
|
|
20
|
+
cwd: directory,
|
|
21
|
+
timeout: GIT_EXEC_TIMEOUT_MS,
|
|
22
|
+
maxBuffer: GIT_EXEC_MAX_BUFFER_BYTES,
|
|
23
|
+
});
|
|
18
24
|
return result.stdout;
|
|
19
25
|
}
|
|
20
26
|
/** A removed file may be absent, but neither traversal nor symlinks may escape. */
|
|
@@ -49,7 +55,7 @@ async function confinedFile(directory, path) {
|
|
|
49
55
|
}
|
|
50
56
|
return absolute;
|
|
51
57
|
}
|
|
52
|
-
export async function sessionDiff(directory, messages) {
|
|
58
|
+
export async function sessionDiff(directory, messages, context = 3) {
|
|
53
59
|
let gitRoot;
|
|
54
60
|
try {
|
|
55
61
|
gitRoot = (await git(directory, ["rev-parse", "--show-toplevel"])).trim();
|
|
@@ -104,6 +110,7 @@ export async function sessionDiff(directory, messages) {
|
|
|
104
110
|
"HEAD",
|
|
105
111
|
"--no-ext-diff",
|
|
106
112
|
"--no-color",
|
|
113
|
+
`--unified=${Math.floor(context)}`,
|
|
107
114
|
"--src-prefix=a/",
|
|
108
115
|
"--dst-prefix=b/",
|
|
109
116
|
"--",
|
|
@@ -118,6 +125,7 @@ export async function sessionDiff(directory, messages) {
|
|
|
118
125
|
"--no-index",
|
|
119
126
|
"--no-ext-diff",
|
|
120
127
|
"--no-color",
|
|
128
|
+
`--unified=${Math.floor(context)}`,
|
|
121
129
|
"--src-prefix=a/",
|
|
122
130
|
"--dst-prefix=b/",
|
|
123
131
|
"--",
|
package/dist/session-history.js
CHANGED
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
3
|
import { applyProviderToolResults, finishProviderToolParts, mapTool, providerToolParts, resultMetadata, resultText, } from "./tool-map.js";
|
|
4
4
|
import { TodoBridge } from "./todo-bridge.js";
|
|
5
|
+
import { contextMessageID } from "./prompt-parts.js";
|
|
5
6
|
import { bindMessageEntries } from "./session-tree.js";
|
|
6
7
|
import { taskParts, updateTaskParts, taskCompletions } from "./subagents.js";
|
|
7
8
|
import { shellTitle, shellUserMessage } from "./session-commands.js";
|
|
8
|
-
import { piModelId, piStopReason, textOf } from "./pi-event-translation.js";
|
|
9
|
+
import { compactionMessages, piModelId, piStopReason, textOf } from "./pi-event-translation.js";
|
|
9
10
|
function entryTime(e) {
|
|
10
11
|
if (typeof e.timestamp === "number")
|
|
11
12
|
return e.timestamp;
|
|
@@ -38,9 +39,7 @@ export function parsePiSessionFile(s, agentName) {
|
|
|
38
39
|
}
|
|
39
40
|
return parsePiSessionText(s, agentName, raw);
|
|
40
41
|
}
|
|
41
|
-
|
|
42
|
-
const sessionID = s.info.id;
|
|
43
|
-
const directory = s.info.directory;
|
|
42
|
+
function parseEntries(raw) {
|
|
44
43
|
const entries = [];
|
|
45
44
|
for (const line of raw.split("\n")) {
|
|
46
45
|
if (line.length === 0)
|
|
@@ -54,12 +53,43 @@ export function parsePiSessionText(s, agentName, raw) {
|
|
|
54
53
|
/* torn trailing write — ignore */
|
|
55
54
|
}
|
|
56
55
|
}
|
|
57
|
-
|
|
58
|
-
|
|
56
|
+
return entries;
|
|
57
|
+
}
|
|
58
|
+
function nativeTree(entries, leaf) {
|
|
59
|
+
return {
|
|
60
|
+
leafId: leaf?.id ?? null,
|
|
61
|
+
entries: entries.flatMap((entry) => {
|
|
62
|
+
if (entry.id === undefined || entry.type === undefined)
|
|
63
|
+
return [];
|
|
64
|
+
const message = entry.message;
|
|
65
|
+
return [
|
|
66
|
+
{
|
|
67
|
+
type: entry.type,
|
|
68
|
+
id: entry.id,
|
|
69
|
+
parentId: entry.parentId ?? null,
|
|
70
|
+
...(message?.role === undefined
|
|
71
|
+
? {}
|
|
72
|
+
: {
|
|
73
|
+
message: {
|
|
74
|
+
role: message.role,
|
|
75
|
+
...(message.clientMessageId === undefined ? {} : { clientMessageId: message.clientMessageId }),
|
|
76
|
+
...(message.role === "user" ? { content: [{ type: "text", text: textOf(message.content) }] } : {}),
|
|
77
|
+
...(message.command === undefined ? {} : { command: message.command }),
|
|
78
|
+
...(message.output === undefined ? {} : { output: message.output }),
|
|
79
|
+
...(message.exitCode === undefined ? {} : { exitCode: message.exitCode }),
|
|
80
|
+
...(message.cancelled === undefined ? {} : { cancelled: message.cancelled }),
|
|
81
|
+
},
|
|
82
|
+
}),
|
|
83
|
+
},
|
|
84
|
+
];
|
|
85
|
+
}),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
function activeChain(s, entries) {
|
|
59
89
|
const byId = new Map();
|
|
60
|
-
for (const
|
|
61
|
-
if (typeof
|
|
62
|
-
byId.set(
|
|
90
|
+
for (const entry of entries)
|
|
91
|
+
if (typeof entry.id === "string")
|
|
92
|
+
byId.set(entry.id, entry);
|
|
63
93
|
let leaf = [...entries].reverse().find((entry) => entry.type !== "session" && typeof entry.id === "string");
|
|
64
94
|
let selectedLeaf = s.tree.revert ? s.tree.revert.previousLeafId : s.tree.leafId;
|
|
65
95
|
if (!s.tree.revert && s.tree.localShellTurns?.length) {
|
|
@@ -77,40 +107,21 @@ export function parsePiSessionText(s, agentName, raw) {
|
|
|
77
107
|
leaf = selectedLeaf === null ? undefined : byId.get(selectedLeaf);
|
|
78
108
|
const chain = [];
|
|
79
109
|
const seen = new Set();
|
|
80
|
-
for (let
|
|
81
|
-
seen.add(
|
|
82
|
-
chain.push(
|
|
83
|
-
|
|
110
|
+
for (let entry = leaf; entry !== undefined && !seen.has(entry);) {
|
|
111
|
+
seen.add(entry);
|
|
112
|
+
chain.push(entry);
|
|
113
|
+
entry = typeof entry.parentId === "string" ? byId.get(entry.parentId) : undefined;
|
|
84
114
|
}
|
|
85
115
|
chain.reverse();
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
id: entry.id,
|
|
96
|
-
parentId: entry.parentId ?? null,
|
|
97
|
-
...(m?.role === undefined
|
|
98
|
-
? {}
|
|
99
|
-
: {
|
|
100
|
-
message: {
|
|
101
|
-
role: m.role,
|
|
102
|
-
...(m.clientMessageId === undefined ? {} : { clientMessageId: m.clientMessageId }),
|
|
103
|
-
...(m.role === "user" ? { content: [{ type: "text", text: textOf(m.content) }] } : {}),
|
|
104
|
-
...(m.command === undefined ? {} : { command: m.command }),
|
|
105
|
-
...(m.output === undefined ? {} : { output: m.output }),
|
|
106
|
-
...(m.exitCode === undefined ? {} : { exitCode: m.exitCode }),
|
|
107
|
-
...(m.cancelled === undefined ? {} : { cancelled: m.cancelled }),
|
|
108
|
-
},
|
|
109
|
-
}),
|
|
110
|
-
},
|
|
111
|
-
];
|
|
112
|
-
}),
|
|
113
|
-
};
|
|
116
|
+
return { chain, nativeTree: nativeTree(entries, leaf) };
|
|
117
|
+
}
|
|
118
|
+
export function parsePiSessionText(s, agentName, raw) {
|
|
119
|
+
const sessionID = s.info.id;
|
|
120
|
+
const directory = s.info.directory;
|
|
121
|
+
const entries = parseEntries(raw);
|
|
122
|
+
const indexOf = new Map();
|
|
123
|
+
entries.forEach((e, i) => indexOf.set(e, i));
|
|
124
|
+
const { chain, nativeTree } = activeChain(s, entries);
|
|
114
125
|
if (s.tree.localShellTurns?.length)
|
|
115
126
|
bindMessageEntries(s, nativeTree);
|
|
116
127
|
const out = [];
|
|
@@ -131,6 +142,45 @@ export function parsePiSessionText(s, agentName, raw) {
|
|
|
131
142
|
currentModel = { providerID: e.provider, modelID: e.modelId };
|
|
132
143
|
continue;
|
|
133
144
|
}
|
|
145
|
+
if (e.type === "compaction" && typeof e.summary === "string" && typeof e.firstKeptEntryId === "string") {
|
|
146
|
+
// Not a run boundary: an automatic compaction can sit between steps of one turn.
|
|
147
|
+
out.push(...compactionMessages(s, e.id ?? `r${indexOf.get(e) ?? 0}`, e.summary, entryTime(e), currentModel));
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
const contextID = contextMessageID(e);
|
|
151
|
+
if (contextID !== undefined) {
|
|
152
|
+
endRun();
|
|
153
|
+
const content = Array.isArray(e.content)
|
|
154
|
+
? e.content
|
|
155
|
+
: typeof e.content === "string"
|
|
156
|
+
? [{ type: "text", text: e.content }]
|
|
157
|
+
: [];
|
|
158
|
+
const time = entryTime(e);
|
|
159
|
+
if (e.id)
|
|
160
|
+
s.tree.messageEntries[contextID] = e.id;
|
|
161
|
+
out.push({
|
|
162
|
+
info: {
|
|
163
|
+
id: contextID,
|
|
164
|
+
sessionID,
|
|
165
|
+
role: "user",
|
|
166
|
+
time: { created: time },
|
|
167
|
+
agent: agentName,
|
|
168
|
+
model: currentModel,
|
|
169
|
+
synthetic: true,
|
|
170
|
+
},
|
|
171
|
+
parts: [
|
|
172
|
+
{
|
|
173
|
+
id: `prt_${contextID}_context`,
|
|
174
|
+
sessionID,
|
|
175
|
+
messageID: contextID,
|
|
176
|
+
type: "text",
|
|
177
|
+
text: textOf(content),
|
|
178
|
+
time: { start: time, end: time },
|
|
179
|
+
},
|
|
180
|
+
],
|
|
181
|
+
});
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
134
184
|
if (e.type !== "message")
|
|
135
185
|
continue;
|
|
136
186
|
const m = e.message;
|
|
@@ -367,7 +417,9 @@ export function parsePiSessionText(s, agentName, raw) {
|
|
|
367
417
|
finishProviderToolParts([...toolParts.values()].flat(), entryTime(lastAssistant), stopReason === "aborted" ? "Cancelled" : "No result");
|
|
368
418
|
}
|
|
369
419
|
s.todoBridge = new TodoBridge(sessionID);
|
|
370
|
-
|
|
420
|
+
// Tree-less snapshots predate branch selection and have no competing ancestor chain.
|
|
421
|
+
const todoEntries = chain.length === 0 && s.tree.leafId === undefined && !s.tree.revert ? entries : chain;
|
|
422
|
+
s.todoBridge.restore(todoEntries, new Map([...toolParts].flatMap(([callID, parts]) => (parts[0] ? [[callID, parts[0]]] : []))));
|
|
371
423
|
// Completions can be appended after the newest message, outside its ancestor chain.
|
|
372
424
|
for (const entry of entries) {
|
|
373
425
|
if (entry.customType !== "omo-senpi:wake")
|
|
@@ -412,5 +464,24 @@ export function parsePiSessionText(s, agentName, raw) {
|
|
|
412
464
|
});
|
|
413
465
|
(s.inputs ??= new Map()).set(messageID, { text, delivery, accepted: true, delivered: false });
|
|
414
466
|
}
|
|
467
|
+
for (const message of out) {
|
|
468
|
+
const metadata = s.tree.promptMetadata?.[message.info.id];
|
|
469
|
+
if (message.info.role === "user" && metadata !== undefined)
|
|
470
|
+
message.info.metadata = metadata;
|
|
471
|
+
const inputParts = s.tree.promptParts?.[message.info.id];
|
|
472
|
+
if (message.info.role === "user" && inputParts !== undefined)
|
|
473
|
+
message.parts = inputParts.map((part, index) => ({
|
|
474
|
+
...part,
|
|
475
|
+
id: part.id ?? `prt_${message.info.id}_${index}`,
|
|
476
|
+
sessionID,
|
|
477
|
+
messageID: message.info.id,
|
|
478
|
+
}));
|
|
479
|
+
const identities = s.tree.messageParts?.[message.info.id];
|
|
480
|
+
for (const [index, part] of message.parts.entries()) {
|
|
481
|
+
const identity = identities?.[index];
|
|
482
|
+
if (identity !== undefined)
|
|
483
|
+
part.id = identity;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
415
486
|
return out;
|
|
416
487
|
}
|