@omercnet/paseo-omp 0.4.0 → 1.0.0-next.160.1

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 (40) hide show
  1. package/README.md +8 -8
  2. package/client/external-url.ts +2 -11
  3. package/client/omp-config-styles.ts +284 -0
  4. package/client/omp-config-surface.tsx +16 -286
  5. package/client/omp-routing-editor.tsx +1 -3
  6. package/client/provider-launch-settings.tsx +118 -0
  7. package/index.client.tsx +21 -2
  8. package/index.server.ts +10 -0
  9. package/package.json +14 -9
  10. package/paseo-plugin.json +2 -1
  11. package/scripts/prepare-dependencies.mjs +44 -11
  12. package/server/omp-models.ts +2 -6
  13. package/server/package-version.ts +1 -1
  14. package/server/protocol-violation-diagnostics.ts +1 -1
  15. package/server/provider/catalog.ts +2 -1
  16. package/server/provider/config-normalization.ts +19 -3
  17. package/server/provider/connection.ts +30 -0
  18. package/server/provider/host-tools.ts +2 -2
  19. package/server/provider/mcp-transport.ts +9 -2
  20. package/server/provider/omp-rpc-environment.ts +368 -0
  21. package/server/provider/omp-rpc-process.ts +119 -0
  22. package/server/provider/omp-rpc-protocol.ts +1949 -0
  23. package/server/provider/omp-rpc-transport.ts +1373 -0
  24. package/server/provider/omp-rpc-values.ts +31 -0
  25. package/server/provider/omp-rpc.ts +267 -3311
  26. package/server/provider/profile-providers.ts +2 -1
  27. package/server/provider/prompt-payload.ts +253 -0
  28. package/server/provider/registration.ts +10 -2
  29. package/server/provider/session-compaction.ts +187 -0
  30. package/server/provider/session-descriptors.ts +52 -18
  31. package/server/provider/session-events.ts +42 -0
  32. package/server/provider/session-permissions.ts +987 -0
  33. package/server/provider/session-terminal.ts +307 -0
  34. package/server/provider/session-usage.ts +234 -0
  35. package/server/provider/session.ts +145 -1853
  36. package/server/provider/settings.ts +8 -5
  37. package/server/provider/subsessions.ts +30 -9
  38. package/server/provider/timeline-projector.ts +140 -29
  39. package/shared/external-url.ts +0 -9
  40. package/shared/provider-launch-settings.ts +18 -0
@@ -4,7 +4,8 @@ import { homedir } from "node:os";
4
4
  import { basename, join, resolve } from "node:path";
5
5
  import { isOmpProfileName } from "../../shared/omp-store";
6
6
  import { ompAgentDir, ompCacheDir, ompDataDir, ompSessionDir, ompStateDir } from "../paths";
7
- import { OmpRpcRuntime, type OmpRuntime, type OmpStartOptions } from "./omp-rpc";
7
+ import { OmpRpcRuntime, type OmpRuntime } from "./omp-rpc";
8
+ import type { OmpStartOptions } from "./omp-rpc-environment";
8
9
  import { parseOmpProviderOptions } from "./provider-options";
9
10
  import { createOmpProvider, type OmpProviderOptions } from "./registration";
10
11
  import { OmpPublicError } from "./security";
