@omercnet/paseo-omp 0.2.1-next.88.1 → 0.2.1-next.90.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.
package/README.md CHANGED
@@ -6,7 +6,7 @@ Community OMP integration for Paseo. The plugin registers the distinct `omp-plug
6
6
 
7
7
  ## Quick start
8
8
 
9
- Requirements: Paseo `^0.8.0`, OMP `18.1.15` or newer, and OMP RPC protocol v2.
9
+ Requirements: Paseo `>=0.8.0 <0.10.0`, OMP `18.1.15` or newer, and OMP RPC protocol v2. Paseo `0.9.0-beta.1` is recommended for npm-managed installation and the complete client integration.
10
10
 
11
11
  ```bash
12
12
  paseo plugin install npm:@omercnet/paseo-omp@<version>
@@ -22,7 +22,7 @@ Open the **OMP** sidebar to review the provider-profile contract, choose which c
22
22
  - [Core-provider issue and parity audit](docs/core-provider-issue-audit.md)
23
23
  - [Alpha release checklist](docs/alpha-release-checklist.md)
24
24
 
25
- The plugin uses only public Paseo 0.8 provider contracts and registers distinct `omp-plugin` and `omp-plugin-<profile>` identities; it does not modify the bundled `omp` provider.
25
+ The plugin uses public Paseo 0.8 and 0.9 beta provider contracts and registers distinct `omp-plugin` and `omp-plugin-<profile>` identities; it does not modify the bundled `omp` provider.
26
26
 
27
27
  ## Named OMP profiles
28
28
 
@@ -36,7 +36,7 @@ Profile selection is request-local: concurrent clients cannot change each other'
36
36
 
37
37
  ## Paseo provider SDK coverage
38
38
 
39
- This matrix inventories the complete capability set exported by the pinned `@getpaseo/plugin` 0.8.0 provider SDK. It measures strict SDK surface coverage, not general product quality.
39
+ This matrix inventories the complete capability set exported by the pinned `@getpaseo/plugin` 0.9.0-beta.1 provider SDK. It measures strict SDK surface coverage, not general product quality.
40
40
 
41
41
  **Current capability completeness: 61.8%.** The provider advertises 11 of 17 capabilities (64.7%); ten are complete and `session.configure` is partial.
42
42
 
@@ -49,7 +49,7 @@ Scoring is deliberately mechanical so releases remain comparable:
49
49
 
50
50
  | SDK capability | Completeness | Support and gap |
51
51
  | --- | ---: | --- |
52
- | `prompt.message` | **100%** | Text, structured attachments, optimistic correlation, streaming, and terminal outcomes map to native OMP prompts. |
52
+ | `prompt.message` | **100%** | Text, structured attachments, optimistic correlation, streaming, and terminal outcomes map to native OMP prompts. Matching terminal request IDs remain authoritative; released OMP builds without them use the ordered compatibility policy described below. |
53
53
  | `prompt.command` | **100%** | Native command discovery plus structured `compact`, `autocompact`, `handoff`, `steer`, and `follow-up` dispatch. Individual commands can still fail when their documented OMP prerequisites are absent. |
54
54
  | `prompt.image` | **100%** | Native image blocks for capable models; private bounded temporary files with cleanup for text-only models. |
55
55
  | `prompt.output_schema` | **0%** | Not advertised. OMP has no equivalent structured-output contract; `outputSchema` fails visibly. |
