@wrenai/genbi 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (286) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +141 -0
  3. package/bin/genbi.mjs +31 -0
  4. package/dist/assets/echarts-LTFC-ysd.js +40 -0
  5. package/dist/assets/index-Bo--QMUN.js +441 -0
  6. package/dist/assets/index-Xs4q7pNo.css +1 -0
  7. package/dist/index.html +14 -0
  8. package/dist/wrenai_logo.png +0 -0
  9. package/dist/wrenai_logo_white.png +0 -0
  10. package/dist-server/harness/auth/detect.d.ts +18 -0
  11. package/dist-server/harness/auth/detect.js +37 -0
  12. package/dist-server/harness/auth/index.d.ts +4 -0
  13. package/dist-server/harness/auth/index.js +2 -0
  14. package/dist-server/harness/auth/probe.d.ts +18 -0
  15. package/dist-server/harness/auth/probe.js +52 -0
  16. package/dist-server/harness/auth/types.d.ts +42 -0
  17. package/dist-server/harness/auth/types.js +9 -0
  18. package/dist-server/harness/bundle/loader.d.ts +32 -0
  19. package/dist-server/harness/bundle/loader.js +43 -0
  20. package/dist-server/harness/bundle/schema.d.ts +199 -0
  21. package/dist-server/harness/bundle/schema.js +127 -0
  22. package/dist-server/harness/bundle/version.d.ts +10 -0
  23. package/dist-server/harness/bundle/version.js +43 -0
  24. package/dist-server/harness/capability/gate.d.ts +6 -0
  25. package/dist-server/harness/capability/gate.js +29 -0
  26. package/dist-server/harness/capability/registry.d.ts +6 -0
  27. package/dist-server/harness/capability/registry.js +17 -0
  28. package/dist-server/harness/cli-args.d.ts +123 -0
  29. package/dist-server/harness/cli-args.js +247 -0
  30. package/dist-server/harness/cli.d.ts +2 -0
  31. package/dist-server/harness/cli.js +151 -0
  32. package/dist-server/harness/compile/cache.d.ts +23 -0
  33. package/dist-server/harness/compile/cache.js +109 -0
  34. package/dist-server/harness/compile/compose-profile.d.ts +34 -0
  35. package/dist-server/harness/compile/compose-profile.js +95 -0
  36. package/dist-server/harness/compile/errors.d.ts +22 -0
  37. package/dist-server/harness/compile/errors.js +41 -0
  38. package/dist-server/harness/compile/fingerprint.d.ts +22 -0
  39. package/dist-server/harness/compile/fingerprint.js +84 -0
  40. package/dist-server/harness/compile/index.d.ts +8 -0
  41. package/dist-server/harness/compile/index.js +7 -0
  42. package/dist-server/harness/compile/pipeline.d.ts +66 -0
  43. package/dist-server/harness/compile/pipeline.js +176 -0
  44. package/dist-server/harness/compile/resolve-binary.d.ts +55 -0
  45. package/dist-server/harness/compile/resolve-binary.js +133 -0
  46. package/dist-server/harness/compile/types.d.ts +107 -0
  47. package/dist-server/harness/compile/types.js +1 -0
  48. package/dist-server/harness/compile/warble-identity.d.ts +12 -0
  49. package/dist-server/harness/compile/warble-identity.js +33 -0
  50. package/dist-server/harness/compliance/gate.d.ts +35 -0
  51. package/dist-server/harness/compliance/gate.js +48 -0
  52. package/dist-server/harness/compliance/index.d.ts +2 -0
  53. package/dist-server/harness/compliance/index.js +1 -0
  54. package/dist-server/harness/compliance/types.d.ts +16 -0
  55. package/dist-server/harness/compliance/types.js +1 -0
  56. package/dist-server/harness/events/emitter.d.ts +34 -0
  57. package/dist-server/harness/events/emitter.js +25 -0
  58. package/dist-server/harness/events/index.d.ts +4 -0
  59. package/dist-server/harness/events/index.js +2 -0
  60. package/dist-server/harness/events/sse.d.ts +20 -0
  61. package/dist-server/harness/events/sse.js +48 -0
  62. package/dist-server/harness/events/types.d.ts +131 -0
  63. package/dist-server/harness/events/types.js +1 -0
  64. package/dist-server/harness/exec/errors.d.ts +16 -0
  65. package/dist-server/harness/exec/errors.js +29 -0
  66. package/dist-server/harness/exec/index.d.ts +5 -0
  67. package/dist-server/harness/exec/index.js +2 -0
  68. package/dist-server/harness/exec/local.d.ts +32 -0
  69. package/dist-server/harness/exec/local.js +134 -0
  70. package/dist-server/harness/exec/policy.d.ts +12 -0
  71. package/dist-server/harness/exec/policy.js +1 -0
  72. package/dist-server/harness/exec/types.d.ts +63 -0
  73. package/dist-server/harness/exec/types.js +1 -0
  74. package/dist-server/harness/guardrails/index.d.ts +2 -0
  75. package/dist-server/harness/guardrails/index.js +1 -0
  76. package/dist-server/harness/guardrails/policy.d.ts +45 -0
  77. package/dist-server/harness/guardrails/policy.js +57 -0
  78. package/dist-server/harness/index.d.ts +37 -0
  79. package/dist-server/harness/index.js +19 -0
  80. package/dist-server/harness/loop/errors.d.ts +53 -0
  81. package/dist-server/harness/loop/errors.js +72 -0
  82. package/dist-server/harness/loop/executor.d.ts +123 -0
  83. package/dist-server/harness/loop/executor.js +380 -0
  84. package/dist-server/harness/loop/guard.d.ts +16 -0
  85. package/dist-server/harness/loop/guard.js +1 -0
  86. package/dist-server/harness/loop/index.d.ts +5 -0
  87. package/dist-server/harness/loop/index.js +3 -0
  88. package/dist-server/harness/native-producer/index.d.ts +109 -0
  89. package/dist-server/harness/native-producer/index.js +302 -0
  90. package/dist-server/harness/npm-package-resolve.d.ts +28 -0
  91. package/dist-server/harness/npm-package-resolve.js +47 -0
  92. package/dist-server/harness/providers/adapters/anthropic.d.ts +7 -0
  93. package/dist-server/harness/providers/adapters/anthropic.js +6 -0
  94. package/dist-server/harness/providers/adapters/mock.d.ts +4 -0
  95. package/dist-server/harness/providers/adapters/mock.js +5 -0
  96. package/dist-server/harness/providers/adapters/openai-compatible.d.ts +10 -0
  97. package/dist-server/harness/providers/adapters/openai-compatible.js +10 -0
  98. package/dist-server/harness/providers/binding.d.ts +23 -0
  99. package/dist-server/harness/providers/binding.js +13 -0
  100. package/dist-server/harness/providers/errors.d.ts +6 -0
  101. package/dist-server/harness/providers/errors.js +12 -0
  102. package/dist-server/harness/providers/index.d.ts +18 -0
  103. package/dist-server/harness/providers/index.js +22 -0
  104. package/dist-server/harness/providers/registry.d.ts +16 -0
  105. package/dist-server/harness/providers/registry.js +24 -0
  106. package/dist-server/harness/render/envelope.d.ts +145 -0
  107. package/dist-server/harness/render/envelope.js +428 -0
  108. package/dist-server/harness/render/errors.d.ts +33 -0
  109. package/dist-server/harness/render/errors.js +46 -0
  110. package/dist-server/harness/render/index.d.ts +5 -0
  111. package/dist-server/harness/render/index.js +3 -0
  112. package/dist-server/harness/render/validate.d.ts +13 -0
  113. package/dist-server/harness/render/validate.js +88 -0
  114. package/dist-server/harness/route/adapter-spec.d.ts +40 -0
  115. package/dist-server/harness/route/adapter-spec.js +61 -0
  116. package/dist-server/harness/route/agent-sdk-cli.d.ts +30 -0
  117. package/dist-server/harness/route/agent-sdk-cli.js +86 -0
  118. package/dist-server/harness/route/agent-sdk-manifest.d.ts +31 -0
  119. package/dist-server/harness/route/agent-sdk-manifest.js +56 -0
  120. package/dist-server/harness/route/artifact-content.d.ts +53 -0
  121. package/dist-server/harness/route/artifact-content.js +83 -0
  122. package/dist-server/harness/route/chat-event-mapper.d.ts +95 -0
  123. package/dist-server/harness/route/chat-event-mapper.js +164 -0
  124. package/dist-server/harness/route/codex-ask-events.d.ts +25 -0
  125. package/dist-server/harness/route/codex-ask-events.js +395 -0
  126. package/dist-server/harness/route/codex-ask.d.ts +15 -0
  127. package/dist-server/harness/route/codex-ask.js +242 -0
  128. package/dist-server/harness/route/codex-local-cli.d.ts +20 -0
  129. package/dist-server/harness/route/codex-local-cli.js +78 -0
  130. package/dist-server/harness/route/codex-local-manifest.d.ts +22 -0
  131. package/dist-server/harness/route/codex-local-manifest.js +180 -0
  132. package/dist-server/harness/route/describe.d.ts +54 -0
  133. package/dist-server/harness/route/describe.js +84 -0
  134. package/dist-server/harness/route/dispatched.d.ts +138 -0
  135. package/dist-server/harness/route/dispatched.js +339 -0
  136. package/dist-server/harness/route/in-process.d.ts +43 -0
  137. package/dist-server/harness/route/in-process.js +106 -0
  138. package/dist-server/harness/route/index.d.ts +19 -0
  139. package/dist-server/harness/route/index.js +12 -0
  140. package/dist-server/harness/route/profile-source.d.ts +37 -0
  141. package/dist-server/harness/route/profile-source.js +76 -0
  142. package/dist-server/harness/route/route.d.ts +33 -0
  143. package/dist-server/harness/route/route.js +114 -0
  144. package/dist-server/harness/route/tier-binding.d.ts +36 -0
  145. package/dist-server/harness/route/tier-binding.js +63 -0
  146. package/dist-server/harness/route/types.d.ts +286 -0
  147. package/dist-server/harness/route/types.js +1 -0
  148. package/dist-server/harness/session/errors.d.ts +4 -0
  149. package/dist-server/harness/session/errors.js +7 -0
  150. package/dist-server/harness/session/gate.d.ts +11 -0
  151. package/dist-server/harness/session/gate.js +13 -0
  152. package/dist-server/harness/session/index.d.ts +5 -0
  153. package/dist-server/harness/session/index.js +3 -0
  154. package/dist-server/harness/session/run.d.ts +60 -0
  155. package/dist-server/harness/session/run.js +333 -0
  156. package/dist-server/harness/session/types.d.ts +29 -0
  157. package/dist-server/harness/session/types.js +1 -0
  158. package/dist-server/harness/setup/codex-events.d.ts +12 -0
  159. package/dist-server/harness/setup/codex-events.js +108 -0
  160. package/dist-server/harness/setup/runner.d.ts +497 -0
  161. package/dist-server/harness/setup/runner.js +1314 -0
  162. package/dist-server/harness/tools/errors.d.ts +107 -0
  163. package/dist-server/harness/tools/errors.js +141 -0
  164. package/dist-server/harness/tools/index.d.ts +13 -0
  165. package/dist-server/harness/tools/index.js +8 -0
  166. package/dist-server/harness/tools/mcp.d.ts +26 -0
  167. package/dist-server/harness/tools/mcp.js +15 -0
  168. package/dist-server/harness/tools/native.d.ts +179 -0
  169. package/dist-server/harness/tools/native.js +322 -0
  170. package/dist-server/harness/tools/parse.d.ts +14 -0
  171. package/dist-server/harness/tools/parse.js +18 -0
  172. package/dist-server/harness/tools/resolve-wren-binary.d.ts +12 -0
  173. package/dist-server/harness/tools/resolve-wren-binary.js +21 -0
  174. package/dist-server/harness/tools/resolve.d.ts +51 -0
  175. package/dist-server/harness/tools/resolve.js +79 -0
  176. package/dist-server/harness/tools/setup-native.d.ts +84 -0
  177. package/dist-server/harness/tools/setup-native.js +353 -0
  178. package/dist-server/harness/tools/wiring.d.ts +10 -0
  179. package/dist-server/harness/tools/wiring.js +16 -0
  180. package/dist-server/providers/setup.provider.yaml +31 -0
  181. package/dist-server/providers/wren.provider.yaml +33 -0
  182. package/dist-server/server/adopt.d.ts +87 -0
  183. package/dist-server/server/adopt.js +263 -0
  184. package/dist-server/server/app.d.ts +6 -0
  185. package/dist-server/server/app.js +2521 -0
  186. package/dist-server/server/auth-choice.d.ts +16 -0
  187. package/dist-server/server/auth-choice.js +36 -0
  188. package/dist-server/server/bin.d.ts +2 -0
  189. package/dist-server/server/bin.js +601 -0
  190. package/dist-server/server/bootstrap-project-binding.d.ts +31 -0
  191. package/dist-server/server/bootstrap-project-binding.js +71 -0
  192. package/dist-server/server/clarify.d.ts +10 -0
  193. package/dist-server/server/clarify.js +17 -0
  194. package/dist-server/server/codex-setup-mcp.d.ts +2 -0
  195. package/dist-server/server/codex-setup-mcp.js +147 -0
  196. package/dist-server/server/compose.d.ts +89 -0
  197. package/dist-server/server/compose.js +271 -0
  198. package/dist-server/server/conn-config.d.ts +30 -0
  199. package/dist-server/server/conn-config.js +253 -0
  200. package/dist-server/server/context-files.d.ts +12 -0
  201. package/dist-server/server/context-files.js +117 -0
  202. package/dist-server/server/context-map.d.ts +30 -0
  203. package/dist-server/server/context-map.js +95 -0
  204. package/dist-server/server/context-source.d.ts +68 -0
  205. package/dist-server/server/context-source.js +85 -0
  206. package/dist-server/server/db.d.ts +862 -0
  207. package/dist-server/server/db.js +1839 -0
  208. package/dist-server/server/enrichment-runner.d.ts +184 -0
  209. package/dist-server/server/enrichment-runner.js +213 -0
  210. package/dist-server/server/enrichment-verify.d.ts +73 -0
  211. package/dist-server/server/enrichment-verify.js +290 -0
  212. package/dist-server/server/enrichment.d.ts +232 -0
  213. package/dist-server/server/enrichment.js +199 -0
  214. package/dist-server/server/env-detect.d.ts +8 -0
  215. package/dist-server/server/env-detect.js +17 -0
  216. package/dist-server/server/env-field-hints.d.ts +46 -0
  217. package/dist-server/server/env-field-hints.js +48 -0
  218. package/dist-server/server/fold.d.ts +133 -0
  219. package/dist-server/server/fold.js +407 -0
  220. package/dist-server/server/harness-profile-sources.d.ts +3 -0
  221. package/dist-server/server/harness-profile-sources.js +24 -0
  222. package/dist-server/server/harness.d.ts +19 -0
  223. package/dist-server/server/harness.js +439 -0
  224. package/dist-server/server/impact.d.ts +18 -0
  225. package/dist-server/server/impact.js +85 -0
  226. package/dist-server/server/interactive-terminal.d.ts +175 -0
  227. package/dist-server/server/interactive-terminal.js +336 -0
  228. package/dist-server/server/native-artifacts.d.ts +1004 -0
  229. package/dist-server/server/native-artifacts.js +564 -0
  230. package/dist-server/server/native-dispatch-registry.d.ts +42 -0
  231. package/dist-server/server/native-dispatch-registry.js +45 -0
  232. package/dist-server/server/native-producer-fence.d.ts +14 -0
  233. package/dist-server/server/native-producer-fence.js +48 -0
  234. package/dist-server/server/native-session-resume.d.ts +6 -0
  235. package/dist-server/server/native-session-resume.js +103 -0
  236. package/dist-server/server/native-session-workspace.d.ts +40 -0
  237. package/dist-server/server/native-session-workspace.js +175 -0
  238. package/dist-server/server/native-sessions.d.ts +374 -0
  239. package/dist-server/server/native-sessions.js +1517 -0
  240. package/dist-server/server/native-wren-runtime.d.ts +20 -0
  241. package/dist-server/server/native-wren-runtime.js +259 -0
  242. package/dist-server/server/node-pty-host.d.ts +31 -0
  243. package/dist-server/server/node-pty-host.js +100 -0
  244. package/dist-server/server/route-intent.d.ts +40 -0
  245. package/dist-server/server/route-intent.js +23 -0
  246. package/dist-server/server/runtime-binding.d.ts +52 -0
  247. package/dist-server/server/runtime-binding.js +258 -0
  248. package/dist-server/server/source-catalog.d.ts +84 -0
  249. package/dist-server/server/source-catalog.js +221 -0
  250. package/dist-server/server/source-driver.d.ts +35 -0
  251. package/dist-server/server/source-driver.js +124 -0
  252. package/dist-server/server/spa.d.ts +2 -0
  253. package/dist-server/server/spa.js +51 -0
  254. package/dist-server/server/subscription-model-catalog.d.ts +27 -0
  255. package/dist-server/server/subscription-model-catalog.js +203 -0
  256. package/dist-server/server/turn.d.ts +249 -0
  257. package/dist-server/server/turn.js +1074 -0
  258. package/dist-server/server/wire-types.d.ts +799 -0
  259. package/dist-server/server/wire-types.js +1 -0
  260. package/dist-server/server/wren-home.d.ts +19 -0
  261. package/dist-server/server/wren-home.js +33 -0
  262. package/dist-server/server/wren-profiles.d.ts +43 -0
  263. package/dist-server/server/wren-profiles.js +123 -0
  264. package/package.json +93 -0
  265. package/profiles/genbi-default/context/binding.yml +9 -0
  266. package/profiles/genbi-default/ir.golden.json +506 -0
  267. package/profiles/genbi-default/profile.yml +16 -0
  268. package/profiles/genbi-enrich-context/components/apply_enrichment/component.yml +42 -0
  269. package/profiles/genbi-enrich-context/components/draft_enrichment/component.yml +30 -0
  270. package/profiles/genbi-enrich-context/components/draft_enrichment/steps/draft.md +34 -0
  271. package/profiles/genbi-enrich-context/components/inspect_context/component.yml +32 -0
  272. package/profiles/genbi-enrich-context/components/inspect_context/steps/inspect.md +13 -0
  273. package/profiles/genbi-enrich-context/context/binding.yml +4 -0
  274. package/profiles/genbi-enrich-context/ir.golden.json +302 -0
  275. package/profiles/genbi-enrich-context/profile.yml +15 -0
  276. package/profiles/genbi-monitor/context/binding.yml +7 -0
  277. package/profiles/genbi-monitor/ir.golden.json +188 -0
  278. package/profiles/genbi-monitor/profile.yml +27 -0
  279. package/profiles/genbi-setup/components/build_context/component.yml +44 -0
  280. package/profiles/genbi-setup/components/build_context/steps/build.md +14 -0
  281. package/profiles/genbi-setup/components/connect_source/component.yml +51 -0
  282. package/profiles/genbi-setup/components/connect_source/steps/connect.md +23 -0
  283. package/profiles/genbi-setup/context/binding.yml +8 -0
  284. package/profiles/genbi-setup/ir.golden.json +140 -0
  285. package/profiles/genbi-setup/profile.yml +18 -0
  286. package/profiles/genbi-setup/raw/schema.json +5 -0