@@ -0,0 +1,253 @@
1
+ import type { ProviderContent, ProviderInput } from "@getpaseo/plugin/server/provider";
2
+ import { getForgeDefinitionOrNeutral } from "@getpaseo/protocol/forge-manifest";
3
+ import { isValidImagePayload } from "./image";
4
+ import type { OmpImage } from "./omp-rpc-protocol";
5
+ import { OmpPublicError, utf8Bytes } from "./security";
6
+
7
+ type SessionPromptInput = Extract<ProviderInput, { type: "session.prompt" }>;
8
+ const MAX_PROMPT_PARTS = 64;
9
+ export const MAX_PROMPT_TEXT_LENGTH = 1024 * 1024;
10
+ const RPC_REQUEST_ID_BYTES = 36;
11
+
12
+ export function isSafeCommandName(name: string): boolean {
13
+ return /^[A-Za-z0-9][A-Za-z0-9._-]*(?::[A-Za-z0-9][A-Za-z0-9._-]*)*$/u.test(name);
14
+ }
15
+
16
+ export type OmpPromptPayload = { text: string; images: OmpImage[]; commandName?: string };
17
+ export function buildOmpPromptRequest(message: string, images: readonly OmpImage[] = []) {
18
+ return {
19
+ type: "prompt" as const,
20
+ message,
21
+ streamingBehavior: "followUp" as const,
22
+ ...(images.length > 0 ? { images } : {}),
23
+ };
24
+ }
25
+
26
+ const REVIEW_LINE_MARKERS = { add: "+", remove: "-", context: " " } as const;
27
+
28
+ function renderPromptAttachmentAsText(part: Exclude<ProviderContent, { type: "image" }>): string {
29
+ switch (part.type) {
30
+ case "forge_change_request": {
31
+ return renderChangeRequestAttachment({
32
+ forge: part.forge ?? "github",
33
+ number: part.number,
34
+ title: part.title,
35
+ url: part.url,
36
+ body: part.body,
37
+ projectPath: part.projectPath,
38
+ baseRefName: part.baseRefName,
39
+ headRefName: part.headRefName,
40
+ });
41
+ }
42
+ case "github_pr": {
43
+ return renderChangeRequestAttachment({
44
+ forge: "github",
45
+ number: part.number,
46
+ title: part.title,
47
+ url: part.url,
48
+ body: part.body,
49
+ baseRefName: part.baseRefName,
50
+ headRefName: part.headRefName,
51
+ });
52
+ }
53
+ case "forge_issue": {
54
+ return renderIssueAttachment({
55
+ forge: part.forge ?? "github",
56
+ number: part.number,
57
+ title: part.title,
58
+ url: part.url,
59
+ body: part.body,
60
+ projectPath: part.projectPath,
61
+ });
62
+ }
63
+ case "github_issue": {
64
+ return renderIssueAttachment({
65
+ forge: "github",
66
+ number: part.number,
67
+ title: part.title,
68
+ url: part.url,
69
+ body: part.body,
70
+ });
71
+ }
72
+ case "text": {
73
+ return part.text;
74
+ }
75
+ case "review": {
76
+ const lines = [`Paseo review attachment (${part.mode})`, `CWD: ${part.cwd}`];
77
+ if (part.baseRef) {
78
+ lines.push(`Base: ${part.baseRef}`);
79
+ }
80
+ part.comments.forEach((comment, index) => {
81
+ lines.push(
82
+ "",
83
+ `Comment ${index + 1}: ${comment.filePath}:${comment.side}:${comment.lineNumber}`,
84
+ comment.body,
85
+ comment.context.hunkHeader,
86
+ );
87
+ const target = comment.context.targetLine;
88
+ for (const line of comment.context.lines) {
89
+ const isTarget =
90
+ line.oldLineNumber === target.oldLineNumber &&
91
+ line.newLineNumber === target.newLineNumber &&
92
+ line.type === target.type &&
93
+ line.content === target.content;
94
+ const prefix = isTarget ? "> " : " ";
95
+ const oldLn = padLineNumber(line.oldLineNumber);
96
+ const newLn = padLineNumber(line.newLineNumber);
97
+ lines.push(`${prefix}${oldLn} ${newLn} ${REVIEW_LINE_MARKERS[line.type]}${line.content}`);
98
+ }
99
+ });
100
+ return lines.join("\n");
101
+ }
102
+ case "uploaded_file": {
103
+ return [
104
+ `Uploaded file: ${part.fileName}`,
105
+ `Path: ${part.path}`,
106
+ `MIME: ${part.mimeType}`,
107
+ `Size: ${part.size} bytes`,
108
+ ].join("\n");
109
+ }
110
+ default:
111
+ throw new Error("unreachable");
112
+ }
113
+ }
114
+
115
+ function renderChangeRequestAttachment(input: {
116
+ forge: string;
117
+ number: number;
118
+ title: string;
119
+ url: string;
120
+ body?: string | null;
121
+ projectPath?: string;
122
+ baseRefName?: string | null;
123
+ headRefName?: string | null;
124
+ }): string {
125
+ const lines = [
126
+ `${formatForgeLabel(input.forge)} ${formatChangeRequestAbbrev(input.forge)} ${formatChangeRequestNumber(input.forge, input.number)}: ${input.title}`,
127
+ input.url,
128
+ ];
129
+ if (input.projectPath) {
130
+ lines.push(`Project: ${input.projectPath}`);
131
+ }
132
+ if (input.baseRefName) {
133
+ lines.push(`Base: ${input.baseRefName}`);
134
+ }
135
+ if (input.headRefName) {
136
+ lines.push(`Head: ${input.headRefName}`);
137
+ }
138
+ if (input.body) {
139
+ lines.push("", input.body);
140
+ }
141
+ return lines.join("\n");
142
+ }
143
+
144
+ function renderIssueAttachment(input: {
145
+ forge: string;
146
+ number: number;
147
+ title: string;
148
+ url: string;
149
+ body?: string | null;
150
+ projectPath?: string;
151
+ }): string {
152
+ const lines = [
153
+ `${formatForgeLabel(input.forge)} Issue ${formatIssueNumber(input.forge, input.number)}: ${input.title}`,
154
+ input.url,
155
+ ];
156
+ if (input.projectPath) {
157
+ lines.push(`Project: ${input.projectPath}`);
158
+ }
159
+ if (input.body) {
160
+ lines.push("", input.body);
161
+ }
162
+ return lines.join("\n");
163
+ }
164
+
165
+ function formatForgeLabel(forge: string): string {
166
+ return getForgeDefinitionOrNeutral(forge).displayName;
167
+ }
168
+
169
+ function formatChangeRequestAbbrev(forge: string): string {
170
+ return getForgeDefinitionOrNeutral(forge).changeRequestAbbrev;
171
+ }
172
+
173
+ function formatChangeRequestNumber(forge: string, number: number): string {
174
+ return `${getForgeDefinitionOrNeutral(forge).changeRequestNumberPrefix}${number}`;
175
+ }
176
+
177
+ function formatIssueNumber(forge: string, number: number): string {
178
+ return `${getForgeDefinitionOrNeutral(forge).issueNumberPrefix}${number}`;
179
+ }
180
+
181
+ function padLineNumber(lineNumber: number | null): string {
182
+ return (lineNumber?.toString() ?? "-").padStart(2);
183
+ }
184
+
185
+ export function promptPayload(input: SessionPromptInput): OmpPromptPayload {
186
+ if (input.prompt.outputSchema !== undefined || input.prompt.clearPendingPermissions) {
187
+ throw new OmpPublicError("OMP does not support structured output or permission controls");
188
+ }
189
+ if (input.prompt.input.type === "command") {
190
+ const name = input.prompt.input.name.trim();
191
+ if (!isSafeCommandName(name)) throw new OmpPublicError("Invalid OMP command name");
192
+ const argumentsText = input.prompt.input.arguments.trim();
193
+ const text = `/${name}${argumentsText ? ` ${argumentsText}` : ""}`;
194
+ if (utf8Bytes(text) > MAX_PROMPT_TEXT_LENGTH)
195
+ throw new OmpPublicError("OMP command is too large");
196
+ return { text, images: [], commandName: name };
197
+ }
198
+ if (input.prompt.input.content.length > MAX_PROMPT_PARTS) {
199
+ throw new OmpPublicError("OMP prompt has too many content parts");
200
+ }
201
+ const parts: string[] = [];
202
+ const images: OmpImage[] = [];
203
+ let length = 0;
204
+ const appendText = (text: string) => {
205
+ length += utf8Bytes(text) + (parts.length > 0 ? 2 : 0);
206
+ if (length > MAX_PROMPT_TEXT_LENGTH) throw new OmpPublicError("OMP prompt is too large");
207
+ parts.push(text);
208
+ };
209
+ for (const part of input.prompt.input.content) {
210
+ if (part.type === "text") {
211
+ appendText(part.text);
212
+ continue;
213
+ }
214
+ if (part.type === "image") {
215
+ if (!isValidImagePayload(part.data, part.mimeType, 8 * 1024 * 1024)) {
216
+ throw new OmpPublicError("OMP prompt image is invalid");
217
+ }
218
+ images.push({ type: "image", data: part.data, mimeType: part.mimeType });
219
+ continue;
220
+ }
221
+ appendText(renderPromptAttachmentAsText(part));
222
+ }
223
+ const text = parts.join("\n\n").trim();
224
+ if (!text && images.length === 0) throw new OmpPublicError("OMP prompt cannot be empty");
225
+ return { text, images };
226
+ }
227
+
228
+ export function inlinePromptFrameBytes(
229
+ payload: OmpPromptPayload,
230
+ delivery: "prompt" | "steer",
231
+ ): number {
232
+ const images = payload.images.map(({ type, mimeType }) => ({ type, data: "", mimeType }));
233
+ const frame =
234
+ delivery === "prompt"
235
+ ? { ...buildOmpPromptRequest(payload.text, images), id: "" }
236
+ : { type: "steer", message: payload.text, ...(images.length > 0 ? { images } : {}) };
237
+ const requestIdBytes = delivery === "prompt" ? RPC_REQUEST_ID_BYTES : 0;
238
+ return (
239
+ utf8Bytes(JSON.stringify(frame)) +
240
+ requestIdBytes +
241
+ payload.images.reduce((total, image) => total + image.data.length, 0) +
242
+ 1
243
+ );
244
+ }
245
+
246
+ export function slashCommandName(text: string): string | undefined {
247
+ if (!text.startsWith("/")) return undefined;
248
+ const body = text.slice(1);
249
+ if (!body) return undefined;
250
+ const firstWhitespace = body.search(/\s/u);
251
+ const name = firstWhitespace === -1 ? body : body.slice(0, firstWhitespace);
252
+ return name || undefined;
253
+ }
@@ -5,9 +5,11 @@ import { z } from "zod";
5
5
  import type { OmpBrowserAuthorizationRegistry } from "../mcp-browser";
