@wrenai/genbi 0.0.4 → 0.0.6

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.
Files changed (234) hide show
  1. package/README.md +65 -3
  2. package/dist/assets/{echarts-LTFC-ysd.js → echarts-DYM4hjvk.js} +2 -2
  3. package/dist/assets/index-A_Wrq7Ey.css +1 -0
  4. package/dist/assets/{index-Bo--QMUN.js → index-DsH129tV.js} +18 -17
  5. package/dist/index.html +2 -2
  6. package/dist-server/harness/bundle/schema.d.ts +38 -0
  7. package/dist-server/harness/bundle/schema.js +18 -10
  8. package/dist-server/harness/bundle/version.js +1 -1
  9. package/dist-server/harness/cli-args.d.ts +10 -2
  10. package/dist-server/harness/cli-args.js +24 -8
  11. package/dist-server/harness/cli.js +75 -9
  12. package/dist-server/harness/compile/cache.js +4 -4
  13. package/dist-server/harness/compile/compose-profile.d.ts +51 -14
  14. package/dist-server/harness/compile/compose-profile.js +85 -19
  15. package/dist-server/harness/compile/context-loader.d.ts +56 -0
  16. package/dist-server/harness/compile/context-loader.js +237 -0
  17. package/dist-server/harness/compile/errors.d.ts +19 -0
  18. package/dist-server/harness/compile/errors.js +35 -0
  19. package/dist-server/harness/compile/index.d.ts +4 -3
  20. package/dist-server/harness/compile/index.js +4 -3
  21. package/dist-server/harness/compile/pipeline.d.ts +15 -7
  22. package/dist-server/harness/compile/pipeline.js +62 -14
  23. package/dist-server/harness/compile/resolve-binary.d.ts +2 -2
  24. package/dist-server/harness/compile/resolve-binary.js +3 -3
  25. package/dist-server/harness/compile/types.d.ts +29 -0
  26. package/dist-server/harness/compile/warble-identity.d.ts +11 -0
  27. package/dist-server/harness/compile/warble-identity.js +13 -0
  28. package/dist-server/harness/components/ai-step.d.ts +4 -0
  29. package/dist-server/harness/components/ai-step.js +40 -0
  30. package/dist-server/harness/components/broker.d.ts +60 -0
  31. package/dist-server/harness/components/broker.js +117 -0
  32. package/dist-server/harness/components/capability-card.d.ts +98 -0
  33. package/dist-server/harness/components/capability-card.js +169 -0
  34. package/dist-server/harness/components/context.d.ts +2 -0
  35. package/dist-server/harness/components/context.js +30 -0
  36. package/dist-server/harness/components/display.d.ts +6 -0
  37. package/dist-server/harness/components/display.js +29 -0
  38. package/dist-server/harness/components/egress-judge.d.ts +12 -0
  39. package/dist-server/harness/components/egress-judge.js +27 -0
  40. package/dist-server/harness/components/egress.d.ts +142 -0
  41. package/dist-server/harness/components/egress.js +469 -0
  42. package/dist-server/harness/components/in-process.d.ts +7 -0
  43. package/dist-server/harness/components/in-process.js +245 -0
  44. package/dist-server/harness/components/normalize.d.ts +35 -0
  45. package/dist-server/harness/components/normalize.js +245 -0
  46. package/dist-server/harness/components/plan.d.ts +31 -0
  47. package/dist-server/harness/components/plan.js +184 -0
  48. package/dist-server/harness/components/report.d.ts +207 -0
  49. package/dist-server/harness/components/report.js +309 -0
  50. package/dist-server/harness/components/runner.d.ts +193 -0
  51. package/dist-server/harness/components/runner.js +492 -0
  52. package/dist-server/harness/components/semantic-guards.d.ts +20 -0
  53. package/dist-server/harness/components/semantic-guards.js +42 -0
  54. package/dist-server/harness/components/terminal-json.d.ts +18 -0
  55. package/dist-server/harness/components/terminal-json.js +82 -0
  56. package/dist-server/harness/components/usage.d.ts +14 -0
  57. package/dist-server/harness/components/usage.js +19 -0
  58. package/dist-server/harness/components/wren-access.d.ts +29 -0
  59. package/dist-server/harness/components/wren-access.js +233 -0
  60. package/dist-server/harness/components/zone-dry-run.d.ts +43 -0
  61. package/dist-server/harness/components/zone-dry-run.js +107 -0
  62. package/dist-server/harness/components/zone-gate.d.ts +76 -0
  63. package/dist-server/harness/components/zone-gate.js +144 -0
  64. package/dist-server/harness/events/index.d.ts +1 -1
  65. package/dist-server/harness/events/types.d.ts +52 -0
  66. package/dist-server/harness/index.d.ts +9 -2
  67. package/dist-server/harness/index.js +5 -1
  68. package/dist-server/harness/loop/executor.d.ts +1 -0
  69. package/dist-server/harness/loop/executor.js +37 -15
  70. package/dist-server/harness/npm-package-resolve.d.ts +2 -0
  71. package/dist-server/harness/npm-package-resolve.js +16 -4
  72. package/dist-server/harness/providers/adapters/openai-compatible.d.ts +13 -0
  73. package/dist-server/harness/providers/adapters/openai-compatible.js +17 -0
  74. package/dist-server/harness/providers/adapters/openai.d.ts +7 -0
  75. package/dist-server/harness/providers/adapters/openai.js +6 -0
  76. package/dist-server/harness/providers/adapters/pi-ai.d.ts +48 -0
  77. package/dist-server/harness/providers/adapters/pi-ai.js +518 -0
  78. package/dist-server/harness/providers/binding.d.ts +45 -4
  79. package/dist-server/harness/providers/binding.js +44 -8
  80. package/dist-server/harness/providers/index.d.ts +9 -3
  81. package/dist-server/harness/providers/index.js +10 -3
  82. package/dist-server/harness/providers/zone.d.ts +96 -0
  83. package/dist-server/harness/providers/zone.js +112 -0
  84. package/dist-server/harness/render/answering-query.d.ts +29 -0
  85. package/dist-server/harness/render/answering-query.js +62 -0
  86. package/dist-server/harness/render/envelope.d.ts +6 -0
  87. package/dist-server/harness/render/envelope.js +10 -3
  88. package/dist-server/harness/route/adapter-spec.d.ts +8 -9
  89. package/dist-server/harness/route/adapter-spec.js +13 -14
  90. package/dist-server/harness/route/codex-ask-events.js +2 -1
  91. package/dist-server/harness/route/codex-ask.d.ts +4 -0
  92. package/dist-server/harness/route/codex-ask.js +43 -45
  93. package/dist-server/harness/route/codex-local-manifest.d.ts +9 -1
  94. package/dist-server/harness/route/codex-local-manifest.js +96 -15
  95. package/dist-server/harness/route/describe.js +45 -6
  96. package/dist-server/harness/route/dispatched.d.ts +20 -0
  97. package/dist-server/harness/route/dispatched.js +122 -24
  98. package/dist-server/harness/route/host-query.d.ts +93 -0
  99. package/dist-server/harness/route/host-query.js +257 -0
  100. package/dist-server/harness/route/in-process.d.ts +22 -1
  101. package/dist-server/harness/route/in-process.js +54 -4
  102. package/dist-server/harness/route/route.js +8 -2
  103. package/dist-server/harness/route/types.d.ts +47 -1
  104. package/dist-server/harness/route/wren-host-proxy.d.ts +33 -0
  105. package/dist-server/harness/route/wren-host-proxy.js +228 -0
  106. package/dist-server/harness/session/run.js +30 -10
  107. package/dist-server/harness/setup/runner.d.ts +17 -3
  108. package/dist-server/harness/setup/runner.js +272 -89
  109. package/dist-server/harness/setup/yaml-scalar.d.ts +23 -0
  110. package/dist-server/harness/setup/yaml-scalar.js +62 -0
  111. package/dist-server/harness/tools/setup-native.d.ts +7 -0
  112. package/dist-server/harness/tools/setup-native.js +7 -4
  113. package/dist-server/server/adopt.js +1 -17
  114. package/dist-server/server/app.d.ts +1 -0
  115. package/dist-server/server/app.js +175 -17
  116. package/dist-server/server/auth-choice.d.ts +13 -1
  117. package/dist-server/server/auth-choice.js +66 -1
  118. package/dist-server/server/bin.js +185 -25
  119. package/dist-server/server/codex-native-session.d.ts +25 -0
  120. package/dist-server/server/codex-native-session.js +1 -0
  121. package/dist-server/server/codex-runtime-composition.d.ts +34 -0
  122. package/dist-server/server/codex-runtime-composition.js +250 -0
  123. package/dist-server/server/codex-structured-ask.d.ts +23 -0
  124. package/dist-server/server/codex-structured-ask.js +102 -0
  125. package/dist-server/server/conn-config.js +4 -16
  126. package/dist-server/server/conversation-socket.d.ts +16 -0
  127. package/dist-server/server/conversation-socket.js +65 -0
  128. package/dist-server/server/db.d.ts +46 -0
  129. package/dist-server/server/db.js +88 -9
  130. package/dist-server/server/env-detect.d.ts +6 -0
  131. package/dist-server/server/env-detect.js +7 -0
  132. package/dist-server/server/fold.d.ts +23 -4
  133. package/dist-server/server/fold.js +42 -5
  134. package/dist-server/server/harness.d.ts +2 -2
  135. package/dist-server/server/harness.js +33 -7
  136. package/dist-server/server/host-evidence-grounding.d.ts +21 -0
  137. package/dist-server/server/host-evidence-grounding.js +93 -0
  138. package/dist-server/server/interactive-terminal.d.ts +10 -6
  139. package/dist-server/server/interactive-terminal.js +33 -8
  140. package/dist-server/server/managed-wren-runtime.d.ts +116 -0
  141. package/dist-server/server/managed-wren-runtime.js +670 -0
  142. package/dist-server/server/native-artifacts.d.ts +112 -7
  143. package/dist-server/server/native-artifacts.js +194 -14
  144. package/dist-server/server/native-component-context.d.ts +19 -0
  145. package/dist-server/server/native-component-context.js +76 -0
  146. package/dist-server/server/native-component-preparation.d.ts +20 -0
  147. package/dist-server/server/native-component-preparation.js +129 -0
  148. package/dist-server/server/native-component-runtime.d.ts +36 -0
  149. package/dist-server/server/native-component-runtime.js +92 -0
  150. package/dist-server/server/native-components.d.ts +84 -0
  151. package/dist-server/server/native-components.js +160 -0
  152. package/dist-server/server/native-dispatch-process.d.ts +8 -0
  153. package/dist-server/server/native-dispatch-process.js +65 -0
  154. package/dist-server/server/native-runtime-spec.d.ts +89 -0
  155. package/dist-server/server/native-runtime-spec.js +169 -0
  156. package/dist-server/server/native-sessions.d.ts +172 -19
  157. package/dist-server/server/native-sessions.js +839 -244
  158. package/dist-server/server/native-shutdown.d.ts +6 -0
  159. package/dist-server/server/native-shutdown.js +28 -0
  160. package/dist-server/server/native-wren-home.d.ts +18 -0
  161. package/dist-server/server/native-wren-home.js +262 -0
  162. package/dist-server/server/node-pty-host.d.ts +2 -1
  163. package/dist-server/server/node-pty-host.js +3 -2
  164. package/dist-server/server/profile-registry.d.ts +139 -0
  165. package/dist-server/server/profile-registry.js +620 -0
  166. package/dist-server/server/runtime-binding.d.ts +4 -1
  167. package/dist-server/server/runtime-binding.js +50 -2
  168. package/dist-server/server/runtime-host/claude-component-step.d.ts +25 -0
  169. package/dist-server/server/runtime-host/claude-component-step.js +186 -0
  170. package/dist-server/server/runtime-host/codex-app-server.d.ts +75 -0
  171. package/dist-server/server/runtime-host/codex-app-server.js +239 -0
  172. package/dist-server/server/runtime-host/codex-compatibility.d.ts +110 -0
  173. package/dist-server/server/runtime-host/codex-compatibility.js +162 -0
  174. package/dist-server/server/runtime-host/codex-component-step.d.ts +19 -0
  175. package/dist-server/server/runtime-host/codex-component-step.js +308 -0
  176. package/dist-server/server/runtime-host/codex-conversation.d.ts +109 -0
  177. package/dist-server/server/runtime-host/codex-conversation.js +295 -0
  178. package/dist-server/server/runtime-host/codex-direct-tools.d.ts +23 -0
  179. package/dist-server/server/runtime-host/codex-direct-tools.js +59 -0
  180. package/dist-server/server/runtime-host/codex-events.d.ts +647 -0
  181. package/dist-server/server/runtime-host/codex-events.js +66 -0
  182. package/dist-server/server/runtime-host/codex-policy.d.ts +16 -0
  183. package/dist-server/server/runtime-host/codex-policy.js +151 -0
  184. package/dist-server/server/runtime-host/codex-process.d.ts +8 -0
  185. package/dist-server/server/runtime-host/codex-process.js +96 -0
  186. package/dist-server/server/runtime-host/codex-rpc.d.ts +47 -0
  187. package/dist-server/server/runtime-host/codex-rpc.js +182 -0
  188. package/dist-server/server/runtime-host/codex-session.d.ts +74 -0
  189. package/dist-server/server/runtime-host/codex-session.js +492 -0
  190. package/dist-server/server/runtime-host/codex-terminal-protocol.d.ts +35 -0
  191. package/dist-server/server/runtime-host/codex-terminal-protocol.js +243 -0
  192. package/dist-server/server/runtime-host/codex-terminal.d.ts +16 -0
  193. package/dist-server/server/runtime-host/codex-terminal.js +120 -0
  194. package/dist-server/server/runtime-host/index.d.ts +5 -0
  195. package/dist-server/server/runtime-host/index.js +3 -0
  196. package/dist-server/server/runtime-host/local.d.ts +29 -0
  197. package/dist-server/server/runtime-host/local.js +90 -0
  198. package/dist-server/server/runtime-host/policy.d.ts +8 -0
  199. package/dist-server/server/runtime-host/policy.js +78 -0
  200. package/dist-server/server/runtime-host/types.d.ts +52 -0
  201. package/dist-server/server/runtime-host/types.js +62 -0
  202. package/dist-server/server/structured-query-evidence.d.ts +7 -0
  203. package/dist-server/server/structured-query-evidence.js +47 -0
  204. package/dist-server/server/structured-runtime.d.ts +52 -0
  205. package/dist-server/server/structured-runtime.js +130 -0
  206. package/dist-server/server/structured-scope.d.ts +4 -0
  207. package/dist-server/server/structured-scope.js +12 -0
  208. package/dist-server/server/turn.d.ts +6 -1
  209. package/dist-server/server/turn.js +86 -26
  210. package/dist-server/server/wire-types.d.ts +67 -3
  211. package/dist-server/server/wren-profiles.js +1 -1
  212. package/managed-wren/README.md +60 -0
  213. package/managed-wren/dependency-wheels.json +282 -0
  214. package/managed-wren/manifest.json +373 -0
  215. package/managed-wren/release-inputs.json +45 -0
  216. package/managed-wren/runtime-tree.cjs +115 -0
  217. package/managed-wren/source-inputs.json +40 -0
  218. package/package.json +15 -6
  219. package/profiles/genbi-default/context/binding.yml +17 -7
  220. package/profiles/genbi-default/context/context.json +146 -0
  221. package/profiles/genbi-default/ir.golden.json +39 -43
  222. package/profiles/genbi-default/profile.yml +31 -0
  223. package/profiles/genbi-enrich-context/context/binding.yml +14 -3
  224. package/profiles/genbi-enrich-context/context/context.json +146 -0
  225. package/profiles/genbi-enrich-context/ir.golden.json +4 -5
  226. package/profiles/genbi-monitor/context/binding.yml +13 -5
  227. package/profiles/genbi-monitor/context/context.json +146 -0
  228. package/profiles/genbi-monitor/ir.golden.json +13 -7
  229. package/profiles/genbi-report/context/binding.yml +19 -0
  230. package/profiles/genbi-report/context/context.json +223 -0
  231. package/profiles/genbi-report/ir.golden.json +328 -0
  232. package/profiles/genbi-report/profile.yml +38 -0
  233. package/profiles/genbi-setup/ir.golden.json +3 -1
  234. package/dist/assets/index-Xs4q7pNo.css +0 -1
