@omercnet/paseo-omp 0.4.0 → 1.0.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 (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
@@ -0,0 +1,987 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import type {
3
+ ProviderEvent,
4
+ ProviderInput,
5
+ ProviderPermissionResponse,
6
+ ProviderToolCallDetail,
7
+ } from "@getpaseo/plugin/server/provider";
8
+ import type { OmpRuntimeSession } from "./omp-rpc";
9
+ import type {
10
+ OmpExtensionUiResponse,
11
+ OmpRpcEvent,
12
+ OmpToolApprovalCancel,
13
+ OmpToolApprovalRequest,
14
+ } from "./omp-rpc-protocol";
15
+ import {
16
+ BoundedStringSet,
17
+ boundedJsonBytes,
18
+ type OmpPublicDataSerializer,
19
+ OmpPublicError,
20
+ utf8Bytes,
21
+ } from "./security";
22
+ import type { ActiveTurn } from "./session-terminal";
23
+ import type { OmpTimelineProjector, OmpTimelineScheduler } from "./timeline-projector";
24
+
25
+ type SessionPermissionInput = Extract<ProviderInput, { type: "session.permission" }>;
26
+ type OmpQuestionRequest = Extract<
27
+ Extract<OmpRpcEvent, { type: "extension_ui_request" }>,
28
+ { method: "select" | "confirm" | "input" | "editor" }
29
+ >;
30
+ type Emit = (event: ProviderEvent) => void;
31
+
32
+ const MAX_PENDING_PERMISSIONS = 32;
33
+ const MAX_PENDING_PERMISSION_BYTES = 2 * 1024 * 1024;
34
+ const MAX_RESOLVED_TOOL_APPROVAL_IDS = 1_024;
35
+ const OMP_ASK_USER_FREEFORM_SENTINEL = "✏️ Type custom response...";
36
+ const MAX_FREEFORM_RESPONSE_BYTES = 64 * 1024;
37
+
38
+ type PendingPermission = {
39
+ nativeId: string;
40
+ header: string;
41
+ fingerprint: string;
42
+ optionValues: ReadonlyMap<string, string>;
43
+ actionBehaviors: ReadonlyMap<string, "allow" | "deny">;
44
+ retainedBytes: number;
45
+ displayValues: ReadonlyMap<string, string>;
46
+ generation: number;
47
+ runtime: OmpRuntimeSession;
48
+ expiresAt?: number;
49
+ timer?: unknown;
50
+ turnId?: string;
51
+ request: OmpQuestionRequest;
52
+ freeformSentinel?: string;
53
+ };
54
+ type PendingToolPermission = {
55
+ nativeId: string;
56
+ toolCallId: string;
57
+ fingerprint: string;
58
+ retainedBytes: number;
59
+ generation: number;
60
+ runtime: OmpRuntimeSession;
61
+ expiresAt?: number;
62
+ timer?: unknown;
63
+ turnId?: string;
64
+ };
65
+
66
+ type PendingFreeformSelection = {
67
+ value: string;
68
+ nativeSelectId: string;
69
+ generation: number;
70
+ runtime: OmpRuntimeSession;
71
+ turnId?: string;
72
+ };
73
+
74
+ function permissionFingerprint(request: OmpQuestionRequest): string {
75
+ return createHash("sha256").update(JSON.stringify(request)).digest("base64url");
76
+ }
77
+
78
+ interface PermissionContext {
79
+ runtime: OmpRuntimeSession;
80
+ generation: number;
81
+ activeTurn: ActiveTurn | null;
82
+ closed: boolean;
83
+ runtimeDead: boolean;
84
+ }
85
+
86
+ interface PermissionHost {
87
+ sessionId: string;
88
+ emit: Emit;
89
+ scheduler: OmpTimelineScheduler;
90
+ dataFilter: OmpPublicDataSerializer;
91
+ projector: OmpTimelineProjector;
92
+ readContext(): PermissionContext;
93
+ handleRuntimeFailure(message?: string): void;
94
+ invalidateRuntime(message: string): void;
95
+ markAgentEvidence(turn: ActiveTurn): void;
96
+ reevaluateDeferredTerminal(): void;
97
+ }
98
+
99
+ export class OmpSessionPermissions {
100
+ private permissionSequence = 0;
101
+ private readonly permissionNamespace = randomUUID();
102
+ private readonly pendingPermissions = new Map<string, PendingPermission>();
103
+ private readonly inFlightPermissions = new Map<string, PendingPermission>();
104
+ private pendingFreeformSelection: PendingFreeformSelection | null = null;
105
+ private readonly pendingToolPermissions = new Map<string, PendingToolPermission>();
106
+ private readonly inFlightToolPermissions = new Map<string, PendingToolPermission>();
107
+ private readonly resolvedToolApprovalIds = new BoundedStringSet(MAX_RESOLVED_TOOL_APPROVAL_IDS);
108
+
109
+ constructor(private readonly host: PermissionHost) {}
110
+
111
+ private get id(): string {
112
+ return this.host.sessionId;
113
+ }
114
+
115
+ private get runtime(): OmpRuntimeSession {
116
+ return this.host.readContext().runtime;
117
+ }
118
+
119
+ private get generation(): number {
120
+ return this.host.readContext().generation;
121
+ }
122
+
123
+ private get activeTurn(): ActiveTurn | null {
124
+ return this.host.readContext().activeTurn;
125
+ }
126
+
127
+ private get closed(): boolean {
128
+ return this.host.readContext().closed;
129
+ }
130
+
131
+ private get runtimeDead(): boolean {
132
+ return this.host.readContext().runtimeDead;
133
+ }
134
+
135
+ private get scheduler(): OmpTimelineScheduler {
136
+ return this.host.scheduler;
137
+ }
138
+
139
+ private get dataFilter(): OmpPublicDataSerializer {
140
+ return this.host.dataFilter;
141
+ }
142
+
143
+ private get projector(): OmpTimelineProjector {
144
+ return this.host.projector;
145
+ }
146
+
147
+ private emit(event: ProviderEvent): void {
148
+ this.host.emit(event);
149
+ }
150
+
151
+ private handleRuntimeFailure(message?: string): void {
152
+ this.host.handleRuntimeFailure(message);
153
+ }
154
+ private invalidateRuntime(message: string): void {
155
+ this.host.invalidateRuntime(message);
156
+ }
157
+
158
+ private markAgentEvidence(turn: ActiveTurn): void {
159
+ this.host.markAgentEvidence(turn);
160
+ }
161
+
162
+ private reevaluateDeferredPermissionTerminal(): void {
163
+ this.host.reevaluateDeferredTerminal();
164
+ }
165
+
166
+ async respond(input: SessionPermissionInput): Promise<void> {
167
+ const typed = this.pendingToolPermissions.get(input.permissionId);
168
+ if (typed) {
169
+ await this.respondToToolPermission(input, typed);
170
+ return;
171
+ }
172
+ const pending = this.pendingPermissions.get(input.permissionId);
173
+ if (!pending) throw new OmpPublicError("Unknown OMP permission request");
174
+ if (!this.permissionOwnerIsCurrent(pending)) {
175
+ this.pendingPermissions.delete(input.permissionId);
176
+ if (pending.timer !== undefined) this.scheduler.clear(pending.timer);
177
+ this.emit({
178
+ type: "session.permission_resolved",
179
+ sessionId: this.id,
180
+ permissionId: input.permissionId,
181
+ });
182
+ throw new OmpPublicError("OMP permission request is no longer active");
183
+ }
184
+ const { generation, runtime } = pending;
185
+ this.pendingPermissions.delete(input.permissionId);
186
+ this.inFlightPermissions.set(input.permissionId, pending);
187
+ if (pending.timer !== undefined) this.scheduler.clear(pending.timer);
188
+ try {
189
+ const freeformValue = this.freeformSelection(pending, input.response);
190
+ if (freeformValue !== undefined) {
191
+ this.pendingFreeformSelection = {
192
+ value: freeformValue,
193
+ nativeSelectId: pending.nativeId,
194
+ generation,
195
+ runtime,
196
+ ...(pending.turnId ? { turnId: pending.turnId } : {}),
197
+ };
198
+ }
199
+ const response = this.extensionUiResponse(pending, input.response);
200
+ await runtime.respondToExtensionUi(response);
201
+ } catch (error) {
202
+ if (this.pendingFreeformSelection?.nativeSelectId === pending.nativeId) {
203
+ this.pendingFreeformSelection = null;
204
+ }
205
+ if (this.inFlightPermissions.get(input.permissionId) !== pending) return;
206
+ this.inFlightPermissions.delete(input.permissionId);
207
+ if (
208
+ this.permissionOwnerIsCurrent(pending) &&
209
+ generation === this.generation &&
210
+ runtime === this.runtime
211
+ ) {
212
+ this.pendingPermissions.set(input.permissionId, pending);
213
+ this.armPermissionTimeout(input.permissionId, pending);
214
+ throw error;
215
+ }
216
+ return;
217
+ }
218
+ if (this.inFlightPermissions.get(input.permissionId) !== pending) return;
219
+ this.inFlightPermissions.delete(input.permissionId);
220
+ this.emit({
221
+ type: "session.permission_resolved",
222
+ sessionId: this.id,
223
+ permissionId: input.permissionId,
224
+ });
225
+ this.reevaluateDeferredPermissionTerminal();
226
+ }
227
+ private async respondToToolPermission(
228
+ input: SessionPermissionInput,
229
+ pending: PendingToolPermission,
230
+ ): Promise<void> {
231
+ if (!this.permissionOwnerIsCurrent(pending)) {
232
+ this.pendingToolPermissions.delete(input.permissionId);
233
+ if (pending.timer !== undefined) this.scheduler.clear(pending.timer);
234
+ this.resolvedToolApprovalIds.add(pending.nativeId);
235
+ this.emit({
236
+ type: "session.permission_resolved",
237
+ sessionId: this.id,
238
+ permissionId: input.permissionId,
239
+ });
240
+ throw new OmpPublicError("OMP permission request is no longer active");
241
+ }
242
+ if (
243
+ input.response.selectedActionId !== undefined &&
244
+ input.response.selectedActionId !== input.response.behavior
245
+ ) {
246
+ throw new OmpPublicError("OMP permission action is invalid");
247
+ }
248
+ this.pendingToolPermissions.delete(input.permissionId);
249
+ this.inFlightToolPermissions.set(input.permissionId, pending);
250
+ if (pending.timer !== undefined) this.scheduler.clear(pending.timer);
251
+ try {
252
+ await pending.runtime.respondToToolApproval({
253
+ type: "tool_approval_response",
254
+ id: pending.nativeId,
255
+ toolCallId: pending.toolCallId,
256
+ approved: input.response.behavior === "allow",
257
+ });
258
+ } catch (error) {
259
+ if (this.inFlightToolPermissions.get(input.permissionId) !== pending) return;
260
+ this.inFlightToolPermissions.delete(input.permissionId);
261
+ this.resolvedToolApprovalIds.add(pending.nativeId);
262
+ this.emit({
263
+ type: "session.permission_resolved",
264
+ sessionId: this.id,
265
+ permissionId: input.permissionId,
266
+ });
267
+ this.reevaluateDeferredPermissionTerminal();
268
+ throw error;
269
+ }
270
+ if (this.inFlightToolPermissions.get(input.permissionId) !== pending) return;
271
+ this.inFlightToolPermissions.delete(input.permissionId);
272
+ this.resolvedToolApprovalIds.add(pending.nativeId);
273
+ this.emit({
274
+ type: "session.permission_resolved",
275
+ sessionId: this.id,
276
+ permissionId: input.permissionId,
277
+ });
278
+ this.reevaluateDeferredPermissionTerminal();
279
+ }
280
+
281
+ submitPendingFreeformSelection(
282
+ request: Extract<OmpQuestionRequest, { method: "input" }>,
283
+ ): boolean {
284
+ const pending = this.pendingFreeformSelection;
285
+ if (!pending) return false;
286
+ this.pendingFreeformSelection = null;
287
+ if (
288
+ pending.generation !== this.generation ||
289
+ pending.runtime !== this.runtime ||
290
+ (pending.turnId !== undefined && this.activeTurn?.turnId !== pending.turnId)
291
+ ) {
292
+ return false;
293
+ }
294
+ void pending.runtime
295
+ .respondToExtensionUi({ type: "extension_ui_response", id: request.id, value: pending.value })
296
+ .catch(() => this.handleRuntimeFailure("OMP freeform response failed"));
297
+ return true;
298
+ }
299
+ private toolPermissionDetail(
300
+ request: OmpToolApprovalRequest,
301
+ ): ProviderToolCallDetail | undefined {
302
+ switch (request.identity.kind) {
303
+ case "shell":
304
+ return {
305
+ type: "shell",
306
+ command: this.dataFilter.text(request.identity.command, 24 * 1024),
307
+ };
308
+ case "edit":
309
+ return {
310
+ type: "edit",
311
+ filePath: this.dataFilter.text(request.identity.paths[0] ?? "", 4_096),
312
+ newString: this.dataFilter.text(request.identity.content, 20 * 1024),
313
+ };
314
+ case "write":
315
+ return {
316
+ type: "write",
317
+ filePath: this.dataFilter.text(request.identity.path, 4_096),
318
+ content: this.dataFilter.text(request.identity.content, 20 * 1024),
319
+ };
320
+ case "other":
321
+ return;
322
+ }
323
+ }
324
+
325
+ publishToolPermission(request: OmpToolApprovalRequest): void {
326
+ const fingerprint = createHash("sha256").update(JSON.stringify(request)).digest("base64url");
327
+ if (this.resolvedToolApprovalIds.has(request.id)) {
328
+ this.handleRuntimeFailure("OMP reused a resolved tool approval identifier");
329
+ return;
330
+ }
331
+ for (const pending of [
332
+ ...this.pendingToolPermissions.values(),
333
+ ...this.inFlightToolPermissions.values(),
334
+ ]) {
335
+ if (pending.nativeId !== request.id) continue;
336
+ if (pending.fingerprint !== fingerprint || pending.toolCallId !== request.toolCallId) {
337
+ this.handleRuntimeFailure("OMP changed a pending tool approval request");
338
+ }
339
+ return;
340
+ }
341
+ const pendingCount =
342
+ this.pendingPermissions.size +
343
+ this.inFlightPermissions.size +
344
+ this.pendingToolPermissions.size +
345
+ this.inFlightToolPermissions.size;
346
+ if (pendingCount >= MAX_PENDING_PERMISSIONS) {
347
+ this.resolvedToolApprovalIds.add(request.id);
348
+ void this.runtime
349
+ .respondToToolApproval({
350
+ type: "tool_approval_response",
351
+ id: request.id,
352
+ toolCallId: request.toolCallId,
353
+ cancelled: true,
354
+ })
355
+ .catch(() => this.handleRuntimeFailure());
356
+ return;
357
+ }
358
+
359
+ this.permissionSequence += 1;
360
+ const permissionId = `omp:permission:${this.permissionNamespace}:${this.permissionSequence}`;
361
+ const detail = this.toolPermissionDetail(request);
362
+ const filteredInput = this.dataFilter.json(request.input, 8 * 1024, 32 * 1024);
363
+ const input =
364
+ filteredInput && typeof filteredInput === "object" && !Array.isArray(filteredInput)
365
+ ? filteredInput
366
+ : {};
367
+ const descriptionParts = [
368
+ request.detail.reason,
369
+ ...request.detail.lines,
370
+ ...(request.detail.providerSafetyChecks ?? []),
371
+ ].filter((part): part is string => Boolean(part));
372
+ const publicRequest = {
373
+ id: permissionId,
374
+ name: `omp.${this.dataFilter.text(request.toolName, 256)}`,
375
+ kind: "tool" as const,
376
+ title: `Allow ${this.dataFilter.text(request.toolName, 256)}?`,
377
+ ...(descriptionParts.length > 0
378
+ ? { description: this.dataFilter.text(descriptionParts.join("\n"), 16 * 1024) }
379
+ : {}),
380
+ input: {
381
+ ...input,
382
+ identity: this.dataFilter.json(request.identity, 20 * 1024, 32 * 1024),
383
+ tier: request.tier,
384
+ toolCallId: request.toolCallId,
385
+ },
386
+ ...(detail ? { detail } : {}),
387
+ actions: [
388
+ { id: "allow", label: "Allow", behavior: "allow" as const, variant: "primary" as const },
389
+ { id: "deny", label: "Deny", behavior: "deny" as const, variant: "danger" as const },
390
+ ],
391
+ metadata: {
392
+ tier: request.tier,
393
+ redacted: request.detail.redacted,
394
+ truncated: request.detail.truncated,
395
+ redactedFields: request.detail.redactedFields,
396
+ truncatedFields: request.detail.truncatedFields,
397
+ },
398
+ };
399
+ const pending: PendingToolPermission = {
400
+ nativeId: request.id,
401
+ toolCallId: request.toolCallId,
402
+ fingerprint,
403
+ retainedBytes: boundedJsonBytes(
404
+ publicRequest,
405
+ MAX_PENDING_PERMISSION_BYTES,
406
+ 512,
407
+ MAX_PENDING_PERMISSION_BYTES,
408
+ 4_096,
409
+ ),
410
+ generation: this.generation,
411
+ runtime: this.runtime,
412
+ ...(this.activeTurn ? { turnId: this.activeTurn.turnId } : {}),
413
+ ...(request.timeout !== undefined ? { expiresAt: Date.now() + request.timeout } : {}),
414
+ };
415
+ let retainedBytes = pending.retainedBytes;
416
+ for (const item of this.pendingPermissions.values()) retainedBytes += item.retainedBytes;
417
+ for (const item of this.inFlightPermissions.values()) retainedBytes += item.retainedBytes;
418
+ for (const item of this.pendingToolPermissions.values()) retainedBytes += item.retainedBytes;
419
+ for (const item of this.inFlightToolPermissions.values()) retainedBytes += item.retainedBytes;
420
+ if (
421
+ pending.retainedBytes === Number.POSITIVE_INFINITY ||
422
+ retainedBytes > MAX_PENDING_PERMISSION_BYTES
423
+ ) {
424
+ void this.runtime
425
+ .respondToToolApproval({
426
+ type: "tool_approval_response",
427
+ id: request.id,
428
+ toolCallId: request.toolCallId,
429
+ cancelled: true,
430
+ })
431
+ .catch(() => this.handleRuntimeFailure());
432
+ this.resolvedToolApprovalIds.add(request.id);
433
+ return;
434
+ }
435
+ this.pendingToolPermissions.set(permissionId, pending);
436
+ this.armToolPermissionTimeout(permissionId, pending);
437
+ this.projector.markAskPermissionRendered();
438
+ if (this.activeTurn) {
439
+ this.activeTurn.awaitingPermissionEvidence = true;
440
+ this.markAgentEvidence(this.activeTurn);
441
+ }
442
+ this.emit({ type: "session.permission", sessionId: this.id, request: publicRequest });
443
+ }
444
+
445
+ cancelToolPermission(request: OmpToolApprovalCancel): void {
446
+ for (const permissions of [this.pendingToolPermissions, this.inFlightToolPermissions]) {
447
+ for (const [permissionId, pending] of permissions) {
448
+ if (pending.nativeId !== request.targetId) continue;
449
+ if (pending.toolCallId !== request.toolCallId) {
450
+ this.handleRuntimeFailure("OMP tool approval cancellation did not match its tool call");
451
+ return;
452
+ }
453
+ permissions.delete(permissionId);
454
+ if (pending.timer !== undefined) this.scheduler.clear(pending.timer);
455
+ this.resolvedToolApprovalIds.add(pending.nativeId);
456
+ this.emit({ type: "session.permission_resolved", sessionId: this.id, permissionId });
457
+ this.reevaluateDeferredPermissionTerminal();
458
+ return;
459
+ }
460
+ }
461
+ if (!this.resolvedToolApprovalIds.has(request.targetId)) {
462
+ this.handleRuntimeFailure("OMP canceled an unknown tool approval request");
463
+ }
464
+ }
465
+
466
+ publishPermission(request: OmpQuestionRequest): void {
467
+ if (request.method === "select" && !request.options?.length) {
468
+ this.handleRuntimeFailure();
469
+ return;
470
+ }
471
+ const fingerprint = permissionFingerprint(request);
472
+ let existingId: string | undefined;
473
+ let existingPending: PendingPermission | undefined;
474
+ for (const [permissionId, pending] of this.pendingPermissions) {
475
+ if (pending.nativeId !== request.id) continue;
476
+ if (pending.fingerprint === fingerprint) {
477
+ existingId = permissionId;
478
+ existingPending = pending;
479
+ } else {
480
+ this.pendingPermissions.delete(permissionId);
481
+ if (pending.timer !== undefined) this.scheduler.clear(pending.timer);
482
+ this.emit({ type: "session.permission_resolved", sessionId: this.id, permissionId });
483
+ }
484
+ break;
485
+ }
486
+ if (!existingId) {
487
+ for (const pending of this.inFlightPermissions.values()) {
488
+ if (pending.nativeId !== request.id) continue;
489
+ if (pending.fingerprint !== fingerprint) this.handleRuntimeFailure();
490
+ return;
491
+ }
492
+ }
493
+ if (
494
+ !existingId &&
495
+ this.pendingPermissions.size +
496
+ this.inFlightPermissions.size +
497
+ this.pendingToolPermissions.size +
498
+ this.inFlightToolPermissions.size >=
499
+ MAX_PENDING_PERMISSIONS
500
+ ) {
501
+ this.rejectPermissionRequest(request, "Too many OMP questions are already pending");
502
+ return;
503
+ }
504
+ if (existingPending?.timer !== undefined) this.scheduler.clear(existingPending.timer);
505
+ if (!existingId) this.permissionSequence += 1;
506
+ const id =
507
+ existingId ?? `omp:permission:${this.permissionNamespace}:${this.permissionSequence}`;
508
+ const header = this.dataFilter.text(request.title ?? "OMP question", 4_096);
509
+ const freeformSentinel =
510
+ request.method === "select" && request.options.includes(OMP_ASK_USER_FREEFORM_SENTINEL)
511
+ ? OMP_ASK_USER_FREEFORM_SENTINEL
512
+ : undefined;
513
+ const optionValues = new Map<string, string>();
514
+ const displayValues = new Map<string, string>();
515
+ const usedOptionLabels = new Set<string>();
516
+ const optionDetails = request.method === "select" ? request.optionDetails : undefined;
517
+ const selectableOptions =
518
+ request.method === "select"
519
+ ? request.options.flatMap((nativeValue, index) =>
520
+ nativeValue === freeformSentinel ? [] : [{ nativeValue, index }],
521
+ )
522
+ : [];
523
+ const options =
524
+ request.method === "select"
525
+ ? selectableOptions.map(({ nativeValue, index }) => {
526
+ const baseLabel = this.dataFilter.text(nativeValue, 4_096);
527
+ let label = baseLabel;
528
+ let suffix = 2;
529
+ while (usedOptionLabels.has(label)) {
530
+ label = `${baseLabel} (${suffix})`;
531
+ suffix += 1;
532
+ }
533
+ usedOptionLabels.add(label);
534
+ const value = `${id}:option:${index}`;
535
+ optionValues.set(value, nativeValue);
536
+ displayValues.set(label, nativeValue);
537
+ return {
538
+ label,
539
+ value,
540
+ ...(optionDetails?.[index]?.description
541
+ ? {
542
+ description: this.dataFilter.text(
543
+ optionDetails[index]?.description ?? "",
544
+ 16_384,
545
+ ),
546
+ }
547
+ : {}),
548
+ };
549
+ })
550
+ : undefined;
551
+ const questionOptions =
552
+ request.method === "confirm" ? [{ label: "Yes" }, { label: "No" }] : (options ?? []);
553
+ const actions =
554
+ request.method === "select"
555
+ ? [
556
+ ...(options ?? []).map((option) => ({
557
+ id: option.value,
558
+ label: option.label,
559
+ behavior: "allow" as const,
560
+ variant: "secondary" as const,
561
+ })),
562
+ {
563
+ id: "cancel",
564
+ label: "Cancel",
565
+ behavior: "deny" as const,
566
+ variant: "secondary" as const,
567
+ },
568
+ ]
569
+ : [
570
+ {
571
+ id: "submit",
572
+ label: request.method === "confirm" ? "Confirm" : "Submit",
573
+ behavior: "allow" as const,
574
+ variant: "primary" as const,
575
+ },
576
+ {
577
+ id: "cancel",
578
+ label: "Cancel",
579
+ behavior: "deny" as const,
580
+ variant: "secondary" as const,
581
+ },
582
+ ];
583
+ const pending: PendingPermission = {
584
+ nativeId: request.id,
585
+ header,
586
+ fingerprint,
587
+ optionValues,
588
+ actionBehaviors: new Map(actions.map((action) => [action.id, action.behavior])),
589
+ displayValues,
590
+ generation: this.generation,
591
+ runtime: this.runtime,
592
+ request,
593
+ retainedBytes: boundedJsonBytes(
594
+ { request, header, options: questionOptions, actions },
595
+ MAX_PENDING_PERMISSION_BYTES,
596
+ 512,
597
+ MAX_PENDING_PERMISSION_BYTES,
598
+ 4_096,
599
+ ),
600
+ ...(this.activeTurn ? { turnId: this.activeTurn.turnId } : {}),
601
+ ...(request.timeout !== undefined ? { expiresAt: Date.now() + request.timeout } : {}),
602
+ ...(freeformSentinel ? { freeformSentinel } : {}),
603
+ };
604
+ let retainedPermissionBytes = pending.retainedBytes;
605
+ for (const [permissionId, retained] of this.pendingPermissions) {
606
+ if (permissionId !== existingId) retainedPermissionBytes += retained.retainedBytes;
607
+ }
608
+ for (const retained of this.inFlightPermissions.values()) {
609
+ retainedPermissionBytes += retained.retainedBytes;
610
+ }
611
+ for (const retained of this.pendingToolPermissions.values()) {
612
+ retainedPermissionBytes += retained.retainedBytes;
613
+ }
614
+ for (const retained of this.inFlightToolPermissions.values()) {
615
+ retainedPermissionBytes += retained.retainedBytes;
616
+ }
617
+ if (
618
+ pending.retainedBytes === Number.POSITIVE_INFINITY ||
619
+ retainedPermissionBytes > MAX_PENDING_PERMISSION_BYTES
620
+ ) {
621
+ if (existingId) {
622
+ this.pendingPermissions.delete(existingId);
623
+ this.emit({
624
+ type: "session.permission_resolved",
625
+ sessionId: this.id,
626
+ permissionId: existingId,
627
+ });
628
+ }
629
+ this.rejectPermissionRequest(request, "OMP question data exceeded the pending input budget");
630
+ return;
631
+ }
632
+ this.pendingPermissions.set(id, pending);
633
+ this.armPermissionTimeout(id, pending);
634
+ this.projector.markAskPermissionRendered();
635
+ if (this.activeTurn) {
636
+ this.activeTurn.awaitingPermissionEvidence = true;
637
+ this.markAgentEvidence(this.activeTurn);
638
+ }
639
+ this.emit({
640
+ type: "session.permission",
641
+ sessionId: this.id,
642
+ request: {
643
+ id,
644
+ name: `omp.${request.method}`,
645
+ kind: "question",
646
+ title: header,
647
+ ...(request.method === "confirm"
648
+ ? { description: this.dataFilter.text(request.message, 64 * 1024) }
649
+ : {}),
650
+ input: {
651
+ questions: [
652
+ {
653
+ header,
654
+ question: this.dataFilter.text(
655
+ request.method === "confirm" ? request.message : request.title,
656
+ 64 * 1024,
657
+ ),
658
+ options: questionOptions,
659
+ multiSelect: false,
660
+ ...(freeformSentinel ? { allowOther: true } : {}),
661
+ ...(request.method === "input" && request.placeholder
662
+ ? { placeholder: this.dataFilter.text(request.placeholder, 4_096) }
663
+ : {}),
664
+ ...((request.method === "input" || request.method === "editor") && request.prefill
665
+ ? { prefill: this.dataFilter.text(request.prefill) }
666
+ : {}),
667
+ },
668
+ ],
669
+ },
670
+ actions,
671
+ },
672
+ });
673
+ }
674
+
675
+ private rejectPermissionRequest(request: OmpQuestionRequest, description: string): void {
676
+ this.emit({
677
+ type: "session.notice",
678
+ sessionId: this.id,
679
+ notice: {
680
+ id: `omp:permission-rejected:${this.permissionSequence + 1}`,
681
+ severity: "warning",
682
+ title: "OMP question canceled",
683
+ description,
684
+ },
685
+ });
686
+ void this.runtime
687
+ .respondToExtensionUi({ type: "extension_ui_response", id: request.id, cancelled: true })
688
+ .catch(() => this.handleRuntimeFailure());
689
+ }
690
+
691
+ private freeformSelection(
692
+ pending: PendingPermission,
693
+ response: ProviderPermissionResponse,
694
+ ): string | undefined {
695
+ if (
696
+ pending.request.method !== "select" ||
697
+ !pending.freeformSentinel ||
698
+ response.behavior !== "allow" ||
699
+ response.selectedActionId !== undefined
700
+ ) {
701
+ return;
702
+ }
703
+ const answers = response.updatedInput?.answers;
704
+ const answer =
705
+ answers && typeof answers === "object" && !Array.isArray(answers)
706
+ ? answers[pending.header]
707
+ : undefined;
708
+ const value = Array.isArray(answer) ? answer[0] : answer;
709
+ if (
710
+ typeof value !== "string" ||
711
+ pending.optionValues.has(value) ||
712
+ pending.displayValues.has(value)
713
+ ) {
714
+ return;
715
+ }
716
+ if (
717
+ value === pending.freeformSentinel ||
718
+ value.trim().length === 0 ||
719
+ value.includes("\0") ||
720
+ utf8Bytes(value) > MAX_FREEFORM_RESPONSE_BYTES
721
+ ) {
722
+ throw new OmpPublicError("OMP freeform response is invalid");
723
+ }
724
+ return value;
725
+ }
726
+
727
+ private extensionUiResponse(
728
+ pending: PendingPermission,
729
+ response: ProviderPermissionResponse,
730
+ ): OmpExtensionUiResponse {
731
+ if (response.selectedActionId !== undefined) {
732
+ const expectedBehavior = pending.actionBehaviors.get(response.selectedActionId);
733
+ if (expectedBehavior === undefined || expectedBehavior !== response.behavior) {
734
+ throw new OmpPublicError("OMP permission action is invalid");
735
+ }
736
+ }
737
+ const { nativeId, request, header } = pending;
738
+ if (request.method === "confirm") {
739
+ if (response.behavior === "deny") {
740
+ return { type: "extension_ui_response", id: nativeId, confirmed: false };
741
+ }
742
+ const answers = response.updatedInput?.answers;
743
+ const answer =
744
+ answers && typeof answers === "object" && !Array.isArray(answers)
745
+ ? answers[header]
746
+ : undefined;
747
+ return {
748
+ type: "extension_ui_response",
749
+ id: nativeId,
750
+ confirmed: typeof answer === "string" ? /^yes$/iu.test(answer.trim()) : true,
751
+ };
752
+ }
753
+ this.reevaluateDeferredPermissionTerminal();
754
+ if (response.behavior === "deny") {
755
+ return { type: "extension_ui_response", id: nativeId, cancelled: true };
756
+ }
757
+ const selectedValue = response.selectedActionId
758
+ ? pending.optionValues.get(response.selectedActionId)
759
+ : undefined;
760
+ if (selectedValue !== undefined) {
761
+ return { type: "extension_ui_response", id: nativeId, value: selectedValue };
762
+ }
763
+ const answers = response.updatedInput?.answers;
764
+ if (!answers || typeof answers !== "object" || Array.isArray(answers)) {
765
+ throw new OmpPublicError("OMP question response requires answers");
766
+ }
767
+ const answer = answers[header];
768
+ const publicValue = Array.isArray(answer) ? answer[0] : answer;
769
+ if (typeof publicValue !== "string") {
770
+ throw new OmpPublicError("OMP question response is invalid");
771
+ }
772
+ if (
773
+ request.method === "select" &&
774
+ pending.freeformSentinel &&
775
+ publicValue !== pending.freeformSentinel &&
776
+ !pending.displayValues.has(publicValue) &&
777
+ !pending.optionValues.has(publicValue)
778
+ ) {
779
+ if (
780
+ publicValue.trim().length === 0 ||
781
+ publicValue.includes("\0") ||
782
+ utf8Bytes(publicValue) > MAX_FREEFORM_RESPONSE_BYTES
783
+ ) {
784
+ throw new OmpPublicError("OMP freeform response is invalid");
785
+ }
786
+ this.pendingFreeformSelection = {
787
+ value: publicValue,
788
+ nativeSelectId: pending.nativeId,
789
+ generation: pending.generation,
790
+ runtime: pending.runtime,
791
+ ...(pending.turnId ? { turnId: pending.turnId } : {}),
792
+ };
793
+ return {
794
+ type: "extension_ui_response",
795
+ id: nativeId,
796
+ value: pending.freeformSentinel,
797
+ };
798
+ }
799
+ if (request.method === "select") {
800
+ const mapped =
801
+ pending.optionValues.get(publicValue) ?? pending.displayValues.get(publicValue);
802
+ if (mapped === undefined) {
803
+ throw new OmpPublicError("OMP selection response is invalid");
804
+ }
805
+ return { type: "extension_ui_response", id: nativeId, value: mapped };
806
+ }
807
+ return { type: "extension_ui_response", id: nativeId, value: publicValue };
808
+ }
809
+
810
+ resolvePermissionByNativeId(nativeId: string): void {
811
+ for (const permissions of [this.pendingPermissions, this.inFlightPermissions]) {
812
+ for (const [permissionId, pending] of permissions) {
813
+ if (pending.nativeId !== nativeId) continue;
814
+ permissions.delete(permissionId);
815
+ if (pending.timer !== undefined) this.scheduler.clear(pending.timer);
816
+ this.emit({ type: "session.permission_resolved", sessionId: this.id, permissionId });
817
+ this.reevaluateDeferredPermissionTerminal();
818
+ return;
819
+ }
820
+ }
821
+ }
822
+
823
+ resolveTurnPermissions(turnId: string): void {
824
+ if (this.pendingFreeformSelection?.turnId === turnId) this.pendingFreeformSelection = null;
825
+ this.resolvePermissions((pending) => pending.turnId === turnId, true);
826
+ this.resolveToolPermissions((pending) => pending.turnId === turnId, true);
827
+ }
828
+ resolveAllPermissions(cancelNative = false): void {
829
+ this.pendingFreeformSelection = null;
830
+ this.resolvePermissions(() => true, cancelNative);
831
+ this.resolveToolPermissions(() => true, cancelNative);
832
+ }
833
+
834
+ private resolvePermissions(
835
+ matches: (pending: PendingPermission) => boolean,
836
+ cancelNative: boolean,
837
+ ): void {
838
+ const permissionIds = new Set<string>();
839
+ for (const permissions of [this.pendingPermissions, this.inFlightPermissions]) {
840
+ for (const [permissionId, pending] of permissions) {
841
+ if (!matches(pending)) continue;
842
+ permissions.delete(permissionId);
843
+ if (pending.timer !== undefined) this.scheduler.clear(pending.timer);
844
+ permissionIds.add(permissionId);
845
+ if (cancelNative && permissions === this.pendingPermissions) {
846
+ void pending.runtime
847
+ .respondToExtensionUi({
848
+ type: "extension_ui_response",
849
+ id: pending.nativeId,
850
+ cancelled: true,
851
+ })
852
+ .catch(() => {
853
+ if (!this.closed && !this.runtimeDead) {
854
+ this.invalidateRuntime("OMP permission cancellation failed");
855
+ }
856
+ });
857
+ }
858
+ }
859
+ }
860
+ for (const permissionId of permissionIds) {
861
+ this.emit({ type: "session.permission_resolved", sessionId: this.id, permissionId });
862
+ }
863
+ this.reevaluateDeferredPermissionTerminal();
864
+ }
865
+ private resolveToolPermissions(
866
+ matches: (pending: PendingToolPermission) => boolean,
867
+ cancelNative: boolean,
868
+ ): void {
869
+ const permissionIds = new Set<string>();
870
+ for (const permissions of [this.pendingToolPermissions, this.inFlightToolPermissions]) {
871
+ for (const [permissionId, pending] of permissions) {
872
+ if (!matches(pending)) continue;
873
+ permissions.delete(permissionId);
874
+ if (pending.timer !== undefined) this.scheduler.clear(pending.timer);
875
+ this.resolvedToolApprovalIds.add(pending.nativeId);
876
+ permissionIds.add(permissionId);
877
+ if (cancelNative && permissions === this.pendingToolPermissions) {
878
+ void pending.runtime
879
+ .respondToToolApproval({
880
+ type: "tool_approval_response",
881
+ id: pending.nativeId,
882
+ toolCallId: pending.toolCallId,
883
+ cancelled: true,
884
+ })
885
+ .catch(() => {
886
+ if (!this.closed && !this.runtimeDead) {
887
+ this.invalidateRuntime("OMP tool permission cancellation failed");
888
+ }
889
+ });
890
+ }
891
+ }
892
+ }
893
+ for (const permissionId of permissionIds) {
894
+ this.emit({ type: "session.permission_resolved", sessionId: this.id, permissionId });
895
+ }
896
+ this.reevaluateDeferredPermissionTerminal();
897
+ }
898
+
899
+ private armToolPermissionTimeout(permissionId: string, pending: PendingToolPermission): void {
900
+ if (pending.expiresAt === undefined) return;
901
+ const remainingMs = Math.max(0, pending.expiresAt - Date.now());
902
+ pending.timer = this.scheduler.set(() => {
903
+ if (this.pendingToolPermissions.get(permissionId) !== pending) return;
904
+ this.pendingToolPermissions.delete(permissionId);
905
+ this.inFlightToolPermissions.set(permissionId, pending);
906
+ void pending.runtime
907
+ .respondToToolApproval({
908
+ type: "tool_approval_response",
909
+ id: pending.nativeId,
910
+ toolCallId: pending.toolCallId,
911
+ cancelled: true,
912
+ timedOut: true,
913
+ })
914
+ .then(
915
+ () => {
916
+ if (this.inFlightToolPermissions.get(permissionId) !== pending) return;
917
+ this.inFlightToolPermissions.delete(permissionId);
918
+ this.resolvedToolApprovalIds.add(pending.nativeId);
919
+ this.emit({ type: "session.permission_resolved", sessionId: this.id, permissionId });
920
+ this.reevaluateDeferredPermissionTerminal();
921
+ },
922
+ () => this.handleRuntimeFailure(),
923
+ );
924
+ }, remainingMs);
925
+ }
926
+
927
+ private armPermissionTimeout(permissionId: string, pending: PendingPermission): void {
928
+ if (pending.expiresAt === undefined) return;
929
+ const remainingMs = Math.max(0, pending.expiresAt - Date.now());
930
+ pending.timer = this.scheduler.set(() => {
931
+ if (this.pendingPermissions.get(permissionId) !== pending) return;
932
+ this.pendingPermissions.delete(permissionId);
933
+ if (!this.permissionOwnerIsCurrent(pending)) {
934
+ this.emit({ type: "session.permission_resolved", sessionId: this.id, permissionId });
935
+ this.reevaluateDeferredPermissionTerminal();
936
+ return;
937
+ }
938
+ this.inFlightPermissions.set(permissionId, pending);
939
+ void pending.runtime
940
+ .respondToExtensionUi({
941
+ type: "extension_ui_response",
942
+ id: pending.nativeId,
943
+ cancelled: true,
944
+ timedOut: true,
945
+ })
946
+ .then(
947
+ () => {
948
+ if (this.inFlightPermissions.get(permissionId) !== pending) return;
949
+ this.inFlightPermissions.delete(permissionId);
950
+ this.emit({ type: "session.permission_resolved", sessionId: this.id, permissionId });
951
+ this.reevaluateDeferredPermissionTerminal();
952
+ },
953
+ () => this.handleRuntimeFailure(),
954
+ );
955
+ }, remainingMs);
956
+ }
957
+
958
+ private permissionOwnerIsCurrent(pending: PendingPermission | PendingToolPermission): boolean {
959
+ if (
960
+ this.closed ||
961
+ this.runtimeDead ||
962
+ pending.generation !== this.generation ||
963
+ pending.runtime !== this.runtime
964
+ ) {
965
+ return false;
966
+ }
967
+ if (pending.turnId === undefined) return true;
968
+ return this.activeTurn?.turnId === pending.turnId && !this.activeTurn.terminal;
969
+ }
970
+
971
+ hasForTurn(turnId: string): boolean {
972
+ const ownsTurn = (pending: PendingPermission | PendingToolPermission) =>
973
+ pending.turnId === turnId;
974
+ return (
975
+ [...this.pendingPermissions.values()].some(ownsTurn) ||
976
+ [...this.inFlightPermissions.values()].some(ownsTurn) ||
977
+ [...this.pendingToolPermissions.values()].some(ownsTurn) ||
978
+ [...this.inFlightToolPermissions.values()].some(ownsTurn)
979
+ );
980
+ }
981
+
982
+ clearPendingFreeformSelection(nativeId?: string): void {
983
+ if (nativeId === undefined || this.pendingFreeformSelection?.nativeSelectId === nativeId) {
984
+ this.pendingFreeformSelection = null;
985
+ }
986
+ }
987
+ }