@wrenai/genbi 0.0.3 → 0.0.5

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 (230) 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 +13 -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 +69 -30
  93. package/dist-server/harness/route/codex-host-query.d.ts +52 -0
  94. package/dist-server/harness/route/codex-host-query.js +191 -0
  95. package/dist-server/harness/route/codex-local-manifest.d.ts +9 -1
  96. package/dist-server/harness/route/codex-local-manifest.js +96 -15
  97. package/dist-server/harness/route/codex-wren-proxy.d.ts +33 -0
  98. package/dist-server/harness/route/codex-wren-proxy.js +227 -0
  99. package/dist-server/harness/route/describe.js +45 -6
  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 +5 -2
  103. package/dist-server/harness/route/types.d.ts +30 -1
  104. package/dist-server/harness/session/run.js +30 -10
  105. package/dist-server/harness/setup/runner.d.ts +2 -0
  106. package/dist-server/harness/setup/runner.js +38 -17
  107. package/dist-server/harness/setup/yaml-scalar.d.ts +23 -0
  108. package/dist-server/harness/setup/yaml-scalar.js +62 -0
  109. package/dist-server/server/adopt.js +1 -17
  110. package/dist-server/server/app.d.ts +1 -0
  111. package/dist-server/server/app.js +163 -14
  112. package/dist-server/server/auth-choice.d.ts +13 -1
  113. package/dist-server/server/auth-choice.js +66 -1
  114. package/dist-server/server/bin.js +197 -26
  115. package/dist-server/server/codex-local-grounding.d.ts +18 -0
  116. package/dist-server/server/codex-local-grounding.js +92 -0
  117. package/dist-server/server/codex-native-session.d.ts +25 -0
  118. package/dist-server/server/codex-native-session.js +1 -0
  119. package/dist-server/server/codex-runtime-composition.d.ts +34 -0
  120. package/dist-server/server/codex-runtime-composition.js +250 -0
  121. package/dist-server/server/codex-structured-ask.d.ts +23 -0
  122. package/dist-server/server/codex-structured-ask.js +102 -0
  123. package/dist-server/server/conn-config.js +4 -16
  124. package/dist-server/server/conversation-socket.d.ts +16 -0
  125. package/dist-server/server/conversation-socket.js +65 -0
  126. package/dist-server/server/db.d.ts +46 -0
  127. package/dist-server/server/db.js +88 -9
  128. package/dist-server/server/env-detect.d.ts +6 -0
  129. package/dist-server/server/env-detect.js +7 -0
  130. package/dist-server/server/fold.d.ts +22 -4
  131. package/dist-server/server/fold.js +37 -5
  132. package/dist-server/server/harness.d.ts +2 -2
  133. package/dist-server/server/harness.js +33 -7
  134. package/dist-server/server/interactive-terminal.d.ts +10 -6
  135. package/dist-server/server/interactive-terminal.js +33 -8
  136. package/dist-server/server/managed-wren-runtime.d.ts +116 -0
  137. package/dist-server/server/managed-wren-runtime.js +670 -0
  138. package/dist-server/server/native-artifacts.d.ts +23 -7
  139. package/dist-server/server/native-artifacts.js +43 -12
  140. package/dist-server/server/native-component-context.d.ts +19 -0
  141. package/dist-server/server/native-component-context.js +76 -0
  142. package/dist-server/server/native-component-preparation.d.ts +20 -0
  143. package/dist-server/server/native-component-preparation.js +129 -0
  144. package/dist-server/server/native-component-runtime.d.ts +36 -0
  145. package/dist-server/server/native-component-runtime.js +92 -0
  146. package/dist-server/server/native-components.d.ts +84 -0
  147. package/dist-server/server/native-components.js +160 -0
  148. package/dist-server/server/native-dispatch-process.d.ts +8 -0
  149. package/dist-server/server/native-dispatch-process.js +65 -0
  150. package/dist-server/server/native-runtime-spec.d.ts +89 -0
  151. package/dist-server/server/native-runtime-spec.js +169 -0
  152. package/dist-server/server/native-sessions.d.ts +172 -19
  153. package/dist-server/server/native-sessions.js +870 -251
  154. package/dist-server/server/native-shutdown.d.ts +6 -0
  155. package/dist-server/server/native-shutdown.js +28 -0
  156. package/dist-server/server/native-wren-home.d.ts +18 -0
  157. package/dist-server/server/native-wren-home.js +262 -0
  158. package/dist-server/server/node-pty-host.d.ts +2 -1
  159. package/dist-server/server/node-pty-host.js +3 -2
  160. package/dist-server/server/profile-registry.d.ts +139 -0
  161. package/dist-server/server/profile-registry.js +620 -0
  162. package/dist-server/server/runtime-binding.d.ts +4 -1
  163. package/dist-server/server/runtime-binding.js +50 -2
  164. package/dist-server/server/runtime-host/claude-component-step.d.ts +25 -0
  165. package/dist-server/server/runtime-host/claude-component-step.js +186 -0
  166. package/dist-server/server/runtime-host/codex-app-server.d.ts +75 -0
  167. package/dist-server/server/runtime-host/codex-app-server.js +239 -0
  168. package/dist-server/server/runtime-host/codex-compatibility.d.ts +110 -0
  169. package/dist-server/server/runtime-host/codex-compatibility.js +162 -0
  170. package/dist-server/server/runtime-host/codex-component-step.d.ts +19 -0
  171. package/dist-server/server/runtime-host/codex-component-step.js +308 -0
  172. package/dist-server/server/runtime-host/codex-conversation.d.ts +109 -0
  173. package/dist-server/server/runtime-host/codex-conversation.js +295 -0
  174. package/dist-server/server/runtime-host/codex-direct-tools.d.ts +23 -0
  175. package/dist-server/server/runtime-host/codex-direct-tools.js +59 -0
  176. package/dist-server/server/runtime-host/codex-events.d.ts +647 -0
  177. package/dist-server/server/runtime-host/codex-events.js +66 -0
  178. package/dist-server/server/runtime-host/codex-policy.d.ts +16 -0
  179. package/dist-server/server/runtime-host/codex-policy.js +151 -0
  180. package/dist-server/server/runtime-host/codex-process.d.ts +8 -0
  181. package/dist-server/server/runtime-host/codex-process.js +96 -0
  182. package/dist-server/server/runtime-host/codex-rpc.d.ts +47 -0
  183. package/dist-server/server/runtime-host/codex-rpc.js +182 -0
  184. package/dist-server/server/runtime-host/codex-session.d.ts +74 -0
  185. package/dist-server/server/runtime-host/codex-session.js +492 -0
  186. package/dist-server/server/runtime-host/codex-terminal-protocol.d.ts +35 -0
  187. package/dist-server/server/runtime-host/codex-terminal-protocol.js +243 -0
  188. package/dist-server/server/runtime-host/codex-terminal.d.ts +16 -0
  189. package/dist-server/server/runtime-host/codex-terminal.js +120 -0
  190. package/dist-server/server/runtime-host/index.d.ts +5 -0
  191. package/dist-server/server/runtime-host/index.js +3 -0
  192. package/dist-server/server/runtime-host/local.d.ts +29 -0
  193. package/dist-server/server/runtime-host/local.js +90 -0
  194. package/dist-server/server/runtime-host/policy.d.ts +8 -0
  195. package/dist-server/server/runtime-host/policy.js +78 -0
  196. package/dist-server/server/runtime-host/types.d.ts +52 -0
  197. package/dist-server/server/runtime-host/types.js +62 -0
  198. package/dist-server/server/structured-query-evidence.d.ts +7 -0
  199. package/dist-server/server/structured-query-evidence.js +47 -0
  200. package/dist-server/server/structured-runtime.d.ts +52 -0
  201. package/dist-server/server/structured-runtime.js +130 -0
  202. package/dist-server/server/structured-scope.d.ts +4 -0
  203. package/dist-server/server/structured-scope.js +12 -0
  204. package/dist-server/server/turn.d.ts +6 -1
  205. package/dist-server/server/turn.js +82 -25
  206. package/dist-server/server/wire-types.d.ts +67 -3
  207. package/dist-server/server/wren-profiles.js +1 -1
  208. package/managed-wren/README.md +60 -0
  209. package/managed-wren/dependency-wheels.json +282 -0
  210. package/managed-wren/manifest.json +373 -0
  211. package/managed-wren/release-inputs.json +45 -0
  212. package/managed-wren/runtime-tree.cjs +115 -0
  213. package/managed-wren/source-inputs.json +40 -0
  214. package/package.json +15 -6
  215. package/profiles/genbi-default/context/binding.yml +17 -7
  216. package/profiles/genbi-default/context/context.json +146 -0
  217. package/profiles/genbi-default/ir.golden.json +39 -43
  218. package/profiles/genbi-default/profile.yml +31 -0
  219. package/profiles/genbi-enrich-context/context/binding.yml +14 -3
  220. package/profiles/genbi-enrich-context/context/context.json +146 -0
  221. package/profiles/genbi-enrich-context/ir.golden.json +4 -5
  222. package/profiles/genbi-monitor/context/binding.yml +13 -5
  223. package/profiles/genbi-monitor/context/context.json +146 -0
  224. package/profiles/genbi-monitor/ir.golden.json +13 -7
  225. package/profiles/genbi-report/context/binding.yml +19 -0
  226. package/profiles/genbi-report/context/context.json +223 -0
  227. package/profiles/genbi-report/ir.golden.json +328 -0
  228. package/profiles/genbi-report/profile.yml +38 -0
  229. package/profiles/genbi-setup/ir.golden.json +3 -1
  230. package/dist/assets/index-Xs4q7pNo.css +0 -1