@@ -0,0 +1,1517 @@
1
+ /**
2
+ * Durable native-session control plane. SQLite owns identity/lifecycle;
3
+ * the PTY remains deliberately process-local and contains the only replay.
4
+ */
5
+ import { createHash, randomUUID } from "node:crypto";
6
+ import { execFileSync, spawn } from "node:child_process";
7
+ import { accessSync, chmodSync, constants, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
8
+ import os from "node:os";
9
+ import path from "node:path";
10
+ import { InteractiveLaunchError, NO_INTERACTIVE_TERMINAL_LEASES, interactiveExecutableAvailable } from "./interactive-terminal.js";
11
+ import { newId } from "./db.js";
12
+ import { NATIVE_MCP_CREDENTIAL_ENV_VAR } from "./native-artifacts.js";
13
+ import { dispatchForPurpose, NATIVE_DISPATCH_REGISTRY, NATIVE_PURPOSES, nativeTargetLabel, sameNativeRuntimeBinding, targetForProvider } from "./native-dispatch-registry.js";
14
+ import { createNativeSessionWorkspace, initializeNativeSessionStateBase, nativeSessionStateBaseAvailable, nativeSessionStateBaseAvailableForProject, validateNativeSessionWorkspace } from "./native-session-workspace.js";
15
+ import { NativeWrenRuntimeError, resolveNativeWrenRuntime } from "./native-wren-runtime.js";
16
+ import { runtimeSettingsCorrection } from "./runtime-binding.js";
17
+ import { sealNativeClaudeResumeHandle, sealNativeResumeHandle, unsealNativeResumeHandle } from "./native-session-resume.js";
18
+ export const NATIVE_VENDORS = ["claude", "codex"];
19
+ export { NATIVE_PURPOSES };
20
+ export const NATIVE_SETUP_RECOVERY_MCP_TOOL_NAME = "report_setup_recovery";
21
+ /** Set only from the sealed, BFF-revalidated Setup bootstrap root at PTY launch. */
22
+ export const NATIVE_SETUP_BOOTSTRAP_ROOT_ENV_VAR = "WARBLE_SETUP_BOOTSTRAP_ROOT";
23
+ const MAX_SAFE_SETUP_RECOVERY_SEQUENCE = Number.MAX_SAFE_INTEGER;
24
+ /** A browser must attach a newly-created PTY promptly or it is an orphan. */
25
+ export const NATIVE_SESSION_INITIAL_ATTACHMENT_GRACE_MS = 15_000;
26
+ /**
27
+ * Ordinary navigation and idle browser detaches keep an already-authorized
28
+ * PTY reconnectable for this product TTL. This is intentionally much longer
29
+ * than the never-attached orphan cleanup below: a Session is a live tool, not
30
+ * a 60-second page view. It remains process-local; a BFF restart cannot
31
+ * recover this PTY without a separately sealed provider resume contract.
32
+ */
33
+ export const NATIVE_SESSION_IDLE_TTL_MS = 30 * 60_000;
34
+ /** @deprecated Use `NATIVE_SESSION_IDLE_TTL_MS`. Retained for test/API compatibility. */
35
+ export const NATIVE_SESSION_POST_CLAIM_DETACH_GRACE_MS = NATIVE_SESSION_IDLE_TTL_MS;
36
+ const START_SEPARATE_REPLAY_TTL_MS = 5 * 60_000;
37
+ const START_SEPARATE_REPLAY_LIMIT = 128;
38
+ const CODEX_NATIVE_PERMISSION_HEADER = "[permissions.warble_native_wren.filesystem]";
39
+ 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
+ const CODEX_RESUME_HANDLE_FILE = path.join(".warble", "codex-thread-id");
41
+ 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}`);
47
+ }
48
+ /**
49
+ * The producer deliberately sees only a sealed binding identity, never a caller-supplied path.
50
+ * After materialization, the BFF adds the canonical bound root to Codex's read-only permission
51
+ * profile so WREN_PROJECT_HOME can resolve through the exact server-owned binding.
52
+ */
53
+ export function grantCodexBoundProjectRead(cwd, projectPath, dataRoots = []) {
54
+ const configPath = path.join(cwd, ".codex", "config.toml");
55
+ const canonicalProject = realpathSync(projectPath);
56
+ const config = readFileSync(configPath, "utf8");
57
+ const headerIndex = config.indexOf(CODEX_NATIVE_PERMISSION_HEADER);
58
+ if (headerIndex < 0 || config.indexOf(CODEX_NATIVE_PERMISSION_HEADER, headerIndex + 1) >= 0) {
59
+ throw new InteractiveLaunchError("native Codex permission profile is invalid");
60
+ }
61
+ const nextTable = config.indexOf("\n[", headerIndex + CODEX_NATIVE_PERMISSION_HEADER.length);
62
+ const insertion = nextTable < 0 ? config.length : nextTable + 1;
63
+ const roots = [...new Set([canonicalProject, ...dataRoots.map((root) => realpathSync(root))])];
64
+ const permissions = roots
65
+ .map((root) => `${JSON.stringify(root)} = "read"\n`)
66
+ .filter((permission) => !config.includes(permission))
67
+ .join("");
68
+ if (!permissions)
69
+ return;
70
+ const updated = `${config.slice(0, insertion)}${permissions}${config.slice(insertion)}`;
71
+ writeFileSync(configPath, updated, { encoding: "utf8", mode: 0o600 });
72
+ chmodSync(configPath, 0o600);
73
+ }
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
+ function nativeSessionRestartReason(target, cause) {
129
+ switch (cause) {
130
+ case "not_terminal":
131
+ return `The ${target} session is still active; resume is only decided once it stops.`;
132
+ case "runtime_settings_correction":
133
+ return `Runtime settings need a correction in Setup before this ${target} session can resume. Restart creates a new isolated session.`;
134
+ case "scope_stale":
135
+ return `The project this session was bound to has changed since it ran, so resume is withheld deliberately. Restart creates a new isolated session against the current project.`;
136
+ case "workspace_unavailable":
137
+ return `The workspace this ${target} session used is no longer available. Restart creates a new isolated session.`;
138
+ case "no_resume_handle":
139
+ return `The current ${target} native launch contract has no sealed provider resume handle. Restart creates a new isolated session.`;
140
+ }
141
+ }
142
+ /**
143
+ * The accepted native-launch schema deliberately has no `resume` field for
144
+ * either current target. `readNativeLaunchSpec` rejects unknown fields, so
145
+ * this matrix is a deterministic statement about the sealed contract rather
146
+ * than a claim based on a browser-supplied handle.
147
+ */
148
+ export function nativeSessionLifecycle(row, resumeAvailability = { available: false, cause: "no_resume_handle" }) {
149
+ if (row.status === "creating" || row.status === "running" || row.status === "detached") {
150
+ return { liveAction: "reattach", resumeAvailable: false };
151
+ }
152
+ const target = row.vendor === "claude" ? "Claude" : "Codex";
153
+ if (resumeAvailability.available) {
154
+ return { liveAction: "resume", resumeAvailable: true, reason: `Resume continues the retained ${target} conversation in a new isolated terminal.` };
155
+ }
156
+ return {
157
+ liveAction: "restart",
158
+ resumeAvailable: false,
159
+ reason: nativeSessionRestartReason(target, resumeAvailability.cause),
160
+ };
161
+ }
162
+ export class NativeSetupRecoveryError extends Error {
163
+ status;
164
+ constructor(message, status = 400) {
165
+ super(message);
166
+ this.status = status;
167
+ }
168
+ }
169
+ /**
170
+ * The profile component each purpose enters through. This host owns these names: the components
171
+ * live in this product's own profiles, and Warble no longer keeps a copy — it materializes
172
+ * whichever verb the scope declares, after checking the declaration against the compiled IR.
173
+ */
174
+ const entryVerbFor = (purpose) => purpose === "analysis" ? "answer_query" : purpose === "setup" ? "connect_source" : "draft_enrichment";
175
+ const agentFor = (purpose, vendor) => vendor === "claude" ? entryVerbFor(purpose) : `genbi-${purpose === "context_enrichment" ? "enrich-context" : purpose}`;
176
+ /**
177
+ * Closed v4 Claude entry contract. This host is the only place the mapping exists: Warble used to
178
+ * carry a matching table and no longer does, so there is no second copy to drift from. Each native
179
+ * purpose enters through its named driver, never the profile-wide scope document. In particular,
180
+ * analysis is pinned to
181
+ * `answer_query`: that driver alone receives `persist_answer`, so a scope-entry launch must not
182
+ * make the capability reachable by analysis step agents.
183
+ *
184
+ * This host deliberately validates the dispatched shape rather than accepting either form. Add a
185
+ * purpose here only together with the corresponding Warble mapping and an exact-shape test; an
186
+ * omitted purpose remains a scope entry and is rejected if Warble emits a pin for it.
187
+ */
188
+ const PINNED_CLAUDE_PURPOSES = ["setup", "context_enrichment"];
189
+ /** Whether this closed producer contract uses the profile-wide Claude scope document. */
190
+ const claudeScopeEntry = (purpose, vendor) => vendor === "claude" && !PINNED_CLAUDE_PURPOSES.includes(purpose);
191
+ /**
192
+ * The entry this host declares in the scope descriptor Warble validates.
193
+ *
194
+ * Scope entry names no verb: the session starts at the profile-wide scope document and its own
195
+ * driver selects the component that owns each request, using the descriptions those components
196
+ * authored. Warble refuses a `scope` entry that also carries a verb, so the two forms are built
197
+ * here as alternatives rather than one shape with an optional field.
198
+ *
199
+ * `analysis` is the only purpose on the scope form today. It is also the one that gives up the
200
+ * most by pinning: its profile mounts four selectable components, and a pinned session can only
201
+ * reach the other three by delegating out of the one it was forced to start as.
202
+ */
203
+ const nativeEntryFor = (purpose, vendor) => claudeScopeEntry(purpose, vendor)
204
+ ? { kind: "scope", prompt: welcomePromptFor(purpose) }
205
+ : { verb: entryVerbFor(purpose), prompt: welcomePromptFor(purpose) };
206
+ /** Closed producer/host contract for the one initial native TUI prompt. */
207
+ const welcomePromptFor = (purpose) => {
208
+ switch (purpose) {
209
+ case "setup":
210
+ return "Help me set up this GenBI project. Start by explaining the next setup step and ask what data source I want to connect.";
211
+ case "analysis":
212
+ return "Help me analyze this data. Ask me what question I want to answer about the server-bound project.";
213
+ case "context_enrichment":
214
+ 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
+ }
216
+ };
217
+ function sameBinding(a, b) {
218
+ return b !== undefined && a.path === b.path && a.identity === b.identity && a.generation === b.generation && a.revision === b.revision;
219
+ }
220
+ function resumeScopeFingerprint(row) {
221
+ return createHash("sha256").update(JSON.stringify([
222
+ row.id, row.purpose, row.vendor, row.scopeKind, row.projectIdentity,
223
+ row.bindingGeneration, row.projectRevision, row.dispatchProfile,
224
+ row.dispatchTarget, row.runtimeGeneration,
225
+ ])).digest("hex");
226
+ }
227
+ function record(value) {
228
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : undefined;
229
+ }
230
+ function hasExactlyKeys(value, keys) {
231
+ return Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key));
232
+ }
233
+ function contained(root, candidate) {
234
+ const relative = path.relative(root, candidate);
235
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
236
+ }
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
+ function configuredWorkspace(cwd, create) {
257
+ try {
258
+ if (create)
259
+ mkdirSync(cwd, { recursive: true, mode: 0o700 });
260
+ const entry = lstatSync(cwd);
261
+ if (!entry.isDirectory() || entry.isSymbolicLink())
262
+ return undefined;
263
+ const resolved = realpathSync(cwd);
264
+ return statSync(resolved).isDirectory() ? resolved : undefined;
265
+ }
266
+ catch {
267
+ return undefined;
268
+ }
269
+ }
270
+ /**
271
+ * Readiness must not create a bootstrap directory. It may nevertheless say
272
+ * ready when the exact configured path is absent but can be safely created by
273
+ * the durable create boundary. Existing non-directories and symlinked roots
274
+ * fail closed rather than being treated as a writable workspace.
275
+ */
276
+ function bootstrapWorkspaceCreatable(cwd) {
277
+ if (configuredWorkspace(cwd, false))
278
+ return true;
279
+ const absolute = path.resolve(cwd);
280
+ let ancestor = absolute;
281
+ const missing = [];
282
+ while (true) {
283
+ try {
284
+ const entry = lstatSync(ancestor);
285
+ if (!entry.isDirectory() || entry.isSymbolicLink())
286
+ return false;
287
+ const canonicalAncestor = realpathSync(ancestor);
288
+ const candidate = path.resolve(canonicalAncestor, ...missing);
289
+ if (!contained(canonicalAncestor, candidate))
290
+ return false;
291
+ accessSync(canonicalAncestor, constants.W_OK | constants.X_OK);
292
+ return true;
293
+ }
294
+ catch (error) {
295
+ if (error.code !== "ENOENT")
296
+ return false;
297
+ const parent = path.dirname(ancestor);
298
+ if (parent === ancestor)
299
+ return false;
300
+ missing.unshift(path.basename(ancestor));
301
+ ancestor = parent;
302
+ }
303
+ }
304
+ }
305
+ function configuredIrPath(irPath) {
306
+ try {
307
+ return statSync(irPath).isFile();
308
+ }
309
+ catch {
310
+ return false;
311
+ }
312
+ }
313
+ /** A deliberately small public classification; never expose process or filesystem detail. */
314
+ export function nativeSessionLaunchFailure(error) {
315
+ const message = error instanceof Error ? error.message : "";
316
+ if (message === "native MCP URL is invalid")
317
+ return message;
318
+ if (message === "native session workspace is unavailable")
319
+ return message;
320
+ if (message === "native session materialization prerequisites are unavailable")
321
+ return message;
322
+ if (message === "native Wren runtime is unavailable")
323
+ return "native session materialization prerequisites are unavailable";
324
+ if (message === "native session producer is incompatible")
325
+ return message;
326
+ if (message === "native session launch artifacts are unavailable" || message === "native session launch specification is invalid" || message === "native session launch specification is incompatible")
327
+ return "native session launch artifacts are unavailable";
328
+ if (message === "native session launch paths are unavailable" || message === "native session launch paths are outside its server-owned scope")
329
+ return "native session launch artifacts are unavailable";
330
+ return "native session launch failed";
331
+ }
332
+ const NATIVE_PRODUCER_CATEGORY = "native_session_producer_incompatible";
333
+ /** Stable browser-safe code for an action UUID fenced by changed Runtime/binding scope. */
334
+ export const NATIVE_SESSION_LAUNCH_ACTION_STALE_CODE = "native_session_launch_action_stale";
335
+ /** Deliberately expose only this deterministic replay fence, never process detail. */
336
+ export function nativeSessionLaunchErrorCode(error) {
337
+ return error instanceof InteractiveLaunchError && error.message === "native session launch action is stale"
338
+ ? NATIVE_SESSION_LAUNCH_ACTION_STALE_CODE
339
+ : undefined;
340
+ }
341
+ const NATIVE_PROBE_TIMEOUT_MS = 5_000;
342
+ const NATIVE_PROBE_MAX_OUTPUT_BYTES = 64 * 1024;
343
+ const NATIVE_PROBE_TARGETS = ["claude", "codex"];
344
+ const NATIVE_HELP_MARKERS = [
345
+ "--target",
346
+ "--purpose",
347
+ "--native-scope",
348
+ "--native-mcp",
349
+ "--out",
350
+ ];
351
+ function nativeProducerResult(available, identity, phase, detail, producer) {
352
+ return {
353
+ available,
354
+ ...(available ? {} : { category: NATIVE_PRODUCER_CATEGORY }),
355
+ ...(available && producer ? { producer } : {}),
356
+ diagnostic: `identity=${identity ?? "unavailable"} phase=${phase} ${detail}`,
357
+ };
358
+ }
359
+ function executableIdentity(executable) {
360
+ try {
361
+ const metadata = statSync(executable);
362
+ if (!metadata.isFile() || (metadata.mode & 0o111) === 0)
363
+ return undefined;
364
+ return `sha256:${createHash("sha256").update(readFileSync(executable)).digest("hex")}`;
365
+ }
366
+ catch {
367
+ return undefined;
368
+ }
369
+ }
370
+ /**
371
+ * The configured spelling may be a PATH lookup or retargetable symlink. A
372
+ * preflight result instead owns the resolved executable and its digest. Check
373
+ * both again before creating a durable row and directly before spawning.
374
+ */
375
+ function assertNativeProducerIdentity(producer) {
376
+ const resolved = localExecutablePath(producer.executable);
377
+ if (resolved !== producer.executable || executableIdentity(producer.executable) !== producer.identity) {
378
+ throw new InteractiveLaunchError("native session producer is incompatible");
379
+ }
380
+ }
381
+ /**
382
+ * Runs a deliberately noninteractive command with a minimal environment. Its
383
+ * output is used only for local help-marker parsing and is never logged or
384
+ * returned: a third-party executable may print arbitrary sensitive text.
385
+ */
386
+ function runNativeProbe(command, args) {
387
+ return new Promise((resolve) => {
388
+ let output = "";
389
+ let timedOut = false;
390
+ let spawnFailed = false;
391
+ let settled = false;
392
+ let timer;
393
+ const finish = (exitCode, signal) => {
394
+ if (settled)
395
+ return;
396
+ settled = true;
397
+ if (timer)
398
+ clearTimeout(timer);
399
+ resolve({ exitCode, signal, output, timedOut, spawnFailed });
400
+ };
401
+ let child;
402
+ try {
403
+ child = spawn(command, [...args], {
404
+ cwd: os.tmpdir(),
405
+ env: { PATH: process.env["PATH"] ?? "" },
406
+ shell: false,
407
+ stdio: ["ignore", "pipe", "pipe"],
408
+ });
409
+ }
410
+ catch {
411
+ spawnFailed = true;
412
+ finish(null, null);
413
+ return;
414
+ }
415
+ const append = (value) => {
416
+ if (output.length >= NATIVE_PROBE_MAX_OUTPUT_BYTES)
417
+ return;
418
+ output += value.toString("utf8", 0, Math.min(value.length, NATIVE_PROBE_MAX_OUTPUT_BYTES - output.length));
419
+ };
420
+ child.stdout?.on("data", append);
421
+ child.stderr?.on("data", append);
422
+ child.once("error", () => {
423
+ spawnFailed = true;
424
+ finish(null, null);
425
+ });
426
+ child.once("close", (exitCode, signal) => finish(exitCode, signal));
427
+ timer = setTimeout(() => {
428
+ timedOut = true;
429
+ child.kill("SIGKILL");
430
+ }, NATIVE_PROBE_TIMEOUT_MS);
431
+ });
432
+ }
433
+ /**
434
+ * Exercises the exact, file-only native dispatch boundary against the
435
+ * configured executable. No model, vendor CLI, project workspace, or real
436
+ * credential is involved. The emitted launch spec is parsed by the same
437
+ * validator used just before a PTY starts.
438
+ */
439
+ export async function probeNativeSessionProducer(input) {
440
+ const executable = localExecutablePath(input.warbleBin);
441
+ if (!executable)
442
+ return nativeProducerResult(false, undefined, "resolve", "result=binary_not_found");
443
+ const identity = executableIdentity(executable);
444
+ if (!identity)
445
+ return nativeProducerResult(false, undefined, "identity", "result=identity_unavailable");
446
+ const producer = { executable, identity };
447
+ const help = await runNativeProbe(executable, ["dispatch", "--help"]);
448
+ if (help.spawnFailed)
449
+ return nativeProducerResult(false, identity, "dispatch_help", "result=spawn_failed");
450
+ if (help.timedOut)
451
+ return nativeProducerResult(false, identity, "dispatch_help", "result=timed_out");
452
+ if (help.exitCode !== 0)
453
+ return nativeProducerResult(false, identity, "dispatch_help", `result=exit_code_${help.exitCode ?? "signal"}`);
454
+ const missingMarkers = NATIVE_HELP_MARKERS.filter((marker) => !help.output.includes(marker));
455
+ if (missingMarkers.length > 0)
456
+ return nativeProducerResult(false, identity, "dispatch_help", `result=missing_markers_${missingMarkers.join(",")}`);
457
+ for (const purpose of NATIVE_PURPOSES) {
458
+ const irPath = input.irPaths[purpose];
459
+ if (!irPath || !configuredIrPath(irPath))
460
+ continue;
461
+ for (const vendor of NATIVE_PROBE_TARGETS) {
462
+ let root;
463
+ try {
464
+ root = mkdtempSync(path.join(os.tmpdir(), "genbi-native-preflight-"));
465
+ chmodSync(root, 0o700);
466
+ root = realpathSync(root);
467
+ const outputRoot = path.join(root, "out");
468
+ mkdirSync(outputRoot, { mode: 0o700 });
469
+ const bootstrapRoot = path.join(root, "bootstrap");
470
+ mkdirSync(bootstrapRoot, { mode: 0o700 });
471
+ const scopeId = `preflight-${purpose}-${vendor}`;
472
+ const scopePath = path.join(root, "scope.json");
473
+ const mcpPath = path.join(root, "mcp.json");
474
+ const wrenRuntime = vendor === "codex" ? resolveNativeWrenRuntime(input.wrenShim) : undefined;
475
+ const binding = NATIVE_DISPATCH_REGISTRY[purpose].scopeKind === "bound_project"
476
+ ? { project_identity: "native-preflight-project", generation: "1", revision: "native-preflight-revision" }
477
+ : undefined;
478
+ 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" });
479
+ 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" });
480
+ const dispatched = await runNativeProbe(executable, ["dispatch", irPath, "--target", targetForProvider(vendor), "--purpose", purpose, "--native-scope", scopePath, "--native-mcp", mcpPath, "--out", outputRoot]);
481
+ const phase = `dispatch_${purpose}_${vendor}`;
482
+ if (dispatched.spawnFailed)
483
+ return nativeProducerResult(false, identity, phase, "result=spawn_failed");
484
+ if (dispatched.timedOut)
485
+ return nativeProducerResult(false, identity, phase, "result=timed_out");
486
+ if (dispatched.exitCode !== 0)
487
+ return nativeProducerResult(false, identity, phase, `result=exit_code_${dispatched.exitCode ?? "signal"}`);
488
+ try {
489
+ readNativeLaunchSpec(outputRoot, purpose, vendor, scopeId, undefined, { version: "1", url: "http://127.0.0.1:0/api/native-sessions/mcp", credential: "native-preflight-nonsecret" });
490
+ }
491
+ catch {
492
+ return nativeProducerResult(false, identity, phase, "result=launch_spec_incompatible");
493
+ }
494
+ }
495
+ catch (error) {
496
+ if (error instanceof NativeWrenRuntimeError)
497
+ return nativeProducerResult(false, identity, `dispatch_${purpose}_${vendor}`, `result=wren_runtime_unavailable (${error.message})`);
498
+ return nativeProducerResult(false, identity, `dispatch_${purpose}_${vendor}`, "result=preflight_workspace_unavailable");
499
+ }
500
+ finally {
501
+ if (root)
502
+ rmSync(root, { recursive: true, force: true });
503
+ }
504
+ }
505
+ }
506
+ return nativeProducerResult(true, identity, "complete", "result=compatible", producer);
507
+ }
508
+ function parseNativeSetupRecoveryReport(value) {
509
+ const report = record(value);
510
+ if (!report || !Object.keys(report).every((key) => ["version", "sequence", "phase", "state", "code", "decision"].includes(key)) ||
511
+ !["version", "sequence", "phase", "state", "code"].every((key) => Object.hasOwn(report, key)))
512
+ throw new NativeSetupRecoveryError("invalid report_setup_recovery v1 input");
513
+ if (report.version !== "1" || typeof report.sequence !== "number" || !Number.isSafeInteger(report.sequence) || report.sequence < 1 || report.sequence > MAX_SAFE_SETUP_RECOVERY_SEQUENCE ||
514
+ (report.phase !== "connect" && report.phase !== "context") ||
515
+ (report.state !== "working" && report.state !== "needs_input" && report.state !== "needs_decision" && report.state !== "retryable_failure" && report.state !== "reported_complete") ||
516
+ (report.code !== "in_progress" && report.code !== "user_action_required" && report.code !== "continue_or_stop" && report.code !== "retryable" && report.code !== "completion_reported"))
517
+ throw new NativeSetupRecoveryError("invalid report_setup_recovery v1 input");
518
+ const decision = report.decision;
519
+ const decisionRecord = record(decision);
520
+ const validDecision = decisionRecord !== undefined && hasExactlyKeys(decisionRecord, ["kind", "choices"]) && decisionRecord.kind === "continue_or_stop" && Array.isArray(decisionRecord.choices) && decisionRecord.choices.length === 2 && decisionRecord.choices[0] === "continue" && decisionRecord.choices[1] === "stop";
521
+ const valid = (report.state === "working" && report.code === "in_progress" && decision === undefined)
522
+ || (report.state === "needs_input" && report.code === "user_action_required" && decision === undefined)
523
+ || (report.state === "needs_decision" && report.code === "continue_or_stop" && validDecision)
524
+ || (report.state === "retryable_failure" && report.code === "retryable" && decision === undefined)
525
+ || (report.state === "reported_complete" && report.code === "completion_reported" && decision === undefined);
526
+ if (!valid)
527
+ throw new NativeSetupRecoveryError("invalid report_setup_recovery v1 input");
528
+ return report;
529
+ }
530
+ /**
531
+ * Strict parser for the public v2/v4 producer contracts. V4 deliberately
532
+ * omits the host-issued scope; Setup exposes only its separately authorized
533
+ * bootstrap root, which is revalidated against the durable host configuration.
534
+ */
535
+ export function readNativeLaunchSpec(cwd, purpose, vendor, scopeId, binding, mcp, materializationState, authorizedWorkspace, providerLaunch) {
536
+ const ownershipRoot = binding?.path ?? authorizedWorkspace;
537
+ const root = materializationState && ownershipRoot
538
+ ? validateNativeSessionWorkspace(materializationState, ownershipRoot, cwd)
539
+ : realpathSync(cwd);
540
+ const specPath = path.join(root, ".warble", "interactive-launch.json");
541
+ if (!existsSync(specPath))
542
+ throw new InteractiveLaunchError("native session launch artifacts are unavailable");
543
+ let raw;
544
+ try {
545
+ raw = JSON.parse(readFileSync(specPath, "utf8"));
546
+ }
547
+ catch {
548
+ throw new InteractiveLaunchError("native session launch specification is invalid");
549
+ }
550
+ const value = record(raw);
551
+ const isV4 = mcp !== undefined;
552
+ const isSetupV4 = isV4 && purpose === "setup";
553
+ const keys = ["version", "target", "purpose", "executable", "argv", "agent", ...(isV4 ? ["mcp", ...(isSetupV4 ? ["bootstrap_root"] : [])] : ["scope"]), "cwd", "artifact_root", "handoff_path"];
554
+ const target = targetForProvider(vendor);
555
+ const executable = vendor === "claude" ? "claude" : "codex";
556
+ const expectedAgent = agentFor(purpose, vendor);
557
+ const scopeEntry = claudeScopeEntry(purpose, vendor);
558
+ if (!value || !hasExactlyKeys(value, keys) ||
559
+ value.version !== (isV4 ? "4" : "2") || value.target !== target || value.purpose !== purpose || value.executable !== executable ||
560
+ !Array.isArray(value.argv) || !value.argv.every((arg) => typeof arg === "string") || !record(value.agent) ||
561
+ (!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") {
562
+ throw new InteractiveLaunchError("native session launch specification is incompatible");
563
+ }
564
+ const welcome = welcomePromptFor(purpose);
565
+ const baseArgv = scopeEntry
566
+ ? isV4 ? [welcome] : []
567
+ : isV4
568
+ ? vendor === "claude" ? ["--agent", expectedAgent, welcome] : [welcome]
569
+ : vendor === "claude" ? ["--agent", expectedAgent] : [];
570
+ const spawnBaseArgv = vendor === "codex" ? ["--dangerously-bypass-hook-trust", ...baseArgv] : baseArgv;
571
+ const agent = value.agent;
572
+ const scope = record(value.scope);
573
+ const launchMcp = record(value.mcp);
574
+ const launchBinding = scope ? record(scope.binding) : undefined;
575
+ const validV2Scope = scope !== undefined && hasExactlyKeys(scope, ["kind", "scope_id", "bootstrap_root", "binding"])
576
+ && (scope.binding === null || (launchBinding !== undefined && hasExactlyKeys(launchBinding, ["project_identity", "generation", "revision"])))
577
+ && scope.kind === (purpose === "setup" ? "bootstrap" : "bound_project") && scope.scope_id === scopeId
578
+ && (purpose === "setup" ? typeof scope.bootstrap_root === "string" : scope.bootstrap_root === null);
579
+ // A scope-entry launch names the whole profile, not one pinned component, so the emitted
580
+ // "agent" descriptor is a distinct kind (`claude_scope`) carrying the profile id rather than
581
+ // `claude_agent`/`expectedAgent`. See `claudeScopeEntry` above.
582
+ const expectedAgentKind = scopeEntry ? "claude_scope" : vendor === "claude" ? "claude_agent" : "codex_skill";
583
+ const expectedAgentName = scopeEntry ? NATIVE_DISPATCH_REGISTRY[purpose].profile : expectedAgent;
584
+ if (!hasExactlyKeys(agent, ["kind", "name"]) || (!isV4 && !validV2Scope) ||
585
+ JSON.stringify(value.argv) !== JSON.stringify(baseArgv) || agent.kind !== expectedAgentKind || agent.name !== expectedAgentName ||
586
+ (isV4 && (!launchMcp || !hasExactlyKeys(launchMcp, ["server_name", "credential_env_var"]) || launchMcp.server_name !== "genbi_session" || launchMcp.credential_env_var !== NATIVE_MCP_CREDENTIAL_ENV_VAR)))
587
+ throw new InteractiveLaunchError("native session launch specification is incompatible");
588
+ let specCwd;
589
+ let artifactRoot;
590
+ let handoffPath;
591
+ try {
592
+ specCwd = realpathSync(value.cwd);
593
+ artifactRoot = realpathSync(value.artifact_root);
594
+ handoffPath = realpathSync(value.handoff_path);
595
+ }
596
+ catch {
597
+ throw new InteractiveLaunchError("native session launch paths are unavailable");
598
+ }
599
+ if (specCwd !== root || artifactRoot !== root || !contained(root, handoffPath))
600
+ throw new InteractiveLaunchError("native session launch paths are outside its server-owned scope");
601
+ let bootstrapRoot;
602
+ if (purpose === "setup") {
603
+ const expectedBootstrapRoot = authorizedWorkspace && configuredWorkspace(authorizedWorkspace, true);
604
+ const launchBootstrapRoot = isSetupV4 ? value.bootstrap_root : scope?.bootstrap_root;
605
+ if (typeof launchBootstrapRoot !== "string")
606
+ throw new InteractiveLaunchError("native session launch bootstrap root is unavailable");
607
+ let canonicalLaunchBootstrapRoot;
608
+ try {
609
+ canonicalLaunchBootstrapRoot = realpathSync(launchBootstrapRoot);
610
+ }
611
+ catch {
612
+ throw new InteractiveLaunchError("native session launch bootstrap root is unavailable");
613
+ }
614
+ if (canonicalLaunchBootstrapRoot !== launchBootstrapRoot || (authorizedWorkspace !== undefined && !expectedBootstrapRoot) || (expectedBootstrapRoot !== undefined && canonicalLaunchBootstrapRoot !== expectedBootstrapRoot) || canonicalLaunchBootstrapRoot === root)
615
+ throw new InteractiveLaunchError("native session launch bootstrap root is incompatible");
616
+ bootstrapRoot = canonicalLaunchBootstrapRoot;
617
+ }
618
+ if (!isV4) {
619
+ if (binding) {
620
+ if (!launchBinding || launchBinding.project_identity !== binding.identity || launchBinding.generation !== String(binding.generation) || launchBinding.revision !== binding.revision)
621
+ throw new InteractiveLaunchError("native session launch binding is stale");
622
+ }
623
+ else if (scope?.binding !== null)
624
+ throw new InteractiveLaunchError("native session launch specification is incompatible");
625
+ }
626
+ if (providerLaunch?.kind === "initial" && vendor !== "claude")
627
+ throw new InteractiveLaunchError("native session launch specification is incompatible");
628
+ // Claude's welcome text is positional. Provider flags must remain before it
629
+ // so neither `--session-id` nor `--resume` can be parsed as conversation
630
+ // text by a future CLI parser.
631
+ const argv = providerLaunch?.kind === "initial"
632
+ ? [...(scopeEntry ? [] : ["--agent", expectedAgent]), "--session-id", providerLaunch.sessionId, ...(isV4 ? [welcome] : [])]
633
+ : providerLaunch?.kind === "resume"
634
+ ? vendor === "claude"
635
+ ? [...(scopeEntry ? [] : ["--agent", expectedAgent]), "--resume", providerLaunch.handle, ...(isV4 ? [welcome] : [])]
636
+ : ["--dangerously-bypass-hook-trust", "-c", 'tui.resume_cwd="current"', "resume", providerLaunch.handle, ...(isV4 ? [welcome] : [])]
637
+ : spawnBaseArgv;
638
+ return { version: isV4 ? "4" : "2", target, executable, argv, cwd: root, artifact_root: root, handoff_path: handoffPath, ...(bootstrapRoot ? { bootstrap_root: bootstrapRoot } : {}) };
639
+ }
640
+ /**
641
+ * Materializes only host-owned, short-lived producer inputs. The scope and
642
+ * MCP descriptor deliberately stay in separate exclusive files because the
643
+ * public Warble v4 contract parses them through distinct flags and keeps the
644
+ * credential out of the scope schema and every argv value.
645
+ */
646
+ export async function dispatchNativeArtifacts(input) {
647
+ let tempRoot;
648
+ try {
649
+ tempRoot = realpathSync(os.tmpdir());
650
+ }
651
+ catch {
652
+ throw new InteractiveLaunchError("native session materialization failed");
653
+ }
654
+ const dir = mkdtempSync(path.join(tempRoot, "genbi-native-dispatch-"));
655
+ const scopePath = path.join(dir, "scope.json");
656
+ const mcpPath = path.join(dir, "mcp.json");
657
+ try {
658
+ const directory = lstatSync(dir);
659
+ if (!directory.isDirectory() || directory.isSymbolicLink() || !contained(tempRoot, dir))
660
+ throw new InteractiveLaunchError("native session materialization failed");
661
+ chmodSync(dir, 0o700);
662
+ if ((lstatSync(dir).mode & 0o777) !== 0o700 || !contained(dir, scopePath) || !contained(dir, mcpPath))
663
+ throw new InteractiveLaunchError("native session materialization failed");
664
+ writeFileSync(scopePath, JSON.stringify(input.scope), { encoding: "utf8", mode: 0o600, flag: "wx" });
665
+ if (input.mcp)
666
+ writeFileSync(mcpPath, JSON.stringify(input.mcp), { encoding: "utf8", mode: 0o600, flag: "wx" });
667
+ const validateDescriptorFile = (file) => {
668
+ const metadata = lstatSync(file);
669
+ if (!metadata.isFile() || metadata.isSymbolicLink() || (metadata.mode & 0o777) !== 0o600)
670
+ throw new InteractiveLaunchError("native session materialization failed");
671
+ };
672
+ validateDescriptorFile(scopePath);
673
+ if (input.mcp)
674
+ validateDescriptorFile(mcpPath);
675
+ const executable = input.producer?.executable ?? input.warbleBin;
676
+ if (input.producer)
677
+ assertNativeProducerIdentity(input.producer);
678
+ const args = ["dispatch", input.irPath, "--target", input.target, "--purpose", input.purpose, "--native-scope", scopePath, ...(input.mcp ? ["--native-mcp", mcpPath] : []), "--out", input.cwd];
679
+ await new Promise((resolve, reject) => {
680
+ const child = spawn(executable, args, { cwd: input.cwd, shell: false, stdio: "ignore" });
681
+ child.once("error", () => reject(new InteractiveLaunchError("native session materialization failed")));
682
+ child.once("exit", (code) => code === 0 ? resolve() : reject(new InteractiveLaunchError("native session materialization failed")));
683
+ });
684
+ }
685
+ finally {
686
+ rmSync(dir, { recursive: true, force: true });
687
+ }
688
+ }
689
+ export class NativeSessionService {
690
+ options;
691
+ sessions = new Map();
692
+ leaseTimers = new Map();
693
+ /** Open/reconnect coalescing only lasts while a matching launch is in flight. */
694
+ launches = new Map();
695
+ /** Bounded post-settlement delivery replay, fenced to the captured server scope. */
696
+ startSeparateReplays = new Map();
697
+ /** In-process duplicate delivery fence for one durable provider-resume action. */
698
+ resumeLaunches = new Map();
699
+ artifactCredentials = new Map();
700
+ /** Kept only while the owning BFF and PTY remain live; never reconstructed from SQLite. */
701
+ recoveryCapabilities = new Map();
702
+ idleTtlMs;
703
+ startSeparateReplayTtlMs;
704
+ startSeparateReplayLimit;
705
+ fixtureMaterializationState;
706
+ constructor(options) {
707
+ this.options = options;
708
+ this.idleTtlMs = options.idleTtlMs ?? options.postClaimDetachGraceMs ?? NATIVE_SESSION_IDLE_TTL_MS;
709
+ if (!Number.isSafeInteger(this.idleTtlMs) || this.idleTtlMs <= 0)
710
+ throw new RangeError("native session idle TTL must be a positive integer");
711
+ this.startSeparateReplayTtlMs = options.startSeparateReplayTtlMs ?? START_SEPARATE_REPLAY_TTL_MS;
712
+ this.startSeparateReplayLimit = options.startSeparateReplayLimit ?? START_SEPARATE_REPLAY_LIMIT;
713
+ if (!Number.isSafeInteger(this.startSeparateReplayTtlMs) || this.startSeparateReplayTtlMs <= 0)
714
+ throw new RangeError("native start-separate replay TTL must be a positive integer");
715
+ if (!Number.isSafeInteger(this.startSeparateReplayLimit) || this.startSeparateReplayLimit <= 0)
716
+ throw new RangeError("native start-separate replay limit must be a positive integer");
717
+ // Unit-only producer seams lack a configured BFF database. Establish their
718
+ // throwaway root at construction, never from the readiness probe.
719
+ if (!options.materializationState && (options.dispatch !== undefined || options.producerAvailable !== undefined || options.warbleBin === "unused")) {
720
+ this.fixtureMaterializationState = initializeNativeSessionStateBase(path.join(mkdtempSync(path.join(os.tmpdir(), "genbi-native-fixture-state-")), "bff.sqlite"));
721
+ }
722
+ }
723
+ /** Existing injected producer seams are test-only and preserve old unit fixtures. */
724
+ legacyFixtureMode() {
725
+ return this.options.dispatch !== undefined || this.options.producerAvailable !== undefined || this.options.warbleBin === "unused";
726
+ }
727
+ /**
728
+ * Production must inject the DB-derived base at BFF startup. Existing unit
729
+ * seams have no BFF database path, so give only those injected producers an
730
+ * external throwaway state root; browser/API routes can never take this path.
731
+ */
732
+ materializationState() {
733
+ if (this.options.materializationState)
734
+ return this.options.materializationState;
735
+ return this.fixtureMaterializationState;
736
+ }
737
+ /**
738
+ * Test-injected producers retain their historical seam. Production always
739
+ * invokes the actual configured executable and logs only the redacted,
740
+ * content-addressed diagnostic; the browser never receives it.
741
+ */
742
+ async productionProducerPreflight() {
743
+ if (this.legacyFixtureMode())
744
+ return undefined;
745
+ const result = await probeNativeSessionProducer({ warbleBin: this.options.warbleBin, irPaths: this.options.irPaths, ...(this.options.wrenShim !== undefined ? { wrenShim: this.options.wrenShim } : {}) });
746
+ const log = result.available ? console.info : console.warn;
747
+ log(`[native-sessions] producer preflight ${result.diagnostic}`);
748
+ return result;
749
+ }
750
+ /**
751
+ * SQLite is the durable source of truth, but a native PTY and its attach
752
+ * capability are deliberately process-local. A BFF restart (or a lease
753
+ * expiry racing a browser reconnect) can therefore leave an old durable
754
+ * `detached` row behind after its process-local terminal is gone. Reconcile
755
+ * that impossible "live" projection at every browser-visible read instead
756
+ * of letting the client repeatedly open a WebSocket to a missing PTY.
757
+ */
758
+ list() {
759
+ return this.options.store.listNativeSessions().map((row) => this.reconcileMissingTerminal(row));
760
+ }
761
+ get(id) {
762
+ const row = this.options.store.getNativeSession(id);
763
+ return row ? this.reconcileMissingTerminal(row) : undefined;
764
+ }
765
+ runtime(id) { return this.sessions.get(id); }
766
+ recovery(id) { return this.options.store.getNativeSetupRecovery(id); }
767
+ reconcileMissingTerminal(row) {
768
+ // `creating` is intentionally not reconciled here: launch awaits can
769
+ // yield while the durable row is still being initialized. It has never
770
+ // owned an attachable capability, so treating it as a missing terminal
771
+ // would race creation and could terminalize a PTY that is about to start.
772
+ if ((row.status !== "running" && row.status !== "detached") || this.sessions.has(row.id))
773
+ return row;
774
+ // Commit the durable fence first. The cleanup below is best effort and
775
+ // cannot leave a browser-facing row claiming that it can reattach.
776
+ this.captureCodexResumeHandle(row);
777
+ const stopped = this.options.store.stopNativeSessionAndRevokeRecoveryAction(row.id, {
778
+ failure: "native terminal is no longer available for attachment",
779
+ });
780
+ this.clearLease(row.id);
781
+ this.clearStartSeparateReplaysForSession(row.id);
782
+ this.recoveryCapabilities.delete(row.id);
783
+ this.options.artifactService?.revoke(this.artifactCredentials.get(row.id));
784
+ this.artifactCredentials.delete(row.id);
785
+ return stopped;
786
+ }
787
+ /** A legacy persisted Runtime must be repaired before it can authorize a PTY. */
788
+ assertRuntimeDispatchable() {
789
+ if (!this.options.store.hasExplicitRuntimeSettings())
790
+ return;
791
+ const correction = runtimeSettingsCorrection(this.options.store.getRuntimeSettings());
792
+ if (correction)
793
+ throw new InteractiveLaunchError(correction);
794
+ }
795
+ /**
796
+ * Reopen only a still-live session in the exact server-owned scope. In
797
+ * particular, a setup session never inherits the active project binding,
798
+ * while a bound-purpose session cannot cross a generation or revision.
799
+ */
800
+ async openOrCreate(input) {
801
+ this.assertRuntimeDispatchable();
802
+ const storedRuntime = this.options.store.getNativeRuntimeBinding();
803
+ // Compatibility seam for existing server-side callers/tests only. Browser
804
+ // routes never admit `vendor`, so all web launches remain runtime-bound.
805
+ const runtime = !storedRuntime.configured && input.vendor
806
+ ? { configured: true, generation: 0, provider: input.vendor, target: targetForProvider(input.vendor), targetLabel: nativeTargetLabel(input.vendor) }
807
+ : storedRuntime;
808
+ const dispatch = dispatchForPurpose(input.purpose, runtime);
809
+ if (!dispatch)
810
+ throw new InteractiveLaunchError("native sessions require a saved Runtime & authentication binding");
811
+ const binding = input.purpose === "setup" ? undefined : this.options.getBinding();
812
+ const reusable = this.list().find((row) => this.matchesOpenScope(row, input.purpose, dispatch.target, runtime.generation, binding));
813
+ if (reusable) {
814
+ const recoveryCapability = this.recoveryCapabilities.get(reusable.id);
815
+ return { row: reusable, capability: this.sessions.get(reusable.id).capability, ...(recoveryCapability ? { recoveryCapability } : {}) };
816
+ }
817
+ const scopeKey = input.purpose === "setup"
818
+ ? JSON.stringify([input.purpose, dispatch.target, runtime.generation, "bootstrap"])
819
+ : JSON.stringify([input.purpose, dispatch.target, runtime.generation, binding?.identity ?? "unbound", binding?.generation ?? null, binding?.revision ?? null]);
820
+ return this.singleFlight(`open:${scopeKey}`, scopeKey, input, binding, runtime);
821
+ }
822
+ /** Opens the exact row selected by the browser after revalidating its scope. */
823
+ async openExisting(input) {
824
+ this.assertRuntimeDispatchable();
825
+ const storedRuntime = this.options.store.getNativeRuntimeBinding();
826
+ const runtime = !storedRuntime.configured && input.vendor
827
+ ? { configured: true, generation: 0, provider: input.vendor, target: targetForProvider(input.vendor), targetLabel: nativeTargetLabel(input.vendor) }
828
+ : storedRuntime;
829
+ const dispatch = dispatchForPurpose(input.purpose, runtime);
830
+ if (!dispatch)
831
+ throw new InteractiveLaunchError("native sessions require a saved Runtime & authentication binding");
832
+ const binding = input.purpose === "setup" ? undefined : this.options.getBinding();
833
+ const row = this.get(input.id);
834
+ if (!row || !this.matchesOpenScope(row, input.purpose, dispatch.target, runtime.generation, binding))
835
+ throw new InteractiveLaunchError("native session is unavailable");
836
+ const recoveryCapability = this.recoveryCapabilities.get(row.id);
837
+ return { row, capability: this.sessions.get(row.id).capability, ...(recoveryCapability ? { recoveryCapability } : {}) };
838
+ }
839
+ /**
840
+ * Starts a separate TUI even when a matching session is live. The caller's
841
+ * action id is intentionally part of the single-flight key: retries of one
842
+ * click coalesce, while a later intentional launch receives new durable and
843
+ * browser-scoped authority.
844
+ */
845
+ async startSeparate(input) {
846
+ this.assertRuntimeDispatchable();
847
+ const storedRuntime = this.options.store.getNativeRuntimeBinding();
848
+ const runtime = !storedRuntime.configured && input.vendor
849
+ ? { configured: true, generation: 0, provider: input.vendor, target: targetForProvider(input.vendor), targetLabel: nativeTargetLabel(input.vendor) }
850
+ : storedRuntime;
851
+ const dispatch = dispatchForPurpose(input.purpose, runtime);
852
+ if (!dispatch)
853
+ throw new InteractiveLaunchError("native sessions require a saved Runtime & authentication binding");
854
+ const binding = input.purpose === "setup" ? undefined : this.options.getBinding();
855
+ const scopeKey = input.purpose === "setup"
856
+ ? JSON.stringify([input.purpose, dispatch.target, runtime.generation, "bootstrap"])
857
+ : JSON.stringify([input.purpose, dispatch.target, runtime.generation, binding?.identity ?? "unbound", binding?.generation ?? null, binding?.revision ?? null]);
858
+ return this.replayStartSeparate(`start:${input.idempotencyKey}`, scopeKey, input, binding, runtime);
859
+ }
860
+ /**
861
+ * Continues only a sealed Claude conversation. This creates a new durable
862
+ * process row and materialization root; it never reattaches or relabels a
863
+ * fresh conversation as a resume.
864
+ */
865
+ async resume(input) {
866
+ this.assertRuntimeDispatchable();
867
+ const source = this.get(input.id);
868
+ if (!source || (source.status !== "exited" && source.status !== "stopped" && source.status !== "interrupted" && source.status !== "failed" && source.status !== "stale")) {
869
+ throw new InteractiveLaunchError("native session resume is unavailable");
870
+ }
871
+ this.captureCodexResumeHandle(source);
872
+ const existingAction = this.options.store.getNativeSessionResumeAction(source.id, input.idempotencyKey);
873
+ const scope = this.resumeScope(source);
874
+ if (!scope) {
875
+ if (existingAction)
876
+ this.failReservedResume(existingAction.resumedSessionId, "native session resume is stale because its Runtime or project binding changed");
877
+ this.options.store.invalidateNativeSessionResume(source.id);
878
+ throw new InteractiveLaunchError("native session resume is stale because its Runtime or project binding changed");
879
+ }
880
+ const launchKey = `${source.id}:${input.idempotencyKey}`;
881
+ const inFlight = this.resumeLaunches.get(launchKey);
882
+ if (inFlight)
883
+ return inFlight;
884
+ const launch = this.resumeReserved(source, input.idempotencyKey, scope);
885
+ this.resumeLaunches.set(launchKey, launch);
886
+ try {
887
+ return await launch;
888
+ }
889
+ finally {
890
+ if (this.resumeLaunches.get(launchKey) === launch)
891
+ this.resumeLaunches.delete(launchKey);
892
+ }
893
+ }
894
+ /** Browser-visible resume state uses the same current-scope fence as launch, plus *why* when it fails. */
895
+ resumeAvailability(row) {
896
+ if (row.status !== "exited" && row.status !== "stopped" && row.status !== "interrupted" && row.status !== "failed" && row.status !== "stale") {
897
+ return { available: false, cause: "not_terminal" };
898
+ }
899
+ this.captureCodexResumeHandle(row);
900
+ if (this.options.store.hasExplicitRuntimeSettings() && runtimeSettingsCorrection(this.options.store.getRuntimeSettings())) {
901
+ return { available: false, cause: "runtime_settings_correction" };
902
+ }
903
+ const scope = this.resumeScope(row);
904
+ if (scope === undefined)
905
+ return { available: false, cause: "scope_stale" };
906
+ if (!this.resumeWorkspaceAvailable(row, scope.binding))
907
+ return { available: false, cause: "workspace_unavailable" };
908
+ if (!this.options.store.hasAvailableNativeSessionResume(row.id, row.vendor))
909
+ return { available: false, cause: "no_resume_handle" };
910
+ return { available: true };
911
+ }
912
+ /** Thin boolean projection kept for callers that only need a yes/no. */
913
+ resumeAvailable(row) {
914
+ return this.resumeAvailability(row).available;
915
+ }
916
+ /**
917
+ * Codex exposes the current thread UUID to hooks. Warble's isolated hook writes
918
+ * only that UUID under this session's BFF-owned materialization. The BFF reads
919
+ * no global Codex state and persists only a provider-bound encrypted envelope.
920
+ */
921
+ captureCodexResumeHandle(row) {
922
+ if (row.vendor !== "codex" || this.options.store.hasAvailableNativeSessionResume(row.id, "codex"))
923
+ return;
924
+ const state = this.materializationState();
925
+ if (!state)
926
+ return;
927
+ const root = path.join(state.root, "native", row.id);
928
+ const file = path.join(root, CODEX_RESUME_HANDLE_FILE);
929
+ try {
930
+ if (!contained(root, file))
931
+ return;
932
+ const entry = lstatSync(file);
933
+ if (!entry.isFile() || entry.isSymbolicLink() || (entry.mode & 0o777) !== 0o600 || realpathSync(file) !== file)
934
+ return;
935
+ const handle = readFileSync(file, "utf8").trim();
936
+ if (!PROVIDER_SESSION_UUID.test(handle))
937
+ return;
938
+ const sealed = sealNativeResumeHandle(state, row.id, "codex", handle);
939
+ this.options.store.saveNativeSessionResumeHandle(row.id, "codex", sealed);
940
+ }
941
+ catch {
942
+ // No completed prompt means no Codex thread exists yet. Missing or unsafe
943
+ // capture state stays restart-only and never degrades terminal cleanup.
944
+ }
945
+ }
946
+ resumeScope(row) {
947
+ const storedRuntime = this.options.store.getNativeRuntimeBinding();
948
+ const runtime = !storedRuntime.configured && this.legacyFixtureMode()
949
+ ? { configured: true, generation: 0, provider: row.vendor, target: targetForProvider(row.vendor), targetLabel: nativeTargetLabel(row.vendor) }
950
+ : storedRuntime;
951
+ const dispatch = dispatchForPurpose(row.purpose, runtime);
952
+ const binding = row.purpose === "setup" ? undefined : this.options.getBinding();
953
+ const matches = dispatch?.provider === row.vendor && row.dispatchProfile === dispatch.profile && row.dispatchTarget === dispatch.target && row.runtimeGeneration === runtime.generation
954
+ && (row.purpose === "setup"
955
+ ? row.scopeKind === "bootstrap" && row.projectIdentity === null && row.bindingGeneration === null && row.projectRevision === null
956
+ : binding !== undefined && row.scopeKind === "bound_project" && row.projectIdentity === binding.identity && row.bindingGeneration === binding.generation && row.projectRevision === binding.revision);
957
+ return matches && dispatch ? { binding, runtime, dispatch } : undefined;
958
+ }
959
+ resumeWorkspaceAvailable(row, binding) {
960
+ const configuredCwd = binding?.path ?? this.options.workspaceRoot;
961
+ const authorizedWorkspace = configuredCwd === undefined ? undefined : configuredWorkspace(configuredCwd, false);
962
+ return authorizedWorkspace !== undefined
963
+ && nativeSessionStateBaseAvailableForProject(this.materializationState(), authorizedWorkspace)
964
+ && (row.purpose === "setup" || binding !== undefined);
965
+ }
966
+ async resumeReserved(source, idempotencyKey, scope) {
967
+ const fingerprint = resumeScopeFingerprint(source);
968
+ const existing = this.options.store.getNativeSessionResumeAction(source.id, idempotencyKey);
969
+ if (existing) {
970
+ if (existing.scopeFingerprint !== fingerprint) {
971
+ this.failReservedResume(existing.resumedSessionId, "native session resume action is stale");
972
+ throw new InteractiveLaunchError("native session resume action is stale");
973
+ }
974
+ return this.recoverReservedResume(existing.resumedSessionId, scope);
975
+ }
976
+ const stored = this.options.store.getNativeSessionResumeHandle(source.id);
977
+ const state = this.materializationState();
978
+ if (!stored || stored.provider !== source.vendor || stored.consumedAt !== null || !state)
979
+ throw new InteractiveLaunchError("native session resume is unavailable");
980
+ let handle;
981
+ try {
982
+ handle = unsealNativeResumeHandle(state, source.id, source.vendor, stored.sealedHandle);
983
+ }
984
+ catch (error) {
985
+ this.options.store.invalidateNativeSessionResume(source.id);
986
+ throw error;
987
+ }
988
+ const childId = newId("native-session");
989
+ const childScopeId = newId("native-scope");
990
+ const sealedChild = sealNativeResumeHandle(state, childId, source.vendor, handle);
991
+ const reserved = this.options.store.reserveNativeSessionResume({
992
+ sourceSessionId: source.id, idempotencyKey, scopeFingerprint: fingerprint, sealedHandle: sealedChild,
993
+ child: {
994
+ id: childId, purpose: source.purpose, vendor: source.vendor, agent: agentFor(source.purpose, source.vendor), scopeKind: source.scopeKind, scopeId: childScopeId,
995
+ dispatchProfile: scope.dispatch.profile, dispatchTarget: scope.dispatch.target, runtimeGeneration: scope.runtime.generation,
996
+ ...(scope.binding ? { projectIdentity: scope.binding.identity, bindingGeneration: scope.binding.generation, projectRevision: scope.binding.revision } : {}),
997
+ },
998
+ });
999
+ if (!reserved)
1000
+ throw new InteractiveLaunchError("native session resume is unavailable");
1001
+ if (!reserved.created)
1002
+ return this.recoverReservedResume(reserved.row.id, scope);
1003
+ return this.create({ purpose: source.purpose, ...(this.legacyFixtureMode() ? { vendor: source.vendor } : {}) }, scope.binding, scope.runtime, { row: reserved.row, handle });
1004
+ }
1005
+ async recoverReservedResume(id, scope) {
1006
+ const row = this.get(id);
1007
+ if (!row)
1008
+ throw new InteractiveLaunchError("native session resume is unavailable");
1009
+ const terminal = this.sessions.get(row.id);
1010
+ if (terminal)
1011
+ return { row, capability: terminal.capability };
1012
+ // Store reconciliation marks an unstarted reservation `interrupted` on a
1013
+ // BFF restart along with live rows. `startedAt === null` distinguishes
1014
+ // this crash window from a child PTY that actually ran and later died.
1015
+ if (row.status !== "creating" && !(row.status === "interrupted" && row.startedAt === null))
1016
+ return { row };
1017
+ const state = this.materializationState();
1018
+ const stored = this.options.store.getNativeSessionResumeHandle(row.id);
1019
+ if (!state || !stored || stored.provider !== row.vendor || stored.consumedAt !== null) {
1020
+ this.failReservedResume(row.id, "native session resume reservation cannot be recovered");
1021
+ throw new InteractiveLaunchError("native session resume reservation cannot be recovered");
1022
+ }
1023
+ let handle;
1024
+ try {
1025
+ handle = unsealNativeResumeHandle(state, row.id, row.vendor, stored.sealedHandle);
1026
+ }
1027
+ catch (error) {
1028
+ this.failReservedResume(row.id, "native session resume reservation cannot be recovered");
1029
+ throw error;
1030
+ }
1031
+ return this.create({ purpose: row.purpose, ...(this.legacyFixtureMode() ? { vendor: row.vendor } : {}) }, scope.binding, scope.runtime, { row, handle });
1032
+ }
1033
+ failReservedResume(id, failure) {
1034
+ const row = this.options.store.getNativeSession(id);
1035
+ if (row?.status === "creating" || (row?.status === "interrupted" && row.startedAt === null))
1036
+ this.options.store.transitionNativeSession(id, "failed", { failure, ended: true });
1037
+ this.options.store.invalidateNativeSessionResume(id);
1038
+ }
1039
+ matchesOpenScope(row, purpose, target, generation, binding) {
1040
+ if (row.purpose !== purpose || row.dispatchTarget !== target || row.runtimeGeneration !== generation || !this.sessions.has(row.id))
1041
+ return false;
1042
+ if (row.status !== "creating" && row.status !== "running" && row.status !== "detached")
1043
+ return false;
1044
+ return purpose === "setup"
1045
+ ? row.scopeKind === "bootstrap" && row.projectIdentity === null && row.bindingGeneration === null && row.projectRevision === null
1046
+ : row.scopeKind === "bound_project" && binding !== undefined && row.projectIdentity === binding.identity && row.bindingGeneration === binding.generation && row.projectRevision === binding.revision;
1047
+ }
1048
+ /**
1049
+ * A start-separate action is browser-scoped by its unguessable UUID. This
1050
+ * BFF has no user principal beyond its local browser capability boundary, so
1051
+ * its captured Runtime/binding scope is also the authorization fence.
1052
+ */
1053
+ replayStartSeparate(launchKey, scopeKey, input, binding, runtime) {
1054
+ const existing = this.startSeparateReplays.get(launchKey);
1055
+ if (existing) {
1056
+ if (existing.scopeKey !== scopeKey)
1057
+ throw new InteractiveLaunchError("native session launch action is stale");
1058
+ if (this.replayIsUsable(existing))
1059
+ return existing.launch;
1060
+ this.startSeparateReplays.delete(launchKey);
1061
+ }
1062
+ const launch = this.create(input, binding, runtime);
1063
+ const replay = { scopeKey, launch };
1064
+ this.startSeparateReplays.set(launchKey, replay);
1065
+ void launch.then((result) => { replay.settledAt = Date.now(); replay.result = result; this.trimStartSeparateReplays(); }, () => { replay.settledAt = Date.now(); this.trimStartSeparateReplays(); });
1066
+ return launch;
1067
+ }
1068
+ replayIsUsable(replay) {
1069
+ if (replay.settledAt === undefined)
1070
+ return true;
1071
+ if (Date.now() - replay.settledAt >= this.startSeparateReplayTtlMs)
1072
+ return false;
1073
+ // A successful response may contain an attach capability. Never replay it
1074
+ // after the owning PTY/capability has gone away; failures carry no terminal
1075
+ // capability and remain safely replayable within the same fenced scope.
1076
+ return !replay.result?.capability || this.sessions.get(replay.result.row.id)?.capability === replay.result.capability;
1077
+ }
1078
+ trimStartSeparateReplays() {
1079
+ for (const [key, replay] of this.startSeparateReplays) {
1080
+ if (replay.settledAt !== undefined && !this.replayIsUsable(replay))
1081
+ this.startSeparateReplays.delete(key);
1082
+ }
1083
+ let settled = [...this.startSeparateReplays.entries()].filter(([, replay]) => replay.settledAt !== undefined);
1084
+ while (settled.length > this.startSeparateReplayLimit) {
1085
+ this.startSeparateReplays.delete(settled.shift()[0]);
1086
+ }
1087
+ }
1088
+ clearStartSeparateReplaysForSession(id) {
1089
+ for (const [key, replay] of this.startSeparateReplays) {
1090
+ if (replay.result?.row.id === id)
1091
+ this.startSeparateReplays.delete(key);
1092
+ }
1093
+ }
1094
+ async singleFlight(launchKey, scopeKey, input, binding, runtime) {
1095
+ const pending = this.launches.get(launchKey);
1096
+ if (pending) {
1097
+ // An action id must never be replayed across a changed Runtime/binding.
1098
+ if (pending.scopeKey !== scopeKey)
1099
+ throw new InteractiveLaunchError("native session launch action is stale");
1100
+ return pending.launch;
1101
+ }
1102
+ const launch = this.create(input, binding, runtime);
1103
+ this.launches.set(launchKey, { scopeKey, launch });
1104
+ try {
1105
+ return await launch;
1106
+ }
1107
+ finally {
1108
+ if (this.launches.get(launchKey)?.launch === launch)
1109
+ this.launches.delete(launchKey);
1110
+ }
1111
+ }
1112
+ async readiness() {
1113
+ const terminalHostAvailable = this.options.terminalHostAvailable ?? (async () => true);
1114
+ const executableAvailable = (vendor) => this.options.executableAvailable
1115
+ ? this.options.executableAvailable(vendor)
1116
+ : interactiveExecutableAvailable(vendor);
1117
+ const hostAvailable = await terminalHostAvailable();
1118
+ const mcpReadiness = this.options.artifactService?.readiness();
1119
+ const runtime = this.options.store.getNativeRuntimeBinding();
1120
+ const runtimeCorrection = this.options.store.hasExplicitRuntimeSettings()
1121
+ ? runtimeSettingsCorrection(this.options.store.getRuntimeSettings())
1122
+ : undefined;
1123
+ const legacyFixture = !runtime.configured && this.legacyFixtureMode();
1124
+ const producer = this.legacyFixtureMode() ? undefined : await this.productionProducerPreflight();
1125
+ const legacyProducerAvailable = this.options.producerAvailable ?? (() => this.options.dispatch !== undefined || localExecutableAvailable(this.options.warbleBin));
1126
+ const result = {};
1127
+ for (const purpose of NATIVE_PURPOSES) {
1128
+ const definition = NATIVE_DISPATCH_REGISTRY[purpose];
1129
+ const dispatch = dispatchForPurpose(purpose, runtime);
1130
+ const scopeKind = definition.scopeKind;
1131
+ const cwd = purpose === "setup" ? this.options.workspaceRoot : this.options.getBinding()?.path;
1132
+ const irPath = this.options.irPaths[purpose];
1133
+ const configuredRoot = cwd === undefined ? undefined : configuredWorkspace(cwd, false);
1134
+ const projectAvailable = configuredRoot !== undefined || (purpose === "setup" && cwd !== undefined && bootstrapWorkspaceCreatable(cwd));
1135
+ // Existing project roots must pass the exact same canonical outside-project
1136
+ // check used by createNativeSessionWorkspace. A missing-but-creatable Setup
1137
+ // root has no state overlap yet, and remains non-mutating at readiness.
1138
+ const stateAvailable = configuredRoot !== undefined
1139
+ ? nativeSessionStateBaseAvailableForProject(this.materializationState(), configuredRoot)
1140
+ : purpose === "setup" && projectAvailable && nativeSessionStateBaseAvailable(this.materializationState());
1141
+ const workspaceUnavailable = !projectAvailable || !stateAvailable;
1142
+ const reason = runtimeCorrection
1143
+ ?? (!dispatch && !legacyFixture
1144
+ ? "native sessions require a saved Runtime & authentication binding"
1145
+ : !cwd
1146
+ ? purpose === "setup" ? "native setup sessions require a workspace root" : "native sessions require a current bound project"
1147
+ : workspaceUnavailable
1148
+ ? "native session workspace is unavailable"
1149
+ : producer && !producer.available
1150
+ ? "native session producer is incompatible"
1151
+ : !irPath || !(this.options.dispatch !== undefined || configuredIrPath(irPath)) || !legacyProducerAvailable()
1152
+ ? "native session materialization prerequisites are unavailable"
1153
+ : mcpReadiness && !mcpReadiness.available
1154
+ ? mcpReadiness.reason
1155
+ : undefined);
1156
+ const executable = dispatch?.provider === "claude" ? "claude" : "codex";
1157
+ const unavailable = reason ?? (!hostAvailable
1158
+ ? "native terminal host cannot spawn local processes on this machine"
1159
+ : dispatch && !executableAvailable(dispatch.provider)
1160
+ ? `the ${executable} interactive CLI is not available on this machine`
1161
+ : undefined);
1162
+ const vendorReadiness = {};
1163
+ if (legacyFixture)
1164
+ for (const vendor of NATIVE_VENDORS) {
1165
+ const legacyExecutable = vendor === "claude" ? "claude" : "codex";
1166
+ vendorReadiness[vendor] = reason
1167
+ ? { available: false, reason }
1168
+ : !hostAvailable ? { available: false, reason: "native terminal host cannot spawn local processes on this machine" }
1169
+ : !executableAvailable(vendor) ? { available: false, reason: `the ${legacyExecutable} interactive CLI is not available on this machine` }
1170
+ : { available: true };
1171
+ }
1172
+ const legacyAvailable = legacyFixture ? Object.values(vendorReadiness).some((value) => value.available) : unavailable === undefined;
1173
+ result[purpose] = {
1174
+ scopeKind,
1175
+ profile: definition.profile,
1176
+ ...(dispatch ? { target: dispatch.target, targetLabel: dispatch.targetLabel } : {}),
1177
+ ...(producer ? { producer: producer.available ? { available: true } : { available: false, category: NATIVE_PRODUCER_CATEGORY } } : {}),
1178
+ available: legacyAvailable,
1179
+ ...(unavailable ? { reason: unavailable } : {}),
1180
+ vendors: vendorReadiness,
1181
+ };
1182
+ }
1183
+ if (legacyFixture) {
1184
+ const legacy = Object.fromEntries(Object.entries(result).map(([purpose, value]) => {
1185
+ const { profile: _profile, target: _target, targetLabel: _targetLabel, ...oldShape } = value;
1186
+ return [purpose, oldShape];
1187
+ }));
1188
+ return legacy;
1189
+ }
1190
+ return {
1191
+ runtime,
1192
+ purposes: result,
1193
+ mcp: this.options.artifactService?.health() ?? {
1194
+ server: "GenBI MCP",
1195
+ tool: "save_dashboard",
1196
+ destination: "GenBI Artifacts",
1197
+ available: false,
1198
+ reason: "GenBI MCP is not configured",
1199
+ },
1200
+ ...result,
1201
+ };
1202
+ }
1203
+ async create(input, binding = input.purpose === "setup" ? undefined : this.options.getBinding(), capturedRuntime = this.options.store.getNativeRuntimeBinding(), resumed) {
1204
+ this.assertRuntimeDispatchable();
1205
+ const purpose = input.purpose;
1206
+ const legacyRuntime = !capturedRuntime.configured && input.vendor
1207
+ ? { configured: true, generation: 0, provider: input.vendor, target: targetForProvider(input.vendor), targetLabel: nativeTargetLabel(input.vendor) }
1208
+ : capturedRuntime;
1209
+ const dispatchDefinition = dispatchForPurpose(purpose, legacyRuntime);
1210
+ if (!dispatchDefinition)
1211
+ throw new InteractiveLaunchError("native sessions require a saved Runtime & authentication binding");
1212
+ if (purpose !== "setup" && !binding)
1213
+ throw new InteractiveLaunchError("native session requires a current bound project");
1214
+ const configuredCwd = binding?.path ?? this.options.workspaceRoot;
1215
+ const configuredIr = this.options.irPaths[purpose];
1216
+ if (!configuredCwd || !configuredIr)
1217
+ throw new InteractiveLaunchError(`native ${dispatchDefinition.profile} session is not configured`);
1218
+ // Compile for this binding rather than dispatching the profile's golden.
1219
+ // Falls back to the configured path only where no resolver is wired (the
1220
+ // fixture-mode tests), never silently in production — see `bin.ts`.
1221
+ const irPath = this.options.resolveDispatchIr === undefined
1222
+ ? configuredIr
1223
+ : await this.options.resolveDispatchIr(purpose, binding);
1224
+ const producer = this.legacyFixtureMode() ? undefined : await this.productionProducerPreflight();
1225
+ if (producer && (!producer.available || !producer.producer))
1226
+ throw new InteractiveLaunchError("native session producer is incompatible");
1227
+ if (producer?.producer)
1228
+ assertNativeProducerIdentity(producer.producer);
1229
+ const scopeId = resumed?.row.scopeId ?? newId("native-scope");
1230
+ const id = resumed?.row.id ?? newId("native-session");
1231
+ const capability = randomUUID();
1232
+ const recoveryCapability = purpose === "setup" ? randomUUID() : undefined;
1233
+ let providerLaunch = resumed ? { kind: "resume", handle: resumed.handle } : undefined;
1234
+ let initialSealedHandle;
1235
+ if (!resumed && dispatchDefinition.provider === "claude") {
1236
+ const state = this.materializationState();
1237
+ if (!state)
1238
+ throw new InteractiveLaunchError("native session resume storage is unavailable");
1239
+ const sessionId = randomUUID();
1240
+ initialSealedHandle = sealNativeClaudeResumeHandle(state, id, sessionId);
1241
+ providerLaunch = { kind: "initial", sessionId };
1242
+ }
1243
+ const row = resumed?.row ?? this.options.store.createNativeSession({ id, purpose, vendor: dispatchDefinition.provider, agent: agentFor(purpose, dispatchDefinition.provider), scopeKind: dispatchDefinition.scopeKind, scopeId,
1244
+ dispatchProfile: dispatchDefinition.profile, dispatchTarget: dispatchDefinition.target, runtimeGeneration: legacyRuntime.generation,
1245
+ ...(binding ? { projectIdentity: binding.identity, bindingGeneration: binding.generation, projectRevision: binding.revision } : {}) });
1246
+ if (initialSealedHandle)
1247
+ this.options.store.saveNativeSessionResumeHandle(id, "claude", initialSealedHandle);
1248
+ if (recoveryCapability) {
1249
+ this.options.store.issueNativeSetupRecoveryAction(id, recoveryCapability);
1250
+ this.recoveryCapabilities.set(id, recoveryCapability);
1251
+ }
1252
+ const artifactService = this.options.artifactService;
1253
+ // Setup is bootstrap-scoped, but its accepted v4 recovery tool still uses
1254
+ // the same short-lived server-owned MCP transport. The tool allowlist,
1255
+ // not a producer-visible scope, decides which operation it can perform.
1256
+ let launchPhase = "workspace";
1257
+ try {
1258
+ // The bootstrap workspace remains the authorized project-creation
1259
+ // context in the producer scope. Every purpose, including Setup, emits
1260
+ // its vendor-owned launch files into a distinct BFF-owned root so a new
1261
+ // scope/MCP digest never collides with a prior ownership marker.
1262
+ const authorizedWorkspace = purpose === "setup"
1263
+ ? configuredWorkspace(configuredCwd, true)
1264
+ : configuredWorkspace(configuredCwd, false);
1265
+ const materializationState = this.materializationState();
1266
+ if (!authorizedWorkspace || !materializationState)
1267
+ throw new InteractiveLaunchError("native session workspace is unavailable");
1268
+ const cwd = createNativeSessionWorkspace(materializationState, authorizedWorkspace, id);
1269
+ const producerAvailable = this.options.producerAvailable ?? (() => this.options.dispatch !== undefined || localExecutableAvailable(this.options.warbleBin));
1270
+ if (!(this.options.dispatch !== undefined || configuredIrPath(irPath)) || !producerAvailable())
1271
+ throw new InteractiveLaunchError("native session materialization prerequisites are unavailable");
1272
+ const mcpReadiness = artifactService?.readiness();
1273
+ if (mcpReadiness && !mcpReadiness.available)
1274
+ throw new InteractiveLaunchError(mcpReadiness.reason ?? "native MCP URL is invalid");
1275
+ const mcp = artifactService?.issue(row, binding);
1276
+ if (mcp)
1277
+ this.artifactCredentials.set(id, mcp.credential);
1278
+ let wrenRuntime;
1279
+ try {
1280
+ wrenRuntime = dispatchDefinition.provider === "codex" && !this.legacyFixtureMode() ? resolveNativeWrenRuntime(this.options.wrenShim) : undefined;
1281
+ }
1282
+ catch (error) {
1283
+ if (error instanceof NativeWrenRuntimeError)
1284
+ throw new InteractiveLaunchError("native Wren runtime is unavailable");
1285
+ throw error;
1286
+ }
1287
+ 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 } } : {}) };
1288
+ const dispatch = this.options.dispatch ?? dispatchNativeArtifacts;
1289
+ validateNativeSessionWorkspace(materializationState, authorizedWorkspace, cwd);
1290
+ launchPhase = "dispatch";
1291
+ await dispatch({ warbleBin: producer?.producer?.executable ?? this.options.warbleBin, ...(producer?.producer ? { producer: producer.producer } : {}), irPath, target: dispatchDefinition.target, cwd, purpose, scope, ...(mcp ? { mcp } : {}) });
1292
+ if (!input.vendor && !sameNativeRuntimeBinding(capturedRuntime, this.options.store.getNativeRuntimeBinding())) {
1293
+ this.options.store.transitionNativeSession(id, "stale", { failure: "native runtime binding changed before launch", ended: true });
1294
+ throw new InteractiveLaunchError("native session is stale because the runtime binding changed");
1295
+ }
1296
+ if (binding && !sameBinding(binding, this.options.getBinding())) {
1297
+ this.options.store.transitionNativeSession(id, "stale", { failure: "native session binding changed before launch", ended: true });
1298
+ throw new InteractiveLaunchError("native session is stale because the bound project changed");
1299
+ }
1300
+ launchPhase = "launch_spec";
1301
+ const spec = readNativeLaunchSpec(cwd, purpose, dispatchDefinition.provider, scopeId, binding, mcp, materializationState, authorizedWorkspace, providerLaunch);
1302
+ let codexWrenHome;
1303
+ if (spec.version === "4" && dispatchDefinition.provider === "codex" && binding) {
1304
+ if (wrenRuntime) {
1305
+ launchPhase = "codex_wren_home";
1306
+ codexWrenHome = (this.options.prepareCodexWrenHome ?? ((input) => materializeCodexWrenHome(input.runtime, input.projectPath, input.cwd)))({
1307
+ runtime: wrenRuntime,
1308
+ projectPath: binding.path,
1309
+ cwd,
1310
+ });
1311
+ }
1312
+ launchPhase = "codex_project_read";
1313
+ grantCodexBoundProjectRead(cwd, binding.path, codexWrenHome?.dataRoots);
1314
+ }
1315
+ launchPhase = "terminal_manager";
1316
+ const terminalManager = await this.options.terminalManager();
1317
+ if (binding && !sameBinding(binding, this.options.getBinding())) {
1318
+ this.options.store.transitionNativeSession(id, "stale", { failure: "native session binding changed before PTY launch", ended: true });
1319
+ throw new InteractiveLaunchError("native session is stale because the bound project changed");
1320
+ }
1321
+ if (!input.vendor && !sameNativeRuntimeBinding(capturedRuntime, this.options.store.getNativeRuntimeBinding())) {
1322
+ this.options.store.transitionNativeSession(id, "stale", { failure: "native runtime binding changed before PTY launch", ended: true });
1323
+ throw new InteractiveLaunchError("native session is stale because the runtime binding changed");
1324
+ }
1325
+ validateNativeSessionWorkspace(materializationState, authorizedWorkspace, cwd);
1326
+ const terminalEnv = {
1327
+ ...process.env,
1328
+ ...(mcp ? { [NATIVE_MCP_CREDENTIAL_ENV_VAR]: mcp.credential } : {}),
1329
+ ...(binding ? { WREN_PROJECT_HOME: binding.path } : {}),
1330
+ ...(codexWrenHome ? { WREN_HOME: codexWrenHome.home } : {}),
1331
+ };
1332
+ // Never inherit a caller/operator value: only the launch spec's already
1333
+ // revalidated Setup root may reach the native TUI.
1334
+ delete terminalEnv[NATIVE_SETUP_BOOTSTRAP_ROOT_ENV_VAR];
1335
+ if (spec.bootstrap_root)
1336
+ terminalEnv[NATIVE_SETUP_BOOTSTRAP_ROOT_ENV_VAR] = spec.bootstrap_root;
1337
+ launchPhase = "terminal_start";
1338
+ const terminal = terminalManager.start(spec, { id, capability }, terminalEnv, NO_INTERACTIVE_TERMINAL_LEASES);
1339
+ this.sessions.set(id, terminal);
1340
+ // Mark running before registering a replaying exit listener. A PTY can
1341
+ // exit between start() and this registration; `onExit` immediately
1342
+ // replays that state and must win over this transition, never be
1343
+ // overwritten by a later unconditional running write.
1344
+ launchPhase = "running_transition";
1345
+ this.options.store.transitionNativeSession(id, "running", { started: true });
1346
+ terminal.onExit((exitCode) => {
1347
+ this.captureCodexResumeHandle(this.options.store.getNativeSession(id) ?? row);
1348
+ this.clearLease(id);
1349
+ this.sessions.delete(id);
1350
+ this.clearStartSeparateReplaysForSession(id);
1351
+ this.recoveryCapabilities.delete(id);
1352
+ artifactService?.revoke(this.artifactCredentials.get(id));
1353
+ this.artifactCredentials.delete(id);
1354
+ const current = this.options.store.getNativeSession(id);
1355
+ if (current && (current.status === "running" || current.status === "detached"))
1356
+ this.options.store.transitionNativeSession(id, "exited", { exitCode, ended: true });
1357
+ });
1358
+ if (this.sessions.has(id))
1359
+ this.armLease(id, "native session attachment timed out", NATIVE_SESSION_INITIAL_ATTACHMENT_GRACE_MS);
1360
+ return { row: this.options.store.getNativeSession(id), capability, ...(recoveryCapability ? { recoveryCapability } : {}) };
1361
+ }
1362
+ catch (error) {
1363
+ logNativeLaunchFailure(purpose, dispatchDefinition.provider, launchPhase, error);
1364
+ this.recoveryCapabilities.delete(id);
1365
+ artifactService?.revoke(this.artifactCredentials.get(id));
1366
+ this.artifactCredentials.delete(id);
1367
+ const current = this.options.store.getNativeSession(id);
1368
+ const failure = nativeSessionLaunchFailure(error);
1369
+ if (current?.status === "creating" || (resumed && current?.status === "interrupted" && current.startedAt === null))
1370
+ this.options.store.transitionNativeSession(id, "failed", { failure, ended: true });
1371
+ if (dispatchDefinition.provider === "claude" || resumed)
1372
+ this.options.store.invalidateNativeSessionResume(id);
1373
+ if (purpose === "setup") {
1374
+ // The failed row and its browser-held recovery capability are the only
1375
+ // recovery surface. No terminal/MCP credential or process detail is
1376
+ // retained after a failed initial launch.
1377
+ return { row: this.options.store.getNativeSession(id), ...(recoveryCapability ? { recoveryCapability } : {}) };
1378
+ }
1379
+ // The HTTP route maps every internal failure through the public
1380
+ // classifier. Keeping the original error here preserves server-side
1381
+ // callers' typed stale/materialization distinctions.
1382
+ throw error;
1383
+ }
1384
+ }
1385
+ attach(id, capability) {
1386
+ const row = this.get(id);
1387
+ const runtime = this.options.store.getNativeRuntimeBinding();
1388
+ const binding = this.options.getBinding();
1389
+ const currentScope = row?.scopeKind === "bootstrap"
1390
+ ? row.projectIdentity === null && row.bindingGeneration === null && row.projectRevision === null
1391
+ : row !== undefined && binding !== undefined && row.projectIdentity === binding.identity && row.bindingGeneration === binding.generation && row.projectRevision === binding.revision;
1392
+ if (!row || (row.status !== "running" && row.status !== "detached") || !currentScope || !runtime.configured || row.dispatchTarget !== runtime.target || row.runtimeGeneration !== runtime.generation)
1393
+ return undefined;
1394
+ const terminal = this.sessions.get(id);
1395
+ if (!terminal || !terminal.claim(capability))
1396
+ return undefined;
1397
+ this.clearLease(id);
1398
+ this.options.store.transitionNativeSession(id, "running");
1399
+ return terminal;
1400
+ }
1401
+ detach(id) {
1402
+ this.sessions.get(id)?.detach();
1403
+ if (this.sessions.has(id)) {
1404
+ this.options.store.transitionNativeSession(id, "detached");
1405
+ this.armLease(id, "native session idle TTL expired", this.idleTtlMs);
1406
+ }
1407
+ }
1408
+ stop(id, capability) {
1409
+ const terminal = this.sessions.get(id);
1410
+ if (!terminal || terminal.capability !== capability)
1411
+ return false;
1412
+ const row = this.options.store.getNativeSession(id);
1413
+ if (row)
1414
+ this.captureCodexResumeHandle(row);
1415
+ if (!this.options.store.stopNativeSessionAndRevokeRecoveryAction(id))
1416
+ return false;
1417
+ this.revokeCapabilities([id]);
1418
+ return true;
1419
+ }
1420
+ revokeCapabilities(ids) {
1421
+ for (const id of ids) {
1422
+ const terminal = this.sessions.get(id);
1423
+ this.clearLease(id);
1424
+ this.clearStartSeparateReplaysForSession(id);
1425
+ this.recoveryCapabilities.delete(id);
1426
+ this.options.artifactService?.revoke(this.artifactCredentials.get(id));
1427
+ this.artifactCredentials.delete(id);
1428
+ this.sessions.delete(id);
1429
+ try {
1430
+ terminal?.close();
1431
+ }
1432
+ catch {
1433
+ // A process kill may fail after the durable session fence committed.
1434
+ // Capabilities are already unreachable; continue revoking every id.
1435
+ console.warn("[native-sessions] terminal close failed during capability revocation");
1436
+ }
1437
+ }
1438
+ }
1439
+ /** Called after the store's atomic runtime switch fence has committed. */
1440
+ revokeRuntimeCapabilities(ids) { this.revokeCapabilities(ids); }
1441
+ /** Called after the store advances the bound-project generation. */
1442
+ revokeBindingCapabilities(ids) { this.revokeCapabilities(ids); }
1443
+ /**
1444
+ * Records only the producer's closed redacted report. Completion remains a
1445
+ * host decision supplied by the canonical-artifact validator at the route.
1446
+ */
1447
+ reportSetupRecovery(sessionId, value, completionValidated) {
1448
+ const session = this.options.store.getNativeSession(sessionId);
1449
+ if (!session || session.purpose !== "setup" || (session.status !== "running" && session.status !== "detached"))
1450
+ throw new NativeSetupRecoveryError("native setup session is unavailable", 409);
1451
+ const report = parseNativeSetupRecoveryReport(value);
1452
+ const recovery = this.options.store.recordNativeSetupRecovery({
1453
+ sessionId,
1454
+ phase: report.phase,
1455
+ state: report.state,
1456
+ code: report.code,
1457
+ sequence: report.sequence,
1458
+ decision: report.state === "needs_decision" ? "continue_or_stop" : null,
1459
+ completionValidated: report.state === "reported_complete" && completionValidated,
1460
+ });
1461
+ if (!recovery)
1462
+ throw new NativeSetupRecoveryError("native setup recovery report is stale", 409);
1463
+ return recovery;
1464
+ }
1465
+ /**
1466
+ * Recovery never resumes a process-owned PTY. Once a terminal session has
1467
+ * ended/interrupted, a fenced action creates a fresh Setup session and its
1468
+ * new terminal/MCP/recovery capabilities. Live terminals must be handled in
1469
+ * their owning browser tab and are rejected here.
1470
+ */
1471
+ async actOnSetupRecovery(input) {
1472
+ const session = this.options.store.getNativeSession(input.id);
1473
+ const recovery = this.options.store.getNativeSetupRecovery(input.id);
1474
+ const recoveryVersion = recovery?.version ?? 0;
1475
+ if (session?.status === "stopped")
1476
+ throw new NativeSetupRecoveryError("native setup recovery action is unavailable", 409);
1477
+ const terminalGone = session?.status === "exited" || session?.status === "interrupted" || session?.status === "failed" || session?.status === "stale";
1478
+ const recoveryAllows = input.action === "retry"
1479
+ ? recovery?.state === "retryable_failure" || session?.status === "interrupted" || session?.status === "failed" || session?.status === "exited"
1480
+ : input.action === "continue"
1481
+ ? recovery?.state === "needs_input" || recovery?.state === "needs_decision"
1482
+ : recovery?.state === "needs_decision";
1483
+ if (!session || session.purpose !== "setup" || !terminalGone || recoveryVersion !== input.expectedVersion || !recoveryAllows || recovery?.completionValidated)
1484
+ throw new NativeSetupRecoveryError("native setup recovery action is unavailable", 409);
1485
+ if (!this.options.store.claimNativeSetupRecoveryAction(input.id, input.capability, input.expectedVersion))
1486
+ throw new NativeSetupRecoveryError("native setup recovery action is unauthorized or stale", 409);
1487
+ try {
1488
+ if (input.action === "stop") {
1489
+ this.options.store.stopNativeSessionAndRevokeRecoveryAction(input.id);
1490
+ return undefined;
1491
+ }
1492
+ return await this.create({ purpose: "setup", ...(this.legacyFixtureMode() ? { vendor: session.vendor } : {}) });
1493
+ }
1494
+ catch (error) {
1495
+ this.options.store.releaseNativeSetupRecoveryAction(input.id);
1496
+ throw error;
1497
+ }
1498
+ }
1499
+ clearLease(id) {
1500
+ const timer = this.leaseTimers.get(id);
1501
+ if (timer)
1502
+ clearTimeout(timer);
1503
+ this.leaseTimers.delete(id);
1504
+ }
1505
+ armLease(id, reason, timeoutMs) {
1506
+ this.clearLease(id);
1507
+ this.leaseTimers.set(id, setTimeout(() => {
1508
+ const terminal = this.sessions.get(id);
1509
+ if (!terminal) {
1510
+ this.leaseTimers.delete(id);
1511
+ return;
1512
+ }
1513
+ this.options.store.transitionNativeSession(id, "stopped", { failure: reason, ended: true });
1514
+ this.revokeCapabilities([id]);
1515
+ }, timeoutMs));
1516
+ }
1517
+ }