@omercnet/paseo-omp 5.1.1-next.335.1 → 5.1.1-next.339.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.335.1",
3
+ "version": "5.1.1-next.339.1",
4
4
  "type": "module",
5
5
  "description": "Paseo integration for OMP, including its direct provider and workspace tooling.",
6
6
  "license": "MIT",
@@ -64,8 +64,8 @@
64
64
  "@getpaseo/client": "0.14.0",
65
65
  "@getpaseo/plugin": "0.14.0",
66
66
  "@getpaseo/server": "0.14.0",
67
- "@oh-my-pi/pi-agent-core": "18.12.6",
68
- "@oh-my-pi/pi-coding-agent": "18.12.6",
67
+ "@oh-my-pi/pi-agent-core": "18.14.0",
68
+ "@oh-my-pi/pi-coding-agent": "18.14.0",
69
69
  "@tanstack/react-query": "^5.102.3",
70
70
  "@types/node": "^24.10.1",
71
71
  "@types/react": "^19.2.18",
@@ -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.335.1"; // x-release-please-version
2
+ export const PASEO_OMP_PACKAGE_VERSION = "5.1.1-next.339.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
  };
@@ -71,6 +71,8 @@ export const OMP_SESSION_EVENT_POLICIES = {
71
71
  tool_execution_end: "normalize",
72
72
  auto_compaction_start: "normalize",
73
73
  auto_compaction_end: "normalize",
74
+ manual_compaction_start: "normalize",
75
+ manual_compaction_end: "normalize",
74
76
  auto_retry_start: "normalize",
75
77
  auto_retry_end: "normalize",
76
78
  retry_fallback_applied: "normalize",
@@ -148,9 +150,15 @@ export const OMP_RPC_COMMAND_POLICIES = {
148
150
  get_logout_accounts: "unsupported",
149
151
  goal: "unsupported",
150
152
  live_mute: "unsupported",
153
+ live_connect: "unsupported",
151
154
  live_start: "unsupported",
152
155
  live_stop: "unsupported",
153
156
  logout: "unsupported",
157
+ navigate_tree: "unsupported",
158
+ abort_branch_summary: "unsupported",
159
+ set_label: "unsupported",
160
+ dictation_start: "unsupported",
161
+ dictation_stop: "unsupported",
154
162
  predict_word: "unsupported",
155
163
  predict_word_feedback: "unsupported",
156
164
  promote_queued_message: "unsupported",
@@ -254,9 +262,14 @@ export const OMP_PROTOCOL_EVENT_TYPES = [
254
262
  "todo_auto_clear",
255
263
  "auto_compaction_start",
256
264
  "auto_compaction_end",
265
+ "manual_compaction_start",
266
+ "manual_compaction_end",
257
267
  "available_commands_update",
258
268
  "notice",
259
269
  "command_output",
270
+ "extension_error",
271
+ "session_info_update",
272
+ "config_update",
260
273
  "extension_ui_request",
261
274
  "prompt_result",
262
275
  "host_tool_call",
@@ -403,6 +416,8 @@ function eventSchemaClassification(
403
416
  case "cache_warming_start":
404
417
  case "cache_warming_end":
405
418
  case "queue_update":
419
+ case "session_info_update":
420
+ case "config_update":
406
421
  return { reason: "configuration-event-schema", expected: "valid-configuration-event" };
407
422
  case "extension_ui_request":
408
423
  return { reason: "extension-ui-schema", expected: "valid-extension-ui-event" };
@@ -1606,6 +1621,17 @@ const OmpAgentSessionEventSchema = z.discriminatedUnion("type", [
1606
1621
  errorMessage: boundedString(64 * 1024).optional(),
1607
1622
  skipped: z.boolean().optional(),
1608
1623
  }),
1624
+ z.object({
1625
+ type: z.literal("manual_compaction_start"),
1626
+ mode: boundedString(MAX_CONFIG_EVENT_TEXT_BYTES).optional(),
1627
+ }),
1628
+ z.object({
1629
+ type: z.literal("manual_compaction_end"),
1630
+ result: OmpCompactionResultSchema.optional(),
1631
+ aborted: z.boolean(),
1632
+ skipped: z.boolean().optional(),
1633
+ errorMessage: boundedString(64 * 1024).optional(),
1634
+ }),
1609
1635
  z.object({
1610
1636
  type: z.literal("notice"),
1611
1637
  id: IDENTIFIER.optional(),
@@ -1799,6 +1825,14 @@ export const OmpRuntimeEventSchema = z.discriminatedUnion("type", [
1799
1825
  commands: z.array(OmpAvailableCommandSchema).max(MAX_ARRAY_ITEMS),
1800
1826
  }),
1801
1827
  z.object({ type: z.literal("command_output"), text: TEXT.optional() }),
1828
+ z.object({
1829
+ type: z.literal("extension_error"),
1830
+ extensionPath: boundedString(MAX_PATH_LENGTH),
1831
+ event: NAME,
1832
+ error: boundedString(64 * 1024),
1833
+ }),
1834
+ z.object({ type: z.literal("session_info_update") }),
1835
+ z.object({ type: z.literal("config_update") }),
1802
1836
  OmpExtensionUiRequestSchema,
1803
1837
  z.object({
1804
1838
  type: z.literal("prompt_result"),
@@ -1997,6 +2031,7 @@ export function parseOmpHostToolAgentResult(value: unknown): OmpHostToolResult["
1997
2031
  export type OmpRpcEvent =
1998
2032
  | z.infer<typeof OmpRuntimeEventSchema>
1999
2033
  | { type: "prompt_error"; id: string; error: string; code?: string }
2034
+ | { type: "session_settled" }
2000
2035
  | { type: "process_exit"; error: string };
2001
2036
  export type OmpAgentSessionEvent = z.infer<typeof OmpAgentSessionEventSchema>;
2002
2037
  export type OmpSubagentSnapshot = z.infer<typeof OmpSubagentsResultSchema>["subagents"][number];
@@ -1090,8 +1090,11 @@ export class OmpRpcProcess {
1090
1090
  });
1091
1091
  return;
1092
1092
  }
1093
- // Emitted once the session has no live or background work. Nothing consumes it yet.
1094
- if (type === "session_settled") return;
1093
+ // Emitted when the session is quiet: nothing live, queued, or pending in the background.
1094
+ if (type === "session_settled") {
1095
+ this.emit({ type: "session_settled" });
1096
+ return;
1097
+ }
1095
1098
  if (type === "ready") {
1096
1099
  if (this.readyReceived) {
1097
1100
  this.recordProtocolViolation("duplicate-ready", {
@@ -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
  };
@@ -7,6 +7,7 @@ import type { OmpSessionUsage } from "./session-usage";
7
7
  import type { OmpTimelineProjector } from "./timeline-projector";
8
8
 
9
9
  type Emit = (event: ProviderEvent) => void;
10
+ export type CompactionOutcome = "completed" | "failed" | "canceled" | "skipped";
10
11
 
11
12
  export interface ActiveCompaction {
12
13
  id: string;
@@ -73,51 +74,54 @@ export class OmpSessionCompaction {
73
74
  });
74
75
  }
75
76
 
76
- handleAutoEnd(
77
+ handleEnd(
77
78
  turn: ActiveTurn,
78
- event: Extract<OmpRpcEvent, { type: "auto_compaction_end" }>,
79
- ): boolean {
79
+ event: Extract<OmpRpcEvent, { type: "auto_compaction_end" | "manual_compaction_end" }>,
80
+ ): CompactionOutcome | undefined {
80
81
  if (this.discardedEnds > 0) {
81
82
  this.discardedEnds -= 1;
82
- return false;
83
+ return undefined;
83
84
  }
84
85
  const operation = this.operation;
86
+ const trigger = event.type === "auto_compaction_end" ? "auto" : "manual";
85
87
  if (
86
- operation?.trigger !== "auto" ||
88
+ operation?.trigger !== trigger ||
87
89
  operation.turnId !== turn.turnId ||
88
90
  operation.generation !== turn.generation
89
91
  ) {
90
- return false;
92
+ return undefined;
91
93
  }
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;
94
+ if (event.type === "auto_compaction_end") {
95
+ if (event.action !== undefined && operation.action !== event.action) {
96
+ this.retire("OMP emitted overlapping compactions");
97
+ this.discardedEnds = 1;
98
+ return undefined;
99
+ }
100
+ if (event.willRetry) {
101
+ operation.retrying = true;
102
+ return undefined;
103
+ }
104
+ if (operation.retrying) {
105
+ operation.retrying = false;
106
+ return undefined;
107
+ }
104
108
  }
105
- const state = event.aborted
109
+ const state: CompactionOutcome = event.aborted
106
110
  ? "canceled"
107
- : event.errorMessage
108
- ? "failed"
109
- : event.skipped
110
- ? "skipped"
111
+ : event.skipped
112
+ ? "skipped"
113
+ : event.errorMessage
114
+ ? "failed"
111
115
  : "completed";
112
116
  this.finish(state, {
113
117
  tokensBefore: event.result?.tokensBefore ?? event.result?.preTokens,
114
118
  message: event.errorMessage,
115
119
  });
116
- return true;
120
+ return state;
117
121
  }
118
122
 
119
123
  finish(
120
- state: "completed" | "failed" | "canceled" | "skipped",
124
+ state: CompactionOutcome,
121
125
  options: { tokensBefore?: number | null; message?: string } = {},
122
126
  ): void {
123
127
  const operation = this.operation;
@@ -24,6 +24,7 @@ export function isRuntimeConfigEvent(event: OmpRpcEvent): boolean {
24
24
  return (
25
25
  event.type === "model_changed" ||
26
26
  event.type === "thinking_level_changed" ||
27
+ event.type === "config_update" ||
27
28
  event.type === "retry_fallback_applied" ||
28
29
  event.type === "retry_fallback_succeeded"
29
30
  );
@@ -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
+ }
@@ -1,6 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import type { ProviderError } from "@getpaseo/plugin/server/provider";
3
3
  import type { OmpMessage, OmpRpcEvent } from "./omp-rpc-protocol";
4
+ import type { CompactionOutcome } from "./session-compaction";
4
5
 
5
6
  export type PendingUser = {
6
7
  clientMessageId: string;
@@ -12,7 +13,7 @@ export type PendingUser = {
12
13
  type TerminalCorrelationEvidence = "fresh-native-user" | "current-assistant";
13
14
 
14
15
  type TerminalCorrelation = {
15
- policy: "initial-turn" | "ordered-legacy" | "native-command";
16
+ policy: "initial-turn" | "ordered-legacy" | "native-command" | "native-run";
16
17
  evidence: Map<TerminalCorrelationEvidence, number>;
17
18
  };
18
19
 
@@ -24,6 +25,7 @@ export type TerminalCandidate = {
24
25
  | "initial-turn"
25
26
  | "ordered-legacy"
26
27
  | "native-command"
28
+ | "native-run"
27
29
  | "interrupted"
28
30
  | "ambiguous";
29
31
  };
@@ -55,6 +57,7 @@ export type ActiveTurn = {
55
57
  usageSampleFloor: number;
56
58
  manualCompaction: boolean;
57
59
  manualCompactionPending: boolean;
60
+ manualCompactionOutcome?: CompactionOutcome;
58
61
  manualCompactionDeadlineTimer?: unknown;
59
62
  agentEndPending: boolean;
60
63
  agentEndRetryTimer?: unknown;
@@ -64,11 +67,14 @@ export type ActiveTurn = {
64
67
  terminalizing: boolean;
65
68
  terminalization?: Promise<void>;
66
69
  terminalOutcome?: TurnOutcome;
70
+ /** Listeners may hold this turn's terminal state from here on, even if publishing threw. */
71
+ terminalEmitAttempted: boolean;
67
72
  operationalTerminalStage?: "failed" | "unresolved";
68
73
  terminalWake?: VoidDeferred;
69
74
  steerReady: VoidDeferred;
70
75
  steersInFlight: number;
71
76
  deferredAgentEnd?: TerminalCandidate;
77
+ heldAgentEnd?: HeldAgentEnd;
72
78
  activeToolCallIds: Set<string>;
73
79
  bufferedEvents: OmpRpcEvent[];
74
80
  pendingUsers: PendingUser[];
@@ -82,6 +88,38 @@ export type ActiveTurn = {
82
88
  lastCompletedAssistantEntryId?: string;
83
89
  };
84
90
 
91
+ type RunEvidence = Pick<
92
+ ActiveTurn,
93
+ | "completedMessageCount"
94
+ | "streamedMessageEntryIds"
95
+ | "streamedMessageIdentityComplete"
96
+ | "lastCompletedAssistantOutcome"
97
+ | "lastCompletedAssistantEntryId"
98
+ >;
99
+
100
+ /** A run that ended with `isTerminal: false`, kept until another run starts or OMP settles. */
101
+ export type HeldAgentEnd = {
102
+ event: Extract<OmpRpcEvent, { type: "agent_end" }>;
103
+ arrivalSequence: number;
104
+ evidence: RunEvidence;
105
+ };
106
+
107
+ /** Hands over the evidence of the run that just ended and starts the next run's evidence empty. */
108
+ export function takeRunEvidence(turn: ActiveTurn): RunEvidence {
109
+ const evidence: RunEvidence = {
110
+ completedMessageCount: turn.completedMessageCount,
111
+ streamedMessageEntryIds: turn.streamedMessageEntryIds.splice(0),
112
+ streamedMessageIdentityComplete: turn.streamedMessageIdentityComplete,
113
+ lastCompletedAssistantOutcome: turn.lastCompletedAssistantOutcome,
114
+ lastCompletedAssistantEntryId: turn.lastCompletedAssistantEntryId,
115
+ };
116
+ turn.completedMessageCount = 0;
117
+ turn.streamedMessageIdentityComplete = true;
118
+ turn.lastCompletedAssistantOutcome = undefined;
119
+ turn.lastCompletedAssistantEntryId = undefined;
120
+ return evidence;
121
+ }
122
+
85
123
  export type VoidDeferred = {
86
124
  promise: Promise<void>;
87
125
  resolve(value?: void | PromiseLike<void>): void;
@@ -249,6 +287,7 @@ export function createActiveTurn(
249
287
  usageSampleFloor: 0,
250
288
  agentEndPending: false,
251
289
  terminalizing: false,
290
+ terminalEmitAttempted: false,
252
291
  manualCompactionPending: manualCompaction,
253
292
  manualCompaction,
254
293
  activitySequence: 0,
@@ -276,6 +315,15 @@ export function createActiveTurn(
276
315
  ],
277
316
  };
278
317
  }
318
+ /** A run OMP started without a client prompt: an extension, goal, or background wake. */
319
+ export function createNativeRunTurn(generation: number): ActiveTurn {
320
+ return {
321
+ ...createActiveTurn("", "", generation, "native-run"),
322
+ promptResultEmitted: true,
323
+ pendingUsers: [],
324
+ };
325
+ }
326
+
279
327
  export function classifyTerminalCandidate(
280
328
  turn: ActiveTurn,
281
329
  event: Extract<OmpRpcEvent, { type: "agent_end" }>,
@@ -286,6 +334,9 @@ export function classifyTerminalCandidate(
286
334
  if (turn.terminalCorrelation.policy === "initial-turn") {
287
335
  return { event, confidence: "initial-turn", arrivalSequence };
288
336
  }
337
+ if (turn.terminalCorrelation.policy === "native-run") {
338
+ return { event, confidence: "native-run", arrivalSequence };
339
+ }
289
340
  const assistantSequence = turn.terminalCorrelation.evidence.get("current-assistant");
290
341
  if (
291
342
  turn.terminalCorrelation.policy === "native-command" &&
@@ -73,10 +73,13 @@ import {
73
73
  assistantTerminalOutcome,
74
74
  classifyTerminalCandidate,
75
75
  createActiveTurn,
76
+ createNativeRunTurn,
77
+ type HeldAgentEnd,
76
78
  historyTerminalOutcome,
77
79
  nativeEntryId,
78
80
  type PendingUser,
79
81
  type TerminalCandidate,
82
+ takeRunEvidence,
80
83
  terminalOutcome,
81
84
  unknownTerminalOutcomeError,
82
85
  } from "./session-terminal";
@@ -330,6 +333,8 @@ export class OmpProviderSession implements OmpOpenedSession {
330
333
  private configRevision = 0;
331
334
  private recoveryUsesNativeConfig = false;
332
335
  private activeAbort: PendingAbort | null = null;
336
+ /** Frames of a run that started while the previous terminal was being confirmed or published. */
337
+ private runStartBacklog: OmpRpcEvent[] | null = null;
333
338
  private readonly usage: OmpSessionUsage;
334
339
  private readonly compaction: OmpSessionCompaction;
335
340
  private revertInFlight = false;
@@ -1458,33 +1463,46 @@ export class OmpProviderSession implements OmpOpenedSession {
1458
1463
  } catch (error) {
1459
1464
  if (this.closed || this.runtimeDead || turn.generation !== this.generation) return;
1460
1465
  turn.manualCompactionPending = false;
1466
+ // OMP 18.13.3+ reports the outcome in manual_compaction_end before rejecting `compact`.
1467
+ const outcome = turn.manualCompactionOutcome;
1468
+ if (outcome === "skipped") {
1469
+ await this.finishTurn(turn, turn.interrupted ? "canceled" : "completed");
1470
+ return;
1471
+ }
1461
1472
  const raw = providerError(error, "OMP compaction failed");
1462
1473
  const failure = { message: this.dataFilter.text(raw.message, 4_096) };
1463
- const state = turn.interrupted ? "canceled" : "failed";
1474
+ const state = turn.interrupted || outcome === "canceled" ? "canceled" : "failed";
1464
1475
  this.compaction.finish(state, { message: failure.message });
1465
1476
  await this.finishTurn(turn, state, state === "failed" ? failure : undefined, true, true);
1466
1477
  }
1467
1478
  }
1468
1479
 
1469
1480
  async canStopRuntime(): Promise<boolean> {
1470
- const hasLocalWork =
1481
+ if (this.hasLocalRuntimeWork()) return false;
1482
+ const state = await this.runtime.getState();
1483
+ // OMP can start a run of its own while the state lookup is pending.
1484
+ if (this.hasLocalRuntimeWork()) return false;
1485
+ // OMP builds that predate these fields report neither, which keeps the runtime alive.
1486
+ return (
1487
+ state.isSettled === true &&
1488
+ state.hasPendingAsyncWork === false &&
1489
+ !state.isStreaming &&
1490
+ !state.isCompacting
1491
+ );
1492
+ }
1493
+
1494
+ private hasLocalRuntimeWork(): boolean {
1495
+ return (
1471
1496
  this.closed ||
1472
1497
  this.runtimeDead !== null ||
1473
1498
  this.recoveryPromise !== null ||
1474
1499
  this.activeTurn !== null ||
1500
+ this.runStartBacklog !== null ||
1475
1501
  this.activeAbort !== null ||
1476
1502
  this.revertInFlight ||
1477
1503
  this.configMutationInFlight ||
1478
1504
  this.permissions.hasPending() ||
1479
- Boolean(this.subsessions?.hasActiveChildren());
1480
- if (hasLocalWork) return false;
1481
- const state = await this.runtime.getState();
1482
- // OMP builds that predate these fields report neither, which keeps the runtime alive.
1483
- return (
1484
- state.isSettled === true &&
1485
- state.hasPendingAsyncWork === false &&
1486
- !state.isStreaming &&
1487
- !state.isCompacting
1505
+ Boolean(this.subsessions?.hasActiveChildren())
1488
1506
  );
1489
1507
  }
1490
1508
 
@@ -2259,6 +2277,17 @@ export class OmpProviderSession implements OmpOpenedSession {
2259
2277
 
2260
2278
  private handleRuntimeEvent(event: OmpRpcEvent): void {
2261
2279
  if (this.closed) return;
2280
+ if (this.runStartBacklog) {
2281
+ this.bufferEvent(this.runStartBacklog, event);
2282
+ return;
2283
+ }
2284
+ // A run that starts while the previous terminal is being confirmed or published waits for it,
2285
+ // so neither the old turn's outcome nor the new run's frames are lost.
2286
+ const pendingTerminal = this.activeTurn?.terminalization ?? this.activeTurn?.agentEndCheck;
2287
+ if (event.type === "agent_start" && pendingTerminal) {
2288
+ this.deferRunStart(event, pendingTerminal);
2289
+ return;
2290
+ }
2262
2291
  if (
2263
2292
  event.type === "subagent_lifecycle" ||
2264
2293
  event.type === "subagent_progress" ||
@@ -2336,7 +2365,8 @@ export class OmpProviderSession implements OmpOpenedSession {
2336
2365
  event.type === "ttsr_triggered" ||
2337
2366
  event.type === "cache_warming_start" ||
2338
2367
  event.type === "cache_warming_end" ||
2339
- event.type === "queue_update"
2368
+ event.type === "queue_update" ||
2369
+ event.type === "session_info_update"
2340
2370
  ) {
2341
2371
  return;
2342
2372
  }
@@ -2353,7 +2383,8 @@ export class OmpProviderSession implements OmpOpenedSession {
2353
2383
  event.type === "auto_retry_end" ||
2354
2384
  event.type === "compaction_start" ||
2355
2385
  event.type === "compaction_end" ||
2356
- event.type === "advisor_yielded"
2386
+ event.type === "advisor_yielded" ||
2387
+ event.type === "extension_error"
2357
2388
  ) {
2358
2389
  this.projector.projectPassive(event);
2359
2390
  return;
@@ -2367,13 +2398,23 @@ export class OmpProviderSession implements OmpOpenedSession {
2367
2398
  this.scheduleCommittedConfigRefresh();
2368
2399
  return;
2369
2400
  }
2370
- if (event.type === "model_changed" || event.type === "thinking_level_changed") {
2401
+ if (
2402
+ event.type === "model_changed" ||
2403
+ event.type === "thinking_level_changed" ||
2404
+ event.type === "config_update"
2405
+ ) {
2371
2406
  this.scheduleCommittedConfigRefresh();
2372
2407
  return;
2373
2408
  }
2374
- const turn = this.activeTurn;
2409
+ const turn =
2410
+ this.activeTurn ?? (event.type === "agent_start" ? this.openNativeRunTurn() : null);
2375
2411
  if (!turn) {
2376
- if (event.type === "auto_compaction_start" || event.type === "auto_compaction_end") {
2412
+ if (
2413
+ event.type === "auto_compaction_start" ||
2414
+ event.type === "auto_compaction_end" ||
2415
+ event.type === "manual_compaction_start" ||
2416
+ event.type === "manual_compaction_end"
2417
+ ) {
2377
2418
  this.projector.projectPassive(event);
2378
2419
  }
2379
2420
  return;
@@ -2386,27 +2427,69 @@ export class OmpProviderSession implements OmpOpenedSession {
2386
2427
  return;
2387
2428
  }
2388
2429
  if (turn.starting) {
2389
- if (
2390
- turn.bufferedEvents.length >= MAX_BUFFERED_TURN_EVENTS ||
2391
- retainedBytes(turn.bufferedEvents, MAX_BUFFERED_TURN_BYTES) +
2392
- boundedJsonBytes(
2393
- event,
2394
- MAX_BUFFERED_TURN_BYTES,
2395
- MAX_BUFFERED_VALUE_ITEMS,
2396
- MAX_BUFFERED_TURN_BYTES,
2397
- MAX_BUFFERED_VALUE_NODES,
2398
- ) >
2399
- MAX_BUFFERED_TURN_BYTES
2400
- ) {
2401
- this.handleRuntimeFailure();
2402
- return;
2403
- }
2404
- turn.bufferedEvents.push(event);
2430
+ this.bufferEvent(turn.bufferedEvents, event);
2405
2431
  return;
2406
2432
  }
2407
2433
  this.handleTurnEvent(turn, event);
2408
2434
  }
2409
2435
 
2436
+ private bufferEvent(buffer: OmpRpcEvent[], event: OmpRpcEvent): void {
2437
+ if (
2438
+ buffer.length >= MAX_BUFFERED_TURN_EVENTS ||
2439
+ retainedBytes(buffer, MAX_BUFFERED_TURN_BYTES) +
2440
+ boundedJsonBytes(
2441
+ event,
2442
+ MAX_BUFFERED_TURN_BYTES,
2443
+ MAX_BUFFERED_VALUE_ITEMS,
2444
+ MAX_BUFFERED_TURN_BYTES,
2445
+ MAX_BUFFERED_VALUE_NODES,
2446
+ ) >
2447
+ MAX_BUFFERED_TURN_BYTES
2448
+ ) {
2449
+ this.handleRuntimeFailure();
2450
+ return;
2451
+ }
2452
+ buffer.push(event);
2453
+ }
2454
+
2455
+ private deferRunStart(event: OmpRpcEvent, pendingTerminal: Promise<void>): void {
2456
+ const backlog = [event];
2457
+ this.runStartBacklog = backlog;
2458
+ const replay = () => {
2459
+ if (this.runStartBacklog !== backlog) return;
2460
+ this.runStartBacklog = null;
2461
+ for (const buffered of backlog) this.handleRuntimeEvent(buffered);
2462
+ };
2463
+ void pendingTerminal.then(replay, replay);
2464
+ }
2465
+
2466
+ private scheduleHeldTurnCheck(turn: ActiveTurn, held: HeldAgentEnd): void {
2467
+ this.scheduler.set(() => this.checkHeldTurn(turn, held), USAGE_POLL_MS);
2468
+ }
2469
+
2470
+ /** Released OMP does not always write `session_settled` once a held run's async work is gone. */
2471
+ private async checkHeldTurn(turn: ActiveTurn, held: HeldAgentEnd): Promise<void> {
2472
+ if (turn.heldAgentEnd !== held || this.activeTurn !== turn) return;
2473
+ const state = await this.usage.boundedSnapshot(turn, FINAL_USAGE_WAIT_MS);
2474
+ if (turn.heldAgentEnd !== held || this.activeTurn !== turn) return;
2475
+ const settled =
2476
+ state?.isSettled === true &&
2477
+ state.hasPendingAsyncWork === false &&
2478
+ !state.isStreaming &&
2479
+ !state.isCompacting;
2480
+ if (settled) this.handleTurnEvent(turn, { type: "session_settled" });
2481
+ else this.scheduleHeldTurnCheck(turn, held);
2482
+ }
2483
+
2484
+ private openNativeRunTurn(): ActiveTurn {
2485
+ const turn = createNativeRunTurn(this.generation);
2486
+ this.activeTurn = turn;
2487
+ turn.acknowledged = true;
2488
+ this.startTurn(turn);
2489
+ this.projector.acceptLiveTurn(turn.turnId);
2490
+ return turn;
2491
+ }
2492
+
2410
2493
  private handleTurnEvent(
2411
2494
  turn: ActiveTurn,
2412
2495
  event: OmpRpcEvent,
@@ -2476,10 +2559,27 @@ export class OmpProviderSession implements OmpOpenedSession {
2476
2559
  this.compaction.start(turn, "auto", event.action);
2477
2560
  return;
2478
2561
  }
2479
- if (event.type === "auto_compaction_end") {
2480
- if (this.compaction.handleAutoEnd(turn, event)) {
2481
- this.usage.schedulePoll(turn, USAGE_REFRESH_MS);
2482
- }
2562
+ if (event.type === "manual_compaction_start") {
2563
+ turn.nativeActivity = true;
2564
+ this.cancelLocalOnlyCompletion(turn);
2565
+ // A no-op when this turn's own `compact` call already opened the operation.
2566
+ this.compaction.start(turn, "manual");
2567
+ return;
2568
+ }
2569
+ if (event.type === "auto_compaction_end" || event.type === "manual_compaction_end") {
2570
+ const outcome = this.compaction.handleEnd(turn, event);
2571
+ if (outcome && turn.manualCompaction) turn.manualCompactionOutcome = outcome;
2572
+ if (outcome) this.usage.schedulePoll(turn, USAGE_REFRESH_MS);
2573
+ return;
2574
+ }
2575
+ if (event.type === "session_settled") {
2576
+ // OMP is quiet, so the held end closes this stretch; a later run opens its own turn.
2577
+ const held = turn.heldAgentEnd;
2578
+ if (!held) return;
2579
+ turn.heldAgentEnd = undefined;
2580
+ Object.assign(turn, held.evidence);
2581
+ // Ownership is judged as of the held end's arrival, never from evidence that came later.
2582
+ this.acceptTerminal(turn, { ...held.event, isTerminal: true }, held.arrivalSequence);
2483
2583
  return;
2484
2584
  }
2485
2585
  if (event.type === "agent_end" && turn.manualCompactionPending) return;
@@ -2502,35 +2602,30 @@ export class OmpProviderSession implements OmpOpenedSession {
2502
2602
  return;
2503
2603
  }
2504
2604
  }
2605
+ if (event.type === "agent_start") {
2606
+ turn.heldAgentEnd = undefined;
2607
+ // Evidence is per run; records OMP writes between runs belong to neither.
2608
+ takeRunEvidence(turn);
2609
+ if (turn.agentEndPending && !turn.interrupted) {
2610
+ // A terminal waiting to retry its idle check is stale once another run starts.
2611
+ this.resetAgentEndProbe(turn);
2612
+ turn.deferredAgentEnd = undefined;
2613
+ this.usage.poll(turn);
2614
+ }
2615
+ }
2505
2616
  if (event.type === "agent_end") {
2506
2617
  if (event.isTerminal === false) {
2507
- turn.completedMessageCount = 0;
2508
- turn.streamedMessageEntryIds.length = 0;
2509
- turn.streamedMessageIdentityComplete = true;
2510
- turn.lastCompletedAssistantOutcome = undefined;
2511
- turn.lastCompletedAssistantEntryId = undefined;
2512
- return;
2513
- }
2514
- const candidate: TerminalCandidate = turn.interrupted
2515
- ? { event, confidence: "interrupted", arrivalSequence: turn.activitySequence }
2516
- : classifyTerminalCandidate(turn, event);
2517
- if (candidate.confidence === "ambiguous") {
2518
- this.deferAmbiguousTerminal(turn, candidate);
2519
- return;
2520
- }
2521
- turn.nativeActivity = true;
2522
- this.cancelLocalOnlyCompletion(turn);
2523
- if (
2524
- (!turn.interrupted &&
2525
- candidate.confidence !== "keyed" &&
2526
- this.hasTerminalConflict(turn, false)) ||
2527
- turn.steersInFlight > 0 ||
2528
- turn.terminalizing
2529
- ) {
2530
- turn.deferredAgentEnd = candidate;
2618
+ const held = {
2619
+ event,
2620
+ arrivalSequence: turn.activitySequence,
2621
+ evidence: takeRunEvidence(turn),
2622
+ };
2623
+ turn.heldAgentEnd = held;
2624
+ this.scheduleHeldTurnCheck(turn, held);
2531
2625
  return;
2532
2626
  }
2533
- this.beginTerminalization(turn, candidate);
2627
+ turn.heldAgentEnd = undefined;
2628
+ this.acceptTerminal(turn, event, turn.activitySequence);
2534
2629
  return;
2535
2630
  }
2536
2631
  if (isNativeTurnActivity(event)) {
@@ -2566,6 +2661,33 @@ export class OmpProviderSession implements OmpOpenedSession {
2566
2661
  if (event.type === "tool_execution_end") this.resumeDeferredAgentEnd();
2567
2662
  }
2568
2663
 
2664
+ private acceptTerminal(
2665
+ turn: ActiveTurn,
2666
+ event: Extract<OmpRpcEvent, { type: "agent_end" }>,
2667
+ arrivalSequence: number,
2668
+ ): void {
2669
+ const candidate: TerminalCandidate = turn.interrupted
2670
+ ? { event, confidence: "interrupted", arrivalSequence }
2671
+ : classifyTerminalCandidate(turn, event, arrivalSequence);
2672
+ if (candidate.confidence === "ambiguous") {
2673
+ this.deferAmbiguousTerminal(turn, candidate);
2674
+ return;
2675
+ }
2676
+ turn.nativeActivity = true;
2677
+ this.cancelLocalOnlyCompletion(turn);
2678
+ if (
2679
+ (!turn.interrupted &&
2680
+ candidate.confidence !== "keyed" &&
2681
+ this.hasTerminalConflict(turn, false)) ||
2682
+ turn.steersInFlight > 0 ||
2683
+ turn.terminalizing
2684
+ ) {
2685
+ turn.deferredAgentEnd = candidate;
2686
+ return;
2687
+ }
2688
+ this.beginTerminalization(turn, candidate);
2689
+ }
2690
+
2569
2691
  private projectUserEcho(turn: ActiveTurn, message: OmpMessage): void {
2570
2692
  turn.userEchoObserved = true;
2571
2693
  const entryId = nativeEntryId(message);
@@ -3033,6 +3155,7 @@ export class OmpProviderSession implements OmpOpenedSession {
3033
3155
  if (!turn.agentEndPending || turn.terminal || this.activeTurn !== turn) return;
3034
3156
  if (
3035
3157
  candidate.confidence === "keyed" ||
3158
+ candidate.confidence === "native-run" ||
3036
3159
  (candidate.confidence === "initial-turn" && !turn.userEchoObserved)
3037
3160
  ) {
3038
3161
  void this.completeAgentEnd(turn, candidate.event);
@@ -3146,14 +3269,16 @@ export class OmpProviderSession implements OmpOpenedSession {
3146
3269
  return;
3147
3270
  }
3148
3271
  if (state) {
3149
- if (state.isStreaming || state.isCompacting) {
3272
+ // A run start buffered behind this check proves this end was the last one before it, so a
3273
+ // streaming state belongs to that new run, not to this turn.
3274
+ if ((state.isStreaming || state.isCompacting) && !this.runStartBacklog) {
3150
3275
  this.ignoreActiveTerminalCandidate(turn, candidate);
3151
3276
  return;
3152
3277
  }
3153
3278
  await this.completeAgentEnd(turn, candidate.event, true);
3154
3279
  return;
3155
3280
  }
3156
- if (turn.userEchoObserved) {
3281
+ if (turn.userEchoObserved && candidate.confidence !== "native-run") {
3157
3282
  await this.cancelIndeterminateTerminal(
3158
3283
  turn,
3159
3284
  "OMP agent_end state could not be confirmed",
@@ -3389,6 +3514,7 @@ export class OmpProviderSession implements OmpOpenedSession {
3389
3514
  throw error;
3390
3515
  }
3391
3516
  this.unclaimedBranchEntries.length = 0;
3517
+ turn.terminalEmitAttempted = true;
3392
3518
  this.emit({
3393
3519
  type: "session.turn",
3394
3520
  sessionId: this.id,
@@ -3398,17 +3524,42 @@ export class OmpProviderSession implements OmpOpenedSession {
3398
3524
  });
3399
3525
  this.runtimeTurnCompleted = true;
3400
3526
  if (this.activeTurn === turn) this.activeTurn = null;
3401
- })();
3527
+ })().catch(() => this.failTerminalization(turn));
3402
3528
  turn.terminalization = terminalization;
3403
3529
  return terminalization;
3404
3530
  }
3405
3531
 
3532
+ /** A turn whose terminal state could not be published must not keep owning the session. */
3533
+ private failTerminalization(turn: ActiveTurn): void {
3534
+ const message = "OMP turn could not be finalized";
3535
+ turn.terminal = true;
3536
+ if (this.activeTurn === turn) this.activeTurn = null;
3537
+ this.recordOperationalFailure({ category: "terminal-outcome", stage: "failed" });
3538
+ // Once the terminal emit was attempted, earlier listeners may already hold it; a second,
3539
+ // conflicting terminal would be worse than failing the runtime alone.
3540
+ if (!turn.terminalEmitAttempted) {
3541
+ try {
3542
+ this.emit({
3543
+ type: "session.turn",
3544
+ sessionId: this.id,
3545
+ turnId: turn.turnId,
3546
+ state: "failed",
3547
+ error: { message },
3548
+ });
3549
+ } catch {
3550
+ // The listener that failed may fail again; the runtime failure below still applies.
3551
+ }
3552
+ }
3553
+ this.handleRuntimeFailure(message);
3554
+ }
3555
+
3406
3556
  private invalidateRuntime(
3407
3557
  message: string,
3408
3558
  compactionState: "failed" | "canceled" = "failed",
3409
3559
  ): void {
3410
3560
  this.imageMaterializer.clear();
3411
3561
  if (this.closed || this.runtimeDead) return;
3562
+ this.runStartBacklog = null;
3412
3563
  this.compaction.resetDiscardedEnds();
3413
3564
  this.permissions.resolveAllPermissions();
3414
3565
  this.recoveryUsesNativeConfig ||=
@@ -467,6 +467,9 @@ export class OmpTimelineProjector {
467
467
  event.type === "auto_compaction_end" ||
468
468
  event.type === "compaction_start" ||
469
469
  event.type === "compaction_end" ||
470
+ event.type === "manual_compaction_start" ||
471
+ event.type === "manual_compaction_end" ||
472
+ event.type === "extension_error" ||
470
473
  event.type === "advisor_yielded" ||
471
474
  event.type === "config_warnings_changed" ||
472
475
  event.type === "advisor_cost_changed" ||
@@ -743,6 +746,19 @@ export class OmpTimelineProjector {
743
746
  });
744
747
  return;
745
748
  }
749
+ if (event.type === "extension_error") {
750
+ this.noticeSequence += 1;
751
+ this.publish({
752
+ type: "notification",
753
+ id: `omp:extension-error:${this.noticeSequence}`,
754
+ level: "error",
755
+ message: this.dataFilter.text(
756
+ `OMP extension ${event.extensionPath} failed in ${event.event}: ${event.error}`,
757
+ 64 * 1024,
758
+ ),
759
+ });
760
+ return;
761
+ }
746
762
  if (event.type === "advisor_yielded") {
747
763
  this.noticeSequence += 1;
748
764
  this.publish({
@@ -753,7 +769,11 @@ export class OmpTimelineProjector {
753
769
  });
754
770
  return;
755
771
  }
756
- if (event.type === "auto_compaction_start" || event.type === "compaction_start") {
772
+ if (
773
+ event.type === "auto_compaction_start" ||
774
+ event.type === "compaction_start" ||
775
+ event.type === "manual_compaction_start"
776
+ ) {
757
777
  const trigger = event.type === "auto_compaction_start" ? "auto" : "manual";
758
778
  const action = event.type === "auto_compaction_start" ? event.action : undefined;
759
779
  if (this.discardedCompactionEnds > 0) {
@@ -781,7 +801,11 @@ export class OmpTimelineProjector {
781
801
  this.publish({ type: "compaction", id: slot.id, status: "loading", trigger });
782
802
  return;
783
803
  }
784
- if (event.type === "auto_compaction_end" || event.type === "compaction_end") {
804
+ if (
805
+ event.type === "auto_compaction_end" ||
806
+ event.type === "compaction_end" ||
807
+ event.type === "manual_compaction_end"
808
+ ) {
785
809
  if (this.discardedCompactionEnds > 0) {
786
810
  this.discardedCompactionEnds -= 1;
787
811
  return;
@@ -803,7 +827,7 @@ export class OmpTimelineProjector {
803
827
  this.discardedCompactionEnds = 1;
804
828
  return;
805
829
  }
806
- if (event.willRetry) {
830
+ if (event.type !== "manual_compaction_end" && event.willRetry) {
807
831
  slot.retrying = true;
808
832
  return;
809
833
  }
@@ -826,10 +850,9 @@ export class OmpTimelineProjector {
826
850
  type: "notification",
827
851
  id: `${slot.id}:skipped`,
828
852
  level: "warning",
829
- message: "OMP compaction was skipped",
853
+ message: this.dataFilter.text(event.errorMessage ?? "OMP compaction was skipped", 4_096),
830
854
  });
831
- }
832
- if (event.aborted || event.errorMessage) {
855
+ } else if (event.aborted || event.errorMessage) {
833
856
  this.publish({
834
857
  type: "error",
835
858
  id: `${slot.id}:error`,
@@ -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>;