@omercnet/paseo-omp 4.0.0 → 5.0.0-next.321.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
@@ -2,7 +2,7 @@ OMP adds an agent provider for the OMP coding agent, plus an OMP sidebar and wor
2
2
 
3
3
  ## Setup
4
4
 
5
- - Paseo ^0.13.0.
5
+ - Paseo ^0.14.0.
6
6
  - OMP 18.1.15 or newer on the daemon host, speaking RPC protocol v2. Older runtimes are rejected.
7
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
 
package/README.md CHANGED
@@ -24,7 +24,7 @@ before capture.
24
24
 
25
25
  ## Quick start
26
26
 
27
- Requirements: Paseo `^0.13.0`, OMP `18.1.15` or newer, and OMP RPC protocol v2. Workspace discovery passes `includeBackground`, which throws on hosts that do not advertise `features.backgroundWorkspaces`; every 0.13 host does.
27
+ Requirements: Paseo `^0.14.0`, OMP `18.1.15` or newer, and OMP RPC protocol v2. Workspace discovery passes `includeBackground`, which throws on hosts that do not advertise `features.backgroundWorkspaces`; every 0.14 host does.
28
28
 
29
29
  ```bash
30
30
  paseo plugin install npm:@omercnet/paseo-omp@<version>
@@ -40,7 +40,7 @@ Open the **OMP** sidebar to review the provider-profile contract, choose which c
40
40
  - [Core-provider issue and parity audit](docs/core-provider-issue-audit.md)
41
41
  - [Release checklist](docs/release-checklist.md)
42
42
 
43
- The plugin uses public Paseo 0.13 provider and client contracts and registers distinct `omp-plugin` and `omp-plugin-<profile>` identities; it does not modify the bundled `omp` provider.
43
+ The plugin uses public Paseo 0.14 provider and client contracts and registers distinct `omp-plugin` and `omp-plugin-<profile>` identities; it does not modify the bundled `omp` provider.
44
44
 
45
45
  ## Named OMP profiles
46
46
 
@@ -54,20 +54,20 @@ 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.13.0, with nightly compatibility checks
57
+ The capability inventory is pinned to `@getpaseo/plugin` 0.14.0-beta.1, with nightly compatibility checks
58
58
  against the latest and beta channels. Provider capability scoring covers
59
- all 17 entries in the SDK's `PROVIDER_CAPABILITIES`; the separate provider/client SDK table below
59
+ all 18 entries in the SDK's `PROVIDER_CAPABILITIES`; the separate provider/client SDK table below
60
60
  also tracks the registration, screen, and sidebar APIs introduced in 0.11. This measures strict SDK surface
61
61
  coverage, not general product quality.
62
62
 
63
- **Provider capability completeness: 61.8%.** The provider advertises 11 of 17 capabilities (64.7%); ten are complete and `session.configure` is partial.
63
+ **Provider capability completeness: 63.9%.** The provider advertises 12 of 18 capabilities (66.7%); eleven are complete and `session.configure` is partial.
64
64
 
65
65
  Scoring is deliberately mechanical so releases remain comparable:
66
66
 
67
67
  - **100%**: advertised, implemented end to end, and covered by automated provider tests.
68
68
  - **50%**: advertised with a meaningful subset, but at least one material SDK operation is rejected.
69
69
  - **0%**: not advertised; requests are rejected rather than silently approximated.
70
- - Overall completeness is the unweighted arithmetic mean of all 17 SDK capability rows.
70
+ - Overall completeness is the unweighted arithmetic mean of all 18 SDK capability rows.
71
71
 
72
72
  | SDK capability | Completeness | Support and gap |
73
73
  | --- | ---: | --- |
@@ -77,6 +77,7 @@ Scoring is deliberately mechanical so releases remain comparable:
77
77
  | `prompt.output_schema` | **0%** | Not advertised. OMP has no equivalent structured-output contract; `outputSchema` fails visibly. |
78
78
  | `prompt.steer` | **100%** | Native steering with turn ownership, user-message correlation, stale-target rejection, and terminal race handling. |
79
79
  | `session.archive` | **0%** | Not advertised. Paseo may archive its agent record, but OMP has no native transcript archive operation. |
80
+ | `session.can_stop_runtime` | **100%** | Answers Paseo's idle-release safety query. Returns `false` while a turn, permission, revert, configuration change, runtime recovery, active subagent, or OMP background async work is pending, and when OMP does not report a settled state; errors and unknown sessions fail the request, which also keeps the runtime. |
80
81
  | `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
82
  | `session.list` | **100%** | Bounded host-wide and cwd-scoped discovery, search, previews, profile-specific session roots, and cleanup-reservation guards. |
