@omercnet/paseo-omp 1.3.1 → 1.3.2-next.230.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/OVERVIEW.md CHANGED
@@ -4,7 +4,7 @@ OMP adds an agent provider for the OMP coding agent, plus an OMP sidebar and wor
4
4
 
5
5
  - Paseo 0.9.2 through 0.9.x, 0.10.x or 0.11.x.
6
6
  - OMP 18.1.15 or newer on the daemon host, speaking RPC protocol v2. Older runtimes are rejected.
7
- - Provider options let you change the OMP command, set environment values, choose an output redaction mode and set a session directory and timeout. Named OMP profiles appear as separate **OMP · <profile>** providers.
7
+ - Provider options let you change the OMP command, set environment values, choose an output redaction mode and set a session directory and timeout. Named OMP profiles appear as separate `OMP · <profile>` providers.
8
8
 
9
9
  ## What the sidebar and panel do
10
10
 
package/README.md CHANGED
@@ -54,8 +54,8 @@ Profile selection is request-local: concurrent clients cannot change each other'
54
54
 
55
55
  ## Paseo provider SDK coverage
56
56
 
57
- The capability inventory is pinned to `@getpaseo/plugin` 0.11.0-beta.3 (PR #251), with nightly
58
- compatibility checks against supported 0.9 and 0.10 hosts. Provider capability scoring still covers
57
+ The capability inventory is pinned to `@getpaseo/plugin` 0.11.1, with nightly compatibility checks
58
+ against supported 0.9 and 0.10 hosts and the latest/beta channels. Provider capability scoring covers
59
59
  all 17 entries in the SDK's `PROVIDER_CAPABILITIES`; the separate provider/client SDK table below
60
60
  also tracks the 0.11 registration, screen, and sidebar APIs. This measures strict SDK surface
61
61
  coverage, not general product quality.
@@ -79,7 +79,7 @@ Scoring is deliberately mechanical so releases remain comparable:
79
79
  | `session.archive` | **0%** | Not advertised. Paseo may archive its agent record, but OMP has no native transcript archive operation. |
80
80
  | `session.configure` | **50%** | Live model and thinking changes commit and republish native state. Live approval-mode changes require a new session, and non-empty SDK `settings` are rejected. |
81
81
  | `session.list` | **100%** | Bounded host-wide and cwd-scoped discovery, search, previews, profile-specific session roots, and cleanup-reservation guards. |
82
- | `session.persistence` | **100%** | Versioned opaque handles, exact resume, import, replay-before-ready, runtime recovery, and cleanup quarantine. |
82
+ | `session.persistence` | **100%** | Versioned opaque handles, exact resume, import, replay-before-ready, runtime recovery, and cleanup quarantine. Archived agents whose recorded workspace has been removed load read-only from their authorized transcript when Paseo opens them with its `history` purpose, without spawning OMP or connecting MCP servers; interactive create and resume still require the workspace. |
83
83
  | `session.revert.both` | **0%** | Not advertised because OMP cannot atomically rewind conversation and workspace files. |
84
84
  | `session.revert.conversation` | **100%** | Native branch-based conversation rewind, replay deduplication, ownership transfer, and failure quarantine. |
85
85
  | `session.revert.files` | **0%** | Not advertised because OMP does not expose a native file-only rewind contract. |
@@ -95,7 +95,7 @@ Provider functionality outside the capability flags is tracked separately:
95
95
  | --- | ---: | --- |
96
96
  | Registration metadata and sanitized SVG icon | **100%** | Stable `omp-plugin` identity, label, description, and bundled icon. |
97
97
  | Strict `providerOptionsSchema` | **100%** | Command, literal environment, names-only profile inherited environment, output-redaction mode, session directory, RPC timeout, and role-model options are validated and normalized. Host-wide inherited names are separately persisted and merged at launch. |
98
- | Availability diagnostics | **50%** | The plugin implements a `checkAvailability` hook with bounded checks that distinguish missing, unrunnable, incompatible, and available OMP runtimes, but no released daemon calls it (no upstream reference at v0.10.2 or v0.11.0-beta.3). Hosts treat the provider as available when a connection opens, and launch failures surface at session start. |
98
+ | Availability diagnostics | **50%** | The plugin implements a `checkAvailability` hook with bounded checks that distinguish missing, unrunnable, incompatible, and available OMP runtimes, but Paseo 0.10.2 and 0.11.1 do not call it. Hosts treat the provider as available when a connection opens, and launch failures surface at session start. |
99
99
  | Catalog cache identity | **100%** | Hash includes effective options, merged host/profile inherited-environment names, settings, scope, cwd, and default command, but never resolves or fingerprints inherited values. |
100
100
  | Models, modes, and thinking catalog | **100%** | Native catalog is mapped to opaque public model IDs with committed defaults and permission-gated modes. |
101
101
  | Connection `send` / `onEvent` / `close` lifecycle | **100%** | Request correlation, multi-session ownership, process recovery, teardown, and provider reload/removal are covered. |
@@ -124,7 +124,7 @@ Existing agents whose provider is `omp` remain owned by the bundled provider. Ne
124
124
 
125
125
  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.
126
126
 
127
- Paseo 0.9.2 is the minimum supported stable release, and 0.10.1 is supported. The controlled Docker canary remains pinned to official 0.9.2; nightly SDK checks cover the newest 0.9, 0.10, and latest releases. The canary preserves nested provider-subagent ancestry and spawning-tool links, and validates npm-managed plugin sources, platform-owned external URL opening, and whole-item timeline transforms before Overview grouping.
127
+ Paseo 0.9.2 is the minimum supported stable release, and 0.10.1 is supported. The controlled Docker canary is pinned to official 0.11.1; nightly SDK checks cover the newest 0.9 and 0.10 patches plus the latest and beta channels. Older Paseo lines are covered by that typecheck/unit matrix, not the current Docker canary. The canary preserves nested provider-subagent ancestry and spawning-tool links, and validates npm-managed plugin sources, platform-owned external URL opening, and whole-item timeline transforms before Overview grouping.
128
128
 
129
129
  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.
130
130
 
package/index.server.ts CHANGED
@@ -17,7 +17,7 @@ import { resolveListOmpSettings, resolveUpdateOmpSettings } from "./server/omp-s
17
17
  import { OmpOperationalFailureCollector } from "./server/operational-failure-diagnostics";
18
18
  import { withOmpStore } from "./server/paths";
19
19
  import { OmpProtocolViolationCollector } from "./server/protocol-violation-diagnostics";
20
- import { withOmpWorkspaceIdentity } from "./server/provider/host-tools";
20
+ import { withOmpSessionOpenEnv } from "./server/provider/host-tools";
21
21
  import {
22
22
  createProfileOmpProvider,
23
23
  discoverOmpProfiles,
@@ -97,9 +97,9 @@ export default function contribute(server: PluginServerContext) {
97
97
  server.handle(openOmpMcpAuthorizationInPaseoBrowser, (input) =>
98
98
  resolveOpenOmpMcpAuthorizationInPaseoBrowser(input, browserAuthorizationRegistry),
99
99
  );
100
- const removeIdentityHook = server.before("agent.session_open", ({ request }) => {
100
+ const removeSessionOpenHook = server.before("agent.session_open", ({ request }) => {
101
101
  if (request.provider !== "omp-plugin" && !request.provider.startsWith("omp-plugin-")) return;
102
- return withOmpWorkspaceIdentity(request);
102
+ return withOmpSessionOpenEnv(request);
103
103
  });
104
104
  server.registerProvider(
105
105
  createOmpProvider({
@@ -111,6 +111,6 @@ export default function contribute(server: PluginServerContext) {
111
111
  );
112
112
  return () => {
113
113
  browserAuthorizationRegistry.clear();
114
- removeIdentityHook();
114
+ removeSessionOpenHook();
115
115
  };
116
116
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@omercnet/paseo-omp",
3
- "version": "1.3.1",
3
+ "version": "1.3.2-next.230.1",
4
4
  "type": "module",
5
5
  "description": "Paseo integration for OMP, including its direct provider and workspace tooling.",
6
6
  "license": "MIT",
@@ -52,16 +52,16 @@
52
52
  "typecheck": "tsc --noEmit -p tsconfig.client.json && tsc --noEmit -p tsconfig.server.json && tsc --noEmit -p tsconfig.tests.json"
53
53
  },
54
54
  "dependencies": {
55
- "@getpaseo/protocol": "0.11.0-beta.5",
55
+ "@getpaseo/protocol": "0.11.1",
56
56
  "@modelcontextprotocol/sdk": "1.30.0",
57
57
  "yaml": "^2.9.0"
58
58
  },
59
59
  "devDependencies": {
60
60
  "@biomejs/biome": "^2.5.10",
61
- "@getpaseo/cli": "0.11.0-beta.5",
62
- "@getpaseo/client": "0.11.0-beta.5",
63
- "@getpaseo/plugin": "0.11.0-beta.5",
64
- "@getpaseo/server": "0.11.0-beta.5",
61
+ "@getpaseo/cli": "0.11.1",
62
+ "@getpaseo/client": "0.11.1",
63
+ "@getpaseo/plugin": "0.11.1",
64
+ "@getpaseo/server": "0.11.1",
65
65
  "@oh-my-pi/pi-agent-core": "18.2.0",
66
66
  "@oh-my-pi/pi-coding-agent": "18.2.0",
67
67
  "@tanstack/react-query": "^5.102.3",
@@ -69,9 +69,9 @@
69
69
  "@types/react": "^19.2.18",
70
70
  "@vitest/coverage-v8": "^5.0.0",
71
71
  "esbuild": "0.28.2",
72
+ "pino": "10.3.1",
72
73
  "react": "19.1.0",
73
74
  "react-native": "0.81.5",
74
- "pino": "10.3.1",
75
75
  "tsx": "^4.20.6",
76
76
  "typescript": "^7.0.2",
77
77
  "vitest": "^5.0.0",
@@ -1,2 +1,2 @@
1
1
  /** Release Please keeps this bundle-safe constant aligned with package.json. */
2
- export const PASEO_OMP_PACKAGE_VERSION = "1.3.1"; // x-release-please-version
2
+ export const PASEO_OMP_PACKAGE_VERSION = "1.3.2-next.230.1"; // x-release-please-version
@@ -12,6 +12,7 @@ type ProviderCatalogOptionsCompat = {
12
12
  import { MAX_INHERITED_ENVIRONMENT_NAMES } from "../../shared/provider-launch-settings";
13
13
  import { parseOmpProviderOptions } from "./provider-options";
14
14
  import { OmpPublicError } from "./security";
15
+ import { withoutOmpSessionPurpose } from "./session-purpose";
15
16
  import { OmpModeSchema } from "./settings";
16
17
 
17
18
  const OMP_BUILTIN_TOOL_NAMES = [
@@ -84,7 +85,7 @@ export function normalizeOmpCatalogOptions(
84
85
  mode: "full",
85
86
  noSession: true,
86
87
  ...(providerOptions.command ? { command: providerOptions.command } : {}),
87
- ...(providerOptions.env ? { env: providerOptions.env } : {}),
88
+ ...(providerOptions.env ? { env: withoutOmpSessionPurpose(providerOptions.env) } : {}),
88
89
  ...(inheritEnv ? { inheritEnv } : {}),
89
90
  outputRedaction: providerOptions.outputRedaction,
90
91
  ...(params.sessionDir ? { sessionDir: params.sessionDir } : {}),
@@ -136,7 +137,7 @@ export function normalizeOmpSessionConfig(
136
137
  .deniedTools;
137
138
  const tools = allowedOmpTools(deniedTools);
138
139
  const params = options.params ?? {};
139
- const env = { ...options.env, ...config.env };
140
+ const env = withoutOmpSessionPurpose({ ...options.env, ...config.env });
140
141
  const inheritEnv = mergeOmpInheritedEnvironmentNames(hostInheritEnv, options.inheritEnv);
141
142
  return {
142
143
  cwd: config.cwd,
@@ -32,7 +32,9 @@ import {
32
32
  OmpPublicError,
33
33
  utf8Bytes,
34
34
  } from "./security";
35
- import { OmpProviderSession, ompPersistenceSessionId } from "./session";
35
+ import { type OmpOpenedSession, OmpProviderSession, ompPersistenceSessionId } from "./session";
36
+ import { OmpHistorySession } from "./session-history";
37
+ import { isOmpHistoryOnlyOpen } from "./session-purpose";
36
38
 
37
39
  type ProviderConfigurationCompat = {
38
40
  providerOptions?: Readonly<Record<string, unknown>>;
@@ -747,7 +749,7 @@ export function createOmpConnection(
747
749
  string,
748
750
  {
749
751
  token: symbol;
750
- session: OmpProviderSession;
752
+ session: OmpOpenedSession;
751
753
  nativeSessionId?: string;
752
754
  removeBrowserAuthorization?: () => void;
753
755
  }
@@ -909,10 +911,15 @@ export function createOmpConnection(
909
911
  opening.set(input.sessionId, { token, controller, settled: openingSettled.promise });
910
912
  let nativeSessionId: string | undefined;
911
913
  let persistentOpening = false;
912
- let session: OmpProviderSession | undefined;
914
+ let historyOnly = false;
915
+ let session: OmpOpenedSession | undefined;
913
916
  try {
917
+ historyOnly = isOmpHistoryOnlyOpen(input);
914
918
  nativeSessionId = ompPersistenceSessionId(input);
915
- if (nativeSessionId) {
919
+ if (historyOnly) {
920
+ // History reads one authorized transcript and runs nothing, so it takes no native
921
+ // session reservation and neither blocks nor is blocked by an interactive session.
922
+ } else if (nativeSessionId) {
916
923
  await nativeReservations.reserve(nativeSessionId, token, controller.signal);
917
924
  } else if (input.config.persist) {
918
925
  await nativeReservations.beginPersistentOpen(token, controller.signal);
@@ -955,23 +962,35 @@ export function createOmpConnection(
955
962
  const retireRewindSession = () => {
956
963
  deleteSession(input.sessionId, token);
957
964
  };
958
- session = await OmpProviderSession.open(
959
- input,
960
- runtime,
961
- safeCapabilities,
962
- sessionEmit,
963
- transitionNativeSession,
964
- quarantineRewindCleanup,
965
- retireRewindSession,
966
- scheduler,
967
- replayTimeoutMs,
968
- controller.signal,
969
- environment,
970
- mcpConnector,
971
- mcpInitializationTimeoutMs,
972
- reportOperationalFailure,
973
- (await resolveHostInheritEnv?.()) ?? [],
974
- );
965
+ const hostInheritEnv = (await resolveHostInheritEnv?.()) ?? [];
966
+ session = historyOnly
967
+ ? await OmpHistorySession.open(
968
+ input,
969
+ runtime,
970
+ safeCapabilities,
971
+ sessionEmit,
972
+ replayTimeoutMs,
973
+ environment,
974
+ hostInheritEnv,
975
+ scheduler,
976
+ )
977
+ : await OmpProviderSession.open(
978
+ input,
979
+ runtime,
980
+ safeCapabilities,
981
+ sessionEmit,
982
+ transitionNativeSession,
983
+ quarantineRewindCleanup,
984
+ retireRewindSession,
985
+ scheduler,
986
+ replayTimeoutMs,
987
+ controller.signal,
988
+ environment,
989
+ mcpConnector,
990
+ mcpInitializationTimeoutMs,
991
+ reportOperationalFailure,
992
+ hostInheritEnv,
993
+ );
975
994
  const discoveredNativeSessionId = session.persistenceSessionId;
976
995
  if (discoveredNativeSessionId) nativeSessionId = discoveredNativeSessionId;
977
996
  if (persistentOpening) {
@@ -995,10 +1014,13 @@ export function createOmpConnection(
995
1014
  return;
996
1015
  }
997
1016
  const browserAgentId = input.config.env.PASEO_AGENT_ID?.trim() || input.sessionId;
998
- const browserAuthorization = browserAuthorizationRegistry?.register(
999
- browserAgentId,
1000
- session.openPaseoBrowser.bind(session),
1001
- );
1017
+ // A history session has no live Paseo browser tools to authorize.
1018
+ const browserAuthorization = historyOnly
1019
+ ? undefined
1020
+ : browserAuthorizationRegistry?.register(
1021
+ browserAgentId,
1022
+ session.openPaseoBrowser.bind(session),
1023
+ );
1002
1024
  session.setBrowserAuthorizationIssuer(browserAuthorization?.issue ?? null);
1003
1025
  const removeBrowserAuthorization = browserAuthorization
1004
1026
  ? () => {
@@ -1155,7 +1177,7 @@ export function createOmpConnection(
1155
1177
  for (const result of operationResults) {
1156
1178
  if (result.status === "rejected") failures.push(result.reason);
1157
1179
  }
1158
- const seenSessions = new Set<OmpProviderSession>();
1180
+ const seenSessions = new Set<OmpOpenedSession>();
1159
1181
  const seenCleanups = new Set<Promise<void>>();
1160
1182
  const cleanupBatch: Promise<void>[] = [];
1161
1183
  for (const [sessionId, { session, nativeSessionId, token }] of sessions) {
@@ -29,6 +29,7 @@ import {
29
29
  truncateUtf8,
30
30
  utf8Bytes,
31
31
  } from "./security";
32
+ import { withOmpSessionPurpose } from "./session-purpose";
32
33
 
33
34
  type HostToolFailureStage = Extract<
34
35
  OmpOperationalFailure,
@@ -1025,3 +1026,19 @@ export function withOmpWorkspaceIdentity<
1025
1026
  else delete env.PASEO_WORKSPACE_ID;
1026
1027
  return { ...request, env };
1027
1028
  }
1029
+
1030
+ /**
1031
+ * The complete env transform for an OMP `agent.session_open` hook: the caller's identity plus the
1032
+ * host-decided launch purpose. Registered once by the plugin and exercised directly by tests.
1033
+ */
1034
+ export function withOmpSessionOpenEnv<
1035
+ T extends {
1036
+ agentId: string;
1037
+ workspaceId: string | null;
1038
+ reason?: string;
1039
+ purpose?: string;
1040
+ env: Record<string, string>;
1041
+ },
1042
+ >(request: T) {
1043
+ return withOmpSessionPurpose(withOmpWorkspaceIdentity(request));
1044
+ }
@@ -0,0 +1,330 @@
1
+ import type {
2
+ ProviderConfigState,
3
+ ProviderEvent,
4
+ ProviderInput,
5
+ ProviderSessionConfig,
6
+ } from "@getpaseo/plugin/server/provider";
7
+ import { normalizeOmpSessionConfig } from "./config-normalization";
8
+ import type { OmpRuntime } from "./omp-rpc";
9
+ import { buildOmpSpawnRequest } from "./omp-rpc-environment";
10
+ import {
11
+ configuredOutputRedactionValues,
12
+ OmpPublicDataSerializer,
13
+ OmpPublicError,
14
+ utf8Bytes,
15
+ } from "./security";
16
+ import {
17
+ authorizeNativeSession,
18
+ fixedSessionMode,
19
+ type OmpOpenedSession,
20
+ ompPersistenceSessionId,
21
+ REPLAY_TIMEOUT_MS,
22
+ waitForReplay,
23
+ } from "./session";
24
+ import { OmpSubsessionProjector } from "./subsessions";
25
+ import {
26
+ defaultOmpTimelineScheduler,
27
+ OmpTimelineProjector,
28
+ type OmpTimelineScheduler,
29
+ } from "./timeline-projector";
30
+
31
+ type SessionOpenInput = Extract<ProviderInput, { type: "session.open" }>;
32
+ type SessionPromptInput = Extract<ProviderInput, { type: "session.prompt" }>;
33
+ type SessionInterruptInput = Extract<ProviderInput, { type: "session.interrupt" }>;
34
+ type SessionConfigureInput = Extract<ProviderInput, { type: "session.configure" }>;
35
+ type SessionRevertInput = Extract<ProviderInput, { type: "session.revert" }>;
36
+ type SessionCloseInput = Extract<ProviderInput, { type: "session.close" }>;
37
+ type SessionPermissionInput = Extract<ProviderInput, { type: "session.permission" }>;
38
+ type Emit = (event: ProviderEvent) => void;
39
+
40
+ // A history session replays a persisted journal and runs nothing. It advertises no prompt,
41
+ // permission, configuration, or rewind capability, so the host rejects those requests itself.
42
+ const HISTORY_CAPABILITIES: readonly string[] = [
43
+ "session.persistence",
44
+ "session.subsession",
45
+ "timeline.plugin",
46
+ ];
47
+ const READ_ONLY_MESSAGE = "OMP history sessions are read-only";
48
+
49
+ /**
50
+ * Serves a persisted OMP session from its authorized transcript alone. It exists for sessions
51
+ * whose recorded working directory is gone: no runtime process is spawned, no MCP server is
52
+ * connected, and the working directory is never touched. The native session identity, workspace
53
+ * authorization, and transcript ownership checks are the same ones a live resume performs.
54
+ */
55
+ export class OmpHistorySession implements OmpOpenedSession {
56
+ readonly id: string;
57
+ readonly cwd: string;
58
+
59
+ private readonly lifetime = new AbortController();
60
+ private readonly dataFilter: OmpPublicDataSerializer;
61
+ private readonly projector: OmpTimelineProjector;
62
+ private readonly subsessions: OmpSubsessionProjector | null;
63
+ private disposed = false;
64
+ private sessionClosedPublished = false;
65
+
66
+ private constructor(
67
+ id: string,
68
+ private readonly config: ProviderSessionConfig,
69
+ private readonly runtime: OmpRuntime,
70
+ private readonly readTranscript: NonNullable<OmpRuntime["readPersistedSessionTranscript"]>,
71
+ private readonly nativeSessionId: string,
72
+ private readonly transcriptFile: string,
73
+ private readonly configState: ProviderConfigState,
74
+ redactionValues: readonly string[],
75
+ private readonly capabilities: readonly string[],
76
+ private readonly emit: Emit,
77
+ private readonly replayTimeoutMs: number,
78
+ scheduler: OmpTimelineScheduler,
79
+ ) {
80
+ this.id = id;
81
+ this.cwd = config.cwd;
82
+ this.dataFilter = new OmpPublicDataSerializer(redactionValues);
83
+ const pluginTimeline = capabilities.includes("timeline.plugin");
84
+ this.projector = new OmpTimelineProjector(
85
+ id,
86
+ emit,
87
+ scheduler,
88
+ redactionValues,
89
+ false,
90
+ pluginTimeline,
91
+ );
92
+ // Same identity key as a live persisted session, so child session ids stay stable across
93
+ // a history load and a later interactive resume of the same native session.
94
+ this.subsessions = capabilities.includes("session.subsession")
95
+ ? new OmpSubsessionProjector(
96
+ id,
97
+ `persisted:${nativeSessionId}`,
98
+ transcriptFile,
99
+ config.cwd,
100
+ emit,
101
+ scheduler,
102
+ () => {},
103
+ redactionValues,
104
+ pluginTimeline,
105
+ )
106
+ : null;
107
+ }
108
+
109
+ get persistenceSessionId(): string {
110
+ return this.nativeSessionId;
111
+ }
112
+
113
+ static async open(
114
+ input: SessionOpenInput,
115
+ runtime: OmpRuntime,
116
+ capabilities: readonly string[],
117
+ emit: Emit,
118
+ replayTimeoutMs = REPLAY_TIMEOUT_MS,
119
+ environment?: NodeJS.ProcessEnv,
120
+ hostInheritEnv: readonly string[] = [],
121
+ scheduler: OmpTimelineScheduler = defaultOmpTimelineScheduler,
122
+ ): Promise<OmpHistorySession> {
123
+ const nativeSessionId = ompPersistenceSessionId(input);
124
+ if (!nativeSessionId || input.history !== "replay" || !input.config.persist) {
125
+ throw new OmpPublicError("OMP history purpose requires a persisted session replay");
126
+ }
127
+ const normalized = normalizeOmpSessionConfig(
128
+ input.config,
129
+ capabilities.includes("permission"),
130
+ hostInheritEnv,
131
+ );
132
+ if (input.config.title && utf8Bytes(input.config.title) > 256) {
133
+ throw new OmpPublicError("OMP session title is too large");
134
+ }
135
+ const readTranscript = runtime.readPersistedSessionTranscript?.bind(runtime);
136
+ if (!readTranscript) throw new OmpPublicError("OMP session history cannot be replayed safely");
137
+ // The same workspace and native-identity authorization as a live resume; only the spawn is
138
+ // skipped. The recorded cwd is compared, never opened.
139
+ const descriptor = await authorizeNativeSession(
140
+ runtime,
141
+ nativeSessionId,
142
+ input.config.cwd,
143
+ normalized.sessionDir,
144
+ );
145
+ if (!descriptor.transcriptFile) {
146
+ throw new OmpPublicError("OMP session transcript could not be resolved in this workspace");
147
+ }
148
+ const configuredValues = configuredOutputRedactionValues(
149
+ normalized.outputRedaction ?? "none",
150
+ normalized.env,
151
+ input.config.mcpServers,
152
+ );
153
+ // Inherited values are redacted exactly as a live session would: they come from the same
154
+ // spawn-request builder, which only reads the server environment.
155
+ const redactionValues =
156
+ normalized.outputRedaction === "configured-values"
157
+ ? [
158
+ ...configuredValues,
159
+ ...buildOmpSpawnRequest({
160
+ ...normalized,
161
+ environment,
162
+ // A resume never reapplies the system prompt or thinking selection.
163
+ systemPrompt: undefined,
164
+ thinkingOption: undefined,
165
+ resumeSessionId: nativeSessionId,
166
+ resumeSessionFile: descriptor.transcriptFile,
167
+ }).inheritedRedactionValues,
168
+ ]
169
+ : configuredValues;
170
+ const configState: ProviderConfigState = {
171
+ ...(input.config.model ? { model: input.config.model } : {}),
172
+ mode: normalized.mode ?? "full",
173
+ ...(input.config.thinkingOption ? { thinkingOption: input.config.thinkingOption } : {}),
174
+ models: [],
175
+ modes: [fixedSessionMode(normalized.mode)],
176
+ thinkingOptions: [],
177
+ settings: [],
178
+ };
179
+ return new OmpHistorySession(
180
+ input.sessionId,
181
+ input.config,
182
+ runtime,
183
+ readTranscript,
184
+ nativeSessionId,
185
+ descriptor.transcriptFile,
186
+ configState,
187
+ redactionValues,
188
+ capabilities.filter((capability) => HISTORY_CAPABILITIES.includes(capability)),
189
+ emit,
190
+ replayTimeoutMs,
191
+ scheduler,
192
+ );
193
+ }
194
+
195
+ async publishOpened(requestId: string): Promise<void> {
196
+ this.emit({
197
+ type: "session.opened",
198
+ requestId,
199
+ sessionId: this.id,
200
+ capabilities: this.capabilities,
201
+ restoration: "core",
202
+ cwd: this.cwd,
203
+ persistence: { version: 1, data: { sessionId: this.nativeSessionId } },
204
+ ...(this.config.title ? { title: this.dataFilter.text(this.config.title, 256) } : {}),
205
+ });
206
+ this.emit({ type: "session.config", sessionId: this.id, config: this.configState });
207
+ await this.replayHistory();
208
+ this.emit({ type: "session.ready", requestId, sessionId: this.id });
209
+ }
210
+
211
+ private async replayHistory(): Promise<void> {
212
+ this.lifetime.signal.throwIfAborted();
213
+ const replay = new AbortController();
214
+ const onAbort = () =>
215
+ replay.abort(new OmpPublicError("OMP session history replay was canceled"));
216
+ this.lifetime.signal.addEventListener("abort", onAbort, { once: true });
217
+ const timeoutHandle = setTimeout(
218
+ () => replay.abort(new OmpPublicError("OMP session history replay timed out")),
219
+ this.replayTimeoutMs,
220
+ );
221
+ try {
222
+ const transcript = await waitForReplay(
223
+ this.readTranscript({
224
+ sessionFile: this.transcriptFile,
225
+ sessionId: this.nativeSessionId,
226
+ cwd: this.cwd,
227
+ signal: replay.signal,
228
+ }),
229
+ replay.signal,
230
+ );
231
+ if (transcript.imageReplayWarning) {
232
+ this.emit({
233
+ type: "timeline.item",
234
+ sessionId: this.id,
235
+ item: {
236
+ id: "omp:replay-image-unavailable",
237
+ type: "notification",
238
+ level: "warning",
239
+ message: "OMP skipped one or more unavailable images while replaying this session.",
240
+ },
241
+ });
242
+ }
243
+ for (const message of transcript.messages) {
244
+ replay.signal.throwIfAborted();
245
+ this.projector.projectReplayMessage(message);
246
+ }
247
+ this.projector.finishReplay();
248
+ await this.subsessions?.replay(transcript.messages, undefined, this.runtime, replay.signal);
249
+ replay.signal.throwIfAborted();
250
+ } catch (error) {
251
+ if (replay.signal.aborted) throw replay.signal.reason;
252
+ throw error;
253
+ } finally {
254
+ clearTimeout(timeoutHandle);
255
+ this.lifetime.signal.removeEventListener("abort", onAbort);
256
+ }
257
+ }
258
+
259
+ openPaseoBrowser(): Promise<void> {
260
+ return Promise.reject(new OmpPublicError(READ_ONLY_MESSAGE));
261
+ }
262
+
263
+ setBrowserAuthorizationIssuer(issue: ((url: string) => string | undefined) | null): void {
264
+ this.projector.setBrowserAuthorizationIssuer(issue);
265
+ }
266
+
267
+ prompt(input: SessionPromptInput): Promise<void> {
268
+ this.emit({
269
+ type: "session.prompt_result",
270
+ sessionId: this.id,
271
+ clientMessageId: input.prompt.clientMessageId,
272
+ result: { type: "failed", error: { message: READ_ONLY_MESSAGE } },
273
+ });
274
+ return Promise.resolve();
275
+ }
276
+
277
+ permission(_input: SessionPermissionInput): Promise<void> {
278
+ return Promise.reject(new OmpPublicError("OMP history sessions have no pending permissions"));
279
+ }
280
+
281
+ configure(input: SessionConfigureInput): Promise<void> {
282
+ this.emit({
283
+ type: "request.failed",
284
+ requestId: input.requestId,
285
+ error: { message: READ_ONLY_MESSAGE },
286
+ });
287
+ return Promise.resolve();
288
+ }
289
+
290
+ revert(input: SessionRevertInput): Promise<void> {
291
+ this.emit({
292
+ type: "request.failed",
293
+ requestId: input.requestId,
294
+ error: { message: READ_ONLY_MESSAGE },
295
+ });
296
+ return Promise.resolve();
297
+ }
298
+
299
+ interrupt(input: SessionInterruptInput): Promise<void> {
300
+ this.emit({ type: "request.completed", requestId: input.requestId });
301
+ return Promise.resolve();
302
+ }
303
+
304
+ beginConnectionShutdown(): void {
305
+ this.lifetime.abort(new Error("OMP provider connection closed"));
306
+ }
307
+
308
+ abortOpen(): Promise<void> {
309
+ this.dispose();
310
+ return Promise.resolve();
311
+ }
312
+
313
+ close(input?: SessionCloseInput): Promise<void> {
314
+ this.dispose();
315
+ if (!this.sessionClosedPublished) {
316
+ this.sessionClosedPublished = true;
317
+ this.emit({ type: "session.closed", sessionId: this.id });
318
+ }
319
+ if (input) this.emit({ type: "request.completed", requestId: input.requestId });
320
+ return Promise.resolve();
321
+ }
322
+
323
+ private dispose(): void {
324
+ if (this.disposed) return;
325
+ this.disposed = true;
326
+ this.lifetime.abort(new Error("OMP provider session closed"));
327
+ this.subsessions?.close();
328
+ this.projector.close();
329
+ }
330
+ }
@@ -0,0 +1,56 @@
1
+ import type { ProviderInput } from "@getpaseo/plugin/server/provider";
2
+ import { OmpPublicError } from "./security";
3
+
4
+ type SessionOpenInput = Extract<ProviderInput, { type: "session.open" }>;
5
+
6
+ /**
7
+ * Paseo tells plugins why a session is being opened only through `before("agent.session_open")`
8
+ * hooks, whose sole writable field is `env`; the plugin provider protocol forwards that env
9
+ * untouched as `session.open.config.env`. The hook is therefore the single writer of this launch
10
+ * marker and the connection is the single reader. It is never part of the stored agent config.
11
+ */
12
+ export const OMP_SESSION_PURPOSE_ENV = "PASEO_OMP_SESSION_PURPOSE";
13
+ const HISTORY_PURPOSE = "history";
14
+
15
+ /**
16
+ * Derives the launch marker from the host-decided purpose. Any marker already present is caller
17
+ * input (for example `env` on agent creation), so it is always discarded first: only a host
18
+ * resume whose purpose is history can produce it, and an absent purpose (older hosts) never does.
19
+ */
20
+ export function withOmpSessionPurpose<
21
+ T extends { reason?: string; purpose?: string; env: Record<string, string> },
22
+ >(request: T): Omit<T, "env"> & { env: Record<string, string> } {
23
+ const env: Record<string, string> = { ...request.env };
24
+ delete env[OMP_SESSION_PURPOSE_ENV];
25
+ if (request.purpose === HISTORY_PURPOSE && request.reason === "resume") {
26
+ env[OMP_SESSION_PURPOSE_ENV] = HISTORY_PURPOSE;
27
+ }
28
+ return { ...request, env };
29
+ }
30
+
31
+ /**
32
+ * True when this open must be served from the persisted transcript alone, without a runtime
33
+ * process or any working-directory access. The marker only narrows a persisted replay: it can
34
+ * never be satisfied by a create, a non-replaying open, or a non-persisted config.
35
+ */
36
+ export function isOmpHistoryOnlyOpen(input: SessionOpenInput): boolean {
37
+ const marker = input.config.env[OMP_SESSION_PURPOSE_ENV];
38
+ if (marker === undefined) return false;
39
+ if (marker !== HISTORY_PURPOSE) throw new OmpPublicError("Invalid OMP session purpose");
40
+ if (input.history !== "replay" || !input.persistence || !input.config.persist) {
41
+ throw new OmpPublicError("OMP history purpose requires a persisted session replay");
42
+ }
43
+ return true;
44
+ }
45
+
46
+ /**
47
+ * Removes the reserved launch marker from any env bound for an OMP process. The marker steers
48
+ * the plugin only; it must never be forwarded to, or settable through per-agent provider options
49
+ * for, a real OMP runtime.
50
+ */
51
+ export function withoutOmpSessionPurpose(
52
+ env: Readonly<Record<string, string>>,
53
+ ): Record<string, string> {
54
+ const { [OMP_SESSION_PURPOSE_ENV]: _reserved, ...rest } = env;
55
+ return rest;
56
+ }
@@ -93,6 +93,23 @@ type RewindCleanupQuarantine = (cleanup: Promise<void>) => void;
93
93
  type RewindSessionRetirement = () => void;
94
94
  type SessionPermissionInput = Extract<ProviderInput, { type: "session.permission" }>;
95
95
  type Emit = (event: ProviderEvent) => void;
96
+
97
+ /** The surface a connection drives for every opened session, live or history-only. */
98
+ export interface OmpOpenedSession {
99
+ readonly persistenceSessionId: string | undefined;
100
+ openPaseoBrowser(url: string): Promise<void>;
101
+ setBrowserAuthorizationIssuer(issue: ((url: string) => string | undefined) | null): void;
102
+ publishOpened(requestId: string): Promise<void>;
103
+ prompt(input: SessionPromptInput): Promise<void>;
104
+ permission(input: SessionPermissionInput): Promise<void>;
105
+ configure(input: SessionConfigureInput): Promise<void>;
106
+ revert(input: SessionRevertInput): Promise<void>;
107
+ interrupt(input: SessionInterruptInput): Promise<void>;
108
+ close(input?: SessionCloseInput): Promise<void>;
109
+ abortOpen(): Promise<void>;
110
+ beginConnectionShutdown(): void;
111
+ }
112
+
96
113
  const LOCAL_ONLY_SETTLE_MS = 5_000;
97
114
  const AGENT_END_STATE_TIMEOUT_MS = 2_000;
98
115
  const AGENT_END_HISTORY_TIMEOUT_MS = 2_000;
@@ -119,7 +136,7 @@ const MAX_PENDING_USER_BYTES = 2 * 1024 * 1024;
119
136
  const MAX_UNCLAIMED_BRANCH_BYTES = 4 * 1024 * 1024;
120
137
  class OmpCatalogEscape extends OmpPublicError {}
121
138
  const MAX_REPLAY_MESSAGES = 100_000;
122
- const REPLAY_TIMEOUT_MS = 20_000;
139
+ export const REPLAY_TIMEOUT_MS = 20_000;
123
140
  const _OMP_ASK_USER_FREEFORM_SENTINEL = "✏️ Type custom response...";
124
141
  const _MAX_FREEFORM_RESPONSE_BYTES = 64 * 1024;
125
142
  const OMP_BUILTIN_COMMANDS: readonly OmpAvailableCommand[] = [
@@ -162,7 +179,7 @@ function applicableThinkingLevel(
162
179
  return model?.reasoning === false ? undefined : level;
163
180
  }
164
181
 
165
- function fixedSessionMode(modeId = "full") {
182
+ export function fixedSessionMode(modeId = "full") {
166
183
  const mode = OMP_MODES.find((candidate) => candidate.id === modeId);
167
184
  if (!mode) throw new OmpPublicError("OMP mode is unavailable");
168
185
  return {
@@ -194,7 +211,7 @@ export function ompPersistenceSessionId(input: SessionOpenInput): string | undef
194
211
  }
195
212
  }
196
213
 
197
- async function authorizeNativeSession(
214
+ export async function authorizeNativeSession(
198
215
  runtime: OmpRuntime,
199
216
  sessionId: string,
200
217
  cwd: string,
@@ -228,7 +245,7 @@ function retainedBytes(values: readonly unknown[], maxBytes: number): number {
228
245
  return total;
229
246
  }
230
247
 
231
- async function waitForReplay<T>(operation: Promise<T>, signal: AbortSignal): Promise<T> {
248
+ export async function waitForReplay<T>(operation: Promise<T>, signal: AbortSignal): Promise<T> {
232
249
  signal.throwIfAborted();
233
250
  const aborted = Promise.withResolvers<never>();
234
251
  const onAbort = () => aborted.reject(signal.reason);
@@ -271,7 +288,7 @@ async function settleSessionCleanup(promises: readonly Promise<void>[]): Promise
271
288
  throw new AggregateError(failures, "OMP session initialization cleanup failed");
272
289
  }
273
290
  }
274
- export class OmpProviderSession {
291
+ export class OmpProviderSession implements OmpOpenedSession {
275
292
  readonly id: string;
276
293
  readonly cwd: string;
277
294
 
@@ -390,7 +390,8 @@ export class OmpSubsessionProjector {
390
390
  }
391
391
  async replay(
392
392
  messages: readonly OmpMessage[],
393
- runtimeSession: OmpRuntimeSession,
393
+ // Undefined replays the persisted journal only; history sessions have no live runtime to ask.
394
+ runtimeSession: OmpRuntimeSession | undefined,
394
395
  runtime: OmpRuntime,
395
396
  signal: AbortSignal,
396
397
  ): Promise<void> {
@@ -411,12 +412,14 @@ export class OmpSubsessionProjector {
411
412
  0,
412
413
  );
413
414
  let snapshots: OmpSubagentSnapshot[] = [];
414
- try {
415
- snapshots = await waitForReplay(runtimeSession.getSubagents(), signal);
416
- } catch (error) {
417
- if (signal.aborted) throw error;
415
+ if (runtimeSession) {
416
+ try {
417
+ snapshots = await waitForReplay(runtimeSession.getSubagents(), signal);
418
+ } catch (error) {
419
+ if (signal.aborted) throw error;
420
+ }
421
+ await this.replaySnapshots(snapshots, runtimeSession, runtime, visited, budget, signal);
418
422
  }
419
- await this.replaySnapshots(snapshots, runtimeSession, runtime, visited, budget, signal);
420
423
  signal.throwIfAborted();
421
424
  this.reconcileSnapshots(snapshots);
422
425
  completed = true;