6
6
  import type { OmpOperationalFailureReporter } from "../operational-failure-diagnostics";
7
7
  import { probeOmpAvailability } from "../provider-diagnostics";
8
+ import { mergeOmpInheritedEnvironmentNames } from "./config-normalization";
8
9
  import { createOmpConnection, OmpNativeSessionReservations } from "./connection";
9
10
  import type { OmpMcpConnector } from "./host-tools";
10
- import { type OmpProtocolViolationDiagnostic, OmpRpcRuntime, type OmpRuntime } from "./omp-rpc";
11
+ import { OmpRpcRuntime, type OmpRuntime } from "./omp-rpc";
12
+ import type { OmpProtocolViolationDiagnostic } from "./omp-rpc-protocol";
11
13
  import { parseOmpProviderOptions } from "./provider-options";
12
14
  import { boundedJsonBytes } from "./security";
13
15
  import { OmpProviderOptionsSchema } from "./settings";
@@ -73,6 +75,7 @@ export interface OmpProviderOptions {
73
75
  options: ProviderCatalogOptionsCompat,
74
76
  timeoutMs: number | undefined,
75
77
  ) => Promise<ProviderAvailabilityCompat>;
78
+ resolveHostInheritEnv?: () => Promise<readonly string[]>;
76
79
  }
