antechamber 0.1.1 → 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 +28 -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 +9 -5
  18. package/dist/pi-event-translation.js +233 -79
  19. package/dist/pi-rpc.d.ts +20 -2
  20. package/dist/pi-rpc.js +96 -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 +12 -15
  30. package/dist/session-commands.js +52 -11
  31. package/dist/session-diff.d.ts +1 -1
  32. package/dist/session-diff.js +10 -2
  33. package/dist/session-history.js +108 -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 -1
  38. package/dist/session-tree.js +51 -26
  39. package/dist/sessions.d.ts +32 -5
  40. package/dist/sessions.js +330 -119
  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 +84 -71
  45. package/dist/tool-map.d.ts +3 -2
  46. package/dist/types.d.ts +152 -1
  47. package/dist/v2.d.ts +18 -4
  48. package/dist/v2.js +128 -44
  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
  };
@@ -69,10 +67,8 @@ export declare function parseSlashCommand(value: unknown): SlashCommandInput | u
69
67
  export declare function slashTitlePrompt(text: string): {
70
68
  sessionTitlePrompt?: string;
71
69
  };
72
- export declare function slashCommandPrompt(input: SlashCommandInput): string;
73
70
  export declare function runSlashCommand(sessions: SessionManager, sessionId: string, input: SlashCommandInput): Promise<OCMessageWithParts | undefined>;
74
71
  export declare function parseShellCommand(value: unknown): string | undefined;
75
- export declare function parseShellResult(value: unknown): ShellResult;
76
72
  export declare function createShellMessage(session: ManagedSession, command: string): ShellMessage;
77
73
  export declare function shellUserMessage(message: ShellMessage): ShellUserMessage;
78
74
  export declare function announceSessionNotice(context: ShareNoticeContext, messageText: string, error?: OCAssistantMessage["error"]): void;
