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.
Files changed (56) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/README.md +17 -14
  3. package/bin/opencode-antechamber +10 -1
  4. package/bin/opencode-pi +11 -1
  5. package/dist/approvals.d.ts +4 -1
  6. package/dist/approvals.js +51 -33
  7. package/dist/catalog.d.ts +1 -1
  8. package/dist/catalog.js +1 -1
  9. package/dist/commands/context.d.ts +0 -1
  10. package/dist/commands/context.js +1 -1
  11. package/dist/commands/doctor.d.ts +3 -2
  12. package/dist/commands/serve.d.ts +2 -1
  13. package/dist/commands/setup.d.ts +2 -3
  14. package/dist/commands/setup.js +16 -4
  15. package/dist/http.d.ts +8 -1
  16. package/dist/http.js +42 -19
  17. package/dist/pi-event-translation.d.ts +21 -3
  18. package/dist/pi-event-translation.js +317 -65
  19. package/dist/pi-rpc.d.ts +26 -2
  20. package/dist/pi-rpc.js +102 -29
  21. package/dist/prompt-parts.d.ts +40 -0
  22. package/dist/prompt-parts.js +157 -0
  23. package/dist/route-services.d.ts +45 -2
  24. package/dist/route-services.js +70 -3
  25. package/dist/routes-v1.js +110 -65
  26. package/dist/routes-v2.js +376 -73
  27. package/dist/server.d.ts +9 -1
  28. package/dist/server.js +109 -9
  29. package/dist/session-commands.d.ts +20 -15
  30. package/dist/session-commands.js +61 -11
  31. package/dist/session-diff.d.ts +1 -1
  32. package/dist/session-diff.js +10 -2
  33. package/dist/session-history.js +113 -42
  34. package/dist/session-hosts.js +2 -2
  35. package/dist/session-store.d.ts +8 -4
  36. package/dist/session-store.js +183 -33
  37. package/dist/session-tree.d.ts +9 -2
  38. package/dist/session-tree.js +51 -63
  39. package/dist/sessions.d.ts +34 -6
  40. package/dist/sessions.js +340 -146
  41. package/dist/sse.d.ts +16 -6
  42. package/dist/sse.js +77 -21
  43. package/dist/subagents.d.ts +5 -0
  44. package/dist/subagents.js +86 -69
  45. package/dist/tool-map.d.ts +3 -2
  46. package/dist/types.d.ts +164 -1
  47. package/dist/v2.d.ts +18 -4
  48. package/dist/v2.js +141 -50
  49. package/dist/version.d.ts +1 -1
  50. package/dist/version.js +1 -1
  51. package/extensions/omo-dag/README.md +2 -0
  52. package/extensions/omo-dag/docs/DESIGN.md +10 -1
  53. package/extensions/omo-dag/panel/index.html +1 -1
  54. package/extensions/omo-dag/panel/main.js +60 -60
  55. package/extensions/omo-dag/service/main.js +72 -8
  56. 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
- this.sse.handleSSE(res, flavor, this.writeSSE.bind(this));
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 directory = url.searchParams.get("directory") ??
173
+ const directoryQuery = url.searchParams.get("directory") ??
86
174
  url.searchParams.get("location[directory]") ??
87
- url.searchParams.get("location") ??
88
- req.headers["x-opencode-directory"] ??
89
- this.options.defaultDirectory;
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
- this.handleSSE(res, "v2");
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
- export type CommandDescriptor = {
4
+ type CommandDescriptor = {
4
5
  name: string;
5
6
  description: string;
6
7
  template: "";
7
8
  };
8
- export type SlashCommandInput = {
9
+ type SlashCommandInput = {
9
10
  command: string;
10
11
  arguments: string;
11
12
  messageID?: string;
13
+ parts?: OCPromptPart[];
12
14
  };
13
- export type ShellResult = {
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
- export type ShellUserMessage = OCMessageWithParts & {
25
+ type ShellUserMessage = OCMessageWithParts & {
24
26
  info: OCUserMessage;
25
27
  parts: [OCTextPart];
26
28
  };
27
- export type ShareNoticeMessage = OCMessageWithParts & {
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
- export interface ShellCommandContext {
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
- export interface ShareNoticeContext {
42
+ interface ShareNoticeContext {
45
43
  sessions: SessionManager;
46
44
  session: ManagedSession;
47
45
  emit: (directory: string, event: OCEvent) => void;
48
46
  }
49
- export type CompactResult = {
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: "BadRequestError";
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
- export declare function slashCommandPrompt(input: SlashCommandInput): string;
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 {};
@@ -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: "BadRequestError",
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 > 60 ? `${command.slice(0, 57)}…` : command;
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
- return { command: value.command, arguments: value.arguments, ...(messageID ? { messageID } : {}) };
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
- export function slashCommandPrompt(input) {
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
- await sessions.prompt(sessionId, slashCommandPrompt(input), undefined, false, input.messageID);
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
- export function parseShellResult(value) {
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 Error("OmO bash returned an invalid result");
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
  }
@@ -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[]>;
@@ -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, { cwd: directory, timeout: 5000, maxBuffer: 16 * 1024 * 1024 });
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
  "--",
@@ -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
- export function parsePiSessionText(s, agentName, raw) {
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
- const indexOf = new Map();
58
- entries.forEach((e, i) => indexOf.set(e, i));
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 e of entries)
61
- if (typeof e.id === "string")
62
- byId.set(e.id, e);
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 cur = leaf; cur !== undefined && !seen.has(cur);) {
81
- seen.add(cur);
82
- chain.push(cur);
83
- cur = typeof cur.parentId === "string" ? byId.get(cur.parentId) : undefined;
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
- const nativeTree = {
87
- leafId: leaf?.id ?? null,
88
- entries: entries.flatMap((entry) => {
89
- if (entry.id === undefined || entry.type === undefined)
90
- return [];
91
- const m = entry.message;
92
- return [
93
- {
94
- type: entry.type,
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
- s.todoBridge.restore(entries, new Map([...toolParts].flatMap(([callID, parts]) => (parts[0] ? [[callID, parts[0]]] : []))));
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
  }