77
80
 
78
81
  function stableJson(value: unknown): string {
@@ -103,6 +106,10 @@ export function createOmpProvider(options: OmpProviderOptions = {}): ProviderReg
103
106
  providerOptionsSchema: OmpProviderOptionsSchema,
104
107
  async getCatalogCacheKey(catalogOptions) {
105
108
  const providerOptions = parseOmpProviderOptions(catalogOptions.providerOptions);
109
+ const inheritEnv = mergeOmpInheritedEnvironmentNames(
110
+ (await options.resolveHostInheritEnv?.()) ?? [],
111
+ providerOptions.inheritEnv,
112
+ );
106
113
  // Profile providers cannot safely hash explicit environment values because they may be
107
114
  // credentials. Disable sharing for that case; otherwise retain every normalized option
108
115
  // that can change discovery alongside the fixed, non-secret store identity.
@@ -111,7 +118,7 @@ export function createOmpProvider(options: OmpProviderOptions = {}): ProviderReg
111
118
  scope: catalogOptions.scope,
112
119
  ...(catalogOptions.scope === "workspace" ? { cwd: catalogOptions.cwd } : {}),
113
120
  ...(options.catalogIdentity ? { store: options.catalogIdentity } : {}),
114
- providerOptions,
121
+ providerOptions: { ...providerOptions, ...(inheritEnv ? { inheritEnv } : {}) },
115
122
  settings: catalogOptions.settings ?? {},
116
123
  defaultCommand: (options.environment ?? process.env).OMP_COMMAND ?? "omp",
117
124
  };
@@ -166,6 +173,7 @@ export function createOmpProvider(options: OmpProviderOptions = {}): ProviderReg
166
173
  options.browserAuthorizationRegistry,
167
174
  undefined,
168
175
  options.reportOperationalFailure,
176
+ options.resolveHostInheritEnv,
169
177
  );