@@ -81,3 +77,4 @@ export declare function completeShellMessage(message: ShellMessage, command: str
81
77
  export declare function failShellMessage(message: ShellMessage, command: string, error: string, metadata?: Record<string, unknown>): void;
82
78
  export declare function compactSession(sessions: SessionManager, sessionId: string): Promise<CompactResult>;
83
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,7 +115,14 @@ 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
127
  /**
118
128
  * OmO never auto-titles a first prompt that starts with "/", so a session opened
@@ -123,11 +133,12 @@ export function slashTitlePrompt(text) {
123
133
  const match = /^\s*\/\S+\s+([\s\S]*\S)/.exec(text);
124
134
  return match?.[1] !== undefined && !match[1].startsWith("/") ? { sessionTitlePrompt: match[1] } : {};
125
135
  }
126
- export function slashCommandPrompt(input) {
136
+ function slashCommandPrompt(input) {
127
137
  return input.arguments === "" ? `/${input.command}` : `/${input.command} ${input.arguments}`;
128
138
  }
129
139
  export async function runSlashCommand(sessions, sessionId, input) {
130
- 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);
131
142
  await sessions.reloadMessages(sessionId);
132
143
  const messages = sessions.messages(sessionId) ?? [];
133
144
  return [...messages].reverse().find((message) => message.info.role === "assistant") ?? messages[messages.length - 1];
@@ -137,7 +148,7 @@ export function parseShellCommand(value) {
137
148
  return undefined;
138
149
  return value.command;
139
150
  }
140
- export function parseShellResult(value) {
151
+ function parseShellResult(value) {
141
152
  if (!isRecord(value) ||
142
153
  typeof value.output !== "string" ||
143
154
  (typeof value.exitCode !== "number" &&
@@ -145,7 +156,7 @@ export function parseShellResult(value) {
145
156
  !(value.cancelled === true && value.exitCode === undefined)) ||
146
157
  typeof value.cancelled !== "boolean" ||
147
158
  typeof value.truncated !== "boolean") {
148
- throw new Error("OmO bash returned an invalid result");
159
+ throw new UpstreamProtocolError("OmO bash returned an invalid result");
149
160
  }
150
161
  return {
151
162
  output: value.output,
@@ -265,6 +276,15 @@ export async function runShellCommand(context) {
265
276
  sessions.messages(session.info.id);
266
277
  const message = createShellMessage(session, command);
267
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
+ };
268
288
  const dispatch = (session.tree.shellDispatch ??= {});
269
289
  dispatch[message.info.id] = false;
270
290
  const afterEntryId = session.tree.leafId ?? null;
@@ -285,8 +305,12 @@ export async function runShellCommand(context) {
285
305
  shellEvent(context, { type: "message.updated", properties: { info: user.info } });
286
306
  shellEvent(context, { type: "message.part.updated", properties: { part: user.parts[0] } });
287
307
  emitShellMessage(context, message);
308
+ shellEvent(context, {
309
+ type: "session.shell.started",
310
+ properties: { sessionID: session.info.id, shell: { ...shell, status: "running" } },
311
+ });
288
312
  try {
289
- sessions.update(session.info.id, {});
313
+ await sessions.update(session.info.id, {});
290
314
  const response = await sessions.sessionRpc(session.info.id, { type: "bash", command }, controller.signal, () => {
291
315
  dispatch[message.info.id] = true;
292
316
  // Only actual dispatch admissions are durable; queued attempts stay in the live cache.
@@ -294,7 +318,7 @@ export async function runShellCommand(context) {
294
318
  // A native result can land beyond the last synchronized leaf.
295
319
  if (!session.tree.revert)
296
320
  delete session.tree.leafId;
297
- sessions.update(session.info.id, {});
321
+ void sessions.update(session.info.id, {}).catch((error) => controller.abort(error));
298
322
  });
299
323
  if (controller.signal.aborted)
300
324
  throw controller.signal.reason;
@@ -326,7 +350,24 @@ export async function runShellCommand(context) {
326
350
  }
327
351
  finally {
328
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
+ });
329
370
  setShellStatus(context, "idle");
330
- sessions.update(session.info.id, {});
371
+ await sessions.update(session.info.id, {});
331
372
  }
332
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,6 +2,7 @@
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";
@@ -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 = [];
@@ -133,7 +144,41 @@ export function parsePiSessionText(s, agentName, raw) {
133
144
  }
134
145
  if (e.type === "compaction" && typeof e.summary === "string" && typeof e.firstKeptEntryId === "string") {
135
146
  // Not a run boundary: an automatic compaction can sit between steps of one turn.
136
- out.push(...compactionMessages(s, e.firstKeptEntryId, e.summary, entryTime(e), currentModel));
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
+ });
137
182
  continue;
138
183
  }
139
184
  if (e.type !== "message")
@@ -372,7 +417,9 @@ export function parsePiSessionText(s, agentName, raw) {
372
417
  finishProviderToolParts([...toolParts.values()].flat(), entryTime(lastAssistant), stopReason === "aborted" ? "Cancelled" : "No result");
373
418
  }
374
419
  s.todoBridge = new TodoBridge(sessionID);
375
- 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]]] : []))));
376
423
  // Completions can be appended after the newest message, outside its ancestor chain.
377
424
  for (const entry of entries) {
378
425
  if (entry.customType !== "omo-senpi:wake")
@@ -417,5 +464,24 @@ export function parsePiSessionText(s, agentName, raw) {
417
464
  });
418
465
  (s.inputs ??= new Map()).set(messageID, { text, delivery, accepted: true, delivered: false });
419
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
+ }
420
486
  return out;
421
487
  }
@@ -1,7 +1,7 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { createHash } from "node:crypto";
3
3
  import { once } from "node:events";
4
- import { closeSync, constants, openSync, readFileSync, readdirSync, readlinkSync } from "node:fs";
4
+ import { closeSync, constants, openSync, readFileSync, readdirSync, readlinkSync, realpathSync } from "node:fs";
5
5
  import { basename } from "node:path";
6
6
  import { findPidfdPython } from "./pidfd.js";
7
7
  /** Capability and warning state are captured once, before any shutdown work. */
@@ -144,7 +144,7 @@ async function stopSupportedSessionHosts(created, python) {
144
144
  try {
145
145
  // The original leader must still exist after the process-table scan:
146
146
  // a recycled group number cannot confer its ownership on new members.
147
- return readlinkSync(`/proc/self/fd/${info.fd}/cwd`) === directory;
147
+ return readlinkSync(`/proc/self/fd/${info.fd}/cwd`) === realpathSync(directory);
148
148
  }
149
149
  catch (error) {
150
150
  if (error instanceof Error && "code" in error && ["ENOENT", "ESRCH"].includes(String(error.code)))