@@ -1,20 +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";
10
- import { InteractiveLaunchError, NO_INTERACTIVE_TERMINAL_LEASES, interactiveExecutableAvailable } from "./interactive-terminal.js";
14
+ import { isDeepStrictEqual } from "node:util";
15
+ import { WarbleBinaryNotFoundError } from "../harness/compile/errors.js";
16
+ import { InteractiveLaunchError, NO_INTERACTIVE_TERMINAL_LEASES } from "./interactive-terminal.js";
11
17
  import { newId } from "./db.js";
12
18
  import { NATIVE_MCP_CREDENTIAL_ENV_VAR } from "./native-artifacts.js";
13
19
  import { dispatchForPurpose, NATIVE_DISPATCH_REGISTRY, NATIVE_PURPOSES, nativeTargetLabel, sameNativeRuntimeBinding, targetForProvider } from "./native-dispatch-registry.js";
14
20
  import { createNativeSessionWorkspace, initializeNativeSessionStateBase, nativeSessionStateBaseAvailable, nativeSessionStateBaseAvailableForProject, validateNativeSessionWorkspace } from "./native-session-workspace.js";
15
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";
16
25
  import { runtimeSettingsCorrection } from "./runtime-binding.js";
17
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";
18
29
  export const NATIVE_VENDORS = ["claude", "codex"];
19
30
  export { NATIVE_PURPOSES };
20
31
  export const NATIVE_SETUP_RECOVERY_MCP_TOOL_NAME = "report_setup_recovery";
@@ -38,12 +49,15 @@ const START_SEPARATE_REPLAY_LIMIT = 128;
38
49
  const CODEX_NATIVE_PERMISSION_HEADER = "[permissions.warble_native_wren.filesystem]";
39
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;
40
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");
41
53
  function logNativeLaunchFailure(purpose, vendor, phase, error) {
42
- const category = error instanceof InteractiveLaunchError ? "rejected" : "unexpected";
43
- // Keep the precise boundary server-side without logging exception text,
44
- // paths, credentials, argv, or other launch material. Browser/API callers
45
- // continue through nativeSessionLaunchFailure's closed public projection.
46
- console.error(`[native-sessions] launch failed purpose=${purpose} vendor=${vendor} phase=${phase} category=${category}`);
54
+ const rejected = error instanceof InteractiveLaunchError;
55
+ // Exception text, paths, credentials and argv stay out of the log for an
56
+ // unexpected error, which may carry any of them. A rejection carries a
57
+ // sentence authored here, and suppressing that turned every diagnosis into a
58
+ // guess: an operator saw a phase and a category and no cause.
59
+ const detail = rejected ? ` reason=${error.message}` : "";
60
+ console.error(`[native-sessions] launch failed purpose=${purpose} vendor=${vendor} phase=${phase} category=${rejected ? "rejected" : "unexpected"}${detail}`);
47
61
  }
48
62
  /**
49
63
  * The producer deliberately sees only a sealed binding identity, never a caller-supplied path.
@@ -71,60 +85,6 @@ export function grantCodexBoundProjectRead(cwd, projectPath, dataRoots = []) {
71
85
  writeFileSync(configPath, updated, { encoding: "utf8", mode: 0o600 });
72
86
  chmodSync(configPath, 0o600);
73
87
  }
74
- const MATERIALIZE_WREN_HOME_SCRIPT = String.raw `
75
- import os
76
- import pathlib
77
- import shutil
78
- import sys
79
- import yaml
80
- from wren.profile import expand_profile_secrets, resolve_profile_for_project
81
-
82
- project = pathlib.Path(sys.argv[1]).resolve(strict=True)
83
- destination = pathlib.Path(sys.argv[2])
84
- name, profile = resolve_profile_for_project(project, strict=True)
85
- if not name or not profile:
86
- raise SystemExit(2)
87
- destination.mkdir(mode=0o700, parents=False, exist_ok=False)
88
- profile_path = destination / "profiles.yml"
89
- profile_path.write_text(yaml.safe_dump({"active": name, "profiles": {name: profile}}, sort_keys=False), encoding="utf-8")
90
- os.chmod(profile_path, 0o600)
91
- project_env = project / ".env"
92
- if project_env.exists():
93
- if project_env.is_symlink() or not project_env.is_file():
94
- raise SystemExit(3)
95
- session_env = destination / ".env"
96
- shutil.copyfile(project_env, session_env)
97
- os.chmod(session_env, 0o600)
98
-
99
- expanded = expand_profile_secrets(profile)
100
- data_source = expanded.get("datasource")
101
- data_root = None
102
- if data_source in ("duckdb", "local_file"):
103
- data_root = expanded.get("url")
104
- elif data_source == "datafusion":
105
- data_root = expanded.get("source")
106
- if data_root:
107
- root = pathlib.Path(data_root).expanduser()
108
- if not root.is_absolute():
109
- root = project / root
110
- print(root.resolve(strict=True))
111
- `;
112
- function materializeCodexWrenHome(runtime, projectPath, cwd) {
113
- const destination = path.join(cwd, ".wren");
114
- try {
115
- const output = execFileSync(runtime.venv_python, ["-c", MATERIALIZE_WREN_HOME_SCRIPT, projectPath, destination], {
116
- cwd: projectPath,
117
- env: process.env,
118
- encoding: "utf8",
119
- stdio: ["ignore", "pipe", "ignore"],
120
- timeout: 10_000,
121
- });
122
- return { home: destination, dataRoots: output.trim() ? [output.trim()] : [] };
123
- }
124
- catch {
125
- throw new InteractiveLaunchError("native Wren project profile is unavailable");
126
- }
127
- }
128
88
  function nativeSessionRestartReason(target, cause) {
129
89
  switch (cause) {
130
90
  case "not_terminal":
@@ -172,6 +132,13 @@ export class NativeSetupRecoveryError extends Error {
172
132
  * whichever verb the scope declares, after checking the declaration against the compiled IR.
173
133
  */