170
178
  },
171
179
  };
@@ -0,0 +1,187 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import type { ProviderEvent } from "@getpaseo/plugin/server/provider";
3
+ import type { OmpRpcEvent } from "./omp-rpc-protocol";
4
+ import type { OmpPublicDataSerializer } from "./security";
5
+ import type { ActiveTurn } from "./session-terminal";
6
+ import type { OmpSessionUsage } from "./session-usage";
7
+ import type { OmpTimelineProjector } from "./timeline-projector";
8
+
9
+ type Emit = (event: ProviderEvent) => void;
10
+
11
+ export interface ActiveCompaction {
12
+ id: string;
13
+ trigger: "auto" | "manual";
14
+ turnId: string;
15
+ generation: number;
16
+ retrying: boolean;
17
+ action?: string;
18
+ preTokens?: number;
19
+ }
20
+
21
+ export class OmpSessionCompaction {
22
+ private operation: ActiveCompaction | null = null;
23
+ private discardedEnds = 0;
24
+
25
+ constructor(
26
+ private readonly sessionId: string,
27
+ private readonly emit: Emit,
28
+ private readonly projector: OmpTimelineProjector,
29
+ private readonly dataFilter: OmpPublicDataSerializer,
30
+ private readonly usage: OmpSessionUsage,
31
+ ) {}
32
+
33
+ get active(): ActiveCompaction | null {
34
+ return this.operation;
35
+ }
36
+
37
+ start(turn: ActiveTurn, trigger: "auto" | "manual", action?: string): void {
38
+ if (this.discardedEnds > 0) {
39
+ this.discardedEnds += 1;
40
+ return;
41
+ }
42
+ const active = this.operation;
43
+ if (active && active.trigger === trigger && active.action === action) {
44
+ active.retrying = false;
45
+ return;
46
+ }
47
+ if (active) {
48
+ this.retire("OMP emitted overlapping compactions");
49
+ this.discardedEnds = 2;
50
+ return;
51
+ }
52
+ const operation: ActiveCompaction = {
53
+ id: randomUUID(),
54
+ trigger,
55
+ turnId: turn.turnId,
56
+ generation: turn.generation,
57
+ action,
58
+ retrying: false,
59
+ preTokens: this.usage.latest?.contextWindowUsedTokens,
60
+ };
61
+ this.operation = operation;
62
+ this.projector.flush(true);
63
+ this.emit({
64
+ type: "timeline.item",
65
+ sessionId: this.sessionId,
66
+ item: {
67
+ id: operation.id,
68
+ type: "compaction",
69
+ status: "loading",
70
+ trigger,
71
+ ...(operation.preTokens !== undefined ? { preTokens: operation.preTokens } : {}),
72
+ },
73
+ });
74
+ }
75
+
76
+ handleAutoEnd(
77
+ turn: ActiveTurn,
78
+ event: Extract<OmpRpcEvent, { type: "auto_compaction_end" }>,
79
+ ): boolean {
80
+ if (this.discardedEnds > 0) {
81
+ this.discardedEnds -= 1;
82
+ return false;
83
+ }
84
+ const operation = this.operation;
85
+ if (
86
+ operation?.trigger !== "auto" ||
87
+ operation.turnId !== turn.turnId ||
88
+ operation.generation !== turn.generation
89
+ ) {
90
+ return false;
91
+ }
92
+ if (event.action !== undefined && operation.action !== event.action) {
93
+ this.retire("OMP emitted overlapping compactions");
94
+ this.discardedEnds = 1;
95
+ return false;
96
+ }
97
+ if (event.willRetry) {
98
+ operation.retrying = true;
99
+ return false;
100
+ }
101
+ if (operation.retrying) {
102
+ operation.retrying = false;
103
+ return false;
104
+ }
105
+ const state = event.aborted
106
+ ? "canceled"
107
+ : event.errorMessage
108
+ ? "failed"
109
+ : event.skipped
110
+ ? "skipped"
111
+ : "completed";
112
+ this.finish(state, {
113
+ tokensBefore: event.result?.tokensBefore ?? event.result?.preTokens,
114
+ message: event.errorMessage,
115
+ });
116
+ return true;
117
+ }
118
+
119
+ finish(
120
+ state: "completed" | "failed" | "canceled" | "skipped",
121
+ options: { tokensBefore?: number | null; message?: string } = {},
122
+ ): void {
123
+ const operation = this.operation;
124
+ if (!operation) return;
125
+ this.operation = null;
126
+ this.projector.flush(true);
127
+ if (state === "completed") {
128
+ this.usage.completeCompaction();
129
+ }
130
+ if (state !== "completed") {
131
+ const defaultMessage =
132
+ state === "failed"
133
+ ? "OMP compaction failed"
134
+ : state === "canceled"
135
+ ? "OMP compaction canceled"
136
+ : "OMP compaction skipped";
137
+ this.emit({
138
+ type: "timeline.item",
139
+ sessionId: this.sessionId,
140
+ item: {
141
+ id: operation.id,
142
+ type: "notification",
143
+ level: state === "failed" ? "error" : "info",
144
+ message: this.dataFilter.text(options.message ?? defaultMessage, 4_096),
145
+ },
146
+ });
147
+ return;
148
+ }
149
+ const tokensBefore = options.tokensBefore ?? operation.preTokens;
150
+ this.emit({
151
+ type: "timeline.item",
152
+ sessionId: this.sessionId,
153
+ item: {
154
+ id: operation.id,
155
+ type: "compaction",
156
+ status: "completed",
157
+ trigger: operation.trigger,
158
+ ...(tokensBefore !== undefined ? { preTokens: tokensBefore } : {}),
159
+ },
160
+ });
161
+ }
162
+
163
+ retire(message: string): void {
164
+ const operation = this.operation;
165
+ if (!operation) return;
166
+ this.operation = null;
167
+ this.emit({
168
+ type: "timeline.item",
169
+ sessionId: this.sessionId,
170
+ item: {
171
+ id: operation.id,
172
+ type: "compaction",
173
+ status: "completed",
174
+ trigger: operation.trigger,
175
+ },
176
+ });
177
+ this.emit({
178
+ type: "timeline.item",
179
+ sessionId: this.sessionId,
180
+ item: { id: `${operation.id}:error`, type: "error", message },
181
+ });
182
+ }
183
+
184
+ resetDiscardedEnds(): void {
185
+ this.discardedEnds = 0;
186
+ }
187
+ }
@@ -2,7 +2,7 @@ import { createHash } from "node:crypto";
2
2
  import { constants, type Dir } from "node:fs";
