@omercnet/paseo-omp 5.1.1-next.332.1 → 5.1.1-next.336.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.
@@ -8,6 +8,7 @@ import {
8
8
  import { useCallback, useEffect, useMemo, useState } from "react";
9
9
  import { Text, View } from "react-native";
10
10
  import {
11
+ MAX_SESSIONS_UPPER_BOUND,
11
12
  providerLaunchSettings,
12
13
  providerLaunchSettingsSchema,
13
14
  } from "../shared/provider-launch-settings";
@@ -23,6 +24,11 @@ function ProviderLaunchSettingsControls({ settings }: { settings: ReadySettings
23
24
  useEffect(() => {
24
25
  setRawNames(savedNames);
25
26
  }, [savedNames]);
27
+ const savedMaxSessions = String(settings.values.maxSessions);
28
+ const [rawMaxSessions, setRawMaxSessions] = useState(() => savedMaxSessions);
29
+ useEffect(() => {
30
+ setRawMaxSessions(savedMaxSessions);
31
+ }, [savedMaxSessions]);
26
32
  const inheritEnv = [
27
33
  ...new Set(
28
34
  rawNames
@@ -31,7 +37,7 @@ function ProviderLaunchSettingsControls({ settings }: { settings: ReadySettings
31
37
  .filter(Boolean),
32
38
  ),
33
39
  ];
34
- const next = providerLaunchSettingsSchema.safeParse({ inheritEnv });
40
+ const next = providerLaunchSettingsSchema.safeParse({ ...settings.values, inheritEnv });
35
41
  const error = next.success
36
42
  ? null
37
43
  : (next.error.issues[0]?.message ?? "Enter valid variable names.");
@@ -39,6 +45,14 @@ function ProviderLaunchSettingsControls({ settings }: { settings: ReadySettings
39
45
  if (!next.success) return;
40
46
  await settings.save(next.data, settings.revision);
41
47
  }, [next, settings]);
48
+ const nextMaxSessions = providerLaunchSettingsSchema.safeParse({
49
+ ...settings.values,
50
+ maxSessions: Number(rawMaxSessions.trim()),
51
+ });
52
+ const saveMaxSessions = useCallback(async () => {
53
+ if (!nextMaxSessions.success) return;
54
+ await settings.save(nextMaxSessions.data, settings.revision);
55
+ }, [nextMaxSessions, settings]);
42
56
 
43
57
  return (
44
58
  <View style={{ gap: 12 }}>
@@ -69,7 +83,9 @@ function ProviderLaunchSettingsControls({ settings }: { settings: ReadySettings
69
83
  actionLabel="Clear"
70
84
  disabled={settings.saving || settings.values.inheritEnv.length === 0}
71
85
  onPress={async () => {
72
- if (await settings.save({ inheritEnv: [] }, settings.revision)) setRawNames("");
86
+ if (await settings.save({ ...settings.values, inheritEnv: [] }, settings.revision)) {
87
+ setRawNames("");
88
+ }
73
89
  }}
74
90
  />
75
91
  {settings.saveError ? (
@@ -83,6 +99,34 @@ function ProviderLaunchSettingsControls({ settings }: { settings: ReadySettings
83
99
  ) : null}
84
100
  </SettingsCard>
85
101
  </SettingsSection>
102
+ <SettingsSection
103
+ title="Session limit"
104
+ info="Applies to all OMP Plugin agents on this host together, including idle ones."
105
+ >
106
+ <SettingsCard>
107
+ <SettingsInput
108
+ key={savedMaxSessions}
109
+ label="Open sessions"
110
+ hint="New OMP Plugin agents are refused while this many sessions are open. Archive idle agents to free sessions."
111
+ initialValue={savedMaxSessions}
112
+ onChangeText={setRawMaxSessions}
113
+ error={
114
+ nextMaxSessions.success
115
+ ? undefined
116
+ : `Enter a whole number from 1 to ${MAX_SESSIONS_UPPER_BOUND}.`
117
+ }
118
+ disabled={settings.saving}
119
+ />
120
+ <SettingsAction
121
+ label="Save session limit"
122
+ actionLabel="Save"
123
+ disabled={
124
+ settings.saving || rawMaxSessions === savedMaxSessions || !nextMaxSessions.success
125
+ }
126
+ onPress={() => void saveMaxSessions()}
127
+ />
128
+ </SettingsCard>
129
+ </SettingsSection>
86
130
  </View>
87
131
  );
88
132
  }
package/index.server.ts CHANGED
@@ -25,6 +25,7 @@ import {
25
25
  } from "./server/provider/profile-providers";
26
26
  import { createOmpProvider } from "./server/provider/registration";
27
27
  import { OmpPublicError } from "./server/provider/security";
28
+ import { OmpSessionLimit } from "./server/provider/session-limit";
28
29
  import { resolveGetOmpProviderHealth } from "./server/provider-diagnostics";
29
30
  import { resolveListOmpQuotas } from "./server/quota";
30
31
  import { resolveListOmpSessions } from "./server/sessions";
@@ -62,6 +63,11 @@ export default function contribute(server: PluginServerContext) {
62
63
  throw new OmpPublicError("OMP host launch settings are unavailable");
63
64
  };
64
65
  const browserAuthorizationRegistry = new OmpBrowserAuthorizationRegistry();
66
+ const sessionLimit = new OmpSessionLimit(async () => {
67
+ const current = await launchSettings.read();
68
+ return current.status === "ready" ? current.values.maxSessions : undefined;
69
+ });
70
+ const removeSessionLimitListener = launchSettings.subscribe(() => sessionLimit.refresh());
65
71
  const protocolViolations = new OmpProtocolViolationCollector();
66
72
  const operationalFailures = new OmpOperationalFailureCollector();
67
73
  const profiles = discoverOmpProfilesSync();
@@ -70,6 +76,7 @@ export default function contribute(server: PluginServerContext) {
70
76
  server.registerProvider(
71
77
  createProfileOmpProvider(profile, {
72
78
  browserAuthorizationRegistry,
79
+ sessionLimit,
73
80
  reportProtocolViolation: protocolViolations.report,
74
81
  reportOperationalFailure: operationalFailures.report,
75
82
  resolveHostInheritEnv,
@@ -104,6 +111,7 @@ export default function contribute(server: PluginServerContext) {
104
111
  server.registerProvider(
105
112
  createOmpProvider({
106
113
  browserAuthorizationRegistry,
114
+ sessionLimit,
107
115
  reportProtocolViolation: protocolViolations.report,
108
116
  reportOperationalFailure: operationalFailures.report,
109
117
  resolveHostInheritEnv,
@@ -111,6 +119,7 @@ export default function contribute(server: PluginServerContext) {
111
119
  );
112
120
  return () => {
113
121
  browserAuthorizationRegistry.clear();
122
+ removeSessionLimitListener();
114
123
  removeSessionOpenHook();
115
124
  };
116
125
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@omercnet/paseo-omp",
3
- "version": "5.1.1-next.332.1",
3
+ "version": "5.1.1-next.336.1",
4
4
  "type": "module",
5
5
  "description": "Paseo integration for OMP, including its direct provider and workspace tooling.",
6
6
  "license": "MIT",
@@ -1,9 +1,11 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import type { RpcInput } from "@getpaseo/plugin";
3
3
  import type { openOmpMcpAuthorizationInPaseoBrowser } from "../shared/mcp";
4
+ import { MAX_SESSIONS_UPPER_BOUND } from "../shared/provider-launch-settings";
4
5
  import { OmpPublicError } from "./provider/security";
5
6
 
6
- const MAX_REGISTERED_SESSIONS = 32;
7
+ // One registration per live session. The shared session limit refuses sessions first.
8
+ const MAX_REGISTERED_SESSIONS = MAX_SESSIONS_UPPER_BOUND;
7
9
  const MAX_AUTHORIZATIONS_PER_SESSION = 16;
8
10
 
9
11
  type BrowserAuthorizationOpener = (url: string) => Promise<void>;
@@ -1,2 +1,2 @@
1
1
  /** Release Please keeps this bundle-safe constant aligned with package.json. */
2
- export const PASEO_OMP_PACKAGE_VERSION = "5.1.1-next.332.1"; // x-release-please-version
2
+ export const PASEO_OMP_PACKAGE_VERSION = "5.1.1-next.336.1"; // x-release-please-version
@@ -34,6 +34,7 @@ import {
34
34
  } from "./security";
35
35
  import { type OmpOpenedSession, OmpProviderSession, ompPersistenceSessionId } from "./session";
36
36
  import { OmpHistorySession } from "./session-history";
37
+ import { OmpSessionLimit } from "./session-limit";
37
38
  import { isOmpHistoryOnlyOpen } from "./session-purpose";
38
39
 
39
40
  type ProviderConfigurationCompat = {
@@ -69,7 +70,6 @@ const SUPPORTED_INPUTS: Readonly<Record<string, true>> = {
69
70
  "session.can_stop_runtime": true,
70
71
  "session.close": true,
71
72
  };
72
- const MAX_CONNECTION_SESSIONS = 32;
73
73
  const MAX_ACTIVE_OPERATIONS = 128;
74
74
  const MAX_PROVIDER_INPUT_BYTES = 2 * 1024 * 1024;
75
75
  const MAX_NESTED_OPTION_BYTES = 256 * 1024;
@@ -723,6 +723,7 @@ export function createOmpConnection(
723
723
  console.error("OMP provider failure", diagnostic),
724
724
  reportOperationalFailure: OmpOperationalFailureReporter = () => {},
725
725
  resolveHostInheritEnv?: () => Promise<readonly string[]>,
726
+ sessionLimit = new OmpSessionLimit(),
726
727
  ): ProviderConnection {
727
728
  const errorDetails = (error: unknown, fallback: string): { message: string } => {
728
729
  if (isOmpPublicError(error)) return { message: error.message };
@@ -767,6 +768,13 @@ export function createOmpConnection(
767
768
  string,
768
769
  { token: symbol; nativeSessionId?: string; cleanup?: Promise<void> }
769
770
  >();
771
+ // One session id is held by `sessions`, `opening` and `failedCleanup` at different moments, and
772
+ // by two of them at once while it opens or fails. After a failed close only unresolved cleanup
773
+ // stays counted, until it drains.
774
+ const untrackSessions = sessionLimit.track(() => {
775
+ if (closed) return failedCleanup.size;
776
+ return new Set([...sessions.keys(), ...opening.keys(), ...failedCleanup.keys()]).size;
777
+ });
770
778
  const shutdown = new AbortController();
771
779
  let catalogCleanup: Promise<void> | null = null;
772
780
  const activeOperations = new Set<Promise<void>>();
@@ -809,7 +817,9 @@ export function createOmpConnection(
809
817
  ...(quarantinedNativeSessionId ? { nativeSessionId: quarantinedNativeSessionId } : {}),
810
818
  });
811
819
  nativeReservations.quarantine(quarantinedNativeSessionId, token, cleanup, () => {
812
- if (failedCleanup.get(sessionId)?.token === token) failedCleanup.delete(sessionId);
820
+ if (failedCleanup.get(sessionId)?.token !== token) return;
821
+ failedCleanup.delete(sessionId);
822
+ if (closed && failedCleanup.size === 0) untrackSessions();
813
823
  });
814
824
  };
815
825
 
@@ -906,8 +916,13 @@ export function createOmpConnection(
906
916
  requestFailure(input.requestId, new OmpPublicError("OMP session already exists"));
907
917
  return;
908
918
  }
909
- if (sessions.size + opening.size + failedCleanup.size >= MAX_CONNECTION_SESSIONS) {
910
- requestFailure(input.requestId, new OmpPublicError("OMP session limit reached"));
919
+ if (sessionLimit.openSessions >= sessionLimit.maxSessions) {
920
+ requestFailure(
921
+ input.requestId,
922
+ new OmpPublicError(
923
+ `OMP session limit reached (${sessionLimit.maxSessions} open sessions). Archive idle agents or raise the session limit in OMP → Plugin.`,
924
+ ),
925
+ );
911
926
  return;
912
927
  }
913
928
  const token = Symbol(input.sessionId);
@@ -1313,7 +1328,12 @@ export function createOmpConnection(
1313
1328
  return () => listeners.delete(listener);
1314
1329
  },
1315
1330
  close() {
1316
- closePromise ??= Promise.resolve().then(disposeConnection);
1331
+ closePromise ??= Promise.resolve()
1332
+ .then(disposeConnection)
1333
+ .then(untrackSessions, (error: unknown) => {
1334
+ if (failedCleanup.size === 0) untrackSessions();
1335
+ throw error;
1336
+ });
1317
1337
  return closePromise;
1318
1338
  },
1319
1339
  };
@@ -12,6 +12,7 @@ import { OmpRpcRuntime, type OmpRuntime } from "./omp-rpc";
12
12
  import type { OmpProtocolViolationDiagnostic } from "./omp-rpc-protocol";
13
13
  import { parseOmpProviderOptions } from "./provider-options";
14
14
  import { boundedJsonBytes } from "./security";
15
+ import { OmpSessionLimit } from "./session-limit";
15
16
  import { OmpProviderOptionsSchema } from "./settings";
16
17
  import type { OmpTimelineScheduler } from "./timeline-projector";
17
18
 
@@ -121,6 +122,8 @@ export interface OmpProviderOptions {
121
122
  mcpInitializationTimeoutMs?: number;
122
123
  mcpConnector?: OmpMcpConnector;
123
124
  browserAuthorizationRegistry?: OmpBrowserAuthorizationRegistry;
125
+ /** Shared by every provider of one plugin instance; each provider has its own otherwise. */
126
+ sessionLimit?: OmpSessionLimit;
124
127
  reportProtocolViolation?: (diagnostic: OmpProtocolViolationDiagnostic) => void | Promise<void>;
125
128
  reportOperationalFailure?: OmpOperationalFailureReporter;
126
129
  availabilityProbe?: (
@@ -150,6 +153,7 @@ export function createOmpProvider(options: OmpProviderOptions = {}): ProviderReg
150
153
  reportProtocolViolation: options.reportProtocolViolation,
151
154
  });
152
155
  const nativeReservations = new OmpNativeSessionReservations();
156
+ const sessionLimit = options.sessionLimit ?? new OmpSessionLimit();
153
157
  const checkAvailability: NonNullable<ProviderRegistrationCompat["checkAvailability"]> = async (
154
158
  catalogOptions,
155
159
  context,
@@ -216,6 +220,7 @@ export function createOmpProvider(options: OmpProviderOptions = {}): ProviderReg
216
220
  const capabilities = CAPABILITIES.filter((capability) =>
217
221
  requestedCapabilities.has(capability),
218
222
  );
223
+ await sessionLimit.refresh();
219
224
  return createOmpConnection(
220
225
  runtime,
221
226
  capabilities,
@@ -229,6 +234,7 @@ export function createOmpProvider(options: OmpProviderOptions = {}): ProviderReg
229
234
  undefined,
230
235
  options.reportOperationalFailure,
231
236
  options.resolveHostInheritEnv,
237
+ sessionLimit,
232
238
  );
233
239
  },
234
240
  };
@@ -0,0 +1,32 @@
1
+ import { DEFAULT_MAX_SESSIONS } from "../../shared/provider-launch-settings";
2
+
3
+ /**
4
+ * Counts open sessions across every provider connection that shares this instance. The plugin
5
+ * shares one instance between all of its OMP providers, so the limit applies to the whole host.
6
+ */
7
+ export class OmpSessionLimit {
8
+ private readonly counts = new Set<() => number>();
9
+ maxSessions = DEFAULT_MAX_SESSIONS;
10
+
11
+ /** `readConfigured` returns the configured limit, or `undefined` to keep the current one. */
12
+ constructor(private readonly readConfigured?: () => Promise<number | undefined>) {}
13
+
14
+ async refresh(): Promise<void> {
15
+ const configured = await this.readConfigured?.();
16
+ if (configured !== undefined) this.maxSessions = configured;
17
+ }
18
+
19
+ /** Adds a connection's open sessions to the total until the returned function runs. */
20
+ track(count: () => number): () => void {
21
+ this.counts.add(count);
22
+ return () => {
23
+ this.counts.delete(count);
24
+ };
25
+ }
26
+
27
+ get openSessions(): number {
28
+ let total = 0;
29
+ for (const count of this.counts) total += count();
30
+ return total;
31
+ }
32
+ }
@@ -372,8 +372,8 @@ export class OmpTimelineProjector {
372
372
  private flushTimer: unknown;
373
373
  private currentTurnId: string | null = null;
374
374
  private assistantSequence = 0;
375
+ // Least recently used first, so a long turn evicts its oldest identities.
375
376
  private readonly turnNativeMessageIds = new Map<string, string>();
376
- private nativeIdentitySaturated = false;
377
377
  private assistantIdentitySequence = 0;
378
378
  private noticeSequence = 0;
379
379
  private toolSequence = 0;
@@ -901,7 +901,6 @@ export class OmpTimelineProjector {
901
901
  this.currentTurnId = null;
902
902
  this.assistantSequence = 0;
903
903
  this.turnNativeMessageIds.clear();
904
- this.nativeIdentitySaturated = false;
905
904
  this.assistantIdentitySequence = 0;
906
905
  this.toolSequence = 0;
907
906
  this.userSequence = 0;
@@ -1174,7 +1173,6 @@ export class OmpTimelineProjector {
1174
1173
  this.currentTurnId = null;
1175
1174
  this.assistantSequence = 0;
1176
1175
  this.turnNativeMessageIds.clear();
1177
- this.nativeIdentitySaturated = false;
1178
1176
  }
1179
1177
 
1180
1178
  close(): void {
@@ -1242,13 +1240,12 @@ export class OmpTimelineProjector {
1242
1240
  this.commandPublishedText = "";
1243
1241
  }
1244
1242
 
1245
- private beginStream(message: OmpAssistantMessage, turnId: string): StreamSnapshot | null {
1243
+ private beginStream(message: OmpAssistantMessage, turnId: string): StreamSnapshot {
1246
1244
  this.assistantSequence += 1;
1247
1245
  const nativeIdentity = assistantIdentity(message);
1248
1246
  const messageId = nativeIdentity
1249
1247
  ? this.messageIdForNativeIdentity(nativeIdentity)
1250
1248
  : this.nextAssistantMessageId(`turn:${turnId}:${this.assistantSequence}`);
1251
- if (!messageId) return null;
1252
1249
  this.stream = {
1253
1250
  messageId,
1254
1251
  ...(nativeIdentity ? { nativeIdentity } : {}),
@@ -1261,15 +1258,16 @@ export class OmpTimelineProjector {
1261
1258
  };
1262
1259
  return this.stream;
1263
1260
  }
1264
- private messageIdForNativeIdentity(nativeIdentity: string): string | undefined {
1261
+ private messageIdForNativeIdentity(nativeIdentity: string): string {
1265
1262
  const existing = this.turnNativeMessageIds.get(nativeIdentity);
1266
- if (existing) return existing;
1267
- if (
1268
- this.nativeIdentitySaturated ||
1269
- this.turnNativeMessageIds.size >= MAX_TURN_NATIVE_IDENTITIES
1270
- ) {
1271
- this.nativeIdentitySaturated = true;
1272
- return undefined;
1263
+ if (existing) {
1264
+ this.turnNativeMessageIds.delete(nativeIdentity);
1265
+ this.turnNativeMessageIds.set(nativeIdentity, existing);
1266
+ return existing;
1267
+ }
1268
+ if (this.turnNativeMessageIds.size >= MAX_TURN_NATIVE_IDENTITIES) {
1269
+ const oldest = this.turnNativeMessageIds.keys().next().value;
1270
+ if (oldest !== undefined) this.turnNativeMessageIds.delete(oldest);
1273
1271
  }
1274
1272
  const messageId = this.nextAssistantMessageId(nativeIdentity);
1275
1273
  this.turnNativeMessageIds.set(nativeIdentity, messageId);
@@ -1290,12 +1288,7 @@ export class OmpTimelineProjector {
1290
1288
  const nativeIdentity = assistantIdentity(message);
1291
1289
  if (this.stream && nativeIdentity && this.stream.nativeIdentity !== nativeIdentity) {
1292
1290
  if (!this.stream.nativeIdentity && !this.stream.published) {
1293
- const messageId = this.messageIdForNativeIdentity(nativeIdentity);
1294
- if (!messageId) {
1295
- this.stream = null;
1296
- return;
1297
- }
1298
- this.stream.messageId = messageId;
1291
+ this.stream.messageId = this.messageIdForNativeIdentity(nativeIdentity);
1299
1292
  this.stream.nativeIdentity = nativeIdentity;
1300
1293
  } else {
1301
1294
  this.flush();
@@ -1303,7 +1296,6 @@ export class OmpTimelineProjector {
1303
1296
  }
1304
1297
  }
1305
1298
  const stream = this.stream ?? this.beginStream(message, turnId);
1306
- if (!stream) return;
1307
1299
  if (update?.contentIndex !== undefined) {
1308
1300
  this.updateBlock(stream, message, update.contentIndex, update);
1309
1301
  return;
@@ -3,9 +3,13 @@ import { z } from "zod";
3
3
 
4
4
  export const MAX_INHERITED_ENVIRONMENT_NAMES = 256;
5
5
  export const environmentNameSchema = z.string().regex(/^[A-Za-z_][A-Za-z0-9_]{0,127}$/u);
6
+ export const DEFAULT_MAX_SESSIONS = 32;
7
+ // Every allowed session can open at once without reaching the 128 in-flight operations bound.
8
+ export const MAX_SESSIONS_UPPER_BOUND = 64;
6
9
 
7
10
  export const providerLaunchSettingsSchema = z.object({
8
11
  inheritEnv: z.array(environmentNameSchema).max(MAX_INHERITED_ENVIRONMENT_NAMES).default([]),
12
+ maxSessions: z.number().int().min(1).max(MAX_SESSIONS_UPPER_BOUND).default(DEFAULT_MAX_SESSIONS),
9
13
  });
10
14
 
11
15
  export type ProviderLaunchSettings = z.infer<typeof providerLaunchSettingsSchema>;