174
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
+ }
175
142
  const agentFor = (purpose, vendor) => vendor === "claude" ? entryVerbFor(purpose) : `genbi-${purpose === "context_enrichment" ? "enrich-context" : purpose}`;
176
143
  /**
177
144
  * Closed v4 Claude entry contract. This host is the only place the mapping exists: Warble used to
@@ -200,9 +167,9 @@ const claudeScopeEntry = (purpose, vendor) => vendor === "claude" && !PINNED_CLA
200
167
  * most by pinning: its profile mounts four selectable components, and a pinned session can only
201
168
  * reach the other three by delegating out of the one it was forced to start as.
202
169
  */
203
- const nativeEntryFor = (purpose, vendor) => claudeScopeEntry(purpose, vendor)
170
+ const nativeEntryFor = (purpose, vendor, entryVerb) => claudeScopeEntry(purpose, vendor)
204
171
  ? { kind: "scope", prompt: welcomePromptFor(purpose) }
205
- : { verb: entryVerbFor(purpose), prompt: welcomePromptFor(purpose) };
172
+ : { verb: selectedNativeEntry(purpose, vendor, entryVerb) ?? entryVerbFor(purpose), prompt: welcomePromptFor(purpose) };
206
173
  /** Closed producer/host contract for the one initial native TUI prompt. */
207
174
  const welcomePromptFor = (purpose) => {
208
175
  switch (purpose) {
@@ -214,6 +181,50 @@ const welcomePromptFor = (purpose) => {
214
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.";
215
182
  }
216
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
+ };
217
228
  function sameBinding(a, b) {
218
229
  return b !== undefined && a.path === b.path && a.identity === b.identity && a.generation === b.generation && a.revision === b.revision;
219
230
  }
@@ -222,6 +233,7 @@ function resumeScopeFingerprint(row) {
222
233
  row.id, row.purpose, row.vendor, row.scopeKind, row.projectIdentity,
223
234
  row.bindingGeneration, row.projectRevision, row.dispatchProfile,
224
235
  row.dispatchTarget, row.runtimeGeneration,
236
+ ...(row.entryVerb && row.entryVerb !== "answer_query" ? [row.entryVerb] : []),
225
237
  ])).digest("hex");
226
238
  }