3
3
  import { type FileHandle, lstat, open, opendir, realpath } from "node:fs/promises";
4
4
  import { homedir } from "node:os";
5
- import { basename, dirname, extname, isAbsolute, join, resolve } from "node:path";
5
+ import { basename, isAbsolute, join, relative, resolve } from "node:path";
6
6
  import { ompSessionDir } from "../paths";
7
7
  import { isValidImagePayload } from "./image";
8
8
 
@@ -510,16 +510,19 @@ interface PersistedTranscriptNode {
510
510
  message?: unknown;
511
511
  }
512
512
 
513
- function persistedTranscriptMessage(record: Record<string, unknown>, entryId: string): unknown {
513
+ function persistedTranscriptMessage(record: Record<string, unknown>, entryId?: string): unknown {
514
514
  if (record.type === "message") {
515
515
  if (!record.message || typeof record.message !== "object" || Array.isArray(record.message))
516
516
  return;
517
- return { ...(record.message as Record<string, unknown>), entryId };
517
+ return {
518
+ ...(record.message as Record<string, unknown>),
519
+ ...(entryId ? { entryId } : {}),
520
+ };
518
521
  }
519
522
  if (record.type !== "custom_message") return;
520
523
  return {
521
524
  role: "custom",
522
- entryId,
525
+ ...(entryId ? { entryId } : {}),
523
526
  customType: record.customType,
524
527
  content: record.content,
525
528
  display: record.display,
@@ -680,6 +683,7 @@ export async function readOmpPersistedSubagentTranscript(
680
683
  childTranscriptId: string,
681
684
  cwd: string,
682
685
  signal?: AbortSignal,
686
+ childSessionFile?: string,
683
687
  ): Promise<OmpPersistedSubagentTranscript> {
684
688
  signal?.throwIfAborted();
685
689
  if (
@@ -687,7 +691,12 @@ export async function readOmpPersistedSubagentTranscript(
687
691
  !parentSessionFile.endsWith(".jsonl") ||
688
692
  parentSessionFile.includes("\0") ||
689
693
  !CHILD_TRANSCRIPT_ID.test(childTranscriptId) ||
690
- basename(childTranscriptId) !== childTranscriptId
694
+ basename(childTranscriptId) !== childTranscriptId ||
695
+ (childSessionFile !== undefined &&
696
+ (!isAbsolute(childSessionFile) ||
697
+ !childSessionFile.endsWith(".jsonl") ||
698
+ childSessionFile.includes("\0") ||
699
+ basename(childSessionFile) !== `${childTranscriptId}.jsonl`))
691
700
  ) {
692
701
  throw new Error("Invalid OMP child transcript descriptor");
693
702
  }
@@ -707,34 +716,53 @@ export async function readOmpPersistedSubagentTranscript(
707
716
  await parentHandle.close().catch(() => undefined);
708
717
  }
709
718
  const canonicalParent = await realpath(parentSessionFile);
710
- const parentExtension = extname(canonicalParent);
711
- const expectedDirectory = canonicalParent.slice(0, -parentExtension.length);
719
+ const expectedDirectory = canonicalParent.slice(0, -".jsonl".length);
712
720
  const canonicalDirectory = await realpath(expectedDirectory).catch(() => undefined);
713
721
  if (!canonicalDirectory || canonicalDirectory !== expectedDirectory) {
714
722
  throw new Error("OMP child transcript directory is not canonically owned by its parent");
715
723
  }
716
- const sessionFile = join(canonicalDirectory, `${childTranscriptId}.jsonl`);
724
+ const requestedFile = resolve(
725
+ childSessionFile ?? join(canonicalDirectory, `${childTranscriptId}.jsonl`),
726
+ );
717
727
  let handle: FileHandle;
718
728
  try {
719
729
  handle = await open(
720
- sessionFile,
730
+ requestedFile,
721
731
  constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0),
722
732
  );
723
733
  } catch {
724
734
  throw new Error("OMP child transcript could not be opened");
725
735
  }
726
736
  try {
727
- const [stat, canonicalChild] = await Promise.all([handle.stat(), realpath(sessionFile)]);
737
+ const [stat, pathStat, canonicalChild] = await Promise.all([
738
+ handle.stat(),
739
+ lstat(requestedFile),
740
+ realpath(requestedFile),
741
+ ]);
728
742
  if (
729
743
  !stat.isFile() ||
730
- stat.size > MAX_CHILD_TRANSCRIPT_BYTES ||
731
- dirname(canonicalChild) !== canonicalDirectory ||
732
- canonicalChild !== sessionFile
744
+ !pathStat.isFile() ||
745
+ pathStat.isSymbolicLink() ||
746
+ stat.dev !== pathStat.dev ||
747
+ stat.ino !== pathStat.ino ||
748
+ stat.size > MAX_CHILD_TRANSCRIPT_BYTES
733
749
  ) {
734
750
  throw new Error("OMP child transcript failed ownership validation");
735
751
  }
736
- const bytes = await handle.readFile();
737
- signal?.throwIfAborted();
752
+ const canonicalRelativeChild = relative(canonicalDirectory, canonicalChild);
753
+ if (
754
+ !canonicalRelativeChild ||
755
+ canonicalRelativeChild.startsWith("..") ||
756
+ isAbsolute(canonicalRelativeChild)
757
+ ) {
758
+ throw new Error("OMP child transcript failed ownership validation");
759
+ }
760
+ const bytes = await readStableFile(
761
+ handle,
762
+ stat.size,
763
+ signal,
764
+ "OMP child transcript changed while reading",
765
+ );
738
766
  const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
739
767
  const messages: unknown[] = [];
740
768
  let nativeSessionId: string | undefined;
@@ -752,15 +780,21 @@ export async function readOmpPersistedSubagentTranscript(
752
780
  nativeSessionId ??= candidateId;
753
781
  if (nativeSessionId !== candidateId)
754
782
  throw new Error("OMP child transcript identity changed");
755
- } else if (record.type === "message" && record.message !== undefined) {
783
+ } else {
784
+ const entryId =
785
+ typeof record.id === "string" && CHILD_TRANSCRIPT_ID.test(record.id)
786
+ ? record.id
787
+ : undefined;
788
+ const message = persistedTranscriptMessage(record, entryId);
789
+ if (message === undefined) continue;
756
790
  if (messages.length >= MAX_CHILD_TRANSCRIPT_MESSAGES) {
757
791
  throw new Error("OMP child transcript exceeds message limits");
758
792
  }
759
- messages.push(record.message);
793
+ messages.push(message);
760
794
  }
761
795
  }
762
796
  if (!nativeSessionId) throw new Error("OMP child transcript is missing session identity");
763
- return { sessionFile, nativeSessionId, byteLength: bytes.byteLength, messages };
797
+ return { sessionFile: canonicalChild, nativeSessionId, byteLength: bytes.byteLength, messages };
764
798
  } catch (error) {
765
799
  if (error instanceof Error) throw error;
766
800
  throw new Error("OMP child transcript could not be decoded");
@@ -0,0 +1,42 @@
1
+ import type { OmpRpcEvent } from "./omp-rpc-protocol";
2
+
3
+ export function isNativeTurnActivity(event: OmpRpcEvent): boolean {
4
+ if (
5
+ event.type === "agent_start" ||
6
+ event.type === "turn_start" ||
7
+ event.type === "turn_end" ||
8
+ event.type === "agent_end" ||
9
+ event.type === "auto_compaction_start" ||
10
+ event.type === "auto_compaction_end"
11
+ ) {
12
+ return true;
13
+ }
14
+ if (
15
+ event.type === "message_start" ||
16
+ event.type === "message_update" ||
17
+ event.type === "message_end"
18
+ ) {
19
+ return event.message.role === "assistant";
20
+ }
21
+ return event.type.startsWith("tool_execution_");
22
+ }
23
+ export function isRuntimeConfigEvent(event: OmpRpcEvent): boolean {
24
+ return (
25
+ event.type === "model_changed" ||
26
+ event.type === "thinking_level_changed" ||
27
+ event.type === "retry_fallback_applied" ||
28
+ event.type === "retry_fallback_succeeded"
29
+ );
30
+ }
31
+
32
+ export function isPassiveUiMethod(method: string): boolean {
33
+ return (
34
+ method === "cancel" ||
35
+ method === "notify" ||
36
+ method === "open_url" ||
37
+ method === "setStatus" ||
38
+ method === "setWidget" ||
39
+ method === "setTitle" ||
40
+ method === "set_editor_text"
41
+ );
42
+ }