82
83
  | `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. |
@@ -125,7 +126,9 @@ Existing agents whose provider is `omp` remain owned by the bundled provider. Ne
125
126
 
126
127
  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.
127
128
 
128
- Paseo 0.13 is the minimum supported release. The controlled Docker canary is pinned to official 0.13.0; nightly SDK checks cover the latest and beta channels. 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.
129
+ Paseo 0.14 is the minimum supported release. The controlled Docker canary is pinned to official 0.14.0-beta.1; nightly SDK checks cover the latest and beta channels. 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.
130
+
131
+ Paseo releases idle resumable agent runtimes after one hour; the agent stays listed as `closed` and the next prompt resumes the same OMP session. The plugin vetoes that release while the session has a running turn, a pending permission, an active subagent, or OMP background async work, and while OMP does not report a settled state (the plugin needs the `hasPendingAsyncWork` and `isSettled` fields in OMP's `get_state`; builds without them are never released). Set `agents.idleRuntimeCleanup: false` in the Paseo config to disable idle release for every provider.
129
132
 
130
133
  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.
131
134
 
@@ -1,22 +1,22 @@
1
1
  {
2
2
  "name": "@omercnet/paseo-omp",
3
- "version": "4.0.0",
3
+ "version": "5.0.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@omercnet/paseo-omp",
9
- "version": "4.0.0",
9
+ "version": "5.0.0",
10
10
  "dependencies": {
11
- "@getpaseo/protocol": "0.13.0",
11
+ "@getpaseo/protocol": "0.14.0-beta.1",
12
12
  "@modelcontextprotocol/sdk": "1.30.0",
13
13
  "yaml": "^2.9.0"
14
14
  }
15
15
  },
16
16
  "node_modules/@getpaseo/protocol": {
17
- "version": "0.13.0",
18
- "resolved": "https://registry.npmjs.org/@getpaseo/protocol/-/protocol-0.13.0.tgz",
19
- "integrity": "sha512-g5Hp6QP5hV7KTCrSRBVye2+qhptwnhhLXRn6PHaS1L8PgmeRh5LvrE8PZq3HRq8B1WxflVDuV2WRB2Z3FNcvJA==",
17
+ "version": "0.14.0-beta.1",
18
+ "resolved": "https://registry.npmjs.org/@getpaseo/protocol/-/protocol-0.14.0-beta.1.tgz",
19
+ "integrity": "sha512-qkZCmLlL5EQ1PkN0Jy8JLIfyqoj34UC2HdJIicERQ/OdrYGQ3krpOfIpYJ3c3BU27GcDL/E4aoA0GbuEVR4qUw==",
20
20
  "dependencies": {
21
21
  "semver": "^7.8.5",
22
22
  "zod": "^4.4.3"
@@ -606,9 +606,9 @@
606
606
  }
607
607
  },
608
608
  "node_modules/hono": {
609
- "version": "4.13.13",
610
- "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.13.tgz",
611
- "integrity": "sha512-CQ46U0ZkAGmbT/4UxdzzGJpacP2IeKgY4a5/tOI9AABbpOMfK739wfDXmv1usCk+3RkKj1hQy4/fjhiwa2xlrA==",
609
+ "version": "4.13.14",
610
+ "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.14.tgz",
611
+ "integrity": "sha512-yhRLQ21U6qa9Hsu2N7Qcbh8tE+WKEcoErjIo56LeSGiGXGp9sA7UpE86Ui9uexb61KakiGwuHMn2QdV7w6i6kQ==",
612
612
  "license": "MIT",
613
613
  "engines": {
614
614
  "node": ">=16.9.0"
@@ -657,9 +657,9 @@
657
657
  "license": "ISC"
658
658
  },
659
659
  "node_modules/ip-address": {
660
- "version": "10.7.4",
661
- "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.4.tgz",
662
- "integrity": "sha512-DWBMKyeM25D70nrIiTkgQth59/G+naKOTep6huF9j+GmeK3DMD60Zvxg5d5qQnz+38AHgou6ean2LECX+lrKDg==",
660
+ "version": "10.7.5",
661
+ "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.5.tgz",
662
+ "integrity": "sha512-t2v0rkILYv9WXmSL7TpAaU3HXgaFYOPUcH3QHgwQ++gpJuH3bqyMy4Fvt/DJCTKwzH3Cf+bAp5W/mFL39KPLzA==",
663
663
  "license": "MIT",
664
664
  "engines": {
665
665
  "node": ">= 12"
@@ -902,9 +902,9 @@
902
902
  }
903
903
  },
904
904
  "node_modules/qs": {
905
- "version": "6.16.1",
906
- "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.1.tgz",
907
- "integrity": "sha512-5Px6J7rfHBAGUhNQ3es4YzcOdBBw0SDcVtThJml4RdnKw4tKs2epA0kPZ+K8X+WiHktT93RHVcQ/3ym5aTCgtg==",
905
+ "version": "6.17.0",
906
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.17.0.tgz",
907
+ "integrity": "sha512-5LK5BZ2XDHMvcUJaf0CQ5HL3aVgKbpklvByC7qLSxYkfE8COEEbJg5xCHWCTqzqi0Gs4Jn5Mv5v3R38W6edscA==",
908
908
  "license": "BSD-3-Clause",
909
909
  "dependencies": {
910
910
  "es-define-property": "^1.0.1",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@omercnet/paseo-omp",
3
- "version": "4.0.0",
3
+ "version": "5.0.0-next.321.1",
4
4
  "type": "module",
5
5
  "description": "Paseo integration for OMP, including its direct provider and workspace tooling.",
6
6
  "license": "MIT",
@@ -16,7 +16,7 @@
16
16
  "publishConfig": {
17
17
  "access": "public"
18
18
  },
19
- "packageManager": "bun@1.4.3",
19
+ "packageManager": "bun@1.4.4",
20
20
  "engines": {
21
21
  "node": ">=22.18.0"
22
22
  },
@@ -53,16 +53,16 @@
53
53
  "typecheck": "tsc --noEmit -p tsconfig.client.json && tsc --noEmit -p tsconfig.server.json && tsc --noEmit -p tsconfig.tests.json"
54
54
  },
55
55
  "dependencies": {
56
- "@getpaseo/protocol": "0.13.0",
56
+ "@getpaseo/protocol": "0.14.0-beta.1",
57
57
  "@modelcontextprotocol/sdk": "1.30.0",
58
58
  "yaml": "^2.9.0"
59
59
  },
60
60
  "devDependencies": {
61
61
  "@biomejs/biome": "^2.5.10",
62
- "@getpaseo/cli": "0.13.0",
63
- "@getpaseo/client": "0.13.0",
64
- "@getpaseo/plugin": "0.13.0",
65
- "@getpaseo/server": "0.13.0",
62
+ "@getpaseo/cli": "0.14.0-beta.1",
63
+ "@getpaseo/client": "0.14.0-beta.1",
64
+ "@getpaseo/plugin": "0.14.0-beta.1",
65
+ "@getpaseo/server": "0.14.0-beta.1",
66
66
  "@oh-my-pi/pi-agent-core": "18.10.1",
67
67
  "@oh-my-pi/pi-coding-agent": "18.10.1",
68
68
  "@tanstack/react-query": "^5.102.3",
@@ -70,15 +70,15 @@
70
70
  "@types/react": "^19.2.18",
71
71
  "@vitest/coverage-v8": "^5.0.0",
72
72
  "esbuild": "0.28.2",
73
+ "pino": "10.3.1",
73
74
  "react": "19.1.0",
74
75
  "react-native": "0.81.5",
75
- "pino": "10.3.1",
76
76
  "tsx": "^4.20.6",
77
77
  "typescript": "^7.0.2",
78
78
  "vitest": "^5.0.0",
79
79
  "zod": "4.4.3"
80
80
  },
81
81
  "overrides": {
82
- "qs": "6.16.0"
82
+ "qs": "6.16.1"
83
83
  }
84
84
  }
package/paseo-plugin.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "paseo-omp",
3
3
  "description": "Paseo integration for OMP, including its direct provider and workspace tooling.",
4
- "requirements": { "paseo": "^0.13.0" },
4
+ "requirements": { "paseo": "^0.14.0" },
5
5
  "build": [["node", "scripts/prepare-dependencies.mjs"]]
6
6
  }
@@ -1,2 +1,2 @@
1
1
  /** Release Please keeps this bundle-safe constant aligned with package.json. */
2
- export const PASEO_OMP_PACKAGE_VERSION = "4.0.0"; // x-release-please-version
2
+ export const PASEO_OMP_PACKAGE_VERSION = "5.0.0-next.321.1"; // x-release-please-version
@@ -48,6 +48,7 @@ const SUPPORTED_CAPABILITIES: Readonly<Record<string, true>> = {
48
48
  "prompt.command": true,
49
49
  "prompt.image": true,
50
50
  "prompt.steer": true,
51
+ "session.can_stop_runtime": true,
51
52
  "session.configure": true,
52
53
  "session.list": true,
53
54
  "session.persistence": true,
@@ -65,6 +66,7 @@ const SUPPORTED_INPUTS: Readonly<Record<string, true>> = {
65
66
  "session.permission": true,
66
67
  "session.revert": true,
67
68
  "session.interrupt": true,
69
+ "session.can_stop_runtime": true,
68
70
  "session.close": true,
69
71
  };
70
72
  const MAX_CONNECTION_SESSIONS = 32;
@@ -1135,6 +1137,21 @@ export function createOmpConnection(
1135
1137
  await session.interrupt(input);
1136
1138
  return;
1137
1139
  }
1140
+ case "session.can_stop_runtime": {
1141
+ const session = sessions.get(input.sessionId)?.session;
1142
+ if (!session) {
1143
+ requestFailure(input.requestId, new OmpPublicError("Unknown OMP session"));
1144
+ return;
1145
+ }
1146
+ const canStop = await session.canStopRuntime();
1147
+ emit({
1148
+ type: "session.can_stop_runtime",
1149
+ requestId: input.requestId,
1150
+ sessionId: input.sessionId,
1151
+ canStop,
1152
+ });
1153
+ return;
1154
+ }
1138
1155
  case "session.close": {
1139
1156
  const slot = sessions.get(input.sessionId);
1140
1157
  if (!slot) {
@@ -155,7 +155,7 @@ export const OMP_RPC_COMMAND_POLICIES = {
155
155
  predict_word_feedback: "unsupported",
156
156
  promote_queued_message: "unsupported",
157
157
  remove_queued_message: "unsupported",
158
- set_ask_dialog: "unsupported",
158
+ set_ask_dialog: "implemented",
159
159
  set_cache_warming: "unsupported",
160
160
  set_slow_mode: "unsupported",
161
161
  steer_subagent: "unsupported",
@@ -524,6 +524,10 @@ const OMP_PROVIDER_NAME = NAME.refine((provider) => !provider.includes("/"));
524
524
  const TEXT = boundedString(MAX_TEXT_LENGTH);
525
525
  const RAW_DISPLAY_TEXT = boundedString(MAX_IMAGE_DATA_LENGTH);
526
526
  const DISPLAY_TRUNCATION_MARKER = "<truncated>";
527
+ /** OMP's `ask` tool has no upstream caps; these bound one dialog like the select schema does. */
528
+ export const MAX_ASK_QUESTIONS = 32;
529
+ export const MAX_ASK_OPTIONS = 128;
530
+ const MAX_ASK_QUESTION_ID_BYTES = 4_096;
527
531
 
528
532
  function boundRawDisplayContent(value: unknown): unknown {
529
533
  if (typeof value === "string") {
@@ -634,6 +638,7 @@ export const OMP_RPC_DIAGNOSTIC_COMMANDS = [
634
638
  "get_messages",
635
639
  "abort",
636
640
  "set_host_tools",
641
+ "set_ask_dialog",
637
642
  ] as const;
638
643
  export type OmpRpcDiagnosticCommand = (typeof OMP_RPC_DIAGNOSTIC_COMMANDS)[number];
639
644
  type JsonBoundViolation = {
@@ -1226,6 +1231,8 @@ export const OmpSessionStateSchema = z.object({
1226
1231
  thinkingLevel: OmpCurrentThinkingLevelSchema,
1227
1232
  isStreaming: z.boolean(),
1228
1233
  isCompacting: z.boolean(),
1234
+ hasPendingAsyncWork: z.boolean().optional(),
1235
+ isSettled: z.boolean().optional(),
1229
1236
  sessionId: IDENTIFIER,
1230
1237
  autoCompactionEnabled: z.boolean().optional(),
1231
1238
  contextUsage: OmpContextUsageSchema.nullable().optional().catch(undefined),
@@ -1695,6 +1702,44 @@ const OmpExtensionUiRequestSchema = z.discriminatedUnion("method", [
1695
1702
  timeout: z.number().nonnegative().finite().optional(),
1696
1703
  })
1697
1704
  .strict(),
1705
+ z
1706
+ .object({
1707
+ ...ExtensionUiBase,
1708
+ method: z.literal("ask"),
1709
+ questions: z
1710
+ .array(
1711
+ z
1712
+ .object({
1713
+ id: boundedString(MAX_ASK_QUESTION_ID_BYTES, 1),
1714
+ question: boundedString(64 * 1024),
1715
+ header: boundedString(4_096).optional(),
1716
+ options: z
1717
+ .array(
1718
+ z
1719
+ .object({
1720
+ label: boundedString(4_096),
1721
+ description: boundedString(16_384).optional(),
1722
+ preview: boundedString(64 * 1024).optional(),
1723
+ })
1724
+ .strict(),
1725
+ )
1726
+ .max(MAX_ASK_OPTIONS),
1727
+ multi: z.boolean().optional(),
1728
+ recommended: z.number().int().nonnegative().optional(),
1729
+ })
1730
+ .strict(),
1731
+ )
1732
+ .min(1)
1733
+ .max(MAX_ASK_QUESTIONS),
1734
+ timeout: z.number().nonnegative().finite().optional(),
1735
+ })
1736
+ .strict()
1737
+ .superRefine((request, context) => {
1738
+ const ids = new Set(request.questions.map((question) => question.id));
1739
+ if (ids.size !== request.questions.length) {
1740
+ context.addIssue({ code: "custom", message: "invalid extension UI request" });
1741
+ }
1742
+ }),
1698
1743
  z.object({ ...ExtensionUiBase, method: z.literal("cancel"), targetId: IDENTIFIER }).strict(),
1699
1744
  z
1700
1745
  .object({
@@ -1859,6 +1904,8 @@ export function projectSessionStateResponseData(value: unknown): unknown {
1859
1904
  "thinkingLevel",
1860
1905
  "isStreaming",
1861
1906
  "isCompacting",
1907
+ "hasPendingAsyncWork",
1908
+ "isSettled",
1862
1909
  "sessionId",
1863
1910
  "autoCompactionEnabled",
1864
1911
  "contextUsage",
@@ -1980,7 +2027,37 @@ export interface OmpPersistedSessionMessages {
1980
2027
 
1981
2028
  export type OmpAvailableCommand = z.infer<typeof OmpAvailableCommandSchema>;
1982
2029
  export type OmpImage = { type: "image"; data: string; mimeType: string };
1983
- export type OmpExtensionUiResponse =
1984
- | { type: "extension_ui_response"; id: string; value: string }
1985
- | { type: "extension_ui_response"; id: string; confirmed: boolean }
1986
- | { type: "extension_ui_response"; id: string; cancelled: true; timedOut?: boolean };
2030
+ export const OmpExtensionUiResponseSchema = z.union([
2031
+ z.object({ type: z.literal("extension_ui_response"), id: IDENTIFIER, value: TEXT }).strict(),
2032
+ z
2033
+ .object({ type: z.literal("extension_ui_response"), id: IDENTIFIER, confirmed: z.boolean() })
2034
+ .strict(),
2035
+ z
2036
+ .object({
2037
+ type: z.literal("extension_ui_response"),
2038
+ id: IDENTIFIER,
2039
+ cancelled: z.literal(true),
2040
+ timedOut: z.boolean().optional(),
2041
+ })
2042
+ .strict(),
2043
+ z
2044
+ .object({
2045
+ type: z.literal("extension_ui_response"),
2046
+ id: IDENTIFIER,
2047
+ answers: z
2048
+ .array(
2049
+ z
2050
+ .object({
2051
+ id: boundedString(MAX_ASK_QUESTION_ID_BYTES, 1),
2052
+ selectedOptions: z.array(boundedString(4_096)).max(MAX_ASK_OPTIONS),
2053
+ customInput: TEXT.optional(),
2054
+ })
2055
+ .strict(),
2056
+ )
2057
+ .min(1)
2058
+ .max(MAX_ASK_QUESTIONS),
2059
+ })
2060
+ .strict(),
2061
+ ]);
2062
+ export type OmpExtensionUiResponse = z.infer<typeof OmpExtensionUiResponseSchema>;
2063
+ export const OmpAskDialogEnabledSchema = z.object({ enabled: z.boolean() }).strict();
@@ -10,6 +10,7 @@ import {
10
10
  MAX_REPLAY_MESSAGES,
11
11
  NAME,
12
12
  OMP_MESSAGE_PAGE_LIMIT,
13
+ OmpAskDialogEnabledSchema,
13
14
  type OmpAvailableCommand,
14
15
  OmpAvailableCommandsResultSchema,
15
16
  OmpBranchMessagesResultSchema,
@@ -18,6 +19,7 @@ import {
18
19
  type OmpCompactionResult,
19
20
  OmpCompactionResultSchema,
20
21
  type OmpExtensionUiResponse,
22
+ OmpExtensionUiResponseSchema,
21
23
  type OmpHostToolDefinition,
22
24
  OmpHostToolDefinitionSchema,
23
25
  type OmpHostToolResult,
@@ -546,7 +548,10 @@ class OmpRpcSession implements OmpRuntimeSession {
546
548
  }
547
549
 
548
550
  respondToExtensionUi(response: OmpExtensionUiResponse): Promise<void> {
549
- return this.process.sendFrame(response);
551
+ // Callers chain `.catch` without a surrounding try, so a bad frame must reject, not throw.
552
+ const parsed = OmpExtensionUiResponseSchema.safeParse(response);
553
+ if (!parsed.success) return Promise.reject(parsed.error);
554
+ return this.process.sendFrame(parsed.data);
550
555
  }
551
556
  respondToToolApproval(response: OmpToolApprovalResponse): Promise<void> {
552
557
  return this.process.sendFrame(OmpToolApprovalResponseSchema.parse(response));
@@ -652,6 +657,7 @@ export class OmpRpcRuntime implements OmpRuntime {
652
657
  }),
653
658
  );
654
659
  options.signal?.throwIfAborted();
660
+ enableAskDialog(process);
655
661
  return new OmpRpcSession(
656
662
  process,
657
663
  removeAbortListener,
@@ -671,3 +677,17 @@ export class OmpRpcRuntime implements OmpRuntime {
671
677
  }
672
678
  }
673
679
  }
680
+
681
+ /**
682
+ * Opts into OMP's single-request `ask` dialog so every question reaches Paseo as one card that can
683
+ * be revisited before submitting. OMP applies the flag in command order, so it is set before any
684
+ * later prompt can ask; startup does not wait for the acknowledgement. OMP releases without
685
+ * `set_ask_dialog` reject it and keep the per-question select/editor fallback, which the
686
+ * permission bridge still handles.
687
+ */
688
+ function enableAskDialog(process: OmpRpcProcess): void {
689
+ process
690
+ .request({ type: "set_ask_dialog", enabled: true })
691
+ .then((data) => OmpAskDialogEnabledSchema.parse(data))
692
+ .catch(() => undefined);
693
+ }
@@ -97,6 +97,7 @@ const CAPABILITIES = [
97
97
  "prompt.command",
98
98
  "prompt.image",
99
99
  "prompt.steer",
100
+ "session.can_stop_runtime",
100
101
  "session.configure",
101
102
  "session.list",
102
103
  "session.persistence",
@@ -301,6 +301,10 @@ export class OmpHistorySession implements OmpOpenedSession {
301
301
  return Promise.resolve();
302
302
  }
303
303
 
304
+ canStopRuntime(): Promise<boolean> {
305
+ return Promise.resolve(true);
306
+ }
307
+
304
308
  beginConnectionShutdown(): void {
305
309
  this.lifetime.abort(new Error("OMP provider connection closed"));
306
310
  }
@@ -25,14 +25,23 @@ import type { OmpTimelineProjector, OmpTimelineScheduler } from "./timeline-proj
25
25
  type SessionPermissionInput = Extract<ProviderInput, { type: "session.permission" }>;
26
26
  type OmpQuestionRequest = Extract<
27
27
  Extract<OmpRpcEvent, { type: "extension_ui_request" }>,
28
- { method: "select" | "confirm" | "input" | "editor" }
28
+ { method: "select" | "confirm" | "input" | "editor" | "ask" }
29
29
  >;
30
+ type OmpAskRequest = Extract<OmpQuestionRequest, { method: "ask" }>;
30
31
  type Emit = (event: ProviderEvent) => void;
31
32
 
32
33
  const MAX_PENDING_PERMISSIONS = 32;
33
34
  const MAX_PENDING_PERMISSION_BYTES = 2 * 1024 * 1024;
34
35
  const MAX_RESOLVED_TOOL_APPROVAL_IDS = 1_024;
35
- const OMP_ASK_USER_FREEFORM_SENTINEL = "✏️ Type custom response...";
36
+ /**
37
+ * Select rows that hand off to a follow-up free-text request: `ask_user` extensions follow with
38
+ * `input`, OMP's own `ask` fallback (hosts without `set_ask_dialog`) follows with `editor`.
39
+ */
40
+ const OMP_FREEFORM_SENTINELS: readonly string[] = [
41
+ "✏️ Type custom response...",
42
+ "Other (type your own)",
43
+ ];
44
+ const OMP_ASK_RECOMMENDED_SUFFIX = " (Recommended)";
36
45
  const MAX_FREEFORM_RESPONSE_BYTES = 64 * 1024;
37
46
 
38
47
  type PendingPermission = {
@@ -50,6 +59,14 @@ type PendingPermission = {
50
59
  turnId?: string;
51
60
  request: OmpQuestionRequest;
52
61
  freeformSentinel?: string;
62
+ askQuestions?: readonly PendingAskQuestion[];
63
+ };
64
+ /** One `ask` question as rendered on the Paseo card, with display labels mapped back to OMP. */
65
+ type PendingAskQuestion = {
66
+ id: string;
67
+ header: string;
68
+ multi: boolean;
69
+ nativeLabels: ReadonlyMap<string, string>;
53
70
  };
54
71
  type PendingToolPermission = {
55
72
  nativeId: string;
@@ -75,6 +92,96 @@ function permissionFingerprint(request: OmpQuestionRequest): string {
75
92
  return createHash("sha256").update(JSON.stringify(request)).digest("base64url");
76
93
  }
77
94
 
95
+ type CardQuestion = {
96
+ header: string;
97
+ question: string;
98
+ options: Array<{ label: string; value?: string; description?: string; preview?: string }>;
99
+ multiSelect: boolean;
100
+ allowOther?: boolean;
101
+ placeholder?: string;
102
+ prefill?: string;
103
+ };
104
+ type AskAnswer = { id: string; selectedOptions: string[]; customInput?: string };
105
+
106
+ function uniqueLabel(base: string, used: Set<string>): string {
107
+ let label = base;
108
+ let suffix = 2;
109
+ while (used.has(label)) {
110
+ label = `${base} (${suffix})`;
111
+ suffix += 1;
112
+ }
113
+ used.add(label);
114
+ return label;
115
+ }
116
+
117
+ /**
118
+ * Paseo's question card submits a multi-select answer as one string: checked option labels in
119
+ * click order, then any typed answer, joined with ", ". Exact labels are consumed from the front,
120
+ * longest first so a label containing ", " wins over its prefix; the rest is the typed answer.
121
+ */
122
+ function splitMultiSelectAnswer(
123
+ answer: string,
124
+ labels: readonly string[],
125
+ ): { selected: string[]; custom: string } {
126
+ const candidates = [...labels].sort((left, right) => right.length - left.length);
127
+ const selected: string[] = [];
128
+ let remaining = answer;
129
+ while (remaining.length > 0) {
130
+ const label = candidates.find(
131
+ (candidate) =>
132
+ !selected.includes(candidate) &&
133
+ (remaining === candidate || remaining.startsWith(`${candidate}, `)),
134
+ );
135
+ if (label === undefined) break;
136
+ selected.push(label);
137
+ remaining = remaining === label ? "" : remaining.slice(label.length + 2);
138
+ }
139
+ return { selected, custom: remaining };
140
+ }
141
+
142
+ /**
143
+ * Converts one card answer to OMP's shape. OMP rejects unknown or repeated labels and, for
144
+ * single-select questions, a selection combined with typed text, so neither is ever produced.
145
+ */
146
+ function askAnswer(question: PendingAskQuestion, answer: unknown): AskAnswer {
147
+ const parts =
148
+ answer === undefined || answer === null
149
+ ? []
150
+ : typeof answer === "string"
151
+ ? [answer]
152
+ : Array.isArray(answer) && answer.every((part) => typeof part === "string")
153
+ ? answer
154
+ : undefined;
155
+ if (!parts) throw new OmpPublicError("OMP question response is invalid");
156
+ const labels = [...question.nativeLabels.keys()];
157
+ const selectedOptions: string[] = [];
158
+ const custom: string[] = [];
159
+ const select = (label: string) => {
160
+ const native = question.nativeLabels.get(label);
161
+ if (native !== undefined && !selectedOptions.includes(native)) selectedOptions.push(native);
162
+ };
163
+ for (const part of parts) {
164
+ if (question.nativeLabels.has(part)) {
165
+ select(part);
166
+ } else if (question.multi) {
167
+ const split = splitMultiSelectAnswer(part, labels);
168
+ for (const label of split.selected) select(label);
169
+ if (split.custom.trim()) custom.push(split.custom);
170
+ } else if (part.trim()) {
171
+ custom.push(part);
172
+ }
173
+ }
174
+ const customInput = custom.join(", ").trim();
175
+ if (
176
+ customInput.includes("\0") ||
177
+ utf8Bytes(customInput) > MAX_FREEFORM_RESPONSE_BYTES ||
178
+ (!question.multi && selectedOptions.length + (customInput ? 1 : 0) > 1)
179
+ ) {
180
+ throw new OmpPublicError("OMP question response is invalid");
181
+ }
182
+ return { id: question.id, selectedOptions, ...(customInput ? { customInput } : {}) };
183
+ }
184
+
78
185
  interface PermissionContext {
79
186
  runtime: OmpRuntimeSession;
80
187
  generation: number;
@@ -279,7 +386,7 @@ export class OmpSessionPermissions {
279
386
  }
280
387
 
281
388
  submitPendingFreeformSelection(
282
- request: Extract<OmpQuestionRequest, { method: "input" }>,
389
+ request: Extract<OmpQuestionRequest, { method: "input" | "editor" }>,
283
390
  ): boolean {
284
391
  const pending = this.pendingFreeformSelection;
285
392
  if (!pending) return false;
@@ -505,10 +612,17 @@ export class OmpSessionPermissions {
505
612
  if (!existingId) this.permissionSequence += 1;
506
613
  const id =
507
614
  existingId ?? `omp:permission:${this.permissionNamespace}:${this.permissionSequence}`;
508
- const header = this.dataFilter.text(request.title ?? "OMP question", 4_096);
615
+ const header = this.dataFilter.text(
616
+ request.method === "ask"
617
+ ? request.questions.length === 1
618
+ ? "OMP question"
619
+ : `OMP questions (${request.questions.length})`
620
+ : (request.title ?? "OMP question"),
621
+ 4_096,
622
+ );
509
623
  const freeformSentinel =
510
- request.method === "select" && request.options.includes(OMP_ASK_USER_FREEFORM_SENTINEL)
511
- ? OMP_ASK_USER_FREEFORM_SENTINEL
624
+ request.method === "select"
625
+ ? request.options.find((option) => OMP_FREEFORM_SENTINELS.includes(option))
512
626
  : undefined;
513
627
  const optionValues = new Map<string, string>();
514
628
  const displayValues = new Map<string, string>();
@@ -523,14 +637,7 @@ export class OmpSessionPermissions {
523
637
  const options =
524
638
  request.method === "select"
525
639
  ? selectableOptions.map(({ nativeValue, index }) => {
526
- const baseLabel = this.dataFilter.text(nativeValue, 4_096);
527
- let label = baseLabel;
528
- let suffix = 2;
529
- while (usedOptionLabels.has(label)) {
530
- label = `${baseLabel} (${suffix})`;
531
- suffix += 1;
532
- }
533
- usedOptionLabels.add(label);
640
+ const label = uniqueLabel(this.dataFilter.text(nativeValue, 4_096), usedOptionLabels);
534
641
  const value = `${id}:option:${index}`;
535
642
  optionValues.set(value, nativeValue);
536
643
  displayValues.set(label, nativeValue);
@@ -550,6 +657,28 @@ export class OmpSessionPermissions {
550
657
  : undefined;
551
658
  const questionOptions =
552
659
  request.method === "confirm" ? [{ label: "Yes" }, { label: "No" }] : (options ?? []);
660
+ const ask = request.method === "ask" ? this.askCard(request) : undefined;
661
+ const cardQuestions: CardQuestion[] =
662
+ request.method === "ask"
663
+ ? (ask?.questions ?? [])
664
+ : [
665
+ {
666
+ header,
667
+ question: this.dataFilter.text(
668
+ request.method === "confirm" ? request.message : request.title,
669
+ 64 * 1024,
670
+ ),
671
+ options: questionOptions,
672
+ multiSelect: false,
673
+ ...(freeformSentinel ? { allowOther: true } : {}),
674
+ ...(request.method === "input" && request.placeholder
675
+ ? { placeholder: this.dataFilter.text(request.placeholder, 4_096) }
676
+ : {}),
677
+ ...((request.method === "input" || request.method === "editor") && request.prefill
678
+ ? { prefill: this.dataFilter.text(request.prefill) }
679
+ : {}),
680
+ },
681
+ ];
553
682
  const actions =
554
683
  request.method === "select"
555
684
  ? [
@@ -591,7 +720,7 @@ export class OmpSessionPermissions {
591
720
  runtime: this.runtime,
592
721
  request,
593
722
  retainedBytes: boundedJsonBytes(
594
- { request, header, options: questionOptions, actions },
723
+ { request, header, questions: cardQuestions, actions },
595
724
  MAX_PENDING_PERMISSION_BYTES,
596
725
  512,
597
726
  MAX_PENDING_PERMISSION_BYTES,
@@ -600,6 +729,7 @@ export class OmpSessionPermissions {
600
729
  ...(this.activeTurn ? { turnId: this.activeTurn.turnId } : {}),
601
730
  ...(request.timeout !== undefined ? { expiresAt: Date.now() + request.timeout } : {}),
602
731
  ...(freeformSentinel ? { freeformSentinel } : {}),
732
+ ...(ask ? { askQuestions: ask.pending } : {}),
603
733
  };
604
734
  let retainedPermissionBytes = pending.retainedBytes;
605
735
  for (const [permissionId, retained] of this.pendingPermissions) {
@@ -647,26 +777,7 @@ export class OmpSessionPermissions {
647
777
  ...(request.method === "confirm"
648
778
  ? { description: this.dataFilter.text(request.message, 64 * 1024) }
649
779
  : {}),
650
- input: {
651
- questions: [
652
- {
653
- header,
654
- question: this.dataFilter.text(
655
- request.method === "confirm" ? request.message : request.title,
656
- 64 * 1024,
657
- ),
658
- options: questionOptions,
659
- multiSelect: false,
660
- ...(freeformSentinel ? { allowOther: true } : {}),
661
- ...(request.method === "input" && request.placeholder
662
- ? { placeholder: this.dataFilter.text(request.placeholder, 4_096) }
663
- : {}),
664
- ...((request.method === "input" || request.method === "editor") && request.prefill
665
- ? { prefill: this.dataFilter.text(request.prefill) }
666
- : {}),
667
- },
668
- ],
669
- },
780
+ input: { questions: cardQuestions },
670
781
  actions,
671
782
  },
672
783
  });
@@ -688,6 +799,76 @@ export class OmpSessionPermissions {
688
799
  .catch(() => this.handleRuntimeFailure());
689
800
  }
690
801
 
802
+ /**
803
+ * Renders every `ask` question on one card. Headers key the card's answers and option labels
804
+ * come back verbatim, so both are made unique here and mapped back to OMP's labels on submit.
805
+ */
806
+ private askCard(request: OmpAskRequest): {
807
+ questions: CardQuestion[];
808
+ pending: PendingAskQuestion[];
809
+ } {
810
+ const usedHeaders = new Set<string>();
811
+ const questions: CardQuestion[] = [];
812
+ const pending: PendingAskQuestion[] = [];
813
+ for (const [index, question] of request.questions.entries()) {
814
+ const header = uniqueLabel(
815
+ this.dataFilter.text(question.header?.trim() || `Question ${index + 1}`, 4_096),
816
+ usedHeaders,
817
+ );
818
+ const usedLabels = new Set<string>();
819
+ const nativeLabels = new Map<string, string>();
820
+ const options = question.options.map((option, optionIndex) => {
821
+ const label = uniqueLabel(
822
+ `${this.dataFilter.text(option.label, 4_096)}${
823
+ optionIndex === question.recommended ? OMP_ASK_RECOMMENDED_SUFFIX : ""
824
+ }`,
825
+ usedLabels,
826
+ );
827
+ nativeLabels.set(label, option.label);
828
+ return {
829
+ label,
830
+ ...(option.description
831
+ ? { description: this.dataFilter.text(option.description, 16_384) }
832
+ : {}),
833
+ ...(option.preview ? { preview: this.dataFilter.text(option.preview, 64 * 1024) } : {}),
834
+ };
835
+ });
836
+ const multi = question.multi === true;
837
+ questions.push({
838
+ header,
839
+ question: this.dataFilter.text(question.question, 64 * 1024),
840
+ options,
841
+ multiSelect: multi,
842
+ allowOther: true,
843
+ });
844
+ pending.push({ id: question.id, header, multi, nativeLabels });
845
+ }
846
+ return { questions, pending };
847
+ }
848
+
849
+ /** Maps card answers to OMP's per-question `selectedOptions`/`customInput` in question order. */
850
+ private askResponse(
851
+ pending: PendingPermission,
852
+ response: ProviderPermissionResponse,
853
+ ): OmpExtensionUiResponse {
854
+ const answers = response.behavior === "allow" ? response.updatedInput?.answers : undefined;
855
+ if (
856
+ !pending.askQuestions ||
857
+ !answers ||
858
+ typeof answers !== "object" ||
859
+ Array.isArray(answers)
860
+ ) {
861
+ throw new OmpPublicError("OMP question response requires answers");
862
+ }
863
+ return {
864
+ type: "extension_ui_response",
865
+ id: pending.nativeId,
866
+ answers: pending.askQuestions.map((question) =>
867
+ askAnswer(question, (answers as Record<string, unknown>)[question.header]),
868
+ ),
869
+ };
870
+ }
871
+
691
872
  private freeformSelection(
692
873
  pending: PendingPermission,
693
874
  response: ProviderPermissionResponse,
@@ -754,6 +935,7 @@ export class OmpSessionPermissions {
754
935
  if (response.behavior === "deny") {
755
936
  return { type: "extension_ui_response", id: nativeId, cancelled: true };
756
937
  }
938
+ if (request.method === "ask") return this.askResponse(pending, response);
757
939
  const selectedValue = response.selectedActionId
758
940
  ? pending.optionValues.get(response.selectedActionId)
759
941
  : undefined;
@@ -968,6 +1150,16 @@ export class OmpSessionPermissions {
968
1150
  return this.activeTurn?.turnId === pending.turnId && !this.activeTurn.terminal;
969
1151
  }
970
1152
 
1153
+ hasPending(): boolean {
1154
+ return (
1155
+ this.pendingPermissions.size > 0 ||
1156
+ this.inFlightPermissions.size > 0 ||
1157
+ this.pendingToolPermissions.size > 0 ||
1158
+ this.inFlightToolPermissions.size > 0 ||
1159
+ this.pendingFreeformSelection !== null
1160
+ );
1161
+ }
1162
+
971
1163
  hasForTurn(turnId: string): boolean {
972
1164
  const ownsTurn = (pending: PendingPermission | PendingToolPermission) =>
973
1165
  pending.turnId === turnId;
@@ -111,6 +111,7 @@ export interface OmpOpenedSession {
111
111
  configure(input: SessionConfigureInput): Promise<void>;
112
112
  revert(input: SessionRevertInput): Promise<void>;
113
113
  interrupt(input: SessionInterruptInput): Promise<void>;
114
+ canStopRuntime(): Promise<boolean>;
114
115
  close(input?: SessionCloseInput): Promise<void>;
115
116
  abortOpen(): Promise<void>;
116
117
  beginConnectionShutdown(): void;
@@ -1465,6 +1466,28 @@ export class OmpProviderSession implements OmpOpenedSession {
1465
1466
  }
1466
1467
  }
1467
1468
 
1469
+ async canStopRuntime(): Promise<boolean> {
1470
+ const hasLocalWork =
1471
+ this.closed ||
1472
+ this.runtimeDead !== null ||
1473
+ this.recoveryPromise !== null ||
1474
+ this.activeTurn !== null ||
1475
+ this.activeAbort !== null ||
1476
+ this.revertInFlight ||
1477
+ this.configMutationInFlight ||
1478
+ this.permissions.hasPending() ||
1479
+ Boolean(this.subsessions?.hasActiveChildren());
1480
+ if (hasLocalWork) return false;
1481
+ const state = await this.runtime.getState();
1482
+ // OMP builds that predate these fields report neither, which keeps the runtime alive.
1483
+ return (
1484
+ state.isSettled === true &&
1485
+ state.hasPendingAsyncWork === false &&
1486
+ !state.isStreaming &&
1487
+ !state.isCompacting
1488
+ );
1489
+ }
1490
+
1468
1491
  async interrupt(input: SessionInterruptInput): Promise<void> {
1469
1492
  const turn = this.activeTurn;
1470
1493
  if (!turn) {
@@ -2280,13 +2303,17 @@ export class OmpProviderSession implements OmpOpenedSession {
2280
2303
  this.permissions.resolvePermissionByNativeId(event.targetId ?? "");
2281
2304
  return;
2282
2305
  }
2283
- if (event.method === "input" && this.permissions.submitPendingFreeformSelection(event))
2306
+ if (
2307
+ (event.method === "input" || event.method === "editor") &&
2308
+ this.permissions.submitPendingFreeformSelection(event)
2309
+ )
2284
2310
  return;
2285
2311
  if (
2286
2312
  event.method === "select" ||
2287
2313
  event.method === "confirm" ||
2288
2314
  event.method === "input" ||
2289
- event.method === "editor"
2315
+ event.method === "editor" ||
2316
+ event.method === "ask"
2290
2317
  ) {
2291
2318
  if (!this.capabilities.includes("permission")) {
2292
2319
  this.handleRuntimeFailure();
@@ -2459,7 +2486,9 @@ export class OmpProviderSession implements OmpOpenedSession {
2459
2486
  if (event.type === "tool_execution_start") turn.activeToolCallIds.add(event.toolCallId);
2460
2487
  if (event.type === "tool_execution_end") {
2461
2488
  turn.activeToolCallIds.delete(event.toolCallId);
2462
- if (event.toolName === "ask_user") this.permissions.clearPendingFreeformSelection();
2489
+ if (event.toolName === "ask_user" || event.toolName === "ask") {
2490
+ this.permissions.clearPendingFreeformSelection();
2491
+ }
2463
2492
  }
2464
2493
  if (event.type === "tool_execution_start" || event.type === "tool_execution_end") {
2465
2494
  try {