@@ -1,21 +1,31 @@
1
+ import { startCodexTerminal } from "./runtime-host/codex-terminal.js";
2
+ import { CodexConversation } from "./runtime-host/codex-conversation.js";
3
+ import { runNativeDispatchProcess } from "./native-dispatch-process.js";
4
+ import { NativeComponentAdmission, nativeComponentHostContract, readNativeComponentPlans } from "./native-components.js";
1
5
  /**
2
6
  * Durable native-session control plane. SQLite owns identity/lifecycle;
3
7
  * the PTY remains deliberately process-local and contains the only replay.
4
8
  */
5
9
  import { createHash, randomUUID } from "node:crypto";
6
- import { execFileSync, spawn } from "node:child_process";
10
+ import { spawn } from "node:child_process";
7
11
  import { accessSync, chmodSync, constants, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
8
12
  import os from "node:os";
9
13
  import path from "node:path";
14
+ import { isDeepStrictEqual } from "node:util";
10
15
  import { WarbleBinaryNotFoundError } from "../harness/compile/errors.js";
11
- import { InteractiveLaunchError, NO_INTERACTIVE_TERMINAL_LEASES, interactiveExecutableAvailable } from "./interactive-terminal.js";
16
+ import { InteractiveLaunchError, NO_INTERACTIVE_TERMINAL_LEASES } from "./interactive-terminal.js";
12
17
  import { newId } from "./db.js";
13
18
  import { NATIVE_MCP_CREDENTIAL_ENV_VAR } from "./native-artifacts.js";
14
19
  import { dispatchForPurpose, NATIVE_DISPATCH_REGISTRY, NATIVE_PURPOSES, nativeTargetLabel, sameNativeRuntimeBinding, targetForProvider } from "./native-dispatch-registry.js";
15
20
  import { createNativeSessionWorkspace, initializeNativeSessionStateBase, nativeSessionStateBaseAvailable, nativeSessionStateBaseAvailableForProject, validateNativeSessionWorkspace } from "./native-session-workspace.js";
16
21
  import { NativeWrenRuntimeError, resolveNativeWrenRuntime } from "./native-wren-runtime.js";
22
+ import { ManagedWrenRuntimeError } from "./managed-wren-runtime.js";
23
+ import { assertNativeExecutableIdentity, assertNativeRuntimeSpec, attestNativeExecutable, buildNativeChildEnvironment, buildNativeRuntimeSpec, nativeProcessEnvironment, resolveNativeExecutable } from "./native-runtime-spec.js";
24
+ import { createEmptyCodexWrenHome, materializeCodexWrenHome } from "./native-wren-home.js";
17
25
  import { runtimeSettingsCorrection } from "./runtime-binding.js";
18
26
  import { sealNativeClaudeResumeHandle, sealNativeResumeHandle, unsealNativeResumeHandle } from "./native-session-resume.js";
27
+ import { RuntimeHost } from "./runtime-host/local.js";
28
+ import { runtimeNotReady } from "./runtime-host/policy.js";
19
29
  export const NATIVE_VENDORS = ["claude", "codex"];
20
30
  export { NATIVE_PURPOSES };
21
31
  export const NATIVE_SETUP_RECOVERY_MCP_TOOL_NAME = "report_setup_recovery";
@@ -39,6 +49,7 @@ const START_SEPARATE_REPLAY_LIMIT = 128;
39
49
  const CODEX_NATIVE_PERMISSION_HEADER = "[permissions.warble_native_wren.filesystem]";
40
50
  const PROVIDER_SESSION_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
41
51
  const CODEX_RESUME_HANDLE_FILE = path.join(".warble", "codex-thread-id");
52
+ const CODEX_WREN_RUNTIME_UNPROVISIONED = runtimeNotReady("codex-app-server", "unprovisioned", "codex_wren_runtime_unprovisioned");
42
53
  function logNativeLaunchFailure(purpose, vendor, phase, error) {
43
54
  const rejected = error instanceof InteractiveLaunchError;
44
55
  // Exception text, paths, credentials and argv stay out of the log for an
@@ -74,60 +85,6 @@ export function grantCodexBoundProjectRead(cwd, projectPath, dataRoots = []) {
74
85
  writeFileSync(configPath, updated, { encoding: "utf8", mode: 0o600 });
75
86
  chmodSync(configPath, 0o600);
76
87
  }
77
- const MATERIALIZE_WREN_HOME_SCRIPT = String.raw `
78
- import os
79
- import pathlib
80
- import shutil
81
- import sys
82
- import yaml
83
- from wren.profile import expand_profile_secrets, resolve_profile_for_project
84
-
85
- project = pathlib.Path(sys.argv[1]).resolve(strict=True)
86
- destination = pathlib.Path(sys.argv[2])
87
- name, profile = resolve_profile_for_project(project, strict=True)
88
- if not name or not profile:
89
- raise SystemExit(2)
90
- destination.mkdir(mode=0o700, parents=False, exist_ok=False)
91
- profile_path = destination / "profiles.yml"
92
- profile_path.write_text(yaml.safe_dump({"active": name, "profiles": {name: profile}}, sort_keys=False), encoding="utf-8")
93
- os.chmod(profile_path, 0o600)
94
- project_env = project / ".env"
95
- if project_env.exists():
96
- if project_env.is_symlink() or not project_env.is_file():
97
- raise SystemExit(3)
98
- session_env = destination / ".env"
99
- shutil.copyfile(project_env, session_env)
100
- os.chmod(session_env, 0o600)
101
-
102
- expanded = expand_profile_secrets(profile)
103
- data_source = expanded.get("datasource")
104
- data_root = None
105
- if data_source in ("duckdb", "local_file"):
106
- data_root = expanded.get("url")
107
- elif data_source == "datafusion":
108
- data_root = expanded.get("source")
109
- if data_root:
110
- root = pathlib.Path(data_root).expanduser()
111
- if not root.is_absolute():
112
- root = project / root
113
- print(root.resolve(strict=True))
114
- `;
115
- function materializeCodexWrenHome(runtime, projectPath, cwd) {
116
- const destination = path.join(cwd, ".wren");
117
- try {
118
- const output = execFileSync(runtime.venv_python, ["-c", MATERIALIZE_WREN_HOME_SCRIPT, projectPath, destination], {
119
- cwd: projectPath,
120
- env: process.env,
121
- encoding: "utf8",
122
- stdio: ["ignore", "pipe", "ignore"],
123
- timeout: 10_000,
124
- });
125
- return { home: destination, dataRoots: output.trim() ? [output.trim()] : [] };
126
- }
127
- catch {
128
- throw new InteractiveLaunchError("native Wren project profile is unavailable");
129
- }
130
- }
131
88
  function nativeSessionRestartReason(target, cause) {
132
89
  switch (cause) {
133
90
  case "not_terminal":
@@ -175,6 +132,13 @@ export class NativeSetupRecoveryError extends Error {
175
132
  * whichever verb the scope declares, after checking the declaration against the compiled IR.
176
133
  */
177
134
  const entryVerbFor = (purpose) => purpose === "analysis" ? "answer_query" : purpose === "setup" ? "connect_source" : "draft_enrichment";
135
+ /** User-selected analysis entry is a single pin; it never widens Claude scope. */
136
+ export function selectedNativeEntry(purpose, vendor, entryVerb) {
137
+ if (entryVerb != null && (vendor !== "codex" || purpose !== "analysis" || !["answer_query", "generate_dashboard"].includes(entryVerb))) {
138
+ throw new InteractiveLaunchError("native session entry is invalid");
139
+ }
140
+ return vendor === "codex" && purpose === "analysis" ? entryVerb ?? "answer_query" : undefined;
141
+ }
178
142
  const agentFor = (purpose, vendor) => vendor === "claude" ? entryVerbFor(purpose) : `genbi-${purpose === "context_enrichment" ? "enrich-context" : purpose}`;
179
143
  /**
180
144
  * Closed v4 Claude entry contract. This host is the only place the mapping exists: Warble used to
@@ -203,9 +167,9 @@ const claudeScopeEntry = (purpose, vendor) => vendor === "claude" && !PINNED_CLA
203
167
  * most by pinning: its profile mounts four selectable components, and a pinned session can only
204
168
  * reach the other three by delegating out of the one it was forced to start as.
205
169
  */
206
- const nativeEntryFor = (purpose, vendor) => claudeScopeEntry(purpose, vendor)
170
+ const nativeEntryFor = (purpose, vendor, entryVerb) => claudeScopeEntry(purpose, vendor)
207
171
  ? { kind: "scope", prompt: welcomePromptFor(purpose) }
208
- : { verb: entryVerbFor(purpose), prompt: welcomePromptFor(purpose) };
172
+ : { verb: selectedNativeEntry(purpose, vendor, entryVerb) ?? entryVerbFor(purpose), prompt: welcomePromptFor(purpose) };
209
173
  /** Closed producer/host contract for the one initial native TUI prompt. */
210
174
  const welcomePromptFor = (purpose) => {
211
175
  switch (purpose) {
@@ -217,6 +181,50 @@ const welcomePromptFor = (purpose) => {
217
181
  return "Help me inspect this project's context and draft a read-only enrichment proposal. Do not apply changes; ask what context I want to review.";
218
182
  }
219
183
  };
184
+ export const DEFAULT_CONVERSATION_PROFILE = NATIVE_DISPATCH_REGISTRY.analysis.profile;
185
+ const DEFAULT_PROFILE_SELECTION = Object.freeze({ id: DEFAULT_CONVERSATION_PROFILE, entry: { kind: "scope" } });
186
+ const PROFILE_ID_PATTERN = /^[a-z0-9][a-z0-9_-]{0,63}$/;
187
+ const CODEX_CUSTOM_PROFILE_UNAVAILABLE = "conversation profiles other than the default are not available on the Codex CLI";
188
+ /**
189
+ * A selection that departs from the purpose's fixed profile, or `undefined` when the registry's
190
+ * rules for that purpose apply unchanged. System purposes never have one.
191
+ */
192
+ const customSelection = (purpose, selection) => purpose === "analysis" && selection !== undefined && selection.id !== NATIVE_DISPATCH_REGISTRY[purpose].profile ? selection : undefined;
193
+ /**
194
+ * The profile id a launch request names, validated for shape and purpose. Only analysis sessions
195
+ * run inside a selectable profile; the system purposes keep their fixed ones.
196
+ */
197
+ function requestedProfileId(purpose, profile) {
198
+ if (profile === undefined)
199
+ return purpose === "analysis" ? DEFAULT_CONVERSATION_PROFILE : NATIVE_DISPATCH_REGISTRY[purpose].profile;
200
+ if (purpose !== "analysis")
201
+ throw new InteractiveLaunchError("native session profile is only selectable for analysis");
202
+ if (!PROFILE_ID_PATTERN.test(profile))
203
+ throw new InteractiveLaunchError("native session profile is invalid");
204
+ return profile;
205
+ }
206
+ const scopeEntryFor = (purpose, vendor, selection) => {
207
+ const custom = customSelection(purpose, selection);
208
+ return custom ? custom.entry.kind === "scope" : claudeScopeEntry(purpose, vendor);
209
+ };
210
+ /** The agent a pinned launch names, or the component a scope session is recorded under. */
211
+ const expectedAgentFor = (purpose, vendor, selection) => {
212
+ const custom = customSelection(purpose, selection);
213
+ if (!custom)
214
+ return agentFor(purpose, vendor);
215
+ return custom.entry.kind === "agent" ? custom.entry.verb : custom.id;
216
+ };
217
+ /** The entry declared in the scope descriptor; a custom profile's form comes from admission. */
218
+ const entryFor = (purpose, vendor, entryVerb, selection) => {
219
+ const custom = customSelection(purpose, selection);
220
+ if (!custom)
221
+ return nativeEntryFor(purpose, vendor, entryVerb);
222
+ // The welcome text is purpose text, not profile text: every conversation profile is entered the
223
+ // same way, so the first turn stays the one string the launch gate already checks.
224
+ return custom.entry.kind === "scope"
225
+ ? { kind: "scope", prompt: welcomePromptFor(purpose) }
226
+ : { verb: custom.entry.verb, prompt: welcomePromptFor(purpose) };
227
+ };
220
228
  function sameBinding(a, b) {
221
229
  return b !== undefined && a.path === b.path && a.identity === b.identity && a.generation === b.generation && a.revision === b.revision;
222
230
  }
@@ -225,6 +233,7 @@ function resumeScopeFingerprint(row) {
225
233
  row.id, row.purpose, row.vendor, row.scopeKind, row.projectIdentity,
226
234
  row.bindingGeneration, row.projectRevision, row.dispatchProfile,
227
235
  row.dispatchTarget, row.runtimeGeneration,
236
+ ...(row.entryVerb && row.entryVerb !== "answer_query" ? [row.entryVerb] : []),
228
237
  ])).digest("hex");
229
238
  }
230
239
  function record(value) {
@@ -237,25 +246,6 @@ function contained(root, candidate) {
237
246
  const relative = path.relative(root, candidate);
238
247
  return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
239
248
  }
240
- function localExecutableAvailable(executable, pathValue = process.env["PATH"]) {
241
- return localExecutablePath(executable, pathValue) !== undefined;
242
- }
243
- function localExecutablePath(executable, pathValue = process.env["PATH"]) {
244
- const candidates = path.isAbsolute(executable) || executable.includes(path.sep)
245
- ? [path.resolve(executable)]
246
- : (pathValue ?? "").split(path.delimiter).filter(Boolean).map((directory) => path.join(directory, executable));
247
- for (const candidate of candidates) {
248
- try {
249
- const stat = statSync(candidate);
250
- if (stat.isFile() && (stat.mode & 0o111) !== 0)
251
- return realpathSync(candidate);
252
- }
253
- catch {
254
- // Try the next PATH entry. The public readiness response never learns this path.
255
- }
256
- }
257
- return undefined;
258
- }
259
249
  function configuredWorkspace(cwd, create) {
260
250
  try {
261
251
  if (create)
@@ -407,25 +397,14 @@ function nativeProducerResult(available, identity, phase, detail, reason, produc
407
397
  vendors: everyVendorUnavailable(available ? { available: true, diagnostic } : { available: false, reason, diagnostic }),
408
398
  };
409
399
  }
410
- function executableIdentity(executable) {
411
- try {
412
- const metadata = statSync(executable);
413
- if (!metadata.isFile() || (metadata.mode & 0o111) === 0)
414
- return undefined;
415
- return `sha256:${createHash("sha256").update(readFileSync(executable)).digest("hex")}`;
416
- }
417
- catch {
418
- return undefined;
419
- }
420
- }
421
400
  /**
422
401
  * The configured spelling may be a PATH lookup or retargetable symlink. A
423
402
  * preflight result instead owns the resolved executable and its digest. Check
424
403
  * both again before creating a durable row and directly before spawning.
425
404
  */
426
405
  function assertNativeProducerIdentity(producer) {
427
- const resolved = localExecutablePath(producer.executable);
428
- if (resolved !== producer.executable || executableIdentity(producer.executable) !== producer.identity) {
406
+ const current = attestNativeExecutable("producer", producer.executable);
407
+ if (current.executable !== producer.executable || current.digest !== producer.identity) {
429
408
  throw new InteractiveLaunchError("native session producer is incompatible");
430
409
  }
431
410
  }
@@ -434,7 +413,7 @@ function assertNativeProducerIdentity(producer) {
434
413
  * output is used only for local help-marker parsing and is never logged or
435
414
  * returned: a third-party executable may print arbitrary sensitive text.
436
415
  */
437
- function runNativeProbe(command, args) {
416
+ function runNativeProbe(command, args, env) {
438
417
  return new Promise((resolve) => {
439
418
  let output = "";
440
419
  let timedOut = false;
@@ -451,9 +430,10 @@ function runNativeProbe(command, args) {
451
430
  };
452
431
  let child;
453
432
  try {
454
- child = spawn(command, [...args], {
433
+ assertNativeExecutableIdentity(command);
434
+ child = spawn(command.executable, [...args], {
455
435
  cwd: os.tmpdir(),
456
- env: { PATH: process.env["PATH"] ?? "" },
436
+ env: nativeProcessEnvironment(env),
457
437
  shell: false,
458
438
  stdio: ["ignore", "pipe", "pipe"],
459
439
  });
@@ -488,14 +468,23 @@ function runNativeProbe(command, args) {
488
468
  * validator used just before a PTY starts.
489
469
  */
490
470
  export async function probeNativeSessionProducer(input) {
491
- const executable = localExecutablePath(input.warbleBin);
492
- if (!executable)
471
+ const producerExecutable = resolveNativeExecutable("producer", input.warbleBin, input.pathValue);
472
+ if (!producerExecutable)
493
473
  return nativeProducerResult(false, undefined, "resolve", "result=binary_not_found", "the Warble binary could not be resolved");
494
- const identity = executableIdentity(executable);
495
- if (!identity)
496
- return nativeProducerResult(false, undefined, "identity", "result=identity_unavailable", "the Warble binary could not be identified");
474
+ const identity = producerExecutable.digest;
475
+ const executable = producerExecutable.executable;
497
476
  const producer = { executable, identity };
498
- const help = await runNativeProbe(executable, ["dispatch", "--help"]);
477
+ const nodeExecutable = input.nodeExecutable ?? attestNativeExecutable("node", process.execPath);
478
+ const childToolDirectories = [...new Set([
479
+ ...(input.childToolDirectories ?? []),
480
+ path.dirname(nodeExecutable.executable),
481
+ path.dirname(executable),
482
+ ])];
483
+ const probeEnv = buildNativeChildEnvironment({
484
+ toolDirectories: childToolDirectories,
485
+ home: input.nativeHome ?? os.homedir(),
486
+ });
487
+ const help = await runNativeProbe(producerExecutable, ["dispatch", "--help"], probeEnv);
499
488
  if (help.spawnFailed)
500
489
  return nativeProducerResult(false, identity, "dispatch_help", "result=spawn_failed", "the Warble binary could not be started");
501
490
  if (help.timedOut)
@@ -510,65 +499,97 @@ export async function probeNativeSessionProducer(input) {
510
499
  // reported the producer incompatible even for a Claude-only install, which is
511
500
  // exactly what an installed package produces.
512
501
  const vendors = {};
502
+ const purposes = Object.fromEntries(NATIVE_PURPOSES.map((purpose) => [purpose, {}]));
513
503
  for (const vendor of NATIVE_PROBE_TARGETS) {
514
- let outcome = { available: true, diagnostic: producerDiagnostic(identity, `complete_${vendor}`, "result=compatible") };
515
- for (const purpose of NATIVE_PURPOSES) {
516
- const irPath = input.irPaths[purpose];
517
- if (!irPath || !configuredIrPath(irPath))
518
- continue;
519
- const phase = `dispatch_${purpose}_${vendor}`;
520
- const fail = (detail, reason) => ({ available: false, reason, diagnostic: producerDiagnostic(identity, phase, detail) });
521
- let root;
522
- try {
523
- root = mkdtempSync(path.join(os.tmpdir(), "genbi-native-preflight-"));
524
- chmodSync(root, 0o700);
525
- root = realpathSync(root);
526
- const outputRoot = path.join(root, "out");
527
- mkdirSync(outputRoot, { mode: 0o700 });
528
- const bootstrapRoot = path.join(root, "bootstrap");
529
- mkdirSync(bootstrapRoot, { mode: 0o700 });
530
- const scopeId = `preflight-${purpose}-${vendor}`;
531
- const scopePath = path.join(root, "scope.json");
532
- const mcpPath = path.join(root, "mcp.json");
533
- const wrenRuntime = vendor === "codex" ? resolveNativeWrenRuntime(input.wrenShim) : undefined;
534
- const binding = NATIVE_DISPATCH_REGISTRY[purpose].scopeKind === "bound_project"
535
- ? { project_identity: "native-preflight-project", generation: "1", revision: "native-preflight-revision" }
536
- : undefined;
537
- writeFileSync(scopePath, JSON.stringify({ version: "3", kind: NATIVE_DISPATCH_REGISTRY[purpose].scopeKind, scope_id: scopeId, cwd: outputRoot, entry: nativeEntryFor(purpose, vendor), ...(purpose === "setup" ? { bootstrap_root: bootstrapRoot } : {}), ...(wrenRuntime ? { wren_runtime: wrenRuntime } : {}), ...(binding ? { binding } : {}) }), { encoding: "utf8", mode: 0o600, flag: "wx" });
538
- writeFileSync(mcpPath, JSON.stringify({ version: "1", url: "http://127.0.0.1:0/api/native-sessions/mcp", credential: "native-preflight-nonsecret" }), { encoding: "utf8", mode: 0o600, flag: "wx" });
539
- const dispatched = await runNativeProbe(executable, ["dispatch", irPath, "--target", targetForProvider(vendor), "--purpose", purpose, "--native-scope", scopePath, "--native-mcp", mcpPath, "--out", outputRoot]);
540
- if (dispatched.spawnFailed) {
541
- outcome = fail("result=spawn_failed", "the Warble binary could not be started for this vendor");
542
- break;
543
- }
544
- if (dispatched.timedOut) {
545
- outcome = fail("result=timed_out", "the Warble binary did not respond for this vendor");
546
- break;
547
- }
548
- if (dispatched.exitCode !== 0) {
549
- outcome = fail(`result=exit_code_${dispatched.exitCode ?? "signal"}`, "Warble could not dispatch this profile for this vendor");
550
- break;
551
- }
504
+ for (const selectedPurpose of NATIVE_PURPOSES) {
505
+ let outcome = { available: true, diagnostic: producerDiagnostic(identity, `complete_${vendor}`, "result=compatible") };
506
+ const probes = [selectedPurpose].flatMap((purpose) => purpose === "analysis" && vendor === "codex" && input.prepareComponents
507
+ ? [{ purpose, entryVerb: "answer_query" }, { purpose, entryVerb: "generate_dashboard" }]
508
+ : [{ purpose, entryVerb: undefined }]);
509
+ for (const { purpose, entryVerb } of probes) {
510
+ const irPath = input.irPaths[purpose];
511
+ if (!irPath || !configuredIrPath(irPath))
512
+ continue;
513
+ const phase = `dispatch_${purpose}_${vendor}${entryVerb ? `_${entryVerb}` : ""}`;
514
+ const fail = (detail, reason) => ({ available: false, reason, diagnostic: producerDiagnostic(identity, phase, detail) });
515
+ let root;
552
516
  try {
553
- readNativeLaunchSpec(outputRoot, purpose, vendor, scopeId, undefined, { version: "1", url: "http://127.0.0.1:0/api/native-sessions/mcp", credential: "native-preflight-nonsecret" });
517
+ root = mkdtempSync(path.join(os.tmpdir(), "genbi-native-preflight-"));
518
+ chmodSync(root, 0o700);
519
+ root = realpathSync(root);
520
+ const outputRoot = path.join(root, "out");
521
+ mkdirSync(outputRoot, { mode: 0o700 });
522
+ const bootstrapRoot = path.join(root, "bootstrap");
523
+ mkdirSync(bootstrapRoot, { mode: 0o700 });
524
+ const scopeId = `preflight-${purpose}-${vendor}`;
525
+ const scopePath = path.join(root, "scope.json");
526
+ const mcpPath = path.join(root, "mcp.json");
527
+ // Production passes the sealed managed record. The legacy resolver is a
528
+ // compatibility seam for deterministic fixture tests only; bin.ts does
529
+ // not provide a shim and so can never select an editable installation.
530
+ const wrenRuntime = vendor === "codex"
531
+ ? input.resolveManagedCodexWrenRuntime
532
+ ? input.resolveManagedCodexWrenRuntime()
533
+ : resolveNativeWrenRuntime(input.wrenShim)
534
+ : undefined;
535
+ const binding = NATIVE_DISPATCH_REGISTRY[purpose].scopeKind === "bound_project"
536
+ ? { project_identity: "native-preflight-project", generation: "1", revision: "native-preflight-revision" }
537
+ : undefined;
538
+ const scope = { version: "3", kind: NATIVE_DISPATCH_REGISTRY[purpose].scopeKind, scope_id: scopeId, cwd: outputRoot, entry: nativeEntryFor(purpose, vendor, entryVerb), ...(purpose === "setup" ? { bootstrap_root: bootstrapRoot } : {}), ...(wrenRuntime ? { wren_runtime: wrenRuntime } : {}), ...(binding ? { binding } : {}) };
539
+ const scopeDocument = JSON.stringify(scope);
540
+ writeFileSync(scopePath, scopeDocument, { encoding: "utf8", mode: 0o600, flag: "wx" });
541
+ const irDocument = input.prepareComponents && purpose === "analysis" ? readFileSync(irPath, "utf8") : undefined;
542
+ const prepared = irDocument ? input.prepareComponents?.({ vendor, purpose, irDocument, scope }) : undefined;
543
+ const hostPath = path.join(root, "component-host.json");
544
+ let receipt;
545
+ if (prepared && irDocument) {
546
+ if (prepared.identity.vendor !== vendor || !isDeepStrictEqual(prepared.identity.binding, binding))
547
+ throw new Error("Incompatible component probe identity");
548
+ writeFileSync(hostPath, JSON.stringify(nativeComponentHostContract(prepared)), { encoding: "utf8", mode: 0o600, flag: "wx" });
549
+ receipt = { identity: structuredClone(prepared.identity), irDocument, scopeDocument,
550
+ hostRoots: structuredClone(prepared.hostRoots), contexts: structuredClone(prepared.contexts) };
551
+ }
552
+ writeFileSync(mcpPath, JSON.stringify({ version: "1", url: "http://127.0.0.1:0/api/native-sessions/mcp", credential: "native-preflight-nonsecret" }), { encoding: "utf8", mode: 0o600, flag: "wx" });
553
+ const dispatched = await runNativeProbe(producerExecutable, ["dispatch", irPath, "--target", targetForProvider(vendor), "--purpose", purpose, "--native-scope", scopePath, "--native-mcp", mcpPath, ...(prepared ? ["--native-host", hostPath] : []), "--out", outputRoot], probeEnv);
554
+ if (dispatched.spawnFailed) {
555
+ outcome = fail("result=spawn_failed", "the Warble binary could not be started for this vendor");
556
+ break;
557
+ }
558
+ if (dispatched.timedOut) {
559
+ outcome = fail("result=timed_out", "the Warble binary did not respond for this vendor");
560
+ break;
561
+ }
562
+ if (dispatched.exitCode !== 0) {
563
+ const missingHost = !prepared && dispatched.output.includes("component_invocation")
564
+ && dispatched.output.includes("no trusted invocation handler is installed");
565
+ outcome = missingHost
566
+ ? fail("result=component_runtime_unprovisioned", "Native component execution has not been provisioned for this profile.")
567
+ : fail(`result=exit_code_${dispatched.exitCode ?? "signal"}`, "Warble could not dispatch this profile for this vendor");
568
+ break;
569
+ }
570
+ try {
571
+ readNativeLaunchSpec(outputRoot, purpose, vendor, scopeId, undefined, { version: "1", url: "http://127.0.0.1:0/api/native-sessions/mcp", credential: "native-preflight-nonsecret" }, undefined, undefined, undefined, receipt);
572
+ }
573
+ catch {
574
+ outcome = fail("result=launch_spec_incompatible", "Warble emitted a launch spec this build cannot run");
575
+ break;
576
+ }
554
577
  }
555
- catch {
556
- outcome = fail("result=launch_spec_incompatible", "Warble emitted a launch spec this build cannot run");
578
+ catch (error) {
579
+ outcome = error instanceof NativeWrenRuntimeError || error instanceof ManagedWrenRuntimeError
580
+ ? fail(`result=wren_runtime_unavailable (${error.message})`, error.message)
581
+ : fail("result=preflight_workspace_unavailable", "the preflight could not create its temporary workspace");
557
582
  break;
558
583
  }
584
+ finally {
585
+ if (root)
586
+ rmSync(root, { recursive: true, force: true });
587
+ }
559
588
  }
560
- catch (error) {
561
- outcome = error instanceof NativeWrenRuntimeError
562
- ? fail(`result=wren_runtime_unavailable (${error.message})`, error.message)
563
- : fail("result=preflight_workspace_unavailable", "the preflight could not create its temporary workspace");
564
- break;
565
- }
566
- finally {
567
- if (root)
568
- rmSync(root, { recursive: true, force: true });
569
- }
589
+ purposes[selectedPurpose][vendor] = outcome;
570
590
  }
571
- vendors[vendor] = outcome;
591
+ vendors[vendor] = NATIVE_PURPOSES.map((purpose) => purposes[purpose][vendor]).find((probe) => !probe.available)
592
+ ?? { available: true, diagnostic: producerDiagnostic(identity, `complete_${vendor}`, "result=compatible") };
572
593
  }
573
594
  const usable = NATIVE_PROBE_TARGETS.filter((vendor) => vendors[vendor].available);
574
595
  const available = usable.length > 0;
@@ -584,6 +605,7 @@ export async function probeNativeSessionProducer(input) {
584
605
  producer,
585
606
  diagnostic: producerDiagnostic(identity, "complete", available ? `result=compatible_${usable.join("+")}` : `result=no_compatible_vendor ${vendorResults}`),
586
607
  vendors,
608
+ purposes,
587
609
  };
588
610
  }
589
611
  function parseNativeSetupRecoveryReport(value) {
@@ -613,7 +635,7 @@ function parseNativeSetupRecoveryReport(value) {
613
635
  * omits the host-issued scope; Setup exposes only its separately authorized
614
636
  * bootstrap root, which is revalidated against the durable host configuration.
615
637
  */
616
- export function readNativeLaunchSpec(cwd, purpose, vendor, scopeId, binding, mcp, materializationState, authorizedWorkspace, providerLaunch) {
638
+ export function readNativeLaunchSpec(cwd, purpose, vendor, scopeId, binding, mcp, materializationState, authorizedWorkspace, providerLaunch, componentReceipt, selection) {
617
639
  const ownershipRoot = binding?.path ?? authorizedWorkspace;
618
640
  const root = materializationState && ownershipRoot
619
641
  ? validateNativeSessionWorkspace(materializationState, ownershipRoot, cwd)
@@ -629,19 +651,28 @@ export function readNativeLaunchSpec(cwd, purpose, vendor, scopeId, binding, mcp
629
651
  throw new InteractiveLaunchError("native session launch specification is invalid");
630
652
  }
631
653
  const value = record(raw);
654
+ const isV5 = componentReceipt !== undefined;
655
+ if (isV5 && (!mcp || purpose !== "analysis"))
656
+ throw new InteractiveLaunchError("native component launch requires a bound analysis session");
632
657
  const isV4 = mcp !== undefined;
633
658
  const isSetupV4 = isV4 && purpose === "setup";
634
- const keys = ["version", "target", "purpose", "executable", "argv", "agent", ...(isV4 ? ["mcp", ...(isSetupV4 ? ["bootstrap_root"] : [])] : ["scope"]), "cwd", "artifact_root", "handoff_path"];
659
+ const keys = ["version", "target", "purpose", "executable", "argv", "agent", ...(isV5 ? ["component_host"] : []), ...(isV4 ? ["mcp", ...(isSetupV4 ? ["bootstrap_root"] : [])] : ["scope"]), "cwd", "artifact_root", "handoff_path"];
635
660
  const target = targetForProvider(vendor);
636
661
  const executable = vendor === "claude" ? "claude" : "codex";
637
- const expectedAgent = agentFor(purpose, vendor);
638
- const scopeEntry = claudeScopeEntry(purpose, vendor);
662
+ const expectedAgent = expectedAgentFor(purpose, vendor, selection);
663
+ const scopeEntry = scopeEntryFor(purpose, vendor, selection);
639
664
  if (!value || !hasExactlyKeys(value, keys) ||
640
- value.version !== (isV4 ? "4" : "2") || value.target !== target || value.purpose !== purpose || value.executable !== executable ||
665
+ value.version !== (isV5 ? "5" : isV4 ? "4" : "2") || value.target !== target || value.purpose !== purpose || value.executable !== executable ||
641
666
  !Array.isArray(value.argv) || !value.argv.every((arg) => typeof arg === "string") || !record(value.agent) ||
642
667
  (!isV4 && !record(value.scope)) || (isV4 && !record(value.mcp)) || (isSetupV4 && typeof value.bootstrap_root !== "string") || typeof value.cwd !== "string" || typeof value.artifact_root !== "string" || typeof value.handoff_path !== "string") {
643
668
  throw new InteractiveLaunchError("native session launch specification is incompatible");
644
669
  }
670
+ if (componentReceipt) {
671
+ const planPath = path.join(root, ".warble", "component-plans.json");
672
+ if (lstatSync(planPath).isSymbolicLink() || !contained(root, realpathSync(planPath)))
673
+ throw new InteractiveLaunchError("native component plan is outside its scope");
674
+ readNativeComponentPlans(readFileSync(planPath, "utf8"), value.component_host, componentReceipt);
675
+ }
645
676
  const welcome = welcomePromptFor(purpose);
646
677
  const baseArgv = scopeEntry
647
678
  ? isV4 ? [welcome] : []
@@ -661,7 +692,7 @@ export function readNativeLaunchSpec(cwd, purpose, vendor, scopeId, binding, mcp
661
692
  // "agent" descriptor is a distinct kind (`claude_scope`) carrying the profile id rather than
662
693
  // `claude_agent`/`expectedAgent`. See `claudeScopeEntry` above.
663
694
  const expectedAgentKind = scopeEntry ? "claude_scope" : vendor === "claude" ? "claude_agent" : "codex_skill";
664
- const expectedAgentName = scopeEntry ? NATIVE_DISPATCH_REGISTRY[purpose].profile : expectedAgent;
695
+ const expectedAgentName = scopeEntry ? (customSelection(purpose, selection)?.id ?? NATIVE_DISPATCH_REGISTRY[purpose].profile) : expectedAgent;
665
696
  if (!hasExactlyKeys(agent, ["kind", "name"]) || (!isV4 && !validV2Scope) ||
666
697
  JSON.stringify(value.argv) !== JSON.stringify(baseArgv) || agent.kind !== expectedAgentKind || agent.name !== expectedAgentName ||
667
698
  (isV4 && (!launchMcp || !hasExactlyKeys(launchMcp, ["server_name", "credential_env_var"]) || launchMcp.server_name !== "genbi_session" || launchMcp.credential_env_var !== NATIVE_MCP_CREDENTIAL_ENV_VAR)))
@@ -716,7 +747,7 @@ export function readNativeLaunchSpec(cwd, purpose, vendor, scopeId, binding, mcp
716
747
  ? [...(scopeEntry ? [] : ["--agent", expectedAgent]), "--resume", providerLaunch.handle, ...(isV4 ? [welcome] : [])]
717
748
  : ["--dangerously-bypass-hook-trust", "-c", 'tui.resume_cwd="current"', "resume", providerLaunch.handle, ...(isV4 ? [welcome] : [])]
718
749
  : spawnBaseArgv;
719
- return { version: isV4 ? "4" : "2", target, executable, argv, cwd: root, artifact_root: root, handoff_path: handoffPath, ...(bootstrapRoot ? { bootstrap_root: bootstrapRoot } : {}) };
750
+ return { version: isV5 ? "5" : isV4 ? "4" : "2", target, executable, argv, cwd: root, artifact_root: root, handoff_path: handoffPath, ...(bootstrapRoot ? { bootstrap_root: bootstrapRoot } : {}) };
720
751
  }
721
752
  /**
722
753
  * Materializes only host-owned, short-lived producer inputs. The scope and
@@ -735,6 +766,7 @@ export async function dispatchNativeArtifacts(input) {
735
766
  const dir = mkdtempSync(path.join(tempRoot, "genbi-native-dispatch-"));
736
767
  const scopePath = path.join(dir, "scope.json");
737
768
  const mcpPath = path.join(dir, "mcp.json");
769
+ const hostPath = path.join(dir, "host.json");
738
770
  try {
739
771
  const directory = lstatSync(dir);
740
772
  if (!directory.isDirectory() || directory.isSymbolicLink() || !contained(tempRoot, dir))
@@ -750,18 +782,20 @@ export async function dispatchNativeArtifacts(input) {
750
782
  if (!metadata.isFile() || metadata.isSymbolicLink() || (metadata.mode & 0o777) !== 0o600)
751
783
  throw new InteractiveLaunchError("native session materialization failed");
752
784
  };
785
+ if (input.componentHost) {
786
+ writeFileSync(hostPath, JSON.stringify(input.componentHost), { encoding: "utf8", mode: 0o600, flag: "wx" });
787
+ validateDescriptorFile(hostPath);
788
+ }
753
789
  validateDescriptorFile(scopePath);
754
790
  if (input.mcp)
755
791
  validateDescriptorFile(mcpPath);
756
- const executable = input.producer?.executable ?? input.warbleBin;
757
- if (input.producer)
758
- assertNativeProducerIdentity(input.producer);
759
- const args = ["dispatch", input.irPath, "--target", input.target, "--purpose", input.purpose, "--native-scope", scopePath, ...(input.mcp ? ["--native-mcp", mcpPath] : []), "--out", input.cwd];
760
- await new Promise((resolve, reject) => {
761
- const child = spawn(executable, args, { cwd: input.cwd, shell: false, stdio: "ignore" });
762
- child.once("error", () => reject(new InteractiveLaunchError("native session materialization failed")));
763
- child.once("exit", (code) => code === 0 ? resolve() : reject(new InteractiveLaunchError("native session materialization failed")));
764
- });
792
+ assertNativeProducerIdentity(input.producer);
793
+ if (input.producer.executable !== input.warbleBin)
794
+ throw new InteractiveLaunchError("native session materialization failed");
795
+ const executable = input.producer.executable;
796
+ const args = ["dispatch", input.irPath, "--target", input.target, "--purpose", input.purpose, "--native-scope", scopePath, ...(input.mcp ? ["--native-mcp", mcpPath] : []), ...(input.componentHost ? ["--native-host", hostPath] : []), "--out", input.cwd];
797
+ await runNativeDispatchProcess({ executable, args, cwd: input.cwd, env: nativeProcessEnvironment(input.env),
798
+ ...(input.signal ? { signal: input.signal } : {}) }).catch(() => { throw new InteractiveLaunchError("native session materialization failed"); });
765
799
  }
766
800
  finally {
767
801
  rmSync(dir, { recursive: true, force: true });
@@ -769,7 +803,28 @@ export async function dispatchNativeArtifacts(input) {
769
803
  }
770
804
  export class NativeSessionService {
771
805
  options;
806
+ runtimeHost;
807
+ nativeHome;
808
+ wrenShim;
809
+ sourceWrenHome;
810
+ nodeExecutable;
811
+ producerExecutable;
812
+ vendorExecutables;
813
+ childToolDirectories;
814
+ conversations = new Map();
772
815
  sessions = new Map();
816
+ componentAdmissions = new Map();
817
+ componentCleanups = new Set();
818
+ componentCleanupFailed = false;
819
+ runtimeCleanupFailed = false;
820
+ pendingTerminalCleanup = new Set();
821
+ pendingHomeCleanup = new Set();
822
+ shuttingDown = false;
823
+ shutdownController = new AbortController();
824
+ shutdownPromise;
825
+ pendingCreates = new Set();
826
+ runtimeSpecs = new Map();
827
+ codexWrenHomes = new Map();
773
828
  leaseTimers = new Map();
774
829
  /** Open/reconnect coalescing only lasts while a matching launch is in flight. */
775
830
  launches = new Map();
@@ -786,6 +841,41 @@ export class NativeSessionService {
786
841
  fixtureMaterializationState;
787
842
  constructor(options) {
788
843
  this.options = options;
844
+ if (options.prepareComponents && !options.prepareComponentProbe)
845
+ throw new Error("Native component execution requires a producer probe");
846
+ this.runtimeHost = options.runtimeHost ?? new RuntimeHost({
847
+ // Compatibility default for server-side unit seams only. The real BFF
848
+ // composes and injects its one policy-owned host in `bin.ts`.
849
+ selected: "local",
850
+ deployment: "development",
851
+ localAvailable: options.terminalHostAvailable ?? (async () => true),
852
+ });
853
+ this.nativeHome = realpathSync(options.nativeHome ?? os.homedir());
854
+ this.wrenShim = options.wrenShim ?? path.join(this.nativeHome, ".local", "bin", "wren");
855
+ const capturedSourceWrenHome = options.sourceWrenHome
856
+ ? undefined
857
+ : process.env["WREN_HOME"]?.trim() || path.join(os.homedir(), ".wren");
858
+ this.sourceWrenHome = options.sourceWrenHome ?? (() => capturedSourceWrenHome);
859
+ this.nodeExecutable = options.nodeExecutable ?? attestNativeExecutable("node", process.execPath);
860
+ const capturedPath = options.pathValue;
861
+ this.producerExecutable = resolveNativeExecutable("producer", options.warbleBin, capturedPath);
862
+ const capturedVendors = {
863
+ ...options.vendorExecutables,
864
+ };
865
+ for (const vendor of NATIVE_VENDORS) {
866
+ if (!capturedVendors[vendor]) {
867
+ const resolved = resolveNativeExecutable("vendor", vendor, capturedPath)
868
+ ?? (this.legacyFixtureMode() ? attestNativeExecutable("vendor", process.execPath) : undefined);
869
+ if (resolved)
870
+ capturedVendors[vendor] = resolved;
871
+ }
872
+ }
873
+ this.vendorExecutables = Object.freeze(capturedVendors);
874
+ this.childToolDirectories = Object.freeze([...new Set([
875
+ ...(options.childToolDirectories ?? []),
876
+ path.dirname(this.nodeExecutable.executable),
877
+ ...Object.values(capturedVendors).filter((value) => value !== undefined).map((value) => path.dirname(value.executable)),
878
+ ])]);
789
879
  this.idleTtlMs = options.idleTtlMs ?? options.postClaimDetachGraceMs ?? NATIVE_SESSION_IDLE_TTL_MS;
790
880
  if (!Number.isSafeInteger(this.idleTtlMs) || this.idleTtlMs <= 0)
791
881
  throw new RangeError("native session idle TTL must be a positive integer");
@@ -823,11 +913,27 @@ export class NativeSessionService {
823
913
  async productionProducerPreflight() {
824
914
  if (this.legacyFixtureMode())
825
915
  return undefined;
826
- const result = await probeNativeSessionProducer({ warbleBin: this.options.warbleBin, irPaths: this.options.irPaths, ...(this.options.wrenShim !== undefined ? { wrenShim: this.options.wrenShim } : {}) });
916
+ const result = await probeNativeSessionProducer({
917
+ warbleBin: this.options.warbleBin,
918
+ irPaths: this.options.irPaths,
919
+ ...(this.options.prepareComponents && this.options.prepareComponentProbe ? { prepareComponents: this.options.prepareComponentProbe } : {}),
920
+ nativeHome: this.nativeHome,
921
+ nodeExecutable: this.nodeExecutable,
922
+ childToolDirectories: this.childToolDirectories,
923
+ ...(this.options.resolveManagedCodexWrenRuntime ? { resolveManagedCodexWrenRuntime: this.options.resolveManagedCodexWrenRuntime } : {}),
924
+ ...(this.options.pathValue !== undefined ? { pathValue: this.options.pathValue } : {}),
925
+ wrenShim: this.wrenShim,
926
+ });
827
927
  const log = result.available ? console.info : console.warn;
828
928
  log(`[native-sessions] producer preflight ${result.diagnostic}`);
829
929
  return result;
830
930
  }
931
+ async assertSelectedRuntimeReady() {
932
+ const readiness = await this.runtimeHost.probe();
933
+ if (readiness.selectedReadiness.state !== "ready")
934
+ throw new InteractiveLaunchError(readiness.selectedReadiness.message);
935
+ return readiness;
936
+ }
831
937
  /**
832
938
  * SQLite is the durable source of truth, but a native PTY and its attach
833
939
  * capability are deliberately process-local. A BFF restart (or a lease
@@ -850,7 +956,7 @@ export class NativeSessionService {
850
956
  // yield while the durable row is still being initialized. It has never
851
957
  // owned an attachable capability, so treating it as a missing terminal
852
958
  // would race creation and could terminalize a PTY that is about to start.
853
- if ((row.status !== "running" && row.status !== "detached") || this.sessions.has(row.id))
959
+ if ((row.status !== "running" && row.status !== "detached") || this.liveCapability(row.id))
854
960
  return row;
855
961
  // Commit the durable fence first. The cleanup below is best effort and
856
962
  // cannot leave a browser-facing row claiming that it can reattach.
@@ -863,10 +969,13 @@ export class NativeSessionService {
863
969
  this.recoveryCapabilities.delete(row.id);
864
970
  this.options.artifactService?.revoke(this.artifactCredentials.get(row.id));
865
971
  this.artifactCredentials.delete(row.id);
972
+ this.cleanupRuntime(row.id);
866
973
  return stopped;
867
974
  }
868
975
  /** A legacy persisted Runtime must be repaired before it can authorize a PTY. */
869
976
  assertRuntimeDispatchable() {
977
+ if (this.shuttingDown)
978
+ throw new InteractiveLaunchError("native session host is shutting down");
870
979
  if (!this.options.store.hasExplicitRuntimeSettings())
871
980
  return;
872
981
  const correction = runtimeSettingsCorrection(this.options.store.getRuntimeSettings());
@@ -890,14 +999,17 @@ export class NativeSessionService {
890
999
  if (!dispatch)
891
1000
  throw new InteractiveLaunchError("native sessions require a saved Runtime & authentication binding");
892
1001
  const binding = input.purpose === "setup" ? undefined : this.options.getBinding();
893
- const reusable = this.list().find((row) => this.matchesOpenScope(row, input.purpose, dispatch.target, runtime.generation, binding));
1002
+ const profileId = requestedProfileId(input.purpose, input.profile);
1003
+ const entryVerb = selectedNativeEntry(input.purpose, dispatch.provider, input.entryVerb);
1004
+ const reusable = this.list().find((row) => this.matchesOpenScope(row, input.purpose, dispatch.target, runtime.generation, binding, profileId) && selectedNativeEntry(row.purpose, row.vendor, row.entryVerb) === entryVerb);
894
1005
  if (reusable) {
895
1006
  const recoveryCapability = this.recoveryCapabilities.get(reusable.id);
896
- return { row: reusable, capability: this.sessions.get(reusable.id).capability, ...(recoveryCapability ? { recoveryCapability } : {}) };
1007
+ return { row: reusable, capability: this.liveCapability(reusable.id), ...(recoveryCapability ? { recoveryCapability } : {}) };
897
1008
  }
1009
+ const selectedEntry = selectedNativeEntry(input.purpose, dispatch.provider, input.entryVerb);
898
1010
  const scopeKey = input.purpose === "setup"
899
1011
  ? JSON.stringify([input.purpose, dispatch.target, runtime.generation, "bootstrap"])
900
- : JSON.stringify([input.purpose, dispatch.target, runtime.generation, binding?.identity ?? "unbound", binding?.generation ?? null, binding?.revision ?? null]);
1012
+ : JSON.stringify([input.purpose, dispatch.target, runtime.generation, binding?.identity ?? "unbound", binding?.generation ?? null, binding?.revision ?? null, selectedEntry, profileId]);
901
1013
  return this.singleFlight(`open:${scopeKey}`, scopeKey, input, binding, runtime);
902
1014
  }
903
1015
  /** Opens the exact row selected by the browser after revalidating its scope. */
@@ -915,7 +1027,7 @@ export class NativeSessionService {
915
1027
  if (!row || !this.matchesOpenScope(row, input.purpose, dispatch.target, runtime.generation, binding))
916
1028
  throw new InteractiveLaunchError("native session is unavailable");
917
1029
  const recoveryCapability = this.recoveryCapabilities.get(row.id);
918
- return { row, capability: this.sessions.get(row.id).capability, ...(recoveryCapability ? { recoveryCapability } : {}) };
1030
+ return { row, capability: this.liveCapability(row.id), ...(recoveryCapability ? { recoveryCapability } : {}) };
919
1031
  }
920
1032
  /**
921
1033
  * Starts a separate TUI even when a matching session is live. The caller's
@@ -933,9 +1045,11 @@ export class NativeSessionService {
933
1045
  if (!dispatch)
934
1046
  throw new InteractiveLaunchError("native sessions require a saved Runtime & authentication binding");
935
1047
  const binding = input.purpose === "setup" ? undefined : this.options.getBinding();
1048
+ const profileId = requestedProfileId(input.purpose, input.profile);
1049
+ const selectedEntry = selectedNativeEntry(input.purpose, dispatch.provider, input.entryVerb);
936
1050
  const scopeKey = input.purpose === "setup"
937
1051
  ? JSON.stringify([input.purpose, dispatch.target, runtime.generation, "bootstrap"])
938
- : JSON.stringify([input.purpose, dispatch.target, runtime.generation, binding?.identity ?? "unbound", binding?.generation ?? null, binding?.revision ?? null]);
1052
+ : JSON.stringify([input.purpose, dispatch.target, runtime.generation, binding?.identity ?? "unbound", binding?.generation ?? null, binding?.revision ?? null, selectedEntry, profileId]);
939
1053
  return this.replayStartSeparate(`start:${input.idempotencyKey}`, scopeKey, input, binding, runtime);
940
1054
  }
941
1055
  /**
@@ -974,6 +1088,8 @@ export class NativeSessionService {
974
1088
  }
975
1089
  /** Browser-visible resume state uses the same current-scope fence as launch, plus *why* when it fails. */
976
1090
  resumeAvailability(row) {
1091
+ if (row.transport === "conversation" || (row.vendor === "codex" && this.options.directCodex?.terminal))
1092
+ return { available: false, cause: "no_resume_handle" };
977
1093
  if (row.status !== "exited" && row.status !== "stopped" && row.status !== "interrupted" && row.status !== "failed" && row.status !== "stale") {
978
1094
  return { available: false, cause: "not_terminal" };
979
1095
  }
@@ -1000,7 +1116,7 @@ export class NativeSessionService {
1000
1116
  * no global Codex state and persists only a provider-bound encrypted envelope.
1001
1117
  */
1002
1118
  captureCodexResumeHandle(row) {
1003
- if (row.vendor !== "codex" || this.options.store.hasAvailableNativeSessionResume(row.id, "codex"))
1119
+ if (row.vendor !== "codex" || this.options.directCodex?.terminal || this.options.store.hasAvailableNativeSessionResume(row.id, "codex"))
1004
1120
  return;
1005
1121
  const state = this.materializationState();
1006
1122
  if (!state)
@@ -1029,7 +1145,12 @@ export class NativeSessionService {
1029
1145
  const runtime = !storedRuntime.configured && this.legacyFixtureMode()
1030
1146
  ? { configured: true, generation: 0, provider: row.vendor, target: targetForProvider(row.vendor), targetLabel: nativeTargetLabel(row.vendor) }
1031
1147
  : storedRuntime;
1032
- const dispatch = dispatchForPurpose(row.purpose, runtime);
1148
+ const registryDispatch = dispatchForPurpose(row.purpose, runtime);
1149
+ // An analysis row resumes inside the profile it ran in; whether that profile is still admitted is
1150
+ // the launch's question (it resolves the id again), not this scope fence's.
1151
+ const dispatch = registryDispatch && row.purpose === "analysis" && row.dispatchProfile !== null
1152
+ ? { ...registryDispatch, profile: row.dispatchProfile }
1153
+ : registryDispatch;
1033
1154
  const binding = row.purpose === "setup" ? undefined : this.options.getBinding();
1034
1155
  const matches = dispatch?.provider === row.vendor && row.dispatchProfile === dispatch.profile && row.dispatchTarget === dispatch.target && row.runtimeGeneration === runtime.generation
1035
1156
  && (row.purpose === "setup"
@@ -1072,7 +1193,7 @@ export class NativeSessionService {
1072
1193
  const reserved = this.options.store.reserveNativeSessionResume({
1073
1194
  sourceSessionId: source.id, idempotencyKey, scopeFingerprint: fingerprint, sealedHandle: sealedChild,
1074
1195
  child: {
1075
- id: childId, purpose: source.purpose, vendor: source.vendor, agent: agentFor(source.purpose, source.vendor), scopeKind: source.scopeKind, scopeId: childScopeId,
1196
+ id: childId, purpose: source.purpose, vendor: source.vendor, agent: source.agent, entryVerb: selectedNativeEntry(source.purpose, source.vendor, source.entryVerb), scopeKind: source.scopeKind, scopeId: childScopeId,
1076
1197
  dispatchProfile: scope.dispatch.profile, dispatchTarget: scope.dispatch.target, runtimeGeneration: scope.runtime.generation,
1077
1198
  ...(scope.binding ? { projectIdentity: scope.binding.identity, bindingGeneration: scope.binding.generation, projectRevision: scope.binding.revision } : {}),
1078
1199
  },
@@ -1081,7 +1202,7 @@ export class NativeSessionService {
1081
1202
  throw new InteractiveLaunchError("native session resume is unavailable");
1082
1203
  if (!reserved.created)
1083
1204
  return this.recoverReservedResume(reserved.row.id, scope);
1084
- return this.create({ purpose: source.purpose, ...(this.legacyFixtureMode() ? { vendor: source.vendor } : {}) }, scope.binding, scope.runtime, { row: reserved.row, handle });
1205
+ return this.create({ purpose: source.purpose, entryVerb: selectedNativeEntry(source.purpose, source.vendor, source.entryVerb), ...(source.purpose === "analysis" && source.dispatchProfile !== null ? { profile: source.dispatchProfile } : {}), ...(this.legacyFixtureMode() ? { vendor: source.vendor } : {}) }, scope.binding, scope.runtime, { row: reserved.row, handle });
1085
1206
  }
1086
1207
  async recoverReservedResume(id, scope) {
1087
1208
  const row = this.get(id);
@@ -1109,7 +1230,7 @@ export class NativeSessionService {
1109
1230
  this.failReservedResume(row.id, "native session resume reservation cannot be recovered");
1110
1231
  throw error;
1111
1232
  }
1112
- return this.create({ purpose: row.purpose, ...(this.legacyFixtureMode() ? { vendor: row.vendor } : {}) }, scope.binding, scope.runtime, { row, handle });
1233
+ return this.create({ purpose: row.purpose, entryVerb: selectedNativeEntry(row.purpose, row.vendor, row.entryVerb), ...(row.purpose === "analysis" && row.dispatchProfile !== null ? { profile: row.dispatchProfile } : {}), ...(this.legacyFixtureMode() ? { vendor: row.vendor } : {}) }, scope.binding, scope.runtime, { row, handle });
1113
1234
  }
1114
1235
  failReservedResume(id, failure) {
1115
1236
  const row = this.options.store.getNativeSession(id);
@@ -1117,8 +1238,11 @@ export class NativeSessionService {
1117
1238
  this.options.store.transitionNativeSession(id, "failed", { failure, ended: true });
1118
1239
  this.options.store.invalidateNativeSessionResume(id);
1119
1240
  }
1120
- matchesOpenScope(row, purpose, target, generation, binding) {
1121
- if (row.purpose !== purpose || row.dispatchTarget !== target || row.runtimeGeneration !== generation || !this.sessions.has(row.id))
1241
+ matchesOpenScope(row, purpose, target, generation, binding, profileId) {
1242
+ if (row.purpose !== purpose || row.dispatchTarget !== target || row.runtimeGeneration !== generation || !this.liveCapability(row.id))
1243
+ return false;
1244
+ // A live session inside another conversation profile is not this one, however its scope matches.
1245
+ if (profileId !== undefined && (row.dispatchProfile ?? NATIVE_DISPATCH_REGISTRY[purpose].profile) !== profileId)
1122
1246
  return false;
1123
1247
  if (row.status !== "creating" && row.status !== "running" && row.status !== "detached")
1124
1248
  return false;
@@ -1154,7 +1278,7 @@ export class NativeSessionService {
1154
1278
  // A successful response may contain an attach capability. Never replay it
1155
1279
  // after the owning PTY/capability has gone away; failures carry no terminal
1156
1280
  // capability and remain safely replayable within the same fenced scope.
1157
- return !replay.result?.capability || this.sessions.get(replay.result.row.id)?.capability === replay.result.capability;
1281
+ return !replay.result?.capability || this.liveCapability(replay.result.row.id) === replay.result.capability;
1158
1282
  }
1159
1283
  trimStartSeparateReplays() {
1160
1284
  for (const [key, replay] of this.startSeparateReplays) {
@@ -1190,12 +1314,110 @@ export class NativeSessionService {
1190
1314
  this.launches.delete(launchKey);
1191
1315
  }
1192
1316
  }
1317
+ /**
1318
+ * Resolves the profile a launch runs inside. The default needs no registry; any other id is
1319
+ * Claude-only (the Codex entry is a purpose skill with no scope form), accepts no entry verb
1320
+ * (its entry form was decided at admission), and must still be admitted right now.
1321
+ */
1322
+ async selectProfile(purpose, profile, provider, entryVerb, resumedRow) {
1323
+ const id = requestedProfileId(purpose, profile);
1324
+ if (resumedRow && (resumedRow.dispatchProfile ?? NATIVE_DISPATCH_REGISTRY[purpose].profile) !== id)
1325
+ throw new InteractiveLaunchError("native session profile changed during resume");
1326
+ // A system purpose always runs its registry profile; the analysis default needs no registry either.
1327
+ if (purpose !== "analysis")
1328
+ return { id, entry: { kind: "scope" } };
1329
+ if (id === DEFAULT_CONVERSATION_PROFILE)
1330
+ return DEFAULT_PROFILE_SELECTION;
1331
+ if (provider !== "claude")
1332
+ throw new InteractiveLaunchError(CODEX_CUSTOM_PROFILE_UNAVAILABLE);
1333
+ // An entry verb beside a selected profile was already refused by `selectedNativeEntry`: Claude
1334
+ // never takes one, and the Codex vendor was refused just above. Nothing to re-check here.
1335
+ void entryVerb;
1336
+ if (!this.options.resolveConversationProfile)
1337
+ throw new InteractiveLaunchError("conversation profile selection is not configured");
1338
+ try {
1339
+ const resolved = await this.options.resolveConversationProfile(id);
1340
+ if (resolved.id !== id)
1341
+ throw new InteractiveLaunchError("conversation profile resolution is inconsistent");
1342
+ return resolved;
1343
+ }
1344
+ catch (error) {
1345
+ if (error instanceof InteractiveLaunchError)
1346
+ throw error;
1347
+ throw new InteractiveLaunchError(error instanceof Error ? error.message : "conversation profile is unavailable");
1348
+ }
1349
+ }
1350
+ /** Analysis readiness, narrowed per conversation profile by the registry's verdict and the vendor. */
1351
+ async conversationProfileReadiness(analysis, provider) {
1352
+ if (!this.options.listConversationProfiles)
1353
+ return undefined;
1354
+ let summaries;
1355
+ try {
1356
+ summaries = await this.options.listConversationProfiles();
1357
+ }
1358
+ catch {
1359
+ return undefined;
1360
+ }
1361
+ const result = {};
1362
+ for (const summary of summaries) {
1363
+ const custom = summary.id !== DEFAULT_CONVERSATION_PROFILE;
1364
+ // A profile the registry refused is refused for good; say that before any runtime reason,
1365
+ // which may come and go, so a picker can tell the two apart.
1366
+ const reason = (!summary.selectable ? summary.reason ?? "this profile is unavailable" : undefined)
1367
+ ?? analysis.reason
1368
+ ?? (custom && provider !== undefined && provider !== "claude" ? CODEX_CUSTOM_PROFILE_UNAVAILABLE : undefined);
1369
+ result[summary.id] = {
1370
+ id: summary.id,
1371
+ scopeKind: "bound_project",
1372
+ profile: summary.id,
1373
+ ...(analysis.target !== undefined ? { target: analysis.target } : {}),
1374
+ ...(analysis.targetLabel !== undefined ? { targetLabel: analysis.targetLabel } : {}),
1375
+ available: analysis.available && reason === undefined,
1376
+ ...(reason !== undefined ? { reason } : {}),
1377
+ ...(custom
1378
+ ? { ...(summary.entryKind !== undefined ? { entryKind: summary.entryKind } : {}), ...(summary.entryVerb !== undefined ? { entryVerb: summary.entryVerb } : {}) }
1379
+ : { entryKind: provider === "codex" ? "agent" : "scope" }),
1380
+ };
1381
+ }
1382
+ return result;
1383
+ }
1193
1384
  async readiness() {
1194
1385
  const terminalHostAvailable = this.options.terminalHostAvailable ?? (async () => true);
1195
1386
  const executableAvailable = (vendor) => this.options.executableAvailable
1196
- ? this.options.executableAvailable(vendor)
1197
- : interactiveExecutableAvailable(vendor);
1387
+ ? this.options.executableAvailable(vendor) && this.vendorExecutables[vendor] !== undefined
1388
+ : this.vendorExecutables[vendor] !== undefined;
1389
+ const runtimeHostReadiness = await this.runtimeHost.probe();
1390
+ if (runtimeHostReadiness.selected === "codex-app-server") {
1391
+ const runtime = this.options.store.getNativeRuntimeBinding();
1392
+ const binding = this.options.getBinding();
1393
+ const reason = runtimeHostReadiness.selectedReadiness.state !== "ready" ? runtimeHostReadiness.selectedReadiness.message
1394
+ : this.options.store.hasExplicitRuntimeSettings() && runtimeSettingsCorrection(this.options.store.getRuntimeSettings()) ? runtimeSettingsCorrection(this.options.store.getRuntimeSettings())
1395
+ : !this.options.directCodex ? "Direct Codex preparation is unavailable."
1396
+ : !runtime.configured || runtime.provider !== "codex" ? "Select a Codex Runtime binding."
1397
+ : !binding ? "Bind a project before starting a conversation."
1398
+ : this.options.directCodex.terminal && !this.options.directCodex.terminalCertified?.("answer_query") ? "Native terminal version is not certified."
1399
+ : this.options.directCodex.terminal && !(await terminalHostAvailable()) ? "Native terminal host is unavailable." : undefined;
1400
+ const purposes = Object.fromEntries(NATIVE_PURPOSES.map((purpose) => {
1401
+ const unavailable = reason ?? (purpose !== "analysis" ? "This purpose is not supported by direct Codex sessions." : undefined);
1402
+ return [purpose, { scopeKind: NATIVE_DISPATCH_REGISTRY[purpose].scopeKind, profile: NATIVE_DISPATCH_REGISTRY[purpose].profile,
1403
+ target: "codex:interactive", targetLabel: "Codex CLI", available: !unavailable, ...(unavailable ? { reason: unavailable } : {}),
1404
+ vendors: { codex: { available: !unavailable }, claude: { available: false } } }];
1405
+ }));
1406
+ const profiles = await this.conversationProfileReadiness(purposes.analysis, "codex");
1407
+ return { runtime, runtimeHost: runtimeHostReadiness, purposes, ...(profiles ? { profiles } : {}), ...purposes, mcp: { server: "GenBI MCP", tool: "save_dashboard", destination: "GenBI Artifacts", available: false, reason: "Direct conversations use host-owned tools." } };
1408
+ }
1198
1409
  const hostAvailable = await terminalHostAvailable();
1410
+ const selectedRuntimeUnavailable = runtimeHostReadiness.selectedReadiness.state === "ready"
1411
+ ? undefined
1412
+ : runtimeHostReadiness.selectedReadiness.message;
1413
+ const codexBackendReadiness = runtimeHostReadiness.backends["codex-app-server"];
1414
+ const codexWrenRuntimeUnavailable = this.legacyFixtureMode()
1415
+ ? undefined
1416
+ : this.options.resolveManagedCodexWrenRuntime === undefined
1417
+ ? CODEX_WREN_RUNTIME_UNPROVISIONED.message
1418
+ : codexBackendReadiness.state === "ready"
1419
+ ? undefined
1420
+ : codexBackendReadiness.message;
1199
1421
  const mcpReadiness = this.options.artifactService?.readiness();
1200
1422
  const runtime = this.options.store.getNativeRuntimeBinding();
1201
1423
  const runtimeCorrection = this.options.store.hasExplicitRuntimeSettings()
@@ -1203,7 +1425,7 @@ export class NativeSessionService {
1203
1425
  : undefined;
1204
1426
  const legacyFixture = !runtime.configured && this.legacyFixtureMode();
1205
1427
  const producer = this.legacyFixtureMode() ? undefined : await this.productionProducerPreflight();
1206
- const legacyProducerAvailable = this.options.producerAvailable ?? (() => this.options.dispatch !== undefined || localExecutableAvailable(this.options.warbleBin));
1428
+ const legacyProducerAvailable = this.options.producerAvailable ?? (() => this.options.dispatch !== undefined || this.producerExecutable !== undefined);
1207
1429
  const result = {};
1208
1430
  for (const purpose of NATIVE_PURPOSES) {
1209
1431
  const definition = NATIVE_DISPATCH_REGISTRY[purpose];
@@ -1223,21 +1445,22 @@ export class NativeSessionService {
1223
1445
  // Only the configured vendor's own probe may block this purpose. Probing
1224
1446
  // both and collapsing them into one boolean let an unselected vendor veto
1225
1447
  // the selected one.
1226
- const selectedProducer = producer && dispatch ? producer.vendors[dispatch.provider] : undefined;
1227
- const reason = runtimeCorrection
1228
- ?? (!dispatch && !legacyFixture
1229
- ? "native sessions require a saved Runtime & authentication binding"
1230
- : !cwd
1231
- ? purpose === "setup" ? "native setup sessions require a workspace root" : "native sessions require a current bound project"
1232
- : workspaceUnavailable
1233
- ? "native session workspace is unavailable"
1234
- : selectedProducer && !selectedProducer.available
1235
- ? selectedProducer.reason ?? "native session producer is incompatible"
1236
- : !irPath || !(this.options.dispatch !== undefined || configuredIrPath(irPath)) || !legacyProducerAvailable()
1237
- ? "native session materialization prerequisites are unavailable"
1238
- : mcpReadiness && !mcpReadiness.available
1239
- ? mcpReadiness.reason
1240
- : undefined);
1448
+ const selectedProducer = producer && dispatch ? (producer.purposes?.[purpose][dispatch.provider] ?? producer.vendors[dispatch.provider]) : undefined;
1449
+ const sharedReason = !dispatch && !legacyFixture
1450
+ ? "native sessions require a saved Runtime & authentication binding"
1451
+ : !cwd
1452
+ ? purpose === "setup" ? "native setup sessions require a workspace root" : "native sessions require a current bound project"
1453
+ : workspaceUnavailable
1454
+ ? "native session workspace is unavailable"
1455
+ : selectedProducer && !selectedProducer.available
1456
+ ? selectedProducer.reason ?? "native session producer is incompatible"
1457
+ : !irPath || !(this.options.dispatch !== undefined || configuredIrPath(irPath)) || !legacyProducerAvailable()
1458
+ ? "native session materialization prerequisites are unavailable"
1459
+ : mcpReadiness && !mcpReadiness.available
1460
+ ? mcpReadiness.reason
1461
+ : undefined;
1462
+ const vendorRuntimeUnavailable = dispatch?.provider === "codex" ? codexWrenRuntimeUnavailable : undefined;
1463
+ const reason = runtimeCorrection ?? selectedRuntimeUnavailable ?? vendorRuntimeUnavailable ?? sharedReason;
1241
1464
  const executable = dispatch?.provider === "claude" ? "claude" : "codex";
1242
1465
  const unavailable = reason ?? (!hostAvailable
1243
1466
  ? "native terminal host cannot spawn local processes on this machine"
@@ -1249,12 +1472,14 @@ export class NativeSessionService {
1249
1472
  // one is unusable and why instead of only that "the producer" is.
1250
1473
  if (!legacyFixture && producer)
1251
1474
  for (const vendor of NATIVE_VENDORS) {
1252
- const probe = producer.vendors[vendor];
1253
- vendorReadiness[vendor] = probe?.available === false
1254
- ? { available: false, reason: probe.reason ?? "native session producer is incompatible" }
1255
- : !executableAvailable(vendor)
1256
- ? { available: false, reason: `the ${vendor === "claude" ? "claude" : "codex"} interactive CLI is not available on this machine` }
1257
- : { available: true };
1475
+ const probe = producer.purposes?.[purpose][vendor] ?? producer.vendors[vendor];
1476
+ vendorReadiness[vendor] = vendor === "codex" && codexWrenRuntimeUnavailable
1477
+ ? { available: false, reason: codexWrenRuntimeUnavailable }
1478
+ : probe?.available === false
1479
+ ? { available: false, reason: probe.reason ?? "native session producer is incompatible" }
1480
+ : !executableAvailable(vendor)
1481
+ ? { available: false, reason: `the ${vendor === "claude" ? "claude" : "codex"} interactive CLI is not available on this machine` }
1482
+ : { available: true };
1258
1483
  }
1259
1484
  if (legacyFixture)
1260
1485
  for (const vendor of NATIVE_VENDORS) {
@@ -1292,9 +1517,12 @@ export class NativeSessionService {
1292
1517
  }));
1293
1518
  return legacy;
1294
1519
  }
1520
+ const profiles = await this.conversationProfileReadiness(result.analysis, runtime.configured ? runtime.provider : undefined);
1295
1521
  return {
1296
1522
  runtime,
1523
+ runtimeHost: runtimeHostReadiness,
1297
1524
  purposes: result,
1525
+ ...(profiles ? { profiles } : {}),
1298
1526
  mcp: this.options.artifactService?.health() ?? {
1299
1527
  server: "GenBI MCP",
1300
1528
  tool: "save_dashboard",
@@ -1307,19 +1535,55 @@ export class NativeSessionService {
1307
1535
  }
1308
1536
  async create(input, binding = input.purpose === "setup" ? undefined : this.options.getBinding(), capturedRuntime = this.options.store.getNativeRuntimeBinding(), resumed) {
1309
1537
  this.assertRuntimeDispatchable();
1538
+ const pending = this.createCaptured(input, binding, capturedRuntime, resumed);
1539
+ this.pendingCreates.add(pending);
1540
+ void pending.finally(() => this.pendingCreates.delete(pending)).catch(() => { });
1541
+ return pending;
1542
+ }
1543
+ async createCaptured(input, binding = input.purpose === "setup" ? undefined : this.options.getBinding(), capturedRuntime = this.options.store.getNativeRuntimeBinding(), resumed) {
1544
+ this.assertRuntimeDispatchable();
1545
+ const runtimeReadiness = await this.assertSelectedRuntimeReady();
1310
1546
  const purpose = input.purpose;
1311
1547
  const legacyRuntime = !capturedRuntime.configured && input.vendor
1312
1548
  ? { configured: true, generation: 0, provider: input.vendor, target: targetForProvider(input.vendor), targetLabel: nativeTargetLabel(input.vendor) }
1313
1549
  : capturedRuntime;
1314
- const dispatchDefinition = dispatchForPurpose(purpose, legacyRuntime);
1315
- if (!dispatchDefinition)
1550
+ const registryDispatch = dispatchForPurpose(purpose, legacyRuntime);
1551
+ if (!registryDispatch)
1316
1552
  throw new InteractiveLaunchError("native sessions require a saved Runtime & authentication binding");
1553
+ const entryVerb = selectedNativeEntry(purpose, registryDispatch.provider, input.entryVerb);
1554
+ if (resumed && selectedNativeEntry(purpose, registryDispatch.provider, resumed.row.entryVerb) !== entryVerb)
1555
+ throw new InteractiveLaunchError("native session entry changed during resume");
1556
+ const selection = await this.selectProfile(purpose, input.profile, registryDispatch.provider, input.entryVerb, resumed?.row);
1557
+ // The launch tuple names the selected profile; everything else is the registry's for the purpose.
1558
+ const dispatchDefinition = { ...registryDispatch, profile: selection.id };
1559
+ if (runtimeReadiness.selected === "codex-app-server") {
1560
+ if (dispatchDefinition.provider !== "codex" || purpose !== "analysis" || !binding || resumed || !this.options.directCodex || customSelection(purpose, selection)) {
1561
+ throw new InteractiveLaunchError("direct Codex session preparation is unavailable");
1562
+ }
1563
+ return this.createDirectCodex(binding, legacyRuntime, entryVerb);
1564
+ }
1565
+ if (runtimeReadiness.selected !== "local")
1566
+ throw new InteractiveLaunchError("native session backend is unavailable");
1567
+ let managedCodexWrenRuntime;
1568
+ if (dispatchDefinition.provider === "codex" && !this.legacyFixtureMode()) {
1569
+ const codexReadiness = runtimeReadiness.backends["codex-app-server"];
1570
+ if (codexReadiness.state !== "ready")
1571
+ throw new InteractiveLaunchError(codexReadiness.message);
1572
+ if (!this.options.resolveManagedCodexWrenRuntime)
1573
+ throw new InteractiveLaunchError(CODEX_WREN_RUNTIME_UNPROVISIONED.message);
1574
+ try {
1575
+ managedCodexWrenRuntime = this.options.resolveManagedCodexWrenRuntime();
1576
+ }
1577
+ catch {
1578
+ throw new InteractiveLaunchError(CODEX_WREN_RUNTIME_UNPROVISIONED.message);
1579
+ }
1580
+ }
1317
1581
  if (purpose !== "setup" && !binding)
1318
1582
  throw new InteractiveLaunchError("native session requires a current bound project");
1319
1583
  const configuredCwd = binding?.path ?? this.options.workspaceRoot;
1320
1584
  const configuredIr = this.options.irPaths[purpose];
1321
1585
  if (!configuredCwd || !configuredIr)
1322
- throw new InteractiveLaunchError(`native ${dispatchDefinition.profile} session is not configured`);
1586
+ throw new InteractiveLaunchError(`native ${selection.id} session is not configured`);
1323
1587
  // Compile for this binding rather than dispatching the profile's golden.
1324
1588
  // Falls back to the configured path only where no resolver is wired (the
1325
1589
  // fixture-mode tests), never silently in production — see `bin.ts`.
@@ -1331,14 +1595,14 @@ export class NativeSessionService {
1331
1595
  try {
1332
1596
  irPath = this.options.resolveDispatchIr === undefined
1333
1597
  ? configuredIr
1334
- : await this.options.resolveDispatchIr(purpose, binding);
1598
+ : await this.options.resolveDispatchIr(purpose, binding, selection.sourceDir);
1335
1599
  }
1336
1600
  catch (error) {
1337
1601
  logNativeLaunchFailure(purpose, dispatchDefinition.provider, "dispatch_ir", error);
1338
1602
  throw error;
1339
1603
  }
1340
1604
  const producer = this.legacyFixtureMode() ? undefined : await this.productionProducerPreflight();
1341
- const launchProducer = producer ? producer.vendors[dispatchDefinition.provider] : undefined;
1605
+ const launchProducer = producer ? (producer.purposes?.[purpose][dispatchDefinition.provider] ?? producer.vendors[dispatchDefinition.provider]) : undefined;
1342
1606
  if (producer && (!launchProducer?.available || !producer.producer)) {
1343
1607
  throw new InteractiveLaunchError(launchProducer?.reason ?? "native session producer is incompatible");
1344
1608
  }
@@ -1358,7 +1622,8 @@ export class NativeSessionService {
1358
1622
  initialSealedHandle = sealNativeClaudeResumeHandle(state, id, sessionId);
1359
1623
  providerLaunch = { kind: "initial", sessionId };
1360
1624
  }
1361
- const row = resumed?.row ?? this.options.store.createNativeSession({ id, purpose, vendor: dispatchDefinition.provider, agent: agentFor(purpose, dispatchDefinition.provider), scopeKind: dispatchDefinition.scopeKind, scopeId,
1625
+ this.assertRuntimeDispatchable();
1626
+ const row = resumed?.row ?? this.options.store.createNativeSession({ id, purpose, vendor: dispatchDefinition.provider, agent: expectedAgentFor(purpose, dispatchDefinition.provider, selection), entryVerb, scopeKind: dispatchDefinition.scopeKind, scopeId,
1362
1627
  dispatchProfile: dispatchDefinition.profile, dispatchTarget: dispatchDefinition.target, runtimeGeneration: legacyRuntime.generation,
1363
1628
  ...(binding ? { projectIdentity: binding.identity, bindingGeneration: binding.generation, projectRevision: binding.revision } : {}) });
1364
1629
  if (initialSealedHandle)
@@ -1384,7 +1649,7 @@ export class NativeSessionService {
1384
1649
  if (!authorizedWorkspace || !materializationState)
1385
1650
  throw new InteractiveLaunchError("native session workspace is unavailable");
1386
1651
  const cwd = createNativeSessionWorkspace(materializationState, authorizedWorkspace, id);
1387
- const producerAvailable = this.options.producerAvailable ?? (() => this.options.dispatch !== undefined || localExecutableAvailable(this.options.warbleBin));
1652
+ const producerAvailable = this.options.producerAvailable ?? (() => this.options.dispatch !== undefined || this.producerExecutable !== undefined);
1388
1653
  if (!(this.options.dispatch !== undefined || configuredIrPath(irPath)) || !producerAvailable())
1389
1654
  throw new InteractiveLaunchError("native session materialization prerequisites are unavailable");
1390
1655
  const mcpReadiness = artifactService?.readiness();
@@ -1394,19 +1659,58 @@ export class NativeSessionService {
1394
1659
  if (mcp)
1395
1660
  this.artifactCredentials.set(id, mcp.credential);
1396
1661
  let wrenRuntime;
1397
- try {
1398
- wrenRuntime = dispatchDefinition.provider === "codex" && !this.legacyFixtureMode() ? resolveNativeWrenRuntime(this.options.wrenShim) : undefined;
1399
- }
1400
- catch (error) {
1401
- if (error instanceof NativeWrenRuntimeError)
1402
- throw new InteractiveLaunchError("native Wren runtime is unavailable");
1403
- throw error;
1662
+ wrenRuntime = managedCodexWrenRuntime;
1663
+ const vendorExecutable = this.vendorExecutables[dispatchDefinition.provider];
1664
+ if (!vendorExecutable)
1665
+ throw new InteractiveLaunchError(`the ${dispatchDefinition.provider} interactive CLI is not available on this machine`);
1666
+ const producerExecutable = producer?.producer
1667
+ ? attestNativeExecutable("producer", producer.producer.executable)
1668
+ : this.options.dispatch !== undefined
1669
+ ? attestNativeExecutable("producer", this.nodeExecutable.executable)
1670
+ : resolveNativeExecutable("producer", this.options.warbleBin, undefined);
1671
+ if (!producerExecutable)
1672
+ throw new InteractiveLaunchError("native session materialization prerequisites are unavailable");
1673
+ const toolDirectories = [...new Set([
1674
+ ...this.childToolDirectories,
1675
+ path.dirname(producerExecutable.executable),
1676
+ ...(wrenRuntime ? [path.join(wrenRuntime.tool_root, "bin"), path.dirname(wrenRuntime.interpreter)] : []),
1677
+ ])];
1678
+ const dispatchEnvironment = buildNativeChildEnvironment({
1679
+ toolDirectories,
1680
+ home: this.nativeHome,
1681
+ ...(binding ? { projectPath: binding.path } : {}),
1682
+ ...(mcp ? { mcpCredential: mcp.credential } : {}),
1683
+ ...(purpose === "setup" ? { setupBootstrapRoot: authorizedWorkspace } : {}),
1684
+ });
1685
+ const scope = { version: "3", kind: row.scopeKind, scope_id: scopeId, cwd, entry: entryFor(purpose, dispatchDefinition.provider, entryVerb, selection), ...(purpose === "setup" ? { bootstrap_root: authorizedWorkspace } : {}), ...(wrenRuntime ? { wren_runtime: wrenRuntime } : {}), ...(binding ? { binding: { project_identity: binding.identity, generation: String(binding.generation), revision: binding.revision } } : {}) };
1686
+ const componentIr = this.options.prepareComponents && binding && purpose === "analysis" ? readFileSync(irPath, "utf8") : undefined;
1687
+ const preparedComponents = componentIr && binding ? await this.options.prepareComponents({ session: row, binding, irDocument: componentIr, scope }) : undefined;
1688
+ if (preparedComponents) {
1689
+ const identity = preparedComponents.identity;
1690
+ if (!mcp || identity.session_id !== id || identity.vendor !== row.vendor || identity.runtime_generation !== String(row.runtimeGeneration)
1691
+ || identity.binding.project_identity !== binding?.identity || identity.binding.generation !== String(binding?.generation) || identity.binding.revision !== binding?.revision)
1692
+ throw new InteractiveLaunchError("native component execution identity is incompatible");
1404
1693
  }
1405
- const scope = { version: "3", kind: row.scopeKind, scope_id: scopeId, cwd, entry: nativeEntryFor(purpose, dispatchDefinition.provider), ...(purpose === "setup" ? { bootstrap_root: authorizedWorkspace } : {}), ...(wrenRuntime ? { wren_runtime: wrenRuntime } : {}), ...(binding ? { binding: { project_identity: binding.identity, generation: String(binding.generation), revision: binding.revision } } : {}) };
1694
+ const componentReceipt = preparedComponents && componentIr ? {
1695
+ identity: structuredClone(preparedComponents.identity), irDocument: componentIr, scopeDocument: JSON.stringify(scope),
1696
+ hostRoots: structuredClone(preparedComponents.hostRoots), contexts: structuredClone(preparedComponents.contexts),
1697
+ } : undefined;
1406
1698
  const dispatch = this.options.dispatch ?? dispatchNativeArtifacts;
1407
1699
  validateNativeSessionWorkspace(materializationState, authorizedWorkspace, cwd);
1408
1700
  launchPhase = "dispatch";
1409
- await dispatch({ warbleBin: producer?.producer?.executable ?? this.options.warbleBin, ...(producer?.producer ? { producer: producer.producer } : {}), irPath, target: dispatchDefinition.target, cwd, purpose, scope, ...(mcp ? { mcp } : {}) });
1701
+ await dispatch({
1702
+ warbleBin: producerExecutable.executable,
1703
+ producer: producer?.producer ?? { executable: producerExecutable.executable, identity: producerExecutable.digest },
1704
+ irPath,
1705
+ target: dispatchDefinition.target,
1706
+ cwd,
1707
+ purpose,
1708
+ scope,
1709
+ ...(preparedComponents ? { componentHost: nativeComponentHostContract(preparedComponents) } : {}),
1710
+ ...(mcp ? { mcp } : {}),
1711
+ env: dispatchEnvironment,
1712
+ signal: this.shutdownController.signal,
1713
+ });
1410
1714
  if (!input.vendor && !sameNativeRuntimeBinding(capturedRuntime, this.options.store.getNativeRuntimeBinding())) {
1411
1715
  this.options.store.transitionNativeSession(id, "stale", { failure: "native runtime binding changed before launch", ended: true });
1412
1716
  throw new InteractiveLaunchError("native session is stale because the runtime binding changed");
@@ -1416,20 +1720,75 @@ export class NativeSessionService {
1416
1720
  throw new InteractiveLaunchError("native session is stale because the bound project changed");
1417
1721
  }
1418
1722
  launchPhase = "launch_spec";
1419
- const spec = readNativeLaunchSpec(cwd, purpose, dispatchDefinition.provider, scopeId, binding, mcp, materializationState, authorizedWorkspace, providerLaunch);
1723
+ const launchSpec = readNativeLaunchSpec(cwd, purpose, dispatchDefinition.provider, scopeId, binding, mcp, materializationState, authorizedWorkspace, providerLaunch, componentReceipt, selection);
1724
+ if (componentReceipt && preparedComponents) {
1725
+ const launch = JSON.parse(readFileSync(path.join(cwd, ".warble", "interactive-launch.json"), "utf8"));
1726
+ const plans = readNativeComponentPlans(readFileSync(path.join(cwd, ".warble", "component-plans.json"), "utf8"), launch.component_host, componentReceipt);
1727
+ const current = () => {
1728
+ const live = this.options.store.getNativeSession(id);
1729
+ if (!live || !["creating", "running", "detached"].includes(live.status) || !sameBinding(binding, this.options.getBinding())
1730
+ || (!input.vendor && !sameNativeRuntimeBinding(capturedRuntime, this.options.store.getNativeRuntimeBinding())))
1731
+ return undefined;
1732
+ try {
1733
+ preparedComponents.assertCurrent();
1734
+ }
1735
+ catch {
1736
+ return undefined;
1737
+ }
1738
+ return componentReceipt.identity;
1739
+ };
1740
+ this.assertRuntimeDispatchable();
1741
+ this.componentAdmissions.set(id, new NativeComponentAdmission(plans, current, (root, plan, signal) => preparedComponents.host(root, plan, signal)));
1742
+ }
1420
1743
  let codexWrenHome;
1421
- if (spec.version === "4" && dispatchDefinition.provider === "codex" && binding) {
1422
- if (wrenRuntime) {
1744
+ // Hosted v5 entries route work through fixed MCP roots. Their outer Codex
1745
+ // session retains the producer's read-only, network-disabled profile; only
1746
+ // the direct v4 route receives a Wren home and bound-project read grant.
1747
+ if (launchSpec.version === "4" && dispatchDefinition.provider === "codex") {
1748
+ if (wrenRuntime && binding) {
1423
1749
  launchPhase = "codex_wren_home";
1424
- codexWrenHome = (this.options.prepareCodexWrenHome ?? ((input) => materializeCodexWrenHome(input.runtime, input.projectPath, input.cwd)))({
1750
+ codexWrenHome = (this.options.prepareCodexWrenHome ?? materializeCodexWrenHome)({
1425
1751
  runtime: wrenRuntime,
1426
1752
  projectPath: binding.path,
1427
1753
  cwd,
1754
+ sourceWrenHome: this.sourceWrenHome(),
1755
+ home: this.nativeHome,
1756
+ toolDirectories,
1428
1757
  });
1429
1758
  }
1430
- launchPhase = "codex_project_read";
1431
- grantCodexBoundProjectRead(cwd, binding.path, codexWrenHome?.dataRoots);
1759
+ else if (wrenRuntime && purpose === "setup") {
1760
+ launchPhase = "codex_wren_home";
1761
+ codexWrenHome = createEmptyCodexWrenHome(cwd);
1762
+ }
1763
+ if (binding) {
1764
+ launchPhase = "codex_project_read";
1765
+ grantCodexBoundProjectRead(cwd, binding.path, codexWrenHome?.dataRoots);
1766
+ }
1432
1767
  }
1768
+ codexWrenHome?.assertActive?.();
1769
+ if (codexWrenHome)
1770
+ this.codexWrenHomes.set(id, codexWrenHome);
1771
+ const executableIdentities = [this.nodeExecutable, producerExecutable, vendorExecutable];
1772
+ if (wrenRuntime) {
1773
+ executableIdentities.push(attestNativeExecutable("wren", wrenRuntime.launcher), attestNativeExecutable("python", wrenRuntime.interpreter));
1774
+ }
1775
+ // Legacy Claude tools use the same server-selected profile store as Setup.
1776
+ // Hosted v5 entries receive only their broker and must not inherit this store.
1777
+ const claudeWrenHome = launchSpec.version === "4" && dispatchDefinition.provider === "claude"
1778
+ && binding && this.options.sourceWrenHome ? this.sourceWrenHome() : undefined;
1779
+ const runtimeSpec = buildNativeRuntimeSpec({
1780
+ backend: runtimeReadiness.selected,
1781
+ vendor: dispatchDefinition.provider,
1782
+ executables: executableIdentities,
1783
+ toolDirectories,
1784
+ workspace: cwd,
1785
+ home: this.nativeHome,
1786
+ ...(binding ? { binding } : {}),
1787
+ ...(codexWrenHome ? { sessionWrenHome: codexWrenHome.home } : claudeWrenHome ? { sessionWrenHome: claudeWrenHome } : {}),
1788
+ ...(dispatchDefinition.provider === "codex" && this.options.codexHome ? { codexHome: this.options.codexHome } : {}),
1789
+ ...(mcp ? { mcpCredential: mcp.credential } : {}),
1790
+ ...(launchSpec.bootstrap_root ? { setupBootstrapRoot: launchSpec.bootstrap_root } : {}),
1791
+ });
1433
1792
  launchPhase = "terminal_manager";
1434
1793
  const terminalManager = await this.options.terminalManager();
1435
1794
  if (binding && !sameBinding(binding, this.options.getBinding())) {
@@ -1441,20 +1800,13 @@ export class NativeSessionService {
1441
1800
  throw new InteractiveLaunchError("native session is stale because the runtime binding changed");
1442
1801
  }
1443
1802
  validateNativeSessionWorkspace(materializationState, authorizedWorkspace, cwd);
1444
- const terminalEnv = {
1445
- ...process.env,
1446
- ...(mcp ? { [NATIVE_MCP_CREDENTIAL_ENV_VAR]: mcp.credential } : {}),
1447
- ...(binding ? { WREN_PROJECT_HOME: binding.path } : {}),
1448
- ...(codexWrenHome ? { WREN_HOME: codexWrenHome.home } : {}),
1449
- };
1450
- // Never inherit a caller/operator value: only the launch spec's already
1451
- // revalidated Setup root may reach the native TUI.
1452
- delete terminalEnv[NATIVE_SETUP_BOOTSTRAP_ROOT_ENV_VAR];
1453
- if (spec.bootstrap_root)
1454
- terminalEnv[NATIVE_SETUP_BOOTSTRAP_ROOT_ENV_VAR] = spec.bootstrap_root;
1803
+ codexWrenHome?.assertActive?.();
1804
+ assertNativeRuntimeSpec(runtimeSpec);
1805
+ this.assertRuntimeDispatchable();
1455
1806
  launchPhase = "terminal_start";
1456
- const terminal = terminalManager.start(spec, { id, capability }, terminalEnv, NO_INTERACTIVE_TERMINAL_LEASES);
1807
+ const terminal = terminalManager.start({ ...launchSpec, hostExecutable: vendorExecutable.executable }, { id, capability }, runtimeSpec.childEnvironment, NO_INTERACTIVE_TERMINAL_LEASES);
1457
1808
  this.sessions.set(id, terminal);
1809
+ this.runtimeSpecs.set(id, runtimeSpec);
1458
1810
  // Mark running before registering a replaying exit listener. A PTY can
1459
1811
  // exit between start() and this registration; `onExit` immediately
1460
1812
  // replays that state and must win over this transition, never be
@@ -1469,6 +1821,7 @@ export class NativeSessionService {
1469
1821
  this.recoveryCapabilities.delete(id);
1470
1822
  artifactService?.revoke(this.artifactCredentials.get(id));
1471
1823
  this.artifactCredentials.delete(id);
1824
+ this.cleanupRuntime(id);
1472
1825
  const current = this.options.store.getNativeSession(id);
1473
1826
  if (current && (current.status === "running" || current.status === "detached"))
1474
1827
  this.options.store.transitionNativeSession(id, "exited", { exitCode, ended: true });
@@ -1479,6 +1832,7 @@ export class NativeSessionService {
1479
1832
  }
1480
1833
  catch (error) {
1481
1834
  logNativeLaunchFailure(purpose, dispatchDefinition.provider, launchPhase, error);
1835
+ this.cleanupRuntime(id);
1482
1836
  this.recoveryCapabilities.delete(id);
1483
1837
  artifactService?.revoke(this.artifactCredentials.get(id));
1484
1838
  this.artifactCredentials.delete(id);
@@ -1500,6 +1854,153 @@ export class NativeSessionService {
1500
1854
  throw error;
1501
1855
  }
1502
1856
  }
1857
+ liveCapability(id) {
1858
+ return this.sessions.get(id)?.capability ?? this.conversations.get(id)?.conversation.capability;
1859
+ }
1860
+ async createDirectCodex(binding, runtime, entryVerb) {
1861
+ const provisioner = this.options.directCodex;
1862
+ if (provisioner.terminal && !provisioner.terminalCertified?.(entryVerb))
1863
+ throw new InteractiveLaunchError("Native terminal version is not certified");
1864
+ const permit = provisioner.backend.prepareLaunch(); // before rows, credentials or workspace writes
1865
+ const capturedBinding = structuredClone(binding);
1866
+ const capturedRuntime = structuredClone(runtime);
1867
+ const id = newId("native-session");
1868
+ const controller = new AbortController();
1869
+ const signal = AbortSignal.any([controller.signal, this.shutdownController.signal]);
1870
+ let prepared;
1871
+ let conversation;
1872
+ let renderer;
1873
+ let closing;
1874
+ const assertActive = () => {
1875
+ signal.throwIfAborted();
1876
+ this.assertRuntimeDispatchable();
1877
+ const row = this.options.store.getNativeSession(id);
1878
+ if (!sameBinding(capturedBinding, this.options.getBinding()) || !sameNativeRuntimeBinding(capturedRuntime, this.options.store.getNativeRuntimeBinding())
1879
+ || (row && !["creating", "running", "detached"].includes(row.status)))
1880
+ throw new InteractiveLaunchError("native session scope is stale");
1881
+ };
1882
+ const close = () => closing ??= (async () => {
1883
+ controller.abort();
1884
+ const results = await Promise.allSettled([renderer?.close(), conversation?.close(), prepared?.input.tools.close()]);
1885
+ // Keep materialized resources if process/tool cleanup could not be confirmed.
1886
+ if (results.some((result) => result.status === "rejected"))
1887
+ throw new InteractiveLaunchError("native conversation cleanup failed");
1888
+ await prepared?.dispose();
1889
+ })();
1890
+ try {
1891
+ assertActive();
1892
+ permit.assertActive();
1893
+ const row = this.options.store.createNativeSession({ id, ...(!provisioner.terminal ? { transport: "conversation" } : {}), purpose: "analysis", vendor: "codex", agent: agentFor("analysis", "codex"),
1894
+ entryVerb, scopeKind: "bound_project", scopeId: newId("native-scope"), dispatchProfile: NATIVE_DISPATCH_REGISTRY.analysis.profile,
1895
+ dispatchTarget: "codex:interactive", runtimeGeneration: capturedRuntime.generation,
1896
+ projectIdentity: capturedBinding.identity, bindingGeneration: capturedBinding.generation, projectRevision: capturedBinding.revision });
1897
+ prepared = await provisioner.prepare({ session: row, binding: capturedBinding, permit, signal, assertActive });
1898
+ assertActive();
1899
+ permit.assertActive();
1900
+ prepared.input.tools.assertCurrent();
1901
+ const hostGuard = prepared.input.assertScopeActive;
1902
+ conversation = new CodexConversation(provisioner.backend, permit, { ...prepared.input,
1903
+ assertScopeActive: () => { assertActive(); hostGuard(); },
1904
+ }, (state, failure) => {
1905
+ if (state !== "closed" && state !== "failed")
1906
+ return;
1907
+ const current = this.options.store.getNativeSession(id);
1908
+ if (current && ["creating", "running", "detached"].includes(current.status)) {
1909
+ this.options.store.transitionNativeSession(id, state === "failed" ? "failed" : "exited", { ended: true,
1910
+ ...(failure ? { failure: `Codex conversation ${failure}` } : {}) });
1911
+ }
1912
+ // Schedule after transition delivers its terminal frame to the attachment.
1913
+ queueMicrotask(() => this.revokeCapabilities([id]));
1914
+ });
1915
+ this.conversations.set(id, { conversation, assertActive, close });
1916
+ await conversation.ready;
1917
+ assertActive();
1918
+ if (provisioner.terminal) {
1919
+ const vendor = prepared.input.spec.executables.vendor;
1920
+ if (!vendor || !prepared.input.model)
1921
+ throw new InteractiveLaunchError("Native terminal configuration is unavailable");
1922
+ renderer = await startCodexTerminal({ id, conversation, manager: await this.options.terminalManager(), vendor,
1923
+ cwd: prepared.input.spec.workspace, model: prepared.input.model, assertActive });
1924
+ this.sessions.set(id, renderer.terminal);
1925
+ }
1926
+ this.options.store.transitionNativeSession(id, "running", { started: true });
1927
+ if (renderer) {
1928
+ renderer.terminal.onExit((exitCode) => {
1929
+ const current = this.options.store.getNativeSession(id);
1930
+ if (current && ["creating", "running", "detached"].includes(current.status)) {
1931
+ this.options.store.transitionNativeSession(id, exitCode === 0 ? "exited" : "failed", { ended: true,
1932
+ ...(exitCode !== 0 ? { failure: "Codex terminal exited" } : {}) });
1933
+ }
1934
+ queueMicrotask(() => this.revokeCapabilities([id]));
1935
+ });
1936
+ }
1937
+ assertActive();
1938
+ this.armLease(id, "native session attachment timed out", NATIVE_SESSION_INITIAL_ATTACHMENT_GRACE_MS);
1939
+ return { row: this.options.store.getNativeSession(id), capability: conversation.capability };
1940
+ }
1941
+ catch (error) {
1942
+ const row = this.options.store.getNativeSession(id);
1943
+ if (row && ["creating", "running", "detached"].includes(row.status))
1944
+ this.options.store.transitionNativeSession(id, "failed", { ended: true, failure: "Direct Codex launch failed" });
1945
+ this.sessions.delete(id);
1946
+ this.conversations.delete(id);
1947
+ permit.release();
1948
+ try {
1949
+ await close();
1950
+ }
1951
+ catch {
1952
+ this.componentCleanupFailed = true;
1953
+ throw new InteractiveLaunchError("native conversation cleanup failed");
1954
+ }
1955
+ throw error;
1956
+ }
1957
+ }
1958
+ attachConversation(id, capability, listener, afterSequence = 0) {
1959
+ if (this.options.directCodex?.terminal)
1960
+ return undefined;
1961
+ const direct = this.conversations.get(id);
1962
+ if (!direct)
1963
+ return undefined;
1964
+ let attachment;
1965
+ try {
1966
+ direct.assertActive();
1967
+ attachment = direct.conversation.attach(capability, listener, afterSequence);
1968
+ }
1969
+ catch {
1970
+ return undefined;
1971
+ }
1972
+ if (!["ready", "running"].includes(direct.conversation.snapshot().state)) {
1973
+ attachment.detach();
1974
+ return undefined;
1975
+ }
1976
+ this.clearLease(id);
1977
+ this.options.store.transitionNativeSession(id, "running");
1978
+ let detached = false;
1979
+ const check = () => { if (detached)
1980
+ throw new InteractiveLaunchError("native attachment is unavailable"); direct.assertActive(); };
1981
+ return { submit: (text) => { check(); return attachment.submit(text); }, interrupt: () => { check(); return attachment.interrupt(); },
1982
+ detach: () => {
1983
+ if (detached)
1984
+ return;
1985
+ detached = true;
1986
+ attachment.detach();
1987
+ if (this.conversations.get(id) === direct && ["ready", "running"].includes(direct.conversation.snapshot().state)) {
1988
+ this.options.store.transitionNativeSession(id, "detached");
1989
+ this.armLease(id, "native session idle TTL expired", this.idleTtlMs);
1990
+ }
1991
+ } };
1992
+ }
1993
+ async stopAndWait(id, capability) {
1994
+ const direct = this.conversations.get(id);
1995
+ if (!direct)
1996
+ return this.stop(id, capability);
1997
+ if (direct.conversation.capability !== capability)
1998
+ return false;
1999
+ this.options.store.stopNativeSessionAndRevokeRecoveryAction(id);
2000
+ this.revokeCapabilities([id]);
2001
+ await direct.close();
2002
+ return true;
2003
+ }
1503
2004
  attach(id, capability) {
1504
2005
  const row = this.get(id);
1505
2006
  const runtime = this.options.store.getNativeRuntimeBinding();
@@ -1517,6 +2018,7 @@ export class NativeSessionService {
1517
2018
  return terminal;
1518
2019
  }
1519
2020
  detach(id) {
2021
+ this.componentAdmissions.get(id)?.cancelActive();
1520
2022
  this.sessions.get(id)?.detach();
1521
2023
  if (this.sessions.has(id)) {
1522
2024
  this.options.store.transitionNativeSession(id, "detached");
@@ -1544,12 +2046,25 @@ export class NativeSessionService {
1544
2046
  this.options.artifactService?.revoke(this.artifactCredentials.get(id));
1545
2047
  this.artifactCredentials.delete(id);
1546
2048
  this.sessions.delete(id);
2049
+ const direct = this.conversations.get(id);
2050
+ this.conversations.delete(id);
2051
+ if (direct) {
2052
+ const cleanup = direct.close();
2053
+ this.componentCleanups.add(cleanup);
2054
+ void cleanup.catch(() => { this.componentCleanupFailed = true; }).finally(() => this.componentCleanups.delete(cleanup));
2055
+ }
2056
+ this.cleanupRuntime(id);
1547
2057
  try {
1548
2058
  terminal?.close();
2059
+ if (terminal)
2060
+ this.pendingTerminalCleanup.delete(terminal);
1549
2061
  }
1550
2062
  catch {
1551
2063
  // A process kill may fail after the durable session fence committed.
1552
2064
  // Capabilities are already unreachable; continue revoking every id.
2065
+ this.runtimeCleanupFailed = true;
2066
+ if (terminal)
2067
+ this.pendingTerminalCleanup.add(terminal);
1553
2068
  console.warn("[native-sessions] terminal close failed during capability revocation");
1554
2069
  }
1555
2070
  }
@@ -1558,6 +2073,81 @@ export class NativeSessionService {
1558
2073
  revokeRuntimeCapabilities(ids) { this.revokeCapabilities(ids); }
1559
2074
  /** Called after the store advances the bound-project generation. */
1560
2075
  revokeBindingCapabilities(ids) { this.revokeCapabilities(ids); }
2076
+ componentTools(id) { return this.componentAdmissions.get(id)?.list() ?? []; }
2077
+ async callComponent(id, tool, input, requestId, signal) {
2078
+ const admission = this.componentAdmissions.get(id);
2079
+ if (!admission)
2080
+ throw new InteractiveLaunchError("native component execution is unavailable");
2081
+ return admission.call(tool, input, requestId, signal);
2082
+ }
2083
+ async drainComponentCleanup() {
2084
+ const cleanup = await Promise.allSettled([...this.componentCleanups]);
2085
+ if (this.componentCleanupFailed || cleanup.some((result) => result.status === "rejected"))
2086
+ throw new InteractiveLaunchError("native component cleanup failed");
2087
+ }
2088
+ shutdown() {
2089
+ this.shuttingDown = true;
2090
+ this.shutdownController.abort();
2091
+ return this.shutdownPromise ??= (async () => {
2092
+ const ids = [...new Set([...this.sessions.keys(), ...this.conversations.keys(), ...this.componentAdmissions.keys()])];
2093
+ for (const id of ids)
2094
+ this.options.store.stopNativeSessionAndRevokeRecoveryAction(id);
2095
+ this.revokeCapabilities(ids);
2096
+ let timer;
2097
+ try {
2098
+ await Promise.race([Promise.allSettled([...this.pendingCreates]), new Promise((_, reject) => {
2099
+ timer = setTimeout(() => reject(new InteractiveLaunchError("native launch cleanup timed out")), 5_000);
2100
+ })]);
2101
+ }
2102
+ finally {
2103
+ clearTimeout(timer);
2104
+ }
2105
+ this.revokeCapabilities([...this.componentAdmissions.keys()]);
2106
+ // Failed resources remain owned after capability revocation, for one final retry.
2107
+ for (const terminal of this.pendingTerminalCleanup) {
2108
+ try {
2109
+ terminal.close();
2110
+ this.pendingTerminalCleanup.delete(terminal);
2111
+ }
2112
+ catch {
2113
+ this.runtimeCleanupFailed = true;
2114
+ }
2115
+ }
2116
+ for (const cleanup of this.pendingHomeCleanup) {
2117
+ try {
2118
+ cleanup();
2119
+ this.pendingHomeCleanup.delete(cleanup);
2120
+ }
2121
+ catch {
2122
+ this.runtimeCleanupFailed = true;
2123
+ }
2124
+ }
2125
+ await this.drainComponentCleanup();
2126
+ if (this.runtimeCleanupFailed)
2127
+ throw new InteractiveLaunchError("native runtime cleanup failed");
2128
+ })();
2129
+ }
2130
+ cleanupRuntime(id) {
2131
+ const admission = this.componentAdmissions.get(id);
2132
+ this.componentAdmissions.delete(id);
2133
+ if (admission) {
2134
+ const cleanup = admission.close();
2135
+ this.componentCleanups.add(cleanup);
2136
+ void cleanup.catch(() => { this.componentCleanupFailed = true; console.warn("[native-sessions] component cleanup failed"); }).finally(() => this.componentCleanups.delete(cleanup));
2137
+ }
2138
+ const home = this.codexWrenHomes.get(id);
2139
+ this.codexWrenHomes.delete(id);
2140
+ this.runtimeSpecs.delete(id);
2141
+ try {
2142
+ home?.cleanup?.();
2143
+ }
2144
+ catch {
2145
+ this.runtimeCleanupFailed = true;
2146
+ if (home?.cleanup)
2147
+ this.pendingHomeCleanup.add(home.cleanup.bind(home));
2148
+ console.warn("[native-sessions] Wren session home cleanup failed");
2149
+ }
2150
+ }
1561
2151
  /**
1562
2152
  * Records only the producer's closed redacted report. Completion remains a
1563
2153
  * host decision supplied by the canonical-artifact validator at the route.
@@ -1622,14 +2212,19 @@ export class NativeSessionService {
1622
2212
  }
1623
2213
  armLease(id, reason, timeoutMs) {
1624
2214
  this.clearLease(id);
1625
- this.leaseTimers.set(id, setTimeout(() => {
1626
- const terminal = this.sessions.get(id);
1627
- if (!terminal) {
2215
+ const timer = setTimeout(() => {
2216
+ if (this.options.store.isClosed()) {
2217
+ this.leaseTimers.delete(id);
2218
+ return;
2219
+ }
2220
+ if (!this.liveCapability(id)) {
1628
2221
  this.leaseTimers.delete(id);
1629
2222
  return;
1630
2223
  }
1631
2224
  this.options.store.transitionNativeSession(id, "stopped", { failure: reason, ended: true });
1632
2225
  this.revokeCapabilities([id]);
1633
- }, timeoutMs));
2226
+ }, timeoutMs);
2227
+ timer.unref();
2228
+ this.leaseTimers.set(id, timer);
1634
2229
  }
1635
2230
  }