227
239
  function record(value) {
@@ -234,25 +246,6 @@ function contained(root, candidate) {
234
246
  const relative = path.relative(root, candidate);
235
247
  return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
236
248
  }
237
- function localExecutableAvailable(executable, pathValue = process.env["PATH"]) {
238
- return localExecutablePath(executable, pathValue) !== undefined;
239
- }
240
- function localExecutablePath(executable, pathValue = process.env["PATH"]) {
241
- const candidates = path.isAbsolute(executable) || executable.includes(path.sep)
242
- ? [path.resolve(executable)]
243
- : (pathValue ?? "").split(path.delimiter).filter(Boolean).map((directory) => path.join(directory, executable));
244
- for (const candidate of candidates) {
245
- try {
246
- const stat = statSync(candidate);
247
- if (stat.isFile() && (stat.mode & 0o111) !== 0)
248
- return realpathSync(candidate);
249
- }
250
- catch {
251
- // Try the next PATH entry. The public readiness response never learns this path.
252
- }
253
- }
254
- return undefined;
255
- }
256
249
  function configuredWorkspace(cwd, create) {
257
250
  try {
258
251
  if (create)
@@ -352,6 +345,16 @@ export function nativeSessionLaunchFailure(error) {
352
345
  return "native session launch artifacts are unavailable";
353
346
  if (message === "native session launch paths are unavailable" || message === "native session launch paths are outside its server-owned scope")
354
347
  return "native session launch artifacts are unavailable";
348
+ // Every InteractiveLaunchError message is authored in this file: a fixed
349
+ // sentence, a fixed profile name, or a reason readiness already publishes.
350
+ // None is built from a path, argv, or process output. Falling through to the
351
+ // generic string threw away the cause of eighteen distinct failures, which is
352
+ // what left "native session launch failed" as the only thing a user could see.
353
+ if (error instanceof InteractiveLaunchError)
354
+ return message;
355
+ // This one is NOT safe to forward: its text embeds the configured binary path.
356
+ if (error instanceof WarbleBinaryNotFoundError)
357
+ return "the Warble binary could not be resolved";
355
358
  return "native session launch failed";
356
359
  }
357
360
  const NATIVE_PRODUCER_CATEGORY = "native_session_producer_incompatible";
@@ -394,25 +397,14 @@ function nativeProducerResult(available, identity, phase, detail, reason, produc
394
397
  vendors: everyVendorUnavailable(available ? { available: true, diagnostic } : { available: false, reason, diagnostic }),
395
398
  };
396
399
  }
397
- function executableIdentity(executable) {
398
- try {
399
- const metadata = statSync(executable);
400
- if (!metadata.isFile() || (metadata.mode & 0o111) === 0)
401
- return undefined;
402
- return `sha256:${createHash("sha256").update(readFileSync(executable)).digest("hex")}`;
403
- }
404
- catch {
405
- return undefined;
406
- }
407
- }
408
400
  /**
409
401
  * The configured spelling may be a PATH lookup or retargetable symlink. A
410
402
  * preflight result instead owns the resolved executable and its digest. Check
411
403
  * both again before creating a durable row and directly before spawning.
412
404
  */
413
405
  function assertNativeProducerIdentity(producer) {
414
- const resolved = localExecutablePath(producer.executable);
415
- 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) {
416
408
  throw new InteractiveLaunchError("native session producer is incompatible");
417
409
  }
418
410
  }
@@ -421,7 +413,7 @@ function assertNativeProducerIdentity(producer) {
421
413
  * output is used only for local help-marker parsing and is never logged or
422
414
  * returned: a third-party executable may print arbitrary sensitive text.
423
415
  */
424
- function runNativeProbe(command, args) {
416
+ function runNativeProbe(command, args, env) {
425
417
  return new Promise((resolve) => {
426
418
  let output = "";
427
419
  let timedOut = false;
@@ -438,9 +430,10 @@ function runNativeProbe(command, args) {
438
430
  };
439
431
  let child;
440
432
  try {
441
- child = spawn(command, [...args], {
433
+ assertNativeExecutableIdentity(command);
434
+ child = spawn(command.executable, [...args], {
442
435
  cwd: os.tmpdir(),
443
- env: { PATH: process.env["PATH"] ?? "" },
436
+ env: nativeProcessEnvironment(env),
444
437
  shell: false,
445
438
  stdio: ["ignore", "pipe", "pipe"],
446
439
  });
@@ -475,14 +468,23 @@ function runNativeProbe(command, args) {
475
468
  * validator used just before a PTY starts.
476
469
  */
477
470
  export async function probeNativeSessionProducer(input) {
478
- const executable = localExecutablePath(input.warbleBin);
479
- if (!executable)
471
+ const producerExecutable = resolveNativeExecutable("producer", input.warbleBin, input.pathValue);
472
+ if (!producerExecutable)
480
473
  return nativeProducerResult(false, undefined, "resolve", "result=binary_not_found", "the Warble binary could not be resolved");
481
- const identity = executableIdentity(executable);
482
- if (!identity)
483
- 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;
484
476
  const producer = { executable, identity };
485
- 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);
486
488
  if (help.spawnFailed)
487
489
  return nativeProducerResult(false, identity, "dispatch_help", "result=spawn_failed", "the Warble binary could not be started");
488
490
  if (help.timedOut)
@@ -497,65 +499,97 @@ export async function probeNativeSessionProducer(input) {
497
499
  // reported the producer incompatible even for a Claude-only install, which is
498
500
  // exactly what an installed package produces.
499
501
  const vendors = {};
502
+ const purposes = Object.fromEntries(NATIVE_PURPOSES.map((purpose) => [purpose, {}]));
500
503
  for (const vendor of NATIVE_PROBE_TARGETS) {
501
- let outcome = { available: true, diagnostic: producerDiagnostic(identity, `complete_${vendor}`, "result=compatible") };
502
- for (const purpose of NATIVE_PURPOSES) {
503
- const irPath = input.irPaths[purpose];
504
- if (!irPath || !configuredIrPath(irPath))
505
- continue;
506
- const phase = `dispatch_${purpose}_${vendor}`;
507
- const fail = (detail, reason) => ({ available: false, reason, diagnostic: producerDiagnostic(identity, phase, detail) });
508
- let root;
509
- try {
510
- root = mkdtempSync(path.join(os.tmpdir(), "genbi-native-preflight-"));
511
- chmodSync(root, 0o700);
512
- root = realpathSync(root);
513
- const outputRoot = path.join(root, "out");
514
- mkdirSync(outputRoot, { mode: 0o700 });
515
- const bootstrapRoot = path.join(root, "bootstrap");
516
- mkdirSync(bootstrapRoot, { mode: 0o700 });
517
- const scopeId = `preflight-${purpose}-${vendor}`;
518
- const scopePath = path.join(root, "scope.json");
519
- const mcpPath = path.join(root, "mcp.json");
520
- const wrenRuntime = vendor === "codex" ? resolveNativeWrenRuntime(input.wrenShim) : undefined;
521
- const binding = NATIVE_DISPATCH_REGISTRY[purpose].scopeKind === "bound_project"
522
- ? { project_identity: "native-preflight-project", generation: "1", revision: "native-preflight-revision" }
523
- : undefined;
524
- 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" });
525
- 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" });
526
- const dispatched = await runNativeProbe(executable, ["dispatch", irPath, "--target", targetForProvider(vendor), "--purpose", purpose, "--native-scope", scopePath, "--native-mcp", mcpPath, "--out", outputRoot]);
527
- if (dispatched.spawnFailed) {
528
- outcome = fail("result=spawn_failed", "the Warble binary could not be started for this vendor");
529
- break;
530
- }
531
- if (dispatched.timedOut) {
532
- outcome = fail("result=timed_out", "the Warble binary did not respond for this vendor");
533
- break;
534
- }
535
- if (dispatched.exitCode !== 0) {
536
- outcome = fail(`result=exit_code_${dispatched.exitCode ?? "signal"}`, "Warble could not dispatch this profile for this vendor");
537
- break;
538
- }
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;
539
516
  try {
540
- 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
+ }
541
577
  }
542
- catch {
543
- 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");
544
582
  break;
545
583
  }
584
+ finally {
585
+ if (root)
586
+ rmSync(root, { recursive: true, force: true });
587
+ }
546
588
  }
547
- catch (error) {
548
- outcome = error instanceof NativeWrenRuntimeError
549
- ? fail(`result=wren_runtime_unavailable (${error.message})`, error.message)
550
- : fail("result=preflight_workspace_unavailable", "the preflight could not create its temporary workspace");
551
- break;
552
- }
553
- finally {
554
- if (root)
555
- rmSync(root, { recursive: true, force: true });
556
- }
589
+ purposes[selectedPurpose][vendor] = outcome;
557
590
  }
558
- 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") };
559
593
  }
560
594
  const usable = NATIVE_PROBE_TARGETS.filter((vendor) => vendors[vendor].available);
561
595
  const available = usable.length > 0;
@@ -571,6 +605,7 @@ export async function probeNativeSessionProducer(input) {
571
605
  producer,
572
606
  diagnostic: producerDiagnostic(identity, "complete", available ? `result=compatible_${usable.join("+")}` : `result=no_compatible_vendor ${vendorResults}`),
573
607
  vendors,
608
+ purposes,
574
609
  };
575
610
  }
576
611
  function parseNativeSetupRecoveryReport(value) {
@@ -600,7 +635,7 @@ function parseNativeSetupRecoveryReport(value) {
600
635
  * omits the host-issued scope; Setup exposes only its separately authorized
601
636
  * bootstrap root, which is revalidated against the durable host configuration.
602
637
  */
603
- 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) {
604
639
  const ownershipRoot = binding?.path ?? authorizedWorkspace;
605
640
  const root = materializationState && ownershipRoot
606
641
  ? validateNativeSessionWorkspace(materializationState, ownershipRoot, cwd)
@@ -616,19 +651,28 @@ export function readNativeLaunchSpec(cwd, purpose, vendor, scopeId, binding, mcp
616
651
  throw new InteractiveLaunchError("native session launch specification is invalid");
617
652
  }
618
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");
619
657
  const isV4 = mcp !== undefined;
620
658
  const isSetupV4 = isV4 && purpose === "setup";
621
- 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"];
622
660
  const target = targetForProvider(vendor);
623
661
  const executable = vendor === "claude" ? "claude" : "codex";
624
- const expectedAgent = agentFor(purpose, vendor);
625
- const scopeEntry = claudeScopeEntry(purpose, vendor);
662
+ const expectedAgent = expectedAgentFor(purpose, vendor, selection);
663
+ const scopeEntry = scopeEntryFor(purpose, vendor, selection);
626
664
  if (!value || !hasExactlyKeys(value, keys) ||
627
- 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 ||
628
666
  !Array.isArray(value.argv) || !value.argv.every((arg) => typeof arg === "string") || !record(value.agent) ||
629
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") {
630
668
  throw new InteractiveLaunchError("native session launch specification is incompatible");
631
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
+ }
632
676
  const welcome = welcomePromptFor(purpose);
633
677
  const baseArgv = scopeEntry
634
678
  ? isV4 ? [welcome] : []
@@ -648,7 +692,7 @@ export function readNativeLaunchSpec(cwd, purpose, vendor, scopeId, binding, mcp
648
692
  // "agent" descriptor is a distinct kind (`claude_scope`) carrying the profile id rather than
649
693
  // `claude_agent`/`expectedAgent`. See `claudeScopeEntry` above.
650
694
  const expectedAgentKind = scopeEntry ? "claude_scope" : vendor === "claude" ? "claude_agent" : "codex_skill";
651
- const expectedAgentName = scopeEntry ? NATIVE_DISPATCH_REGISTRY[purpose].profile : expectedAgent;
695
+ const expectedAgentName = scopeEntry ? (customSelection(purpose, selection)?.id ?? NATIVE_DISPATCH_REGISTRY[purpose].profile) : expectedAgent;
652
696
  if (!hasExactlyKeys(agent, ["kind", "name"]) || (!isV4 && !validV2Scope) ||
653
697
  JSON.stringify(value.argv) !== JSON.stringify(baseArgv) || agent.kind !== expectedAgentKind || agent.name !== expectedAgentName ||
654
698
  (isV4 && (!launchMcp || !hasExactlyKeys(launchMcp, ["server_name", "credential_env_var"]) || launchMcp.server_name !== "genbi_session" || launchMcp.credential_env_var !== NATIVE_MCP_CREDENTIAL_ENV_VAR)))
@@ -703,7 +747,7 @@ export function readNativeLaunchSpec(cwd, purpose, vendor, scopeId, binding, mcp
703
747
  ? [...(scopeEntry ? [] : ["--agent", expectedAgent]), "--resume", providerLaunch.handle, ...(isV4 ? [welcome] : [])]
704
748
  : ["--dangerously-bypass-hook-trust", "-c", 'tui.resume_cwd="current"', "resume", providerLaunch.handle, ...(isV4 ? [welcome] : [])]
705
749
  : spawnBaseArgv;
706
- 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 } : {}) };
707
751
  }
708
752
  /**
709
753
  * Materializes only host-owned, short-lived producer inputs. The scope and
@@ -722,6 +766,7 @@ export async function dispatchNativeArtifacts(input) {
722
766
  const dir = mkdtempSync(path.join(tempRoot, "genbi-native-dispatch-"));
723
767
  const scopePath = path.join(dir, "scope.json");
724
768
  const mcpPath = path.join(dir, "mcp.json");
769
+ const hostPath = path.join(dir, "host.json");
725
770
  try {
726
771
  const directory = lstatSync(dir);
727
772
  if (!directory.isDirectory() || directory.isSymbolicLink() || !contained(tempRoot, dir))
@@ -737,18 +782,20 @@ export async function dispatchNativeArtifacts(input) {
737
782
  if (!metadata.isFile() || metadata.isSymbolicLink() || (metadata.mode & 0o777) !== 0o600)
738
783
  throw new InteractiveLaunchError("native session materialization failed");
739
784
  };
785
+ if (input.componentHost) {
786
+ writeFileSync(hostPath, JSON.stringify(input.componentHost), { encoding: "utf8", mode: 0o600, flag: "wx" });
787
+ validateDescriptorFile(hostPath);
788
+ }
740
789
  validateDescriptorFile(scopePath);
741
790
  if (input.mcp)
742
791
  validateDescriptorFile(mcpPath);
743
- const executable = input.producer?.executable ?? input.warbleBin;
744
- if (input.producer)
745
- assertNativeProducerIdentity(input.producer);
746
- const args = ["dispatch", input.irPath, "--target", input.target, "--purpose", input.purpose, "--native-scope", scopePath, ...(input.mcp ? ["--native-mcp", mcpPath] : []), "--out", input.cwd];
747
- await new Promise((resolve, reject) => {
748
- const child = spawn(executable, args, { cwd: input.cwd, shell: false, stdio: "ignore" });
749
- child.once("error", () => reject(new InteractiveLaunchError("native session materialization failed")));
750
- child.once("exit", (code) => code === 0 ? resolve() : reject(new InteractiveLaunchError("native session materialization failed")));
751
- });
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"); });
752
799
  }
753
800
  finally {
754
801
  rmSync(dir, { recursive: true, force: true });
@@ -756,7 +803,28 @@ export async function dispatchNativeArtifacts(input) {
756
803
  }
757
804
  export class NativeSessionService {
758
805
  options;
806
+ runtimeHost;
807
+ nativeHome;
808
+ wrenShim;
809
+ sourceWrenHome;
810
+ nodeExecutable;
811
+ producerExecutable;
812
+ vendorExecutables;
813
+ childToolDirectories;
814
+ conversations = new Map();
759
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();
760
828
  leaseTimers = new Map();
761
829
  /** Open/reconnect coalescing only lasts while a matching launch is in flight. */
762
830
  launches = new Map();
@@ -773,6 +841,41 @@ export class NativeSessionService {
773
841
  fixtureMaterializationState;
774
842
  constructor(options) {
775
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
+ ])]);
776
879
  this.idleTtlMs = options.idleTtlMs ?? options.postClaimDetachGraceMs ?? NATIVE_SESSION_IDLE_TTL_MS;
777
880
  if (!Number.isSafeInteger(this.idleTtlMs) || this.idleTtlMs <= 0)
778
881
  throw new RangeError("native session idle TTL must be a positive integer");
@@ -810,11 +913,27 @@ export class NativeSessionService {
810
913
  async productionProducerPreflight() {
811
914
  if (this.legacyFixtureMode())
812
915
  return undefined;
813
- 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
+ });
814
927
  const log = result.available ? console.info : console.warn;
815
928
  log(`[native-sessions] producer preflight ${result.diagnostic}`);
816
929
  return result;
817
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
+ }
818
937
  /**
819
938
  * SQLite is the durable source of truth, but a native PTY and its attach
820
939
  * capability are deliberately process-local. A BFF restart (or a lease
@@ -837,7 +956,7 @@ export class NativeSessionService {
837
956
  // yield while the durable row is still being initialized. It has never
838
957
  // owned an attachable capability, so treating it as a missing terminal
839
958
  // would race creation and could terminalize a PTY that is about to start.
840
- if ((row.status !== "running" && row.status !== "detached") || this.sessions.has(row.id))
959
+ if ((row.status !== "running" && row.status !== "detached") || this.liveCapability(row.id))
841
960
  return row;
842
961
  // Commit the durable fence first. The cleanup below is best effort and
843
962
  // cannot leave a browser-facing row claiming that it can reattach.
@@ -850,10 +969,13 @@ export class NativeSessionService {
850
969
  this.recoveryCapabilities.delete(row.id);
851
970
  this.options.artifactService?.revoke(this.artifactCredentials.get(row.id));
852
971
  this.artifactCredentials.delete(row.id);
972
+ this.cleanupRuntime(row.id);
853
973
  return stopped;
854
974
  }
855
975
  /** A legacy persisted Runtime must be repaired before it can authorize a PTY. */
856
976
  assertRuntimeDispatchable() {
977
+ if (this.shuttingDown)
978
+ throw new InteractiveLaunchError("native session host is shutting down");
857
979
  if (!this.options.store.hasExplicitRuntimeSettings())
858
980
  return;
859
981
  const correction = runtimeSettingsCorrection(this.options.store.getRuntimeSettings());
@@ -877,14 +999,17 @@ export class NativeSessionService {
877
999
  if (!dispatch)
878
1000
  throw new InteractiveLaunchError("native sessions require a saved Runtime & authentication binding");
879
1001
  const binding = input.purpose === "setup" ? undefined : this.options.getBinding();
880
- 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);
881
1005
  if (reusable) {
882
1006
  const recoveryCapability = this.recoveryCapabilities.get(reusable.id);
883
- return { row: reusable, capability: this.sessions.get(reusable.id).capability, ...(recoveryCapability ? { recoveryCapability } : {}) };
1007
+ return { row: reusable, capability: this.liveCapability(reusable.id), ...(recoveryCapability ? { recoveryCapability } : {}) };
884
1008
  }
1009
+ const selectedEntry = selectedNativeEntry(input.purpose, dispatch.provider, input.entryVerb);
885
1010
  const scopeKey = input.purpose === "setup"
886
1011
  ? JSON.stringify([input.purpose, dispatch.target, runtime.generation, "bootstrap"])
887
- : 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]);
888
1013
  return this.singleFlight(`open:${scopeKey}`, scopeKey, input, binding, runtime);
889
1014
  }
890
1015
  /** Opens the exact row selected by the browser after revalidating its scope. */
@@ -902,7 +1027,7 @@ export class NativeSessionService {
902
1027
  if (!row || !this.matchesOpenScope(row, input.purpose, dispatch.target, runtime.generation, binding))
903
1028
  throw new InteractiveLaunchError("native session is unavailable");
904
1029
  const recoveryCapability = this.recoveryCapabilities.get(row.id);
905
- return { row, capability: this.sessions.get(row.id).capability, ...(recoveryCapability ? { recoveryCapability } : {}) };
1030
+ return { row, capability: this.liveCapability(row.id), ...(recoveryCapability ? { recoveryCapability } : {}) };
906
1031
  }
907
1032
  /**
908
1033
  * Starts a separate TUI even when a matching session is live. The caller's
@@ -920,9 +1045,11 @@ export class NativeSessionService {
920
1045
  if (!dispatch)
921
1046
  throw new InteractiveLaunchError("native sessions require a saved Runtime & authentication binding");
922
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);
923
1050
  const scopeKey = input.purpose === "setup"
924
1051
  ? JSON.stringify([input.purpose, dispatch.target, runtime.generation, "bootstrap"])
925
- : 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]);
926
1053
  return this.replayStartSeparate(`start:${input.idempotencyKey}`, scopeKey, input, binding, runtime);
927
1054
  }
928
1055
  /**
@@ -961,6 +1088,8 @@ export class NativeSessionService {
961
1088
  }
962
1089
  /** Browser-visible resume state uses the same current-scope fence as launch, plus *why* when it fails. */
963
1090
  resumeAvailability(row) {
1091
+ if (row.transport === "conversation" || (row.vendor === "codex" && this.options.directCodex?.terminal))
1092
+ return { available: false, cause: "no_resume_handle" };
964
1093
  if (row.status !== "exited" && row.status !== "stopped" && row.status !== "interrupted" && row.status !== "failed" && row.status !== "stale") {
965
1094
  return { available: false, cause: "not_terminal" };
966
1095
  }
@@ -987,7 +1116,7 @@ export class NativeSessionService {
987
1116
  * no global Codex state and persists only a provider-bound encrypted envelope.
988
1117
  */
989
1118
  captureCodexResumeHandle(row) {
990
- 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"))
991
1120
  return;
992
1121
  const state = this.materializationState();
993
1122
  if (!state)
@@ -1016,7 +1145,12 @@ export class NativeSessionService {
1016
1145
  const runtime = !storedRuntime.configured && this.legacyFixtureMode()
1017
1146
  ? { configured: true, generation: 0, provider: row.vendor, target: targetForProvider(row.vendor), targetLabel: nativeTargetLabel(row.vendor) }
1018
1147
  : storedRuntime;
1019
- 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;
1020
1154
  const binding = row.purpose === "setup" ? undefined : this.options.getBinding();
1021
1155
  const matches = dispatch?.provider === row.vendor && row.dispatchProfile === dispatch.profile && row.dispatchTarget === dispatch.target && row.runtimeGeneration === runtime.generation
1022
1156
  && (row.purpose === "setup"
@@ -1059,7 +1193,7 @@ export class NativeSessionService {
1059
1193
  const reserved = this.options.store.reserveNativeSessionResume({
1060
1194
  sourceSessionId: source.id, idempotencyKey, scopeFingerprint: fingerprint, sealedHandle: sealedChild,
1061
1195
  child: {
1062
- 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,
1063
1197
  dispatchProfile: scope.dispatch.profile, dispatchTarget: scope.dispatch.target, runtimeGeneration: scope.runtime.generation,
1064
1198
  ...(scope.binding ? { projectIdentity: scope.binding.identity, bindingGeneration: scope.binding.generation, projectRevision: scope.binding.revision } : {}),
1065
1199
  },
@@ -1068,7 +1202,7 @@ export class NativeSessionService {
1068
1202
  throw new InteractiveLaunchError("native session resume is unavailable");
1069
1203
  if (!reserved.created)
1070
1204
  return this.recoverReservedResume(reserved.row.id, scope);
1071
- 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 });
1072
1206
  }
1073
1207
  async recoverReservedResume(id, scope) {
1074
1208
  const row = this.get(id);
@@ -1096,7 +1230,7 @@ export class NativeSessionService {
1096
1230
  this.failReservedResume(row.id, "native session resume reservation cannot be recovered");
1097
1231
  throw error;
1098
1232
  }
1099
- 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 });
1100
1234
  }
1101
1235
  failReservedResume(id, failure) {
1102
1236
  const row = this.options.store.getNativeSession(id);
@@ -1104,8 +1238,11 @@ export class NativeSessionService {
1104
1238
  this.options.store.transitionNativeSession(id, "failed", { failure, ended: true });
1105
1239
  this.options.store.invalidateNativeSessionResume(id);
1106
1240
  }
1107
- matchesOpenScope(row, purpose, target, generation, binding) {
1108
- 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)
1109
1246
  return false;
1110
1247
  if (row.status !== "creating" && row.status !== "running" && row.status !== "detached")
1111
1248
  return false;
@@ -1141,7 +1278,7 @@ export class NativeSessionService {
1141
1278
  // A successful response may contain an attach capability. Never replay it
1142
1279
  // after the owning PTY/capability has gone away; failures carry no terminal
1143
1280
  // capability and remain safely replayable within the same fenced scope.
1144
- 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;
1145
1282
  }
1146
1283
  trimStartSeparateReplays() {
1147
1284
  for (const [key, replay] of this.startSeparateReplays) {
@@ -1177,12 +1314,110 @@ export class NativeSessionService {
1177
1314
  this.launches.delete(launchKey);
1178
1315
  }
1179
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
+ }
1180
1384
  async readiness() {
1181
1385
  const terminalHostAvailable = this.options.terminalHostAvailable ?? (async () => true);
1182
1386
  const executableAvailable = (vendor) => this.options.executableAvailable
1183
- ? this.options.executableAvailable(vendor)
1184
- : 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
+ }
1185
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;
1186
1421
  const mcpReadiness = this.options.artifactService?.readiness();
1187
1422
  const runtime = this.options.store.getNativeRuntimeBinding();
1188
1423
  const runtimeCorrection = this.options.store.hasExplicitRuntimeSettings()
@@ -1190,7 +1425,7 @@ export class NativeSessionService {
1190
1425
  : undefined;
1191
1426
  const legacyFixture = !runtime.configured && this.legacyFixtureMode();
1192
1427
  const producer = this.legacyFixtureMode() ? undefined : await this.productionProducerPreflight();
1193
- 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);
1194
1429
  const result = {};
1195
1430
  for (const purpose of NATIVE_PURPOSES) {
1196
1431
  const definition = NATIVE_DISPATCH_REGISTRY[purpose];
@@ -1210,21 +1445,22 @@ export class NativeSessionService {
1210
1445
  // Only the configured vendor's own probe may block this purpose. Probing
1211
1446
  // both and collapsing them into one boolean let an unselected vendor veto
1212
1447
  // the selected one.
1213
- const selectedProducer = producer && dispatch ? producer.vendors[dispatch.provider] : undefined;
1214
- const reason = runtimeCorrection
1215
- ?? (!dispatch && !legacyFixture
1216
- ? "native sessions require a saved Runtime & authentication binding"
1217
- : !cwd
1218
- ? purpose === "setup" ? "native setup sessions require a workspace root" : "native sessions require a current bound project"
1219
- : workspaceUnavailable
1220
- ? "native session workspace is unavailable"
1221
- : selectedProducer && !selectedProducer.available
1222
- ? selectedProducer.reason ?? "native session producer is incompatible"
1223
- : !irPath || !(this.options.dispatch !== undefined || configuredIrPath(irPath)) || !legacyProducerAvailable()
1224
- ? "native session materialization prerequisites are unavailable"
1225
- : mcpReadiness && !mcpReadiness.available
1226
- ? mcpReadiness.reason
1227
- : 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;
1228
1464
  const executable = dispatch?.provider === "claude" ? "claude" : "codex";
1229
1465
  const unavailable = reason ?? (!hostAvailable
1230
1466
  ? "native terminal host cannot spawn local processes on this machine"
@@ -1236,12 +1472,14 @@ export class NativeSessionService {
1236
1472
  // one is unusable and why instead of only that "the producer" is.
1237
1473
  if (!legacyFixture && producer)
1238
1474
  for (const vendor of NATIVE_VENDORS) {
1239
- const probe = producer.vendors[vendor];
1240
- vendorReadiness[vendor] = probe?.available === false
1241
- ? { available: false, reason: probe.reason ?? "native session producer is incompatible" }
1242
- : !executableAvailable(vendor)
1243
- ? { available: false, reason: `the ${vendor === "claude" ? "claude" : "codex"} interactive CLI is not available on this machine` }
1244
- : { 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 };
1245
1483
  }
1246
1484
  if (legacyFixture)
1247
1485
  for (const vendor of NATIVE_VENDORS) {
@@ -1279,9 +1517,12 @@ export class NativeSessionService {
1279
1517
  }));
1280
1518
  return legacy;
1281
1519
  }
1520
+ const profiles = await this.conversationProfileReadiness(result.analysis, runtime.configured ? runtime.provider : undefined);
1282
1521
  return {
1283
1522
  runtime,
1523
+ runtimeHost: runtimeHostReadiness,
1284
1524
  purposes: result,
1525
+ ...(profiles ? { profiles } : {}),
1285
1526
  mcp: this.options.artifactService?.health() ?? {
1286
1527
  server: "GenBI MCP",
1287
1528
  tool: "save_dashboard",
@@ -1294,27 +1535,74 @@ export class NativeSessionService {
1294
1535
  }
1295
1536
  async create(input, binding = input.purpose === "setup" ? undefined : this.options.getBinding(), capturedRuntime = this.options.store.getNativeRuntimeBinding(), resumed) {
1296
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();
1297
1546
  const purpose = input.purpose;
1298
1547
  const legacyRuntime = !capturedRuntime.configured && input.vendor
1299
1548
  ? { configured: true, generation: 0, provider: input.vendor, target: targetForProvider(input.vendor), targetLabel: nativeTargetLabel(input.vendor) }
1300
1549
  : capturedRuntime;
1301
- const dispatchDefinition = dispatchForPurpose(purpose, legacyRuntime);
1302
- if (!dispatchDefinition)
1550
+ const registryDispatch = dispatchForPurpose(purpose, legacyRuntime);
1551
+ if (!registryDispatch)
1303
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
+ }
1304
1581
  if (purpose !== "setup" && !binding)
1305
1582
  throw new InteractiveLaunchError("native session requires a current bound project");
1306
1583
  const configuredCwd = binding?.path ?? this.options.workspaceRoot;
1307
1584
  const configuredIr = this.options.irPaths[purpose];
1308
1585
  if (!configuredCwd || !configuredIr)
1309
- throw new InteractiveLaunchError(`native ${dispatchDefinition.profile} session is not configured`);
1586
+ throw new InteractiveLaunchError(`native ${selection.id} session is not configured`);
1310
1587
  // Compile for this binding rather than dispatching the profile's golden.
1311
1588
  // Falls back to the configured path only where no resolver is wired (the
1312
1589
  // fixture-mode tests), never silently in production — see `bin.ts`.
1313
- const irPath = this.options.resolveDispatchIr === undefined
1314
- ? configuredIr
1315
- : await this.options.resolveDispatchIr(purpose, binding);
1590
+ // Compiling for this binding runs before the launch try/catch below, so a
1591
+ // failure here reached the HTTP route unlogged and unclassified — the case
1592
+ // where the server printed nothing at all and the browser said only "native
1593
+ // session launch failed".
1594
+ let irPath;
1595
+ try {
1596
+ irPath = this.options.resolveDispatchIr === undefined
1597
+ ? configuredIr
1598
+ : await this.options.resolveDispatchIr(purpose, binding, selection.sourceDir);
1599
+ }
1600
+ catch (error) {
1601
+ logNativeLaunchFailure(purpose, dispatchDefinition.provider, "dispatch_ir", error);
1602
+ throw error;
1603
+ }
1316
1604
  const producer = this.legacyFixtureMode() ? undefined : await this.productionProducerPreflight();
1317
- const launchProducer = producer ? producer.vendors[dispatchDefinition.provider] : undefined;
1605
+ const launchProducer = producer ? (producer.purposes?.[purpose][dispatchDefinition.provider] ?? producer.vendors[dispatchDefinition.provider]) : undefined;
1318
1606
  if (producer && (!launchProducer?.available || !producer.producer)) {
1319
1607
  throw new InteractiveLaunchError(launchProducer?.reason ?? "native session producer is incompatible");
1320
1608
  }
@@ -1334,7 +1622,8 @@ export class NativeSessionService {
1334
1622
  initialSealedHandle = sealNativeClaudeResumeHandle(state, id, sessionId);
1335
1623
  providerLaunch = { kind: "initial", sessionId };
1336
1624
  }
1337
- 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,
1338
1627
  dispatchProfile: dispatchDefinition.profile, dispatchTarget: dispatchDefinition.target, runtimeGeneration: legacyRuntime.generation,
1339
1628
  ...(binding ? { projectIdentity: binding.identity, bindingGeneration: binding.generation, projectRevision: binding.revision } : {}) });
1340
1629
  if (initialSealedHandle)
@@ -1360,7 +1649,7 @@ export class NativeSessionService {
1360
1649
  if (!authorizedWorkspace || !materializationState)
1361
1650
  throw new InteractiveLaunchError("native session workspace is unavailable");
1362
1651
  const cwd = createNativeSessionWorkspace(materializationState, authorizedWorkspace, id);
1363
- 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);
1364
1653
  if (!(this.options.dispatch !== undefined || configuredIrPath(irPath)) || !producerAvailable())
1365
1654
  throw new InteractiveLaunchError("native session materialization prerequisites are unavailable");
1366
1655
  const mcpReadiness = artifactService?.readiness();
@@ -1370,19 +1659,58 @@ export class NativeSessionService {
1370
1659
  if (mcp)
1371
1660
  this.artifactCredentials.set(id, mcp.credential);
1372
1661
  let wrenRuntime;
1373
- try {
1374
- wrenRuntime = dispatchDefinition.provider === "codex" && !this.legacyFixtureMode() ? resolveNativeWrenRuntime(this.options.wrenShim) : undefined;
1375
- }
1376
- catch (error) {
1377
- if (error instanceof NativeWrenRuntimeError)
1378
- throw new InteractiveLaunchError("native Wren runtime is unavailable");
1379
- 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");
1380
1693
  }
1381
- 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;
1382
1698
  const dispatch = this.options.dispatch ?? dispatchNativeArtifacts;
1383
1699
  validateNativeSessionWorkspace(materializationState, authorizedWorkspace, cwd);
1384
1700
  launchPhase = "dispatch";
1385
- 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
+ });
1386
1714
  if (!input.vendor && !sameNativeRuntimeBinding(capturedRuntime, this.options.store.getNativeRuntimeBinding())) {
1387
1715
  this.options.store.transitionNativeSession(id, "stale", { failure: "native runtime binding changed before launch", ended: true });
1388
1716
  throw new InteractiveLaunchError("native session is stale because the runtime binding changed");
@@ -1392,20 +1720,75 @@ export class NativeSessionService {
1392
1720
  throw new InteractiveLaunchError("native session is stale because the bound project changed");
1393
1721
  }
1394
1722
  launchPhase = "launch_spec";
1395
- 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
+ }
1396
1743
  let codexWrenHome;
1397
- if (spec.version === "4" && dispatchDefinition.provider === "codex" && binding) {
1398
- 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) {
1399
1749
  launchPhase = "codex_wren_home";
1400
- codexWrenHome = (this.options.prepareCodexWrenHome ?? ((input) => materializeCodexWrenHome(input.runtime, input.projectPath, input.cwd)))({
1750
+ codexWrenHome = (this.options.prepareCodexWrenHome ?? materializeCodexWrenHome)({
1401
1751
  runtime: wrenRuntime,
1402
1752
  projectPath: binding.path,
1403
1753
  cwd,
1754
+ sourceWrenHome: this.sourceWrenHome(),
1755
+ home: this.nativeHome,
1756
+ toolDirectories,
1404
1757
  });
1405
1758
  }
1406
- launchPhase = "codex_project_read";
1407
- 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
+ }
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));
1408
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
+ });
1409
1792
  launchPhase = "terminal_manager";
1410
1793
  const terminalManager = await this.options.terminalManager();
1411
1794
  if (binding && !sameBinding(binding, this.options.getBinding())) {
@@ -1417,20 +1800,13 @@ export class NativeSessionService {
1417
1800
  throw new InteractiveLaunchError("native session is stale because the runtime binding changed");
1418
1801
  }
1419
1802
  validateNativeSessionWorkspace(materializationState, authorizedWorkspace, cwd);
1420
- const terminalEnv = {
1421
- ...process.env,
1422
- ...(mcp ? { [NATIVE_MCP_CREDENTIAL_ENV_VAR]: mcp.credential } : {}),
1423
- ...(binding ? { WREN_PROJECT_HOME: binding.path } : {}),
1424
- ...(codexWrenHome ? { WREN_HOME: codexWrenHome.home } : {}),
1425
- };
1426
- // Never inherit a caller/operator value: only the launch spec's already
1427
- // revalidated Setup root may reach the native TUI.
1428
- delete terminalEnv[NATIVE_SETUP_BOOTSTRAP_ROOT_ENV_VAR];
1429
- if (spec.bootstrap_root)
1430
- terminalEnv[NATIVE_SETUP_BOOTSTRAP_ROOT_ENV_VAR] = spec.bootstrap_root;
1803
+ codexWrenHome?.assertActive?.();
1804
+ assertNativeRuntimeSpec(runtimeSpec);
1805
+ this.assertRuntimeDispatchable();
1431
1806
  launchPhase = "terminal_start";
1432
- 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);
1433
1808
  this.sessions.set(id, terminal);
1809
+ this.runtimeSpecs.set(id, runtimeSpec);
1434
1810
  // Mark running before registering a replaying exit listener. A PTY can
1435
1811
  // exit between start() and this registration; `onExit` immediately
1436
1812
  // replays that state and must win over this transition, never be
@@ -1445,6 +1821,7 @@ export class NativeSessionService {
1445
1821
  this.recoveryCapabilities.delete(id);
1446
1822
  artifactService?.revoke(this.artifactCredentials.get(id));
1447
1823
  this.artifactCredentials.delete(id);
1824
+ this.cleanupRuntime(id);
1448
1825
  const current = this.options.store.getNativeSession(id);
1449
1826
  if (current && (current.status === "running" || current.status === "detached"))
1450
1827
  this.options.store.transitionNativeSession(id, "exited", { exitCode, ended: true });
@@ -1455,6 +1832,7 @@ export class NativeSessionService {
1455
1832
  }
1456
1833
  catch (error) {
1457
1834
  logNativeLaunchFailure(purpose, dispatchDefinition.provider, launchPhase, error);
1835
+ this.cleanupRuntime(id);
1458
1836
  this.recoveryCapabilities.delete(id);
1459
1837
  artifactService?.revoke(this.artifactCredentials.get(id));
1460
1838
  this.artifactCredentials.delete(id);
@@ -1476,6 +1854,153 @@ export class NativeSessionService {
1476
1854
  throw error;
1477
1855
  }
1478
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
+ }
1479
2004
  attach(id, capability) {
1480
2005
  const row = this.get(id);
1481
2006
  const runtime = this.options.store.getNativeRuntimeBinding();
@@ -1493,6 +2018,7 @@ export class NativeSessionService {
1493
2018
  return terminal;
1494
2019
  }
1495
2020
  detach(id) {
2021
+ this.componentAdmissions.get(id)?.cancelActive();
1496
2022
  this.sessions.get(id)?.detach();
1497
2023
  if (this.sessions.has(id)) {
1498
2024
  this.options.store.transitionNativeSession(id, "detached");
@@ -1520,12 +2046,25 @@ export class NativeSessionService {
1520
2046
  this.options.artifactService?.revoke(this.artifactCredentials.get(id));
1521
2047
  this.artifactCredentials.delete(id);
1522
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);
1523
2057
  try {
1524
2058
  terminal?.close();
2059
+ if (terminal)
2060
+ this.pendingTerminalCleanup.delete(terminal);
1525
2061
  }
1526
2062
  catch {
1527
2063
  // A process kill may fail after the durable session fence committed.
1528
2064
  // Capabilities are already unreachable; continue revoking every id.
2065
+ this.runtimeCleanupFailed = true;
2066
+ if (terminal)
2067
+ this.pendingTerminalCleanup.add(terminal);
1529
2068
  console.warn("[native-sessions] terminal close failed during capability revocation");
1530
2069
  }
1531
2070
  }
@@ -1534,6 +2073,81 @@ export class NativeSessionService {
1534
2073
  revokeRuntimeCapabilities(ids) { this.revokeCapabilities(ids); }
1535
2074
  /** Called after the store advances the bound-project generation. */
1536
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
+ }
1537
2151
  /**
1538
2152
  * Records only the producer's closed redacted report. Completion remains a
1539
2153
  * host decision supplied by the canonical-artifact validator at the route.
@@ -1598,14 +2212,19 @@ export class NativeSessionService {
1598
2212
  }
1599
2213
  armLease(id, reason, timeoutMs) {
1600
2214
  this.clearLease(id);
1601
- this.leaseTimers.set(id, setTimeout(() => {
1602
- const terminal = this.sessions.get(id);
1603
- 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)) {
1604
2221
  this.leaseTimers.delete(id);
1605
2222
  return;
1606
2223
  }
1607
2224
  this.options.store.transitionNativeSession(id, "stopped", { failure: reason, ended: true });
1608
2225
  this.revokeCapabilities([id]);
1609
- }, timeoutMs));
2226
+ }, timeoutMs);
2227
+ timer.unref();
2228
+ this.leaseTimers.set(id, timer);
1610
2229
  }
1611
2230
  }