@@ -97,7 +97,9 @@ The plugin always remains a separate provider. It registers `omp-plugin` and `om
97
97
 
98
98
  Existing agents whose provider is `omp` remain owned by the bundled provider. New plugin agents persist under their selected `omp-plugin` or `omp-plugin-<profile>` identity with the plugin's versioned opaque handle. The plugin can list and import OMP-native sessions through its own provider flow, but it performs no implicit conversion of bundled-provider records.
99
99
 
100
- OMP `18.1.15` is the oldest version tested end to end. The direct provider's hard compatibility gate is `rpc-ui` protocol v2: metadata-free legacy ready frames and v1-only runtimes are rejected before a provider session opens because they cannot support the advertised persistence and conversation-rewind capabilities. Typed tool approvals remain capability-gated and fall back as described above.
100
+ OMP `18.1.15` is the oldest version tested end to end. The direct provider's hard compatibility gate is `rpc-ui` protocol v2: metadata-free legacy ready frames and v1-only runtimes are rejected before a provider session opens because they cannot support the advertised persistence and conversation-rewind capabilities. Typed tool approvals remain capability-gated and fall back as described above. For terminal completion, a matching `agent_end.requestId` is authoritative and mismatches are ignored before state changes. Released OMP 18.2.x builds that omit that field use an ordered fallback requiring a fresh correlated native user entry followed by current-turn assistant activity, an idle non-compacting runtime, and no conflicting permission, tool, steer, or child-session work. Ambiguous events while OMP is active are ignored; unresolved ambiguity after confirmed idle fails only the Paseo turn. This trades a bounded residual same-agent stale-event risk for compatibility with released OMP instead of terminating and lazily restarting the runtime after every later prompt. Paseo clients, including mobile clients inside reconnect grace, do not own or terminate the daemon-managed OMP session.
101
+
102
+ Paseo 0.9.0-beta.1 is the canary target and provides npm-managed plugin sources, platform-owned external URL opening, and whole-item timeline transforms before Overview grouping. Paseo 0.8.x remains supported for provider operation and Git or directory installation; its clients do not provide the external URL opener and retain their older timeline presentation semantics.
101
103
 
102
104
  Install, update, disable, or remove `paseo-omp` independently of the bundled provider. Verify the provider snapshot contains `omp-plugin` after installation; a bundled `omp` entry may remain present and is not modified by this plugin.
103
105
 
package/SUPPORT.md CHANGED
@@ -16,7 +16,7 @@ Do not put credentials, private repository paths, session transcripts, or unreda
16
16
 
17
17
  ## Supported versions
18
18
 
19
- - Paseo: `^0.8.0` on both the daemon and every app loading the client entry.
19
+ - Paseo: `>=0.8.0 <0.10.0` on both the daemon and every app loading the client entry. The pinned SDK and controlled canary use `0.9.0-beta.1`; 0.8.x remains the compatibility line.
20
20
  - OMP: `18.1.15` is the oldest release in the required real-binary regression job. The hard runtime contract is `rpc-ui` protocol v2, not the version string alone.
21
21
  - Plugin release channel: alpha. Backward compatibility is best effort until stable `0.1.0`; every known migration requirement must be stated in the release notes.
22
22
  - Typed approvals: optional. When both peers negotiate `typedToolApprovals: 1`, the plugin uses typed tool permissions. Otherwise it retains the bounded generic extension-question flow.
@@ -26,15 +26,17 @@ Do not put credentials, private repository paths, session transcripts, or unreda
26
26
  - `omp` and `omp-plugin` are independent provider identities. Agents, provider settings, and persisted handles do not migrate automatically between them.
27
27
  - Do not open the same underlying OMP session concurrently through both providers. Reservation tracking is provider-local and cannot coordinate ownership with Paseo's bundled adapter.
28
28
  - OMP 18.1.15 does not advertise typed tool approvals. The plugin uses its bounded generic permission fallback until both peers negotiate `typedToolApprovals: 1`.
29
- - OMP releases that do not correlate ordinary `prompt` requests with their terminal `agent_end` remain limited on ownership-sensitive later turns: the immediate response may omit `agentInvoked: true`, legacy `prompt_result` reports only local-only `false` results, and live user messages may omit their persisted `entryId`. Branch history can correlate user timeline entries, but even a new exact-text entry cannot authenticate a terminal event or its success/error outcome. The plugin accepts a later-turn terminal carrying the matching RPC `requestId`; without that identity, it rejects the event and fails the turn closed after the ownership deadline. An unavailable state or history lookup cannot bypass that guard.
30
- - Timeline correlation rebuilds an invalid watermark from a complete pre-prompt `get_branch_messages` snapshot, not replayed model context or an evicting identity cache. Snapshots are limited to 1,024 entries and 4 MiB; duplicate IDs, surplus exact-text matches, unavailable history, and exceeded bounds leave users uncorrelated rather than claiming an old entry. Repeated accepted prompts within a turn consume matching branch occurrences in order. User entries never grant terminal ownership to a later turn.
29
+ - OMP releases through 18.2.x may omit the originating prompt `requestId` from `agent_end`. A matching ID is authoritative when present, and a mismatch is discarded before changing turn state. For an unkeyed later-turn terminal, the plugin uses a bounded ordered fallback: the accepted prompt must not be known local-only, a fresh native user entry must correlate to it, current-turn assistant activity must follow that entry, OMP must report idle and non-compacting, and no permission, tool, steer, or child-session work may remain. Ambiguous candidates are ignored while OMP is active; once OMP is confirmed idle, unresolved ambiguity fails only the Paseo turn and keeps the OMP process available. This deliberately accepts a residual same-agent risk: a sufficiently delayed unkeyed event with indistinguishable ordered evidence can still be misattributed. It is safer than accepting any idle `agent_end`, while remaining usable with released OMP builds that cannot provide request identity.
30
+ - Timeline correlation rebuilds an invalid watermark from a complete pre-prompt `get_branch_messages` snapshot, not replayed model context or an evicting identity cache. Snapshots are limited to 1,024 entries and 4 MiB; duplicate IDs, surplus exact-text matches, unavailable history, and exceeded bounds leave users uncorrelated rather than claiming an old entry. Repeated accepted prompts within a turn consume matching branch occurrences in order.
31
31
  - Configured MCP servers are supported and bridged into OMP. Paseo's own orchestration tools appear under their native names when the daemon's **Enable Paseo tools** / `daemon.mcp.injectIntoAgents` setting is enabled; other MCP servers remain namespaced. Exact Paseo `toolPolicy` preapproval cannot be represented by OMP `set_host_tools` and therefore fails session startup closed. `disallowedTools` applies only to recognized native OMP built-ins; unknown names are rejected and MCP tools are not silently filtered through it.
32
32
  - `qwen2.5:0.5b` is provided only for free exploratory inference. It may ignore exact-output instructions and is not a deterministic protocol or tool-use oracle; use `canary-mock/Deterministic Canary` for assertions.
33
33
  - The deterministic mock does not implement OMP's compaction-summary contract, so `/compact` reports `OMP compaction failed` in the canary; compaction remains covered by protocol fixtures.
34
34
  - `/handoff` reports `OMP command failed` in the controlled canary even with deterministic role models configured; treat handoff as unavailable there until its native prerequisite is isolated.
35
35
  - On official Paseo 0.8.0, requesting a live approval-mode change that the plugin rejects can trigger the daemon's unhandled-rejection restart path. Create a new `full`, `write`, or `ask` session instead of changing mode in place.
36
+ - Paseo 0.8 clients do not expose the platform-owned plugin URL opener and transform timeline items after Overview grouping. Use a 0.9.0-beta.1 app for external documentation/device-authorization actions and for reliable image-card transformation of every source tool call.
36
37
  - Native Fast mode and a first-class plan mode are not exposed. `/handoff` is implemented but is not reproducible in the controlled canary without its native OMP workflow prerequisites.
37
38
  - Terminal-started OMP sessions are discoverable and importable but are not registered automatically through a terminal hook.
39
+ - Paseo clients do not own OMP sessions. Mobile or desktop disconnects remain subject to Paseo's reconnect grace while the daemon-owned provider and OMP child continue independently; reconnecting can reveal an existing turn but does not transfer or sever runtime ownership.
38
40
  - OMP tool output reaching RPC stdout is strictly parsed and bounded, but channel purity ultimately depends on OMP keeping non-protocol output off stdout.
39
41
 
40
42
  The deduplicated comparison with every known OMP report in `getpaseo/paseo` is maintained in [docs/core-provider-issue-audit.md](docs/core-provider-issue-audit.md).
package/TESTING.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # OMP provider parity audit
2
2
 
3
- Validated against the Paseo plugin SDK versions pinned in `package.json`, official Paseo Docker image `0.8.0@sha256:5518da7cdd35f132e8a944c35e509c677a90a8f3ec8a78df98f7fb5fd5e2c6c3`, and the real OMP compatibility matrix: high-use historical releases 17.2.15, 17.3.4, 18.0.11, and 18.1.10; minimum supported release 18.1.15; latest published 18.1 patch 18.1.22; and current release 18.2.0.
3
+ Validated against the Paseo plugin SDK versions pinned in `package.json` and the real OMP compatibility matrix: high-use historical releases 17.2.15, 17.3.4, 18.0.11, and 18.1.10; minimum supported release 18.1.15; latest published 18.1 patch 18.1.22; and current release 18.2.0. Validation against official Paseo Docker image `0.9.0-beta.1@sha256:f75a0eb3547ad3cc6bbdeaa7277d2d50eb4d06a9dd669d480371d7adf1c911b5` is partial as recorded below.
4
4
 
5
5
  Classifications:
6
6
 
@@ -32,7 +32,7 @@ Classifications:
32
32
  | Image prompts | **Equivalent** | Valid native image models receive image blocks; text-only models receive private content-addressed local files with an aggregate cap and turn/session/failure cleanup. The file path is valid because the direct provider and OMP child share the daemon host. |
33
33
  | Structured attachments | **Equivalent** | Forge change requests/issues, legacy GitHub forms, text, reviews, and uploaded files render to bounded OMP prompt text; regression in `tests/provider.test.ts`. |
34
34
  | Optimistic message correlation | **Equivalent** | Native entry lookup, repeated-text occurrence correlation, steering correlation, replay-boundary dedupe, and exactly-one `session.prompt_result` regressions. Bounded branch snapshots rebuild incomplete or evicted replay watermarks; duplicate IDs, surplus matching entries, unavailable snapshots, and count/byte overflow retain local user-message fallback instead of claiming an old entry. |
35
- | Terminal ownership on later turns | **Version-gated** | A new exact-text branch entry proves user-message persistence, not ownership of an unkeyed `agent_end`. The plugin accepts a later-turn terminal only when it carries the originating prompt's RPC `requestId`. Older OMP releases remain fail-closed after the ownership deadline. `tests/provider.test.ts` covers matching and mismatched request IDs, stale ends before and after new entries, repeated prompts, evidence arriving during an older terminal check, and unavailable or hung history. |
35
+ | Terminal correlation on later turns | **Equivalent with legacy fallback** | A matching `agent_end.requestId` is authoritative and a mismatch is discarded before turn state changes. Released OMP builds that omit the field use ordered evidence from the accepted prompt: a fresh branch-correlated native user entry, later current-turn assistant activity, an idle non-compacting runtime, and no conflicting permission, tool, steer, or child-session work. `prompt_result.agentInvoked: false` remains local-only. Active ambiguous terminals are ignored; confirmed-idle ambiguity fails only the Paseo turn without killing the OMP process. Tests cover three sequential legacy prompts, stale terminals before evidence and while active, repeated text, local-only results, keyed mismatch/match, and degraded state/history. The remaining risk is same-agent misattribution when a delayed unkeyed event is indistinguishable from this ordered evidence. |
36
36
  | Streaming assistant text | **Equivalent** | `OmpTimelineProjector` publishes stable complete snapshots with frame coalescing and bounded retained bytes. |
37
37
  | Streaming reasoning | **Equivalent** | Indexed thinking blocks map to stable `reasoning` items and share stream bounds. |
38
38
  | `contentIndex` ordering | **Equivalent** | Stable 0→1→0 updates, sparse-index rejection, and 64-block bounds are tested in `tests/provider.test.ts`. |
@@ -103,9 +103,9 @@ npm run test:integration:canary
103
103
 
104
104
  Set `PASEO_CANARY_OMP_VERSION` to exercise another pinned release. The local runner knows the SHA-256 values for every version in the compatibility matrix; an unlisted version requires explicit `PASEO_CANARY_OMP_SHA256_AMD64` and `PASEO_CANARY_OMP_SHA256_ARM64` values. It allocates isolated loopback ports, builds a version-specific image, waits for both services to become healthy, runs the deterministic provider scenarios, prints container logs on failure, and always removes its containers, volumes, and image namespace.
105
105
 
106
- The Linux real-OMP CI matrix downloads checksummed `omp-linux-x64` assets for OMP 17.2.15, 17.3.4, 18.0.11, 18.1.10, 18.1.15, 18.1.22, and 18.2.0, verifies each binary's pinned GitHub release SHA-256, and runs `PASEO_OMP_REAL_E2E=1 PASEO_OMP_VERSION=<version> npm test -- tests/provider.real.e2e.test.ts`. The historical entries are the four pre-floor releases with more than 4,000 downloads shown by npm for the seven days ending 2026-09-15; they are compatibility regression probes, not a support commitment. The remaining entries cover the supported floor, newest patch in that minor line, and current release. The test uses the real OMP binary and a local deterministic OpenAI-compatible model endpoint, so catalog, text-plus-Bash, and oversized-image execution are mandatory without repository secrets.
106
+ The Linux real-OMP CI matrix downloads checksummed `omp-linux-x64` assets for OMP 17.2.15, 17.3.4, 18.0.11, 18.1.10, 18.1.15, 18.1.22, and 18.2.0, verifies each binary's pinned GitHub release SHA-256, and runs `PASEO_OMP_REAL_E2E=1 PASEO_OMP_VERSION=<version> npm test -- tests/provider.real.e2e.test.ts`. The historical entries are the four pre-floor releases with more than 4,000 downloads shown by npm for the seven days ending 2026-09-15; they are compatibility regression probes, not a support commitment. The remaining entries cover the supported floor, newest patch in that minor line, and current release. The test uses the real OMP binary and a local deterministic OpenAI-compatible model endpoint, including three sequential prompts on one native session, a Bash tool, and oversized-image transport.
107
107
 
108
- The controlled canary in `canary/compose.yml` builds this plugin into that official Paseo image and installs checksummed OMP binaries for `amd64` and `arm64`. `canary/smoke.ts` passed catalog/mode discovery, text and image prompts, Bash, a configured stdio MCP tool, permission allow/deny/cancel, steering, interruption, session listing/import/resume, subagents, conversation rewind followed by another turn, Hub process visibility, usage, and every plugin RPC. Browser verification loaded the global OMP health/configuration surface and the agent-scoped Hub, Memory, and Sessions controls. The default mock returned `CANARY_MOCK_OK`, `CANARY_TOOL_OK`, and `CANARY_MCP_OK`; the optional Ollama profile pulled `qwen2.5:0.5b` and completed turns without paid credentials. The harness records the canary-only `/compact` and `/handoff` failures instead of masking them.
108
+ The controlled canary in `canary/compose.yml` successfully built the plugin into that official Paseo image, started the daemon, loaded `paseo-omp`, and completed runtime health, catalog/mode discovery, initial text, model switching, `autocompact`, `follow-up`, and the documented compact/handoff error collection. It then failed on the first Bash scenario because the later turn ended without request-correlated terminal ownership: `OMP terminal ownership could not be confirmed`. The subsequent image, MCP, permission, steering, interruption, persistence, subagent, Hub, usage, rewind, browser, and optional Ollama scenarios were not reached and remain unverified on Paseo 0.9.0-beta.1.
109
109
 
110
110
  ## OMP RPC compatibility intake
111
111
 
@@ -142,9 +142,9 @@ The go/no-go criteria, manual acceptance boundary, and alpha limitation list are
142
142
  - `npm test -- tests/provider-conformance.test.ts`: host-boundary conformance coverage includes `prompt.command`, `session.configure`, typed and fallback permission allow/deny/cancel paths, registry-driven reload/removal, verified stubborn-descendant cleanup, and sequential turns plus interrupt races with post-turn barriers.
143
143
  - `PASEO_OMP_REAL_E2E=1 PASEO_OMP_VERSION=<version> npm test -- tests/provider.real.e2e.test.ts`: the installed matrix version's catalog and hermetic real-binary text/Bash and oversized-image scenarios run against a local deterministic model.
144
144
  - `npm run test:coverage`: Vitest enforces aggregate 85% function and 89% line coverage over loaded source modules. Generated `dist/**` trees are excluded.
145
- - `npm run test:integration:install`: a packed npm package retains required production dependencies and bundles successfully; a fresh Git-style checkout installs with lifecycle scripts disabled, typechecks, and loads the server contribution.
145
+ - `npm run test:integration:install`: npm package acquisition retains required production dependencies, and a fresh Git-style checkout runs frozen production-only preparation with lifecycle scripts disabled. Both installed trees resolve runtime dependencies, compile the client and server entries with Paseo's host compiler, and load the server contribution.
146
146
  - `npm run test:integration:docker`: verifies the host/container ownership boundary.
147
147
  - `npm run test:integration:wsl`: locally skips when `wsl.exe` is unavailable; Windows CI sets `PASEO_OMP_REQUIRE_WSL=1`, so this boundary remains required there.
148
- - `docker compose -f canary/compose.yml`: official Paseo 0.8.0, deterministic mock, Tailscale-bound web UI, and optional Ollama `qwen2.5:0.5b` passed end-to-end.
148
+ - `docker compose -f canary/compose.yml`: official Paseo 0.9.0-beta.1 image build, daemon startup, plugin load, and early deterministic scenarios passed; the smoke stopped at the first later-turn Bash scenario with `OMP terminal ownership could not be confirmed`, so the remaining scenarios are unverified.
149
149
  - `zizmor .github/workflows`: no findings (offline audit; six repository-wide suppressions remain).
150
150
  - Release Please 17.1.2 `config.json` and `manifest.json` schema validation: passed for `release-please-config.json` and `.release-please-manifest.json`.
@@ -0,0 +1,15 @@
1
+ import * as paseoClient from "@getpaseo/plugin/client";
2
+ import { Linking } from "react-native";
3
+ import { selectExternalUrlOpener, validatedHttpUrl } from "../shared/external-url";
4
+
5
+ const paseoOpenExternalUrl =
6
+ "openExternalUrl" in paseoClient && typeof paseoClient.openExternalUrl === "function"
7
+ ? paseoClient.openExternalUrl
8
+ : undefined;
9
+ const openExternalUrl = selectExternalUrlOpener(paseoOpenExternalUrl, (url) =>
10
+ Linking.openURL(url),
11
+ );
12
+
13
+ export function openOmpExternalUrl(url: string): Promise<void> {
14
+ return openExternalUrl(validatedHttpUrl(url));
15
+ }
@@ -1,11 +1,12 @@
1
1
  import { type PluginTimelineItemProps, useRpc } from "@getpaseo/plugin/client";
2
2
  import { Icon } from "@getpaseo/plugin/client/react-native";
3
3
  import { useMemo, useState } from "react";
4
- import { Linking, Pressable, Text, View } from "react-native";
4
+ import { Pressable, Text, View } from "react-native";
5
5
  import {
6
6
  type OmpMcpAuthorizationTimeline,
7
7
  openOmpMcpAuthorizationInPaseoBrowser,
8
8
  } from "../shared/mcp";
9
+ import { openOmpExternalUrl } from "./external-url";
9
10
 
10
11
  function browserAuthorizationError(error: unknown): string {
11
12
  const detail = error instanceof Error ? error.message : String(error);
@@ -98,7 +99,7 @@ export function OmpMcpAuthorizationCard({
98
99
  setError(null);
99
100
  setMessage(null);
100
101
  try {
101
- await Linking.openURL(item.data.url);
102
+ await openOmpExternalUrl(item.data.url);
102
103
  } catch {
103
104
  setError("Could not open the authorization URL. Copy the URL below into a browser.");
104
105
  } finally {
@@ -10,7 +10,7 @@ import { useIsMutating, useMutation, useQuery, useQueryClient } from "@tanstack/
10
10
  import type { ReactNode } from "react";
11
11
  import { useCallback, useMemo, useState } from "react";
12
12
  import type { TextStyle, ViewStyle } from "react-native";
13
- import { Linking, Pressable, ScrollView, Switch, Text, View } from "react-native";
13
+ import { Pressable, ScrollView, Switch, Text, View } from "react-native";
14
14
  import type { ComposerPillSettings } from "../shared/composer-pill-settings";
15
15
  import { listOmpConfig, type OmpConfig } from "../shared/omp-config";
16
16
  import {
@@ -26,6 +26,7 @@ import {
26
26
  import { type OmpStore, storeLabel } from "../shared/omp-store";
27
27
  import { getOmpProviderHealth, type OmpProviderHealth } from "../shared/provider-diagnostics";
28
28
  import { ComposerPillSettingsSection } from "./composer-pill-settings";
29
+ import { openOmpExternalUrl } from "./external-url";
29
30
  import {
30
31
  documentationForSettingCategory,
31
32
  documentationForSettingPath,
@@ -1072,7 +1073,7 @@ function OmpConfigContent({
1072
1073
  const openDocumentation = useCallback(async (link: OmpDocumentationLink) => {
1073
1074
  setDocumentationError(null);
1074
1075
  try {
1075
- await Linking.openURL(link.url);
1076
+ await openOmpExternalUrl(link.url);
1076
1077
  } catch {
1077
1078
  setDocumentationError(`Could not open ${link.label.toLocaleLowerCase()}.`);
1078
1079
  }
@@ -0,0 +1,9 @@
1
+ import type { PluginClientContext } from "@getpaseo/plugin/client";
2
+
3
+ type AsyncResult<Callable> = Callable extends (...args: never[]) => Promise<infer Result>
4
+ ? Result
5
+ : never;
6
+
7
+ export type PaseoApi = PluginClientContext["paseo"];
8
+ export type PaseoAgentListResult = AsyncResult<PaseoApi["agents"]["list"]>;
9
+ export type PaseoProviderSnapshotResult = AsyncResult<PaseoApi["providers"]["snapshot"]>;
@@ -1,4 +1,3 @@
1
- import type { PaseoApi, PaseoProviderSnapshotResult } from "@getpaseo/client";
2
1
  import type {
3
2
  OmpLspDiagnostics,
4
3
  OmpMcpDiagnostics,
@@ -8,8 +7,8 @@ import type {
8
7
  OmpVersionStatus,
9
8
  PathState,
10
9
  } from "../shared/provider-diagnostics";
11
-
12
10
  import { isOmpProvider } from "./omp-store-state";
11
+ import type { PaseoApi, PaseoProviderSnapshotResult } from "./paseo-types";
13
12
 
14
13
  export type ProviderHealthTone = "ok" | "warning" | "danger" | "muted";
15
14
  export const OMP_PROVIDER_IDS = ["omp", "omp-plugin"] as const;
@@ -7,7 +7,7 @@ This checklist prepares `paseo-omp-v0.1.0-alpha.1`. It does not authorize public
7
7
  - [ ] Release Please proposes `0.1.0-alpha.1` from manifest version `0.0.0`.
8
8
  - [ ] Package and tag names are `@omercnet/paseo-omp` and `paseo-omp-v0.1.0-alpha.1`.
9
9
  - [ ] Provider identity remains `omp-plugin`; bundled `omp` remains independent and enabled or disabled by the user.
10
- - [ ] Paseo requirement remains an official released range, currently `^0.8.0`.
10
+ - [ ] Paseo requirement remains the reviewed dual-version range, currently `>=0.8.0 <0.10.0`.
11
11
  - [ ] Minimum tested OMP version, checksum, CI job, README, SUPPORT, and TESTING agree.
12
12
 
13
13
  ## Required gates
@@ -1,6 +1,6 @@
1
1
  # Core OMP provider issue audit
2
2
 
3
- This audit compares reports in `getpaseo/paseo` with the community `omp-plugin` provider. It was refreshed on 2026-09-12 from GitHub issue titles and bodies containing `OMP`, `oh-my-pi`, or `rpc-ui`, OMP-related pull requests, and materially equivalent Pi/RPC reports. GitHub Discussions were also inspected; the repository had no OMP-related discussion.
3
+ This audit compares reports in `getpaseo/paseo` with the community `omp-plugin` provider. It was refreshed on 2026-09-18 from GitHub issue titles and bodies containing `OMP`, `oh-my-pi`, or `rpc-ui`, OMP-related pull requests, and materially equivalent Pi/RPC reports. GitHub Discussions were also inspected; the repository had no OMP-related discussion.
4
4
 
5
5
  Issue and pull-request pairs are consolidated by root cause. A closed upstream issue does not prove the plugin implements the behavior, and an open upstream issue does not imply the plugin is affected.
6
6
 
@@ -20,6 +20,7 @@ Status meanings:
20
20
  | [#2260](https://github.com/getpaseo/paseo/issues/2260), [PR #2261](https://github.com/getpaseo/paseo/pull/2261) | **Verified** | Incomplete or compacted `agent_end` frames use complete streamed `message_end` evidence or bounded history correlated by entry ID, with idle state confirmed before and after retrieval. Success, failure, native cancellation, and concurrent interrupts are preserved; missing, unavailable, non-correlatable, or conflicting terminal evidence fails closed with content-free count diagnostics. |
21
21
  | [#2281](https://github.com/getpaseo/paseo/issues/2281), [PR #2282](https://github.com/getpaseo/paseo/pull/2282) | **Verified** | Local-only prompt results and structured commands have explicit terminal ownership. |
22
22
  | [#3654](https://github.com/getpaseo/paseo/issues/3654), [#3998](https://github.com/getpaseo/paseo/issues/3998), [PR #3667](https://github.com/getpaseo/paseo/pull/3667) | **Verified** | Post-`agent_end` state reconciliation is bounded. Stale or unavailable `get_state` cannot leave a turn running forever. |
23
+ | Upstream OMP [PR #12331](https://github.com/can1357/oh-my-pi/pull/12331) and released OMP 18.2.x unkeyed `agent_end` frames | **Mitigated** | Request-keyed terminals use exact matching. For released binaries that omit the key, the plugin accepts only ordered current-prompt evidence: a fresh branch-correlated user entry followed by assistant activity, confirmed native idle/non-compacting state, and no active permission, tool, steer, or child work. Active or pre-evidence stale candidates are ignored. Confirmed-idle ambiguity fails only the Paseo turn, not the OMP runtime. The remaining same-agent stale-event misattribution risk is explicit and bounded; waiting for the upstream protocol fix would make every later turn unusable on published releases. Client reconnects, including mobile reconnect grace, are host transport behavior and do not own the daemon-managed provider session. |
23
24
  | [#3999](https://github.com/getpaseo/paseo/issues/3999), [#4000](https://github.com/getpaseo/paseo/issues/4000), [#4039](https://github.com/getpaseo/paseo/issues/4039), [PR #3772](https://github.com/getpaseo/paseo/pull/3772), [PR #4217](https://github.com/getpaseo/paseo/pull/4217) | **Verified** | `prompt.steer` is negotiated and implemented with expected-turn checks, acknowledgement ordering, duplicate correlation, and interrupt/terminal race coverage. |
24
25
  | Shared RPC cancellation [#3540](https://github.com/getpaseo/paseo/issues/3540), Pi [#3749](https://github.com/getpaseo/paseo/issues/3749) | **Verified in provider** | Native abort, exactly-one terminal event, permission cleanup, descendant cleanup, and uncertain-cleanup quarantine are tested. UI keyboard delivery remains host-owned. |
25
26
  | [#3218](https://github.com/getpaseo/paseo/issues/3218) | **Verified at provider boundary** | Session close owns and awaits OMP process cleanup. Whether every archive UI route invokes provider close is a host concern. |
@@ -4,29 +4,53 @@ Paseo plugins are trusted, unsandboxed code. Review this plugin and its producti
4
4
 
5
5
  ## Requirements
6
6
 
7
- - Paseo daemon and apps: `^0.8.0`
7
+ - Paseo daemon and apps: `>=0.8.0 <0.10.0`; `0.9.0-beta.1` is recommended
8
8
  - OMP: `18.1.15` or newer is the supported floor
9
9
  - OMP RPC: protocol v2 must negotiate successfully
10
10
 
11
11
  The first public build is an alpha. Alpha releases are compatibility previews and may require deleting and re-importing agents created by an earlier preview.
12
12
 
13
- ## Install a release
13
+ ## Install or update from npm on Paseo 0.9
14
14
 
15
- Install an exact published version from npm:
15
+ Paseo 0.9.0-beta.1 can acquire the published package and its production dependencies directly from npm on the daemon host:
16
16
 
17
17
  ```bash
18
18
  paseo plugin install npm:@omercnet/paseo-omp@<version>
19
19
  paseo plugin ls paseo-omp
20
20
  ```
21
21
 
22
- Alternatively, install the matching reviewed Git tag:
22
+ Check for the registry's current `latest` version and approve the proposed update, or select an exact version explicitly:
23
+
24
+ ```bash
25
+ paseo plugin update paseo-omp
26
+ paseo plugin update paseo-omp --version <new-version>
27
+ ```
28
+
29
+ The daemon uses its own npm registry and authentication configuration. npm acquisition installs the artifact's production dependencies before preparation; because npm artifacts omit `package-lock.json`, the preparation helper leaves that tree unchanged. A Git checkout with the committed lockfile runs frozen `npm ci --omit=dev --ignore-scripts`. A failed download, preparation, compatibility check, or activation keeps the installed revision active. Paseo 0.8 does not support npm plugin sources; use a Git tag or local directory instead.
30
+
31
+ ## Install a Git release on Paseo 0.8 or 0.9
32
+
33
+ Install the matching reviewed Git tag:
23
34
 
24
35
  ```bash
25
36
  paseo plugin add omercnet/paseo-plugins:paseo-omp --ref paseo-omp-v<version>
26
37
  paseo plugin ls paseo-omp
27
38
  ```
28
39
 
29
- A Git tag-pinned installation does not advance through `paseo plugin update`. To upgrade, record the current installation, then replace it with the new tag in one maintenance window:
40
+ ### Update a Git installation on Paseo 0.9
41
+
42
+ An ordinary update reviews the remote repository's current default HEAD. To move directly to another reviewed tag or commit, select it explicitly:
43
+
44
+ ```bash
45
+ paseo plugin update paseo-omp
46
+ paseo plugin update paseo-omp --ref paseo-omp-v<new-version>
47
+ ```
48
+
49
+ The install-time `--ref` does not constrain later updates. Paseo 0.9 stages and validates the selected revision before replacing the active installation, so plugin settings remain intact and a failed update leaves the previous revision running.
50
+
51
+ ### Update a tag-pinned Git installation on Paseo 0.8
52
+
53
+ Paseo 0.8 does not provide the 0.9 non-destructive explicit-ref update flow. Record the current installation, then replace it with the new tag in one maintenance window:
30
54
 
31
55
  ```bash
32
56
  paseo plugin ls paseo-omp --json > paseo-omp-before-update.json
@@ -56,12 +80,10 @@ paseo plugin ls paseo-omp
56
80
 
57
81
  ## Track a branch
58
82
 
59
- Tracking `main` executes future dependency and plugin updates with the daemon user's privileges. Record the installed commit before each update:
83
+ Tracking `main` executes future dependency and plugin updates with the daemon user's privileges:
60
84
 
61
85
  ```bash
62
86
  paseo plugin add omercnet/paseo-plugins:paseo-omp --ref main
63
- paseo plugin ls paseo-omp --json > paseo-omp-before-update.json
64
- paseo plugin update paseo-omp
65
87
  ```
66
88
 
67
- A failed Git build or incompatible update leaves the previous revision active. Remove and re-add the recorded commit to roll back.
89
+ On Paseo 0.9, preview and approve the remote default HEAD with `paseo plugin update paseo-omp`, or select `main` explicitly with `paseo plugin update paseo-omp --ref main`. On Paseo 0.8, record the installed commit before its existing branch-update workflow. A failed build or compatibility check leaves the previous revision active.
package/index.client.tsx CHANGED
@@ -1,4 +1,3 @@
1
- import type { PaseoAgentListResult, PaseoApi } from "@getpaseo/client";
2
1
  import { settingsRpc } from "@getpaseo/plugin";
3
2
  import type {
4
3
  PluginButtonRegistration,
@@ -19,6 +18,7 @@ import {
19
18
  isOmpProvider,
20
19
  ompStoreKey,
21
20
  } from "./client/omp-store-state";
21
+ import type { PaseoAgentListResult, PaseoApi } from "./client/paseo-types";
22
22
  import { quotaProviderIcon } from "./client/provider-icon";
23
23
  import { OmpImageTimeline } from "./client/provider-image";
24
24
  import { QuotaPopover } from "./client/quota-popover";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@omercnet/paseo-omp",
3
- "version": "0.2.1-next.88.1",
3
+ "version": "0.2.1-next.90.1",
4
4
  "type": "module",
5
5
  "description": "Paseo integration for OMP, including its direct provider and workspace tooling.",
6
6
  "license": "MIT",
@@ -57,20 +57,19 @@
57
57
  "typecheck": "tsc --noEmit"
58
58
  },
59
59
  "dependencies": {
60
+ "@getpaseo/protocol": "0.9.0-beta.1",
60
61
  "@modelcontextprotocol/sdk": "1.29.0",
61
62
  "yaml": "^2.9.0"
62
63
  },
63
64
  "devDependencies": {
64
65
  "@biomejs/biome": "^2.5.10",
65
- "@getpaseo/cli": "0.8.0",
66
- "@getpaseo/client": "0.8.0",
67
- "@getpaseo/plugin": "0.8.0",
68
- "@getpaseo/protocol": "0.8.0",
66
+ "@getpaseo/cli": "0.9.0-beta.1",
67
+ "@getpaseo/client": "0.9.0-beta.1",
68
+ "@getpaseo/plugin": "0.9.0-beta.1",
69
69
  "@tanstack/react-query": "^5.102.3",
70
70
  "@types/node": "^24.10.1",
71
71
  "@types/react": "^19.2.18",
72
72
  "@vitest/coverage-v8": "^5.0.0",
73
- "esbuild": "^0.28.0",
74
73
  "react": "19.1.0",
75
74
  "react-native": "0.81.5",
76
75
  "tsx": "^4.20.6",
package/paseo-plugin.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "id": "paseo-omp",
3
- "requirements": { "paseo": "^0.8.0" },
3
+ "requirements": { "paseo": ">=0.8.0 <0.10.0" },
4
4
  "build": [["node", "scripts/prepare-dependencies.mjs"]]
5
5
  }
@@ -8,10 +8,14 @@ const projectRoot = dirname(dirname(fileURLToPath(import.meta.url)));
8
8
  export function prepareDependencies(root = projectRoot, execute = execFileSync) {
9
9
  if (!existsSync(join(root, "package-lock.json"))) return false;
10
10
 
11
- execute(process.platform === "win32" ? "npm.cmd" : "npm", ["ci", "--ignore-scripts"], {
12
- cwd: root,
13
- stdio: "inherit",
14
- });
11
+ execute(
12
+ process.platform === "win32" ? "npm.cmd" : "npm",
13
+ ["ci", "--omit=dev", "--ignore-scripts"],
14
+ {
15
+ cwd: root,
16
+ stdio: "inherit",
17
+ },
18
+ );
15
19
  return true;
16
20
  }
17
21
 
@@ -228,6 +228,24 @@ type PendingUser = {
228
228
  fallbackOnFinish: boolean;
229
229
  bufferedEchoes: OmpMessage[];
230
230
  };
231
+ type TerminalCorrelationEvidence = "fresh-native-user" | "current-assistant";
232
+
233
+ type TerminalCorrelation = {
234
+ policy: "initial-turn" | "ordered-legacy" | "native-command";
235
+ evidence: Map<TerminalCorrelationEvidence, number>;
236
+ };
237
+
238
+ type TerminalCandidate = {
239
+ event: Extract<OmpRpcEvent, { type: "agent_end" }>;
240
+ arrivalSequence: number;
241
+ confidence:
242
+ | "keyed"
243
+ | "initial-turn"
244
+ | "ordered-legacy"
245
+ | "native-command"
246
+ | "interrupted"
247
+ | "ambiguous";
248
+ };
231
249
 
232
250
  type ActiveTurn = {
233
251
  turnId: string;
@@ -245,10 +263,8 @@ type ActiveTurn = {
245
263
  awaitingPermissionEvidence: boolean;
246
264
  activitySequence: number;
247
265
  acknowledged: boolean;
248
- terminalOwnershipEvidence: boolean;
249
- terminalOwnershipRequired: boolean;
266
+ terminalCorrelation: TerminalCorrelation;
250
267
  replayingBufferedEvents: boolean;
251
- bufferedTerminalOwnershipEvidence: boolean;
252
268
  agentInvoked?: boolean;
253
269
  nativeRequestId?: string;
254
270
  promptAcceptedEventIndex?: number;
@@ -263,14 +279,15 @@ type ActiveTurn = {
263
279
  agentEndRetryTimer?: unknown;
264
280
  agentEndDeadlineTimer?: unknown;
265
281
  agentEndCheck?: Promise<void>;
266
- terminalOwnershipTimer?: unknown;
282
+ ambiguousTerminalTimer?: unknown;
267
283
  terminalizing: boolean;
268
284
  terminalization?: Promise<void>;
269
285
  terminalOutcome?: TurnOutcome;
270
286
  terminalWake?: VoidDeferred;
271
287
  steerReady: VoidDeferred;
272
288
  steersInFlight: number;
273
- deferredAgentEnd?: Extract<OmpRpcEvent, { type: "agent_end" }>;
289
+ deferredAgentEnd?: TerminalCandidate;
290
+ activeToolCallIds: Set<string>;
274
291
  bufferedEvents: OmpRpcEvent[];
275
292
  pendingUsers: PendingUser[];
276
293
  userEchoes: OmpMessage[];
@@ -784,7 +801,7 @@ function createActiveTurn(
784
801
  clientMessageId: string,
785
802
  text: string,
786
803
  generation: number,
787
- terminalOwnershipRequired: boolean,
804
+ terminalPolicy: TerminalCorrelation["policy"],
788
805
  manualCompaction = false,
789
806
  ): ActiveTurn {
790
807
  return {
@@ -809,11 +826,10 @@ function createActiveTurn(
809
826
  manualCompaction,
810
827
  activitySequence: 0,
811
828
  acknowledged: false,
812
- terminalOwnershipEvidence: false,
829
+ terminalCorrelation: { policy: terminalPolicy, evidence: new Map() },
813
830
  replayingBufferedEvents: false,
814
- bufferedTerminalOwnershipEvidence: false,
815
- terminalOwnershipRequired,
816
831
  steersInFlight: 0,
832
+ activeToolCallIds: new Set(),
817
833
  steerReady: Promise.withResolvers<void>(),
818
834
  userCorrelationActive: false,
819
835
  userLookups: new Set(),
@@ -833,6 +849,35 @@ function createActiveTurn(
833
849
  ],
834
850
  };
835
851
  }
852
+ function classifyTerminalCandidate(
853
+ turn: ActiveTurn,
854
+ event: Extract<OmpRpcEvent, { type: "agent_end" }>,
855
+ arrivalSequence = turn.activitySequence,
856
+ ): TerminalCandidate {
857
+ if (event.requestId !== undefined) return { event, confidence: "keyed", arrivalSequence };
858
+ if (turn.agentInvoked === false) return { event, confidence: "ambiguous", arrivalSequence };
859
+ if (turn.terminalCorrelation.policy === "initial-turn") {
860
+ return { event, confidence: "initial-turn", arrivalSequence };
861
+ }
862
+ const assistantSequence = turn.terminalCorrelation.evidence.get("current-assistant");
863
+ if (
864
+ turn.terminalCorrelation.policy === "native-command" &&
865
+ assistantSequence !== undefined &&
866
+ assistantSequence <= arrivalSequence
867
+ ) {
868
+ return { event, confidence: "native-command", arrivalSequence };
869
+ }
870
+ const userSequence = turn.terminalCorrelation.evidence.get("fresh-native-user");
871
+ if (
872
+ userSequence !== undefined &&
873
+ assistantSequence !== undefined &&
874
+ userSequence < assistantSequence &&
875
+ assistantSequence <= arrivalSequence
876
+ ) {
877
+ return { event, confidence: "ordered-legacy", arrivalSequence };
878
+ }
879
+ return { event, confidence: "ambiguous", arrivalSequence };
880
+ }
836
881
 
837
882
  export class OmpProviderSession {
838
883
  readonly id: string;
@@ -1864,7 +1909,7 @@ export class OmpProviderSession {
1864
1909
  input.prompt.clientMessageId,
1865
1910
  payload.text,
1866
1911
  this.generation,
1867
- this.runtimeTurnCompleted,
1912
+ this.runtimeTurnCompleted ? "ordered-legacy" : "initial-turn",
1868
1913
  slashCommandName(payload.text) === "compact",
1869
1914
  );
1870
1915
  this.activeTurn = turn;
@@ -1901,7 +1946,11 @@ export class OmpProviderSession {
1901
1946
  return;
1902
1947
  }
1903
1948
  const ownershipPending = turn.pendingUsers[0];
1904
- if (turn.terminalOwnershipRequired && !this.branchWatermarkValid && ownershipPending) {
1949
+ if (
1950
+ turn.terminalCorrelation.policy === "ordered-legacy" &&
1951
+ !this.branchWatermarkValid &&
1952
+ ownershipPending
1953
+ ) {
1905
1954
  await this.refreshBranchEntries(turn, ownershipPending);
1906
1955
  if (
1907
1956
  this.closed ||
@@ -1939,13 +1988,10 @@ export class OmpProviderSession {
1939
1988
  0,
1940
1989
  turn.promptAcceptedEventIndex ?? bufferedEvents.length,
1941
1990
  );
1942
- for (const event of preAcceptanceEvents) this.handleTurnEvent(turn, event);
1991
+ for (const event of preAcceptanceEvents) this.handleTurnEvent(turn, event, false);
1943
1992
  this.projector.acceptLiveTurn(turn.turnId);
1944
1993
  for (const event of bufferedEvents) this.handleTurnEvent(turn, event);
1945
1994
  turn.replayingBufferedEvents = false;
1946
- if (acknowledgement.agentInvoked === true && turn.bufferedTerminalOwnershipEvidence) {
1947
- this.markTerminalOwnershipEvidence(turn);
1948
- }
1949
1995
  if (
1950
1996
  acknowledgement.agentInvoked === false &&
1951
1997
  turn.agentInvoked !== true &&
@@ -2096,7 +2142,7 @@ export class OmpProviderSession {
2096
2142
  if (this.activeTurn) {
2097
2143
  throw new OmpPublicError("OMP already has an active turn; send this message as a steer");
2098
2144
  }
2099
- const turn = createActiveTurn(clientMessageId, text, this.generation, false);
2145
+ const turn = createActiveTurn(clientMessageId, text, this.generation, "native-command");
2100
2146
  this.activeTurn = turn;
2101
2147
  try {
2102
2148
  await invoke();
@@ -2994,6 +3040,7 @@ export class OmpProviderSession {
2994
3040
  return;
2995
3041
  }
2996
3042
  turn.localOnlyDisabled = true;
3043
+ this.cancelAmbiguousTerminal(turn);
2997
3044
  turn.deferredAgentEnd = undefined;
2998
3045
  this.acceptPendingUser(turn, pending);
2999
3046
  this.emit({
@@ -3145,7 +3192,11 @@ export class OmpProviderSession {
3145
3192
  this.handleTurnEvent(turn, event);
3146
3193
  }
3147
3194
 
3148
- private handleTurnEvent(turn: ActiveTurn, event: OmpRpcEvent): void {
3195
+ private handleTurnEvent(
3196
+ turn: ActiveTurn,
3197
+ event: OmpRpcEvent,
3198
+ correlatesToAcceptedPrompt = true,
3199
+ ): void {
3149
3200
  if (
3150
3201
  turn.generation !== this.generation ||
3151
3202
  turn.terminal ||
@@ -3161,7 +3212,7 @@ export class OmpProviderSession {
3161
3212
  ) {
3162
3213
  return;
3163
3214
  }
3164
- if (event.type === "message_end") {
3215
+ if (event.type === "message_end" && correlatesToAcceptedPrompt) {
3165
3216
  const entryId = nativeEntryId(event.message);
3166
3217
  if (!entryId || turn.streamedMessageEntryIds.length >= MAX_AGENT_END_CORRELATION_MESSAGES) {
3167
3218
  turn.streamedMessageIdentityComplete = false;
@@ -3187,15 +3238,19 @@ export class OmpProviderSession {
3187
3238
  if (event.type === "prompt_result") {
3188
3239
  if (!event.id || event.id !== turn.nativeRequestId) return;
3189
3240
  if (event.agentInvoked) {
3241
+ // A request-keyed prompt result proves dispatch, but an unkeyed terminal still needs
3242
+ // ordered native user and assistant evidence from this accepted prompt.
3190
3243
  this.markAgentEvidence(turn);
3191
- if (turn.replayingBufferedEvents) turn.bufferedTerminalOwnershipEvidence = true;
3192
- else this.markTerminalOwnershipEvidence(turn);
3193
3244
  return;
3194
3245
  }
3195
3246
  if (turn.localOnlyDisabled || turn.steersInFlight > 0) return;
3196
3247
  if (!turn.nativeActivity && !turn.awaitingPermissionEvidence) {
3197
3248
  turn.agentInvoked = false;
3198
3249
  turn.localOnlyEligible = true;
3250
+ if (turn.deferredAgentEnd?.confidence === "ambiguous") {
3251
+ turn.deferredAgentEnd = undefined;
3252
+ this.cancelAmbiguousTerminal(turn);
3253
+ }
3199
3254
  this.scheduleLocalOnlyCompletion(turn);
3200
3255
  }
3201
3256
  return;
@@ -3243,8 +3298,10 @@ export class OmpProviderSession {
3243
3298
  return;
3244
3299
  }
3245
3300
  if (event.type === "agent_end" && turn.manualCompactionPending) return;
3246
- if (event.type === "tool_execution_end" && event.toolName === "ask_user") {
3247
- this.pendingFreeformSelection = null;
3301
+ if (event.type === "tool_execution_start") turn.activeToolCallIds.add(event.toolCallId);
3302
+ if (event.type === "tool_execution_end") {
3303
+ turn.activeToolCallIds.delete(event.toolCallId);
3304
+ if (event.toolName === "ask_user") this.pendingFreeformSelection = null;
3248
3305
  }
3249
3306
  if (event.type === "tool_execution_start" || event.type === "tool_execution_end") {
3250
3307
  try {
@@ -3254,23 +3311,6 @@ export class OmpProviderSession {
3254
3311
  return;
3255
3312
  }
3256
3313
  }
3257
- if (isNativeTurnActivity(event)) {
3258
- turn.nativeActivity = true;
3259
- this.cancelLocalOnlyCompletion(turn);
3260
- }
3261
- if (event.type === "message_end" && event.message.role === "user") {
3262
- this.markAgentEvidence(turn);
3263
- this.projectUserEcho(turn, event.message);
3264
- return;
3265
- }
3266
- if (
3267
- (event.type === "message_start" ||
3268
- event.type === "message_update" ||
3269
- event.type === "message_end") &&
3270
- event.message.role === "assistant"
3271
- ) {
3272
- turn.awaitingPermissionEvidence = false;
3273
- }
3274
3314
  if (event.type === "agent_end") {
3275
3315
  if (event.isTerminal === false) {
3276
3316
  turn.completedMessageCount = 0;
@@ -3280,41 +3320,51 @@ export class OmpProviderSession {
3280
3320
  turn.lastCompletedAssistantEntryId = undefined;
3281
3321
  return;
3282
3322
  }
3283
- if (event.requestId !== undefined) this.markTerminalOwnershipEvidence(turn);
3284
- if (
3285
- !turn.interrupted &&
3286
- turn.terminalOwnershipRequired &&
3287
- !turn.terminalOwnershipEvidence &&
3288
- !(turn.replayingBufferedEvents && turn.bufferedTerminalOwnershipEvidence)
3289
- ) {
3290
- this.scheduleTerminalOwnershipTimeout(turn);
3323
+ const candidate: TerminalCandidate = turn.interrupted
3324
+ ? { event, confidence: "interrupted", arrivalSequence: turn.activitySequence }
3325
+ : classifyTerminalCandidate(turn, event);
3326
+ if (candidate.confidence === "ambiguous") {
3327
+ this.deferAmbiguousTerminal(turn, candidate);
3291
3328
  return;
3292
3329
  }
3293
- const hasAssistantEvidence =
3294
- event.messages?.some((message) => message.role === "assistant") ?? false;
3295
- if (turn.awaitingPermissionEvidence && !hasAssistantEvidence) {
3296
- turn.deferredAgentEnd = event;
3297
- return;
3298
- }
3299
- if (hasAssistantEvidence) turn.awaitingPermissionEvidence = false;
3300
- if (turn.terminalizing) {
3301
- turn.deferredAgentEnd = event;
3302
- return;
3303
- }
3304
- if (turn.steersInFlight > 0) {
3305
- turn.deferredAgentEnd = event;
3330
+ turn.nativeActivity = true;
3331
+ this.cancelLocalOnlyCompletion(turn);
3332
+ if (
3333
+ (!turn.interrupted &&
3334
+ candidate.confidence !== "keyed" &&
3335
+ this.hasTerminalConflict(turn, false)) ||
3336
+ turn.steersInFlight > 0 ||
3337
+ turn.terminalizing
3338
+ ) {
3339
+ turn.deferredAgentEnd = candidate;
3306
3340
  return;
3307
3341
  }
3308
- this.beginTerminalization(turn, event);
3342
+ this.beginTerminalization(turn, candidate);
3309
3343
  return;
3310
3344
  }
3311
- if (isNativeTurnActivity(event)) this.markAgentEvidence(turn);
3345
+ if (isNativeTurnActivity(event)) {
3346
+ turn.nativeActivity = true;
3347
+ this.cancelLocalOnlyCompletion(turn);
3348
+ }
3312
3349
  if (event.type === "message_end" && event.message.role === "user") {
3313
3350
  this.markAgentEvidence(turn);
3314
3351
  this.projectUserEcho(turn, event.message);
3315
3352
  return;
3316
3353
  }
3354
+ if (
3355
+ (event.type === "message_start" ||
3356
+ event.type === "message_update" ||
3357
+ event.type === "message_end") &&
3358
+ event.message.role === "assistant"
3359
+ ) {
3360
+ turn.awaitingPermissionEvidence = false;
3361
+ if (correlatesToAcceptedPrompt) {
3362
+ turn.terminalCorrelation.evidence.set("current-assistant", turn.activitySequence + 1);
3363
+ }
3364
+ }
3365
+ if (isNativeTurnActivity(event)) this.markAgentEvidence(turn);
3317
3366
  this.projector.project(event, turn.turnId);
3367
+ if (event.type === "tool_execution_end") this.resumeDeferredAgentEnd();
3318
3368
  }
3319
3369
 
3320
3370
  private projectUserEcho(turn: ActiveTurn, message: OmpMessage): void {
@@ -3385,6 +3435,7 @@ export class OmpProviderSession {
3385
3435
  pending.bufferedEchoes.push(...turn.userEchoes.splice(0));
3386
3436
  return;
3387
3437
  }
3438
+ const observedSequence = turn.activitySequence;
3388
3439
  let resolvedId = entryId ?? this.claimUnclaimedBranchEntry(turn, pending.text);
3389
3440
  if (!resolvedId && (await this.refreshBranchEntries(turn, pending))) {
3390
3441
  resolvedId = this.claimUnclaimedBranchEntry(turn, pending.text);
@@ -3400,7 +3451,7 @@ export class OmpProviderSession {
3400
3451
  turn.userEchoes.shift();
3401
3452
  if (!resolvedId) return;
3402
3453
  turn.pendingUsers.shift();
3403
- this.publishCorrelatedUser(turn, pending, resolvedId);
3454
+ this.publishCorrelatedUser(turn, pending, resolvedId, observedSequence);
3404
3455
  }
3405
3456
  }
3406
3457
  private async refreshBranchEntries(turn: ActiveTurn, pending: PendingUser): Promise<boolean> {
@@ -4234,23 +4285,27 @@ export class OmpProviderSession {
4234
4285
  return this.activeTurn?.turnId === pending.turnId && !this.activeTurn.terminal;
4235
4286
  }
4236
4287
 
4237
- private reevaluateDeferredPermissionTerminal(): void {
4238
- const turn = this.activeTurn;
4239
- if (!turn?.deferredAgentEnd || turn.terminal || turn.terminalizing) return;
4288
+ private hasTerminalConflict(turn: ActiveTurn, includeChildren = true): boolean {
4240
4289
  const ownsTurn = (pending: PendingPermission | PendingToolPermission) =>
4241
4290
  pending.turnId === turn.turnId;
4242
- if (
4291
+ return (
4292
+ turn.awaitingPermissionEvidence ||
4243
4293
  [...this.pendingPermissions.values()].some(ownsTurn) ||
4244
4294
  [...this.inFlightPermissions.values()].some(ownsTurn) ||
4245
4295
  [...this.pendingToolPermissions.values()].some(ownsTurn) ||
4246
- [...this.inFlightToolPermissions.values()].some(ownsTurn)
4247
- ) {
4248
- return;
4249
- }
4250
- const hasAssistantEvidence =
4251
- turn.deferredAgentEnd.messages?.some((message) => message.role === "assistant") ?? false;
4252
- if (!hasAssistantEvidence || turn.awaitingPermissionEvidence) return;
4296
+ [...this.inFlightToolPermissions.values()].some(ownsTurn) ||
4297
+ turn.activeToolCallIds.size > 0 ||
4298
+ turn.steersInFlight > 0 ||
4299
+ (includeChildren && Boolean(this.subsessions?.hasActiveChildren()))
4300
+ );
4301
+ }
4302
+
4303
+ private reevaluateDeferredPermissionTerminal(): void {
4304
+ const turn = this.activeTurn;
4305
+ if (!turn?.deferredAgentEnd || turn.terminal || turn.terminalizing) return;
4306
+ if (this.hasTerminalConflict(turn)) return;
4253
4307
  const deferred = turn.deferredAgentEnd;
4308
+ if (deferred.confidence === "ambiguous") return;
4254
4309
  turn.deferredAgentEnd = undefined;
4255
4310
  this.beginTerminalization(turn, deferred);
4256
4311
  }
@@ -4266,12 +4321,18 @@ export class OmpProviderSession {
4266
4321
  return this.slashCommands.has(commandName);
4267
4322
  }
4268
4323
 
4269
- private publishCorrelatedUser(turn: ActiveTurn, pending: PendingUser, entryId?: string): void {
4324
+ private publishCorrelatedUser(
4325
+ turn: ActiveTurn,
4326
+ pending: PendingUser,
4327
+ entryId: string | undefined,
4328
+ observedSequence: number,
4329
+ ): void {
4270
4330
  if (entryId) {
4271
4331
  if (this.emittedEntryIds.has(entryId)) return;
4272
4332
  this.seenEntryIds.add(entryId);
4273
4333
  this.emittedEntryIds.add(entryId);
4274
- if (!turn.terminalOwnershipRequired) this.markTerminalOwnershipEvidence(turn);
4334
+ turn.terminalCorrelation.evidence.set("fresh-native-user", observedSequence);
4335
+ this.refreshAmbiguousTerminal(turn);
4275
4336
  if (this.branchWatermarkValid && !this.branchEntryIds.has(entryId)) {
4276
4337
  if (this.branchEntryIds.size >= MAX_UNCLAIMED_BRANCH_ENTRIES)
4277
4338
  this.quarantineBranchEntries();
@@ -4291,12 +4352,7 @@ export class OmpProviderSession {
4291
4352
  turn.activitySequence += 1;
4292
4353
  turn.localOnlyEligible = false;
4293
4354
  this.cancelLocalOnlyCompletion(turn);
4294
- if (!turn.awaitingPermissionEvidence) turn.deferredAgentEnd = undefined;
4295
- }
4296
-
4297
- private markTerminalOwnershipEvidence(turn: ActiveTurn): void {
4298
- turn.terminalOwnershipEvidence = true;
4299
- this.cancelTerminalOwnershipTimeout(turn);
4355
+ this.refreshAmbiguousTerminal(turn);
4300
4356
  }
4301
4357
 
4302
4358
  private scheduleLocalOnlyCompletion(turn: ActiveTurn): void {
@@ -4322,32 +4378,68 @@ export class OmpProviderSession {
4322
4378
  turn.localOnlyTimer = undefined;
4323
4379
  }
4324
4380
 
4325
- private scheduleTerminalOwnershipTimeout(turn: ActiveTurn): void {
4326
- if (
4327
- turn.terminalOwnershipTimer !== undefined ||
4328
- turn.terminalOwnershipEvidence ||
4329
- (turn.agentInvoked === false && turn.localOnlyEligible)
4330
- ) {
4381
+ private refreshAmbiguousTerminal(turn: ActiveTurn): void {
4382
+ const candidate = turn.deferredAgentEnd;
4383
+ if (candidate?.confidence !== "ambiguous") return;
4384
+ const resolved = classifyTerminalCandidate(turn, candidate.event, candidate.arrivalSequence);
4385
+ if (resolved.confidence !== "ambiguous") {
4386
+ this.cancelAmbiguousTerminal(turn);
4387
+ turn.deferredAgentEnd = undefined;
4388
+ if (turn.terminalizing || this.hasTerminalConflict(turn, false)) {
4389
+ turn.deferredAgentEnd = resolved;
4390
+ } else {
4391
+ this.beginTerminalization(turn, resolved);
4392
+ }
4331
4393
  return;
4332
4394
  }
4333
- turn.terminalOwnershipTimer = this.scheduler.set(() => {
4334
- turn.terminalOwnershipTimer = undefined;
4335
- if (
4336
- this.closed ||
4337
- turn.terminal ||
4338
- this.activeTurn !== turn ||
4339
- turn.terminalOwnershipEvidence
4340
- ) {
4341
- return;
4342
- }
4343
- this.handleRuntimeFailure("OMP terminal ownership could not be confirmed");
4395
+ if (turn.activitySequence > candidate.arrivalSequence) {
4396
+ turn.deferredAgentEnd = undefined;
4397
+ this.cancelAmbiguousTerminal(turn);
4398
+ }
4399
+ }
4400
+
4401
+ private deferAmbiguousTerminal(turn: ActiveTurn, candidate: TerminalCandidate): void {
4402
+ if (turn.agentInvoked === false && turn.localOnlyEligible) return;
4403
+ this.cancelAmbiguousTerminal(turn);
4404
+ turn.deferredAgentEnd = candidate;
4405
+ turn.ambiguousTerminalTimer = this.scheduler.set(() => {
4406
+ turn.ambiguousTerminalTimer = undefined;
4407
+ void this.settleAmbiguousTerminal(turn, candidate);
4344
4408
  }, AGENT_END_STATE_TIMEOUT_MS);
4345
4409
  }
4346
4410
 
4347
- private cancelTerminalOwnershipTimeout(turn: ActiveTurn): void {
4348
- if (turn.terminalOwnershipTimer === undefined) return;
4349
- this.scheduler.clear(turn.terminalOwnershipTimer);
4350
- turn.terminalOwnershipTimer = undefined;
4411
+ private cancelAmbiguousTerminal(turn: ActiveTurn): void {
4412
+ if (turn.ambiguousTerminalTimer === undefined) return;
4413
+ this.scheduler.clear(turn.ambiguousTerminalTimer);
4414
+ turn.ambiguousTerminalTimer = undefined;
4415
+ }
4416
+
4417
+ private async settleAmbiguousTerminal(
4418
+ turn: ActiveTurn,
4419
+ candidate: TerminalCandidate,
4420
+ ): Promise<void> {
4421
+ if (
4422
+ this.closed ||
4423
+ turn.terminal ||
4424
+ this.activeTurn !== turn ||
4425
+ turn.deferredAgentEnd !== candidate
4426
+ ) {
4427
+ return;
4428
+ }
4429
+ await Promise.allSettled(turn.userLookups);
4430
+ this.refreshAmbiguousTerminal(turn);
4431
+ if (turn.terminal || this.activeTurn !== turn || turn.deferredAgentEnd !== candidate) return;
4432
+ const state = await this.boundedTerminalState(turn, FINAL_USAGE_WAIT_MS);
4433
+ if (turn.terminal || this.activeTurn !== turn || turn.deferredAgentEnd !== candidate) return;
4434
+ if (!state) {
4435
+ this.handleRuntimeFailure("OMP agent_end state could not be confirmed");
4436
+ return;
4437
+ }
4438
+ turn.deferredAgentEnd = undefined;
4439
+ if (state.isStreaming || state.isCompacting) return;
4440
+ await this.finishTurn(turn, "failed", {
4441
+ message: "OMP unkeyed agent_end could not be correlated to the current prompt",
4442
+ });
4351
4443
  }
4352
4444
 
4353
4445
  private async completeLocalOnlyTurn(turn: ActiveTurn): Promise<void> {
@@ -4373,15 +4465,44 @@ export class OmpProviderSession {
4373
4465
  await this.finishTurn(turn, "completed", undefined, false, false, true);
4374
4466
  }
4375
4467
 
4376
- private beginTerminalization(
4377
- turn: ActiveTurn,
4378
- event: Extract<OmpRpcEvent, { type: "agent_end" }>,
4379
- ): void {
4468
+ private resetAgentEndProbe(turn: ActiveTurn): void {
4469
+ turn.agentEndPending = false;
4470
+ turn.terminalizing = false;
4471
+ if (turn.agentEndRetryTimer !== undefined) {
4472
+ this.scheduler.clear(turn.agentEndRetryTimer);
4473
+ turn.agentEndRetryTimer = undefined;
4474
+ }
4475
+ if (turn.agentEndDeadlineTimer !== undefined) {
4476
+ this.scheduler.clear(turn.agentEndDeadlineTimer);
4477
+ turn.agentEndDeadlineTimer = undefined;
4478
+ }
4479
+ }
4480
+ private ignoreActiveTerminalCandidate(turn: ActiveTurn, candidate: TerminalCandidate): void {
4481
+ const replacement =
4482
+ turn.deferredAgentEnd && turn.deferredAgentEnd !== candidate
4483
+ ? turn.deferredAgentEnd
4484
+ : undefined;
4485
+ this.resetAgentEndProbe(turn);
4486
+ turn.deferredAgentEnd = replacement;
4487
+ if (replacement && replacement.confidence !== "ambiguous") {
4488
+ turn.deferredAgentEnd = undefined;
4489
+ this.beginTerminalization(turn, replacement);
4490
+ return;
4491
+ }
4492
+ this.pollUsage(turn);
4493
+ }
4494
+
4495
+ private beginTerminalization(turn: ActiveTurn, candidate: TerminalCandidate): void {
4380
4496
  if (turn.terminal || turn.terminalizing || turn.agentEndPending || this.activeTurn !== turn) {
4381
4497
  return;
4382
4498
  }
4383
- if (!turn.interrupted && this.deferAgentEndForSubsessions(turn, event)) return;
4499
+ if (!turn.interrupted && this.deferAgentEndForSubsessions(turn, candidate)) return;
4384
4500
  turn.agentEndPending = true;
4501
+ if (turn.deferredAgentEnd?.confidence === "ambiguous") {
4502
+ turn.deferredAgentEnd = undefined;
4503
+ }
4504
+ turn.terminalizing = true;
4505
+ this.cancelAmbiguousTerminal(turn);
4385
4506
  turn.usageSampleFloor = this.usageSequence + 1;
4386
4507
  if (this.usageSample?.turn === turn && this.usageSample.sequence < turn.usageSampleFloor) {
4387
4508
  this.usageSample = null;
@@ -4391,21 +4512,21 @@ export class OmpProviderSession {
4391
4512
  turn.agentEndDeadlineTimer = undefined;
4392
4513
  if (!turn.agentEndPending || turn.terminal || this.activeTurn !== turn) return;
4393
4514
  if (
4394
- turn.userEchoObserved ||
4395
- (turn.terminalOwnershipRequired && !turn.terminalOwnershipEvidence)
4515
+ candidate.confidence === "keyed" ||
4516
+ (candidate.confidence === "initial-turn" && !turn.userEchoObserved)
4396
4517
  ) {
4397
- this.handleRuntimeFailure("OMP agent_end state could not be confirmed");
4518
+ void this.completeAgentEnd(turn, candidate.event);
4398
4519
  } else {
4399
- void this.completeAgentEnd(turn, event);
4520
+ this.handleRuntimeFailure("OMP agent_end state could not be confirmed");
4400
4521
  }
4401
4522
  }, AGENT_END_SETTLE_MS);
4402
- this.finishFromAgentEnd(turn, event);
4523
+ this.finishFromAgentEnd(turn, candidate);
4403
4524
  }
4404
4525
 
4405
4526
  private resumeAfterFailedSteer(turn: ActiveTurn): void {
4406
4527
  if (turn.terminal || this.activeTurn !== turn || turn.steersInFlight > 0) return;
4407
4528
  const deferred = turn.deferredAgentEnd;
4408
- if (deferred) {
4529
+ if (deferred && deferred.confidence !== "ambiguous" && !this.hasTerminalConflict(turn)) {
4409
4530
  turn.deferredAgentEnd = undefined;
4410
4531
  this.beginTerminalization(turn, deferred);
4411
4532
  return;
@@ -4414,13 +4535,11 @@ export class OmpProviderSession {
4414
4535
  this.scheduleLocalOnlyCompletion(turn);
4415
4536
  }
4416
4537
  }
4417
- private deferAgentEndForSubsessions(
4418
- turn: ActiveTurn,
4419
- event: Extract<OmpRpcEvent, { type: "agent_end" }>,
4420
- ): boolean {
4538
+
4539
+ private deferAgentEndForSubsessions(turn: ActiveTurn, candidate: TerminalCandidate): boolean {
4421
4540
  if (!this.subsessions?.hasActiveChildren()) return false;
4422
4541
  turn.terminalizing = false;
4423
- turn.deferredAgentEnd = event;
4542
+ turn.deferredAgentEnd = candidate;
4424
4543
  void this.subsessions.reconcile(this.runtime).catch(() => {
4425
4544
  if (!turn.terminal && this.activeTurn === turn) {
4426
4545
  this.handleRuntimeFailure("OMP subagent reconciliation failed");
@@ -4431,31 +4550,22 @@ export class OmpProviderSession {
4431
4550
 
4432
4551
  private resumeDeferredAgentEnd(): void {
4433
4552
  const turn = this.activeTurn;
4434
- if (
4435
- !turn ||
4436
- turn.terminal ||
4437
- turn.terminalizing ||
4438
- turn.steersInFlight > 0 ||
4439
- this.subsessions?.hasActiveChildren()
4440
- ) {
4553
+ if (!turn || turn.terminal || turn.terminalizing || this.hasTerminalConflict(turn)) {
4441
4554
  return;
4442
4555
  }
4443
- const event = turn.deferredAgentEnd;
4444
- if (!event) return;
4556
+ const candidate = turn.deferredAgentEnd;
4557
+ if (!candidate || candidate.confidence === "ambiguous") return;
4445
4558
  turn.deferredAgentEnd = undefined;
4446
- this.beginTerminalization(turn, event);
4559
+ this.beginTerminalization(turn, candidate);
4447
4560
  }
4448
4561
 
4449
- private finishFromAgentEnd(
4450
- turn: ActiveTurn,
4451
- event: Extract<OmpRpcEvent, { type: "agent_end" }>,
4452
- ): void {
4562
+ private finishFromAgentEnd(turn: ActiveTurn, candidate: TerminalCandidate): void {
4453
4563
  if (!turn.agentEndPending || turn.terminal) return;
4454
4564
  if (turn.agentEndCheck) {
4455
- turn.deferredAgentEnd = event;
4565
+ turn.deferredAgentEnd = candidate;
4456
4566
  return;
4457
4567
  }
4458
- const check = this.checkAgentEndState(turn, event);
4568
+ const check = this.checkAgentEndState(turn, candidate);
4459
4569
  turn.agentEndCheck = check;
4460
4570
  void check.finally(() => {
4461
4571
  if (turn.agentEndCheck !== check) return;
@@ -4467,13 +4577,7 @@ export class OmpProviderSession {
4467
4577
  });
4468
4578
  }
4469
4579
 
4470
- private async checkAgentEndState(
4471
- turn: ActiveTurn,
4472
- event: Extract<OmpRpcEvent, { type: "agent_end" }>,
4473
- ): Promise<void> {
4474
- // Evidence arriving during this check cannot authorize an older terminal frame.
4475
- const ownershipObserved =
4476
- turn.terminalOwnershipEvidence || turn.bufferedTerminalOwnershipEvidence;
4580
+ private async checkAgentEndState(turn: ActiveTurn, candidate: TerminalCandidate): Promise<void> {
4477
4581
  await Promise.allSettled(turn.userLookups);
4478
4582
  if (!turn.agentEndPending || turn.terminal || this.activeTurn !== turn) return;
4479
4583
  while (turn.userEchoes.length > 0) {
@@ -4482,40 +4586,42 @@ export class OmpProviderSession {
4482
4586
  await Promise.allSettled(turn.userLookups);
4483
4587
  if (!turn.agentEndPending || turn.terminal || this.activeTurn !== turn) return;
4484
4588
  }
4589
+ if (
4590
+ !turn.interrupted &&
4591
+ candidate.confidence !== "keyed" &&
4592
+ this.hasTerminalConflict(turn, false)
4593
+ ) {
4594
+ this.resetAgentEndProbe(turn);
4595
+ turn.deferredAgentEnd = candidate;
4596
+ this.pollUsage(turn);
4597
+ return;
4598
+ }
4485
4599
  if (turn.interrupted) {
4486
- await this.completeAgentEnd(turn, event);
4600
+ await this.completeAgentEnd(turn, candidate.event);
4487
4601
  return;
4488
4602
  }
4489
4603
  const state = await this.boundedTerminalState(turn, FINAL_USAGE_WAIT_MS);
4490
4604
  if (!turn.agentEndPending || turn.terminal || this.activeTurn !== turn) return;
4491
4605
  if (!turn.interrupted && this.subsessions?.hasActiveChildren()) {
4492
- turn.agentEndPending = false;
4493
- if (this.deferAgentEndForSubsessions(turn, event)) return;
4606
+ this.resetAgentEndProbe(turn);
4607
+ if (this.deferAgentEndForSubsessions(turn, candidate)) return;
4608
+ }
4609
+ if (
4610
+ !turn.interrupted &&
4611
+ candidate.confidence !== "keyed" &&
4612
+ this.hasTerminalConflict(turn, false)
4613
+ ) {
4614
+ this.resetAgentEndProbe(turn);
4615
+ turn.deferredAgentEnd = candidate;
4616
+ this.pollUsage(turn);
4617
+ return;
4494
4618
  }
4495
4619
  if (state) {
4496
4620
  if (state.isStreaming || state.isCompacting) {
4497
- if (!turn.terminalOwnershipEvidence && !turn.terminalOwnershipRequired) {
4498
- const message = "OMP agent_end arrived while the native runtime remained active";
4499
- this.invalidateRuntime(message);
4500
- await this.finishTurn(turn, "failed", { message }, true, true);
4501
- return;
4502
- }
4503
- turn.agentEndPending = false;
4504
- turn.terminalizing = false;
4505
- turn.deferredAgentEnd = undefined;
4506
- return;
4507
- }
4508
- if (
4509
- turn.terminalOwnershipRequired &&
4510
- (!ownershipObserved || !turn.terminalOwnershipEvidence)
4511
- ) {
4512
- turn.agentEndPending = false;
4513
- turn.terminalizing = false;
4514
- turn.deferredAgentEnd = undefined;
4515
- this.scheduleTerminalOwnershipTimeout(turn);
4621
+ this.ignoreActiveTerminalCandidate(turn, candidate);
4516
4622
  return;
4517
4623
  }
4518
- await this.completeAgentEnd(turn, event, true);
4624
+ await this.completeAgentEnd(turn, candidate.event, true);
4519
4625
  return;
4520
4626
  }
4521
4627
  if (turn.userEchoObserved) {
@@ -4525,7 +4631,7 @@ export class OmpProviderSession {
4525
4631
  if (turn.agentEndRetryTimer === undefined) {
4526
4632
  turn.agentEndRetryTimer = this.scheduler.set(() => {
4527
4633
  turn.agentEndRetryTimer = undefined;
4528
- this.finishFromAgentEnd(turn, event);
4634
+ this.finishFromAgentEnd(turn, candidate);
4529
4635
  }, USAGE_POLL_MS);
4530
4636
  }
4531
4637
  }
@@ -4556,8 +4662,11 @@ export class OmpProviderSession {
4556
4662
  )
4557
4663
  return;
4558
4664
  if (!turn.interrupted && state && (state.isStreaming || state.isCompacting)) {
4559
- turn.agentEndPending = false;
4560
- turn.deferredAgentEnd = undefined;
4665
+ this.ignoreActiveTerminalCandidate(turn, {
4666
+ event,
4667
+ arrivalSequence: turn.activitySequence,
4668
+ confidence: event.requestId === undefined ? "ordered-legacy" : "keyed",
4669
+ });
4561
4670
  return;
4562
4671
  }
4563
4672
  if (state && !state.isStreaming && !state.isCompacting) {
@@ -4623,6 +4732,7 @@ export class OmpProviderSession {
4623
4732
  turn.starting = false;
4624
4733
  turn.terminal = true;
4625
4734
  this.stopUsagePoll(turn);
4735
+ this.cancelAmbiguousTerminal(turn);
4626
4736
  this.resolveTurnPermissions(turn.turnId);
4627
4737
  this.projector.finishTurn(turn.turnId);
4628
4738
  if (this.activeTurn === turn) this.activeTurn = null;
@@ -4677,7 +4787,7 @@ export class OmpProviderSession {
4677
4787
  turn.manualCompactionPending = false;
4678
4788
  turn.agentEndPending = false;
4679
4789
  this.cancelLocalOnlyCompletion(turn);
4680
- this.cancelTerminalOwnershipTimeout(turn);
4790
+ this.cancelAmbiguousTerminal(turn);
4681
4791
  this.stopUsagePoll(turn);
4682
4792
  if (turn.manualCompactionDeadlineTimer !== undefined) {
4683
4793
  this.scheduler.clear(turn.manualCompactionDeadlineTimer);
@@ -0,0 +1,21 @@
1
+ export type ExternalUrlOpener = (url: string) => Promise<void>;
2
+
3
+ export function selectExternalUrlOpener(
4
+ paseoOpener: ExternalUrlOpener | undefined,
5
+ fallback: ExternalUrlOpener,
6
+ ): ExternalUrlOpener {
7
+ return paseoOpener ?? fallback;
8
+ }
9
+
10
+ export function validatedHttpUrl(value: string): string {
11
+ let url: URL;
12
+ try {
13
+ url = new URL(value);
14
+ } catch {
15
+ throw new Error("Only absolute HTTP(S) URLs are supported.");
16
+ }
17
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
18
+ throw new Error("Only absolute HTTP(S) URLs are supported.");
19
+ }
20
+ return url.href;
21
+ }