okengine 0.9.0 → 0.10.0

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 (175) hide show
  1. package/AGENTS.md +1 -1
  2. package/manifest.v1.schema.json +9 -1
  3. package/package.json +3 -3
  4. package/site/content/docs/console/runs.mdx +10 -0
  5. package/site/content/docs/console/traces.mdx +4 -0
  6. package/site/content/docs/elements/ai.mdx +54 -32
  7. package/site/content/docs/elements/channel.mdx +105 -14
  8. package/site/content/docs/elements/clock.mdx +1 -0
  9. package/site/content/docs/elements/flow.mdx +42 -8
  10. package/site/content/docs/get-started/basic-usage.mdx +5 -1
  11. package/site/content/docs/get-started/installation.mdx +9 -5
  12. package/site/content/docs/meta.json +2 -0
  13. package/site/content/docs/plugins/otp.mdx +58 -46
  14. package/site/content/docs/providers/azure-redis.mdx +85 -0
  15. package/site/content/docs/providers/cockroachdb.mdx +86 -0
  16. package/site/content/docs/providers/digitalocean-caching.mdx +88 -0
  17. package/site/content/docs/providers/dragonfly-cloud.mdx +82 -0
  18. package/site/content/docs/providers/elasticache.mdx +92 -0
  19. package/site/content/docs/providers/index.mdx +101 -0
  20. package/site/content/docs/providers/memorystore.mdx +89 -0
  21. package/site/content/docs/providers/meta.json +20 -0
  22. package/site/content/docs/providers/neon.mdx +115 -0
  23. package/site/content/docs/providers/redis-cloud.mdx +93 -0
  24. package/site/content/docs/providers/supabase.mdx +117 -0
  25. package/site/content/docs/providers/upstash.mdx +91 -0
  26. package/site/content/docs/providers/yugabytedb.mdx +95 -0
  27. package/site/content/docs/recipes/caddy.mdx +128 -0
  28. package/site/content/docs/recipes/cockroachdb.mdx +127 -0
  29. package/site/content/docs/recipes/dragonfly.mdx +125 -0
  30. package/site/content/docs/recipes/index.mdx +146 -0
  31. package/site/content/docs/recipes/llama-cpp.mdx +153 -0
  32. package/site/content/docs/recipes/mailpit.mdx +136 -0
  33. package/site/content/docs/recipes/meilisearch.mdx +139 -0
  34. package/site/content/docs/recipes/meta.json +31 -0
  35. package/site/content/docs/recipes/ollama.mdx +142 -0
  36. package/site/content/docs/recipes/openbao.mdx +141 -0
  37. package/site/content/docs/recipes/pgdog.mdx +137 -0
  38. package/site/content/docs/recipes/postgres.mdx +136 -0
  39. package/site/content/docs/recipes/redis.mdx +130 -0
  40. package/site/content/docs/recipes/rustfs.mdx +148 -0
  41. package/site/content/docs/recipes/sglang.mdx +105 -0
  42. package/site/content/docs/recipes/supabase-docker.mdx +145 -0
  43. package/site/content/docs/recipes/timescale.mdx +129 -0
  44. package/site/content/docs/recipes/traefik.mdx +136 -0
  45. package/site/content/docs/recipes/valkey.mdx +121 -0
  46. package/site/content/docs/recipes/vllm.mdx +106 -0
  47. package/site/content/docs/recipes/yugabytedb.mdx +135 -0
  48. package/site/content/docs/reference/cli.md +42 -6
  49. package/site/content/docs/reference/configuration.mdx +5 -3
  50. package/site/content/docs/reference/environment-variables.mdx +12 -12
  51. package/site/content/docs/reference/fx.mdx +38 -4
  52. package/src/auth/otp-capability.ts +14 -14
  53. package/src/cli/ai-setup/ai-setup.test.ts +32 -12
  54. package/src/cli/ai-setup/apply.ts +65 -23
  55. package/src/cli/ai-setup/catalog.ts +1316 -35
  56. package/src/cli/ai-setup/detect-ollama.ts +47 -0
  57. package/src/cli/ai-setup/index.ts +44 -7
  58. package/src/cli/ai-setup/prompts.ts +430 -486
  59. package/src/cli/ai-setup/recommend.test.ts +150 -50
  60. package/src/cli/ai-setup/recommend.ts +118 -101
  61. package/src/cli/ask-seed.test.ts +68 -0
  62. package/src/cli/ask-seed.ts +79 -0
  63. package/src/cli/db-seed-redact.test.ts +25 -0
  64. package/src/cli/db-seed.ts +36 -7
  65. package/src/cli/db.test.ts +1 -1
  66. package/src/cli/db.ts +2 -2
  67. package/src/cli/dev-controls.test.ts +107 -0
  68. package/src/cli/dev-controls.ts +354 -0
  69. package/src/cli/dev-schema-sync.ts +12 -4
  70. package/src/cli/dev.test.ts +50 -30
  71. package/src/cli/dev.ts +607 -66
  72. package/src/cli/drizzle-env.test.ts +40 -6
  73. package/src/cli/drizzle-env.ts +22 -1
  74. package/src/cli/ensure-drizzle-config.ts +6 -4
  75. package/src/cli/hero-meta.test.ts +13 -1
  76. package/src/cli/hero-meta.ts +28 -6
  77. package/src/cli/index.ts +5 -0
  78. package/src/cli/registry.ts +24 -0
  79. package/src/cli/replay.test.ts +105 -0
  80. package/src/cli/replay.ts +206 -0
  81. package/src/compiler/extract.ts +3 -3
  82. package/src/config/index.ts +3 -3
  83. package/src/console/server/claim.ts +3 -1
  84. package/src/console/ui/dist/assets/{index-ClX0ZCe5.js → index-D-9KFce5.js} +2 -2
  85. package/src/console/ui/dist/assets/panel-overview-CW0CQEc2.js +1 -0
  86. package/src/console/ui/dist/assets/panel-runs-BxuyDI3x.js +1 -0
  87. package/src/console/ui/dist/assets/{panel-signals-Dsxdu_AR.js → panel-signals-BXofbj7v.js} +1 -1
  88. package/src/console/ui/dist/assets/{panel-store-hy7O8HOs.js → panel-store-B8mH-arU.js} +1 -1
  89. package/src/console/ui/dist/assets/{panel-traces-CBiAe5go.js → panel-traces-DSA-ZWnf.js} +1 -1
  90. package/src/console/ui/dist/index.html +1 -1
  91. package/src/console/ui/overview/slo.ts +31 -3
  92. package/src/console/ui/overview/types.ts +6 -0
  93. package/src/console/ui/runs/errors.test.ts +53 -0
  94. package/src/console/ui/runs/errors.ts +88 -0
  95. package/src/console/ui/runs/index.ts +8 -0
  96. package/src/console/ui/runs/search.ts +16 -0
  97. package/src/console/ui/shell/panels/runs/RunsPanel.tsx +72 -1
  98. package/src/docker/ai-model-status.test.ts +101 -0
  99. package/src/docker/ai-model-status.ts +276 -0
  100. package/src/docker/compose-health.test.ts +115 -0
  101. package/src/docker/compose-health.ts +234 -0
  102. package/src/docker/compose.ts +24 -8
  103. package/src/docker/derive.ts +35 -9
  104. package/src/docker/docker.test.ts +304 -12
  105. package/src/docker/helpers.ts +56 -0
  106. package/src/docker/index.ts +52 -0
  107. package/src/docker/ollama-pull.ts +232 -0
  108. package/src/docker/recipes/cockroach.ts +24 -0
  109. package/src/docker/recipes/index.ts +32 -0
  110. package/src/docker/recipes/llama-cpp.ts +273 -0
  111. package/src/docker/recipes/ollama.ts +19 -23
  112. package/src/docker/recipes/pgdog.ts +4 -1
  113. package/src/docker/recipes/postgres.ts +5 -18
  114. package/src/docker/recipes/sglang.ts +55 -0
  115. package/src/docker/recipes/supabase.ts +21 -0
  116. package/src/docker/recipes/timescale.ts +18 -0
  117. package/src/docker/recipes/vllm.ts +44 -0
  118. package/src/docker/recipes/yugabyte.ts +23 -0
  119. package/src/docker/stack-id.test.ts +22 -0
  120. package/src/docker/stack-id.ts +38 -4
  121. package/src/docker/types.ts +15 -0
  122. package/src/drivers/ai-ollama.integration.test.ts +7 -4
  123. package/src/drivers/signal-engine.ts +10 -0
  124. package/src/drivers/signal-postgres.ts +24 -2
  125. package/src/drivers/signal-types.ts +10 -0
  126. package/src/elements/channel/fallback.test.ts +91 -0
  127. package/src/elements/channel/hard-bounce.test.ts +82 -0
  128. package/src/elements/channel/injection.test.ts +153 -0
  129. package/src/elements/channel/locale-ar.test.ts +102 -0
  130. package/src/elements/channel/otp-delivery.ts +5 -2
  131. package/src/elements/channel/receipts-ledger.test.ts +100 -0
  132. package/src/elements/channel/runtime.ts +24 -1
  133. package/src/elements/channel/test-helpers.ts +60 -0
  134. package/src/elements/channel/whatsapp-compliance.test.ts +124 -0
  135. package/src/elements/store/cache.ts +1 -1
  136. package/src/elements/store/emit-drizzle.ts +43 -9
  137. package/src/elements/store/runtime.ts +1 -1
  138. package/src/elements/store/schema-decl.test.ts +2 -0
  139. package/src/elements/store/schema-decl.ts +15 -0
  140. package/src/elements/store/seed.ts +1 -1
  141. package/src/index.ts +1 -0
  142. package/src/kernel/app.ts +118 -11
  143. package/src/kernel/boot-bind/signal.ts +6 -2
  144. package/src/kernel/boot-bind/store.ts +2 -1
  145. package/src/kernel/boot.test.ts +6 -5
  146. package/src/kernel/boot.ts +8 -3
  147. package/src/kernel/compensate.test.ts +131 -0
  148. package/src/kernel/correlation.test.ts +108 -0
  149. package/src/kernel/effect-timing.test.ts +82 -0
  150. package/src/kernel/flow.ts +38 -0
  151. package/src/kernel/fx-runs.test.ts +49 -0
  152. package/src/kernel/fx.test.ts +14 -0
  153. package/src/kernel/fx.ts +111 -1
  154. package/src/manifest/types.ts +8 -2
  155. package/src/plugins/auth-delivery.mailpit.integration.test.ts +1 -1
  156. package/src/plugins/auth-methods.security.test.ts +3 -3
  157. package/src/plugins/auth-methods.test.ts +1 -1
  158. package/src/plugins/index.ts +2 -2
  159. package/src/plugins/otp.test.ts +21 -19
  160. package/src/plugins/otp.ts +36 -34
  161. package/src/plugins/taqnyat.live.test.ts +3 -3
  162. package/src/runs/collect.ts +3 -0
  163. package/src/runs/export-otlp.ts +64 -0
  164. package/src/runs/index.ts +16 -0
  165. package/src/runs/parquet.ts +9 -0
  166. package/src/runs/types.ts +5 -0
  167. package/src/runs/window.test.ts +68 -0
  168. package/src/runs/window.ts +148 -0
  169. package/src/runtime/boot-warn.test.ts +45 -0
  170. package/src/runtime/boot-warn.ts +17 -0
  171. package/src/runtime/dev-request-log.ts +5 -5
  172. package/src/term.test.ts +95 -10
  173. package/src/term.ts +369 -14
  174. package/src/console/ui/dist/assets/panel-overview-DONDzBd2.js +0 -1
  175. package/src/console/ui/dist/assets/panel-runs-D5zf-D9c.js +0 -1
package/src/cli/dev.ts CHANGED
@@ -19,21 +19,39 @@ import {
19
19
  stackAppSlug,
20
20
  stackInstanceId,
21
21
  writeDerivedFiles,
22
+ formatStackEnv,
22
23
  type DeriveOptions,
23
24
  } from "../docker/index.ts";
24
25
  import type { Manifest } from "../manifest/types.ts";
25
26
  import type { McpContext } from "../mcp/tools.ts";
26
27
  import { APP_PORT, CONSOLE_PORT, DOCS_MCP_PORT, MCP_PORT } from "../runtime/types.ts";
27
28
  import {
29
+ clearTerminalScreen,
30
+ createAnchoredBoard,
31
+ createBootProgress,
32
+ devStatusFromAiPhase,
33
+ formatAppReadyLine,
34
+ formatBootWarn,
35
+ formatCliChrome,
28
36
  formatDevBanner,
37
+ formatDevHeroDetails,
29
38
  formatDevLogSeparator,
30
39
  formatServiceLine,
31
40
  formatStackSummary,
32
41
  formatStatusLine,
42
+ type BootProgress,
43
+ type DevStatus,
44
+ type StackSummaryService,
33
45
  } from "../term.ts";
34
46
  import { clientAdd } from "./client-add.ts";
35
47
  import { resolveDriverId } from "../config/index.ts";
36
48
  import { askDevMode, type AskDevModeFn } from "./ask-dev-mode.ts";
49
+ import {
50
+ controlServicesFromStack,
51
+ formatDevControlsHint,
52
+ startDevControls,
53
+ type DevComposeControlAction,
54
+ } from "./dev-controls.ts";
37
55
  import {
38
56
  createDebouncedRunner,
39
57
  isDomainSchemaWatchPath,
@@ -41,7 +59,13 @@ import {
41
59
  } from "./db-auto-push.ts";
42
60
  import { resolveDrizzleConfigPath, runPush } from "./db.ts";
43
61
  import { readDevMode, shouldAskDevMode, writeDevMode, type DevMode } from "./dev-mode.ts";
44
- import { buildDevHeroSnapshot, encodeHeroSnapshot } from "./hero-meta.ts";
62
+ import {
63
+ buildDevHeroSnapshot,
64
+ encodeHeroSnapshot,
65
+ formatHeroSystemLine,
66
+ resolveDevProfile,
67
+ resolveDevRuntimeEnv,
68
+ } from "./hero-meta.ts";
45
69
  import { loadManifest, loadOkeConfig, resolveImages } from "./load-config.ts";
46
70
  import { mcpContextFromConsole } from "./mcp-from-console.ts";
47
71
  import { resolveDevPorts } from "./ports.ts";
@@ -198,6 +222,17 @@ export interface DevOptions {
198
222
  cwd: string,
199
223
  env: Readonly<Record<string, string>>,
200
224
  ) => void;
225
+ /**
226
+ * Poll compose service health (injectable). Default: {@link watchComposeHealth}.
227
+ *
228
+ * @param options - Compose files, cwd, env, optional per-service updates
229
+ */
230
+ readonly composeHealth?: (options: {
231
+ readonly files: readonly string[];
232
+ readonly cwd: string;
233
+ readonly env: Record<string, string>;
234
+ readonly onUpdate?: (service: string, status: DevStatus) => void;
235
+ }) => Promise<Map<string, DevStatus>>;
201
236
  /**
202
237
  * Regenerate client types (injectable).
203
238
  *
@@ -260,7 +295,21 @@ export interface DevResult {
260
295
  * @param options - Flags
261
296
  */
262
297
  export async function runDev(options: DevOptions = {}): Promise<DevResult> {
263
- const write = options.write ?? ((t) => process.stdout.write(t));
298
+ /** Reassigned to {@link createAnchoredBoard} wrap after the status board paints. */
299
+ let write: (text: string) => void = options.write ?? ((t) => process.stdout.write(t));
300
+ const chromeWrite = (t: string) => write(formatCliChrome(t));
301
+ const previousWarn = console.warn;
302
+ console.warn = (...args: unknown[]) => {
303
+ const msg = args.map(String).join(" ");
304
+ if (/^oke boot:/i.test(msg.trim())) {
305
+ write(formatBootWarn(msg.trim()));
306
+ return;
307
+ }
308
+ previousWarn.apply(console, args as Parameters<typeof console.warn>);
309
+ };
310
+ const restoreWarn = () => {
311
+ console.warn = previousWarn;
312
+ };
264
313
  const cwd = options.cwd ?? process.cwd();
265
314
  const preferredApp = options.appPort ?? Number(Bun.env.PORT ?? APP_PORT);
266
315
  const preferredConsole = options.consolePort ?? CONSOLE_PORT;
@@ -301,6 +350,7 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
301
350
  }
302
351
  if (!entry) {
303
352
  console.error("oke dev: no entry found (src/app.ts)");
353
+ restoreWarn();
304
354
  return { code: 1 };
305
355
  }
306
356
 
@@ -308,6 +358,7 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
308
358
  const explicitDocker = options.docker === true || Array.isArray(options.docker);
309
359
  if (explicitLocal && explicitDocker) {
310
360
  console.error("oke dev: use either --local or --docker, not both");
361
+ restoreWarn();
311
362
  return { code: 1 };
312
363
  }
313
364
 
@@ -331,6 +382,7 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
331
382
  const chosen = await ask();
332
383
  if (chosen === null) {
333
384
  console.error("oke dev: cancelled");
385
+ restoreWarn();
334
386
  return { code: 1 };
335
387
  }
336
388
  mode = chosen;
@@ -340,6 +392,28 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
340
392
  mode = "local";
341
393
  }
342
394
 
395
+ // Paint chrome immediately so docker/vault work is never a blank 6s wait.
396
+ let okeVersion = "0.0.0";
397
+ try {
398
+ const pkg = (await Bun.file(resolve(import.meta.dir, "../../package.json")).json()) as {
399
+ version?: string;
400
+ };
401
+ if (typeof pkg.version === "string") okeVersion = pkg.version;
402
+ } catch {
403
+ // shipped binary may not sit next to package.json
404
+ }
405
+ const earlyProfile = resolveDevProfile({ docker: mode === "docker", nodeEnv: "development" });
406
+ write(
407
+ formatDevBanner({
408
+ profile: earlyProfile,
409
+ runtimeEnv: resolveDevRuntimeEnv(earlyProfile),
410
+ system: formatHeroSystemLine(),
411
+ version: okeVersion,
412
+ }),
413
+ );
414
+ /** Ephemeral compose/vault/health/AI lines — cleared before the final board. */
415
+ const bootProgress: BootProgress = createBootProgress(write);
416
+
343
417
  let stackRoles: string[] | null = null;
344
418
  let composeFiles: string[] | null = null;
345
419
  let stackEnv: Record<string, string> | null = null;
@@ -351,6 +425,26 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
351
425
  } | null = null;
352
426
  let stackSqlDriver = "postgres";
353
427
  let stackKvDriver = "redis";
428
+ /** Printed after the hero so Docker ports sit under the Starting column. */
429
+ let pendingStackSummary: {
430
+ readonly project: string;
431
+ readonly services: StackSummaryService[];
432
+ readonly appDrivers: readonly string[];
433
+ } | null = null;
434
+ /** Background AI model poller — cancelled in `stop()`. */
435
+ let stopAiModelWatch: (() => void) | null = null;
436
+ /** Session compose health poller — cancelled in `stop()`. */
437
+ let stopComposeHealthWatch: (() => void) | null = null;
438
+ /** Latest compose ps -a statuses (service name → ●). */
439
+ let liveComposeHealth = new Map<string, DevStatus>();
440
+ /** Start after hero/stack chrome so status lines land in the right place. */
441
+ let pendingAiModelWatch: {
442
+ readonly url: string;
443
+ readonly model: string;
444
+ readonly kind: "openai-compatible" | "ollama";
445
+ } | null = null;
446
+ /** Hero AI status dot (yellow while model loads). */
447
+ let heroAiStatus: DevStatus | undefined;
354
448
  let loadedConfig: Awaited<ReturnType<typeof loadOkeConfig>>["config"] | null = null;
355
449
  try {
356
450
  loadedConfig = (await loadOkeConfig(cwd)).config;
@@ -371,6 +465,7 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
371
465
  write(formatStatusLine(`meilisearch local server on ${meili.url}`));
372
466
  } catch (err) {
373
467
  console.error(err instanceof Error ? err.message : String(err));
468
+ restoreWarn();
374
469
  return { code: 1 };
375
470
  }
376
471
  }
@@ -386,6 +481,7 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
386
481
  images = resolveImages(loaded.config);
387
482
  } catch (err) {
388
483
  console.error(err instanceof Error ? err.message : String(err));
484
+ restoreWarn();
389
485
  return { code: 1 };
390
486
  }
391
487
  } else {
@@ -434,6 +530,44 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
434
530
  );
435
531
  stackEnv = { ...derived.stackEnv };
436
532
 
533
+ const roleLabel = (role: string): string => {
534
+ if (role === "store.sql") return "postgres";
535
+ if (role === "store.kv") return "redis";
536
+ if (role === "store.files") return "files";
537
+ if (role === "channel.email") return "mail";
538
+ return role;
539
+ };
540
+ const extraLabel = (role: string, containerPort: number): string => {
541
+ if (role === "channel.email" && containerPort === 8025) return "mail-ui";
542
+ if (role === "store.files" && containerPort === 9001) return "files-ui";
543
+ return `${roleLabel(role)}+${containerPort}`;
544
+ };
545
+ const appDrivers = [
546
+ ...(stackRoles.includes("store.sql") ? [stackSqlDriver] : []),
547
+ ...(stackRoles.includes("store.kv") ? [stackKvDriver] : []),
548
+ ];
549
+ const stackAiModel =
550
+ stackEnv.OKE_AI_MODEL?.trim() || process.env.OKE_AI_MODEL?.trim() || undefined;
551
+ pendingStackSummary = {
552
+ project: `oke-${appSlug}`,
553
+ services: derived.specs.flatMap((spec) => [
554
+ {
555
+ label: roleLabel(spec.role),
556
+ hostPort: spec.hostPort,
557
+ serviceName: spec.serviceName,
558
+ status: "pending" as const,
559
+ ...(spec.role === "ai" && stackAiModel ? { detail: stackAiModel } : {}),
560
+ },
561
+ ...resolveExtraPorts(spec, { images, instanceId }).map((p) => ({
562
+ label: extraLabel(spec.role, p.containerPort),
563
+ hostPort: p.hostPort,
564
+ serviceName: spec.serviceName,
565
+ status: "pending" as const,
566
+ })),
567
+ ]),
568
+ appDrivers,
569
+ };
570
+
437
571
  if (!options.dryRun) {
438
572
  try {
439
573
  await writeDerivedFiles(derived, dockerOut, {
@@ -445,17 +579,26 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
445
579
  const args = ["compose", ...files.flatMap((f) => ["-f", f]), "up", "-d"];
446
580
  const proc = Bun.spawn(["docker", ...args], {
447
581
  cwd: dir,
448
- stdout: "inherit",
449
- stderr: "inherit",
582
+ stdout: "pipe",
583
+ stderr: "pipe",
450
584
  env: { ...process.env, ...stackEnv! },
451
585
  });
452
- const code = await proc.exited;
586
+ const [stdout, stderr, code] = await Promise.all([
587
+ new Response(proc.stdout).text(),
588
+ new Response(proc.stderr).text(),
589
+ proc.exited,
590
+ ]);
453
591
  if (code !== 0) {
454
- throw new Error(`oke dev --docker: docker compose exited ${code}`);
592
+ const detail = [stdout.trim(), stderr.trim()].filter(Boolean).join("\n");
593
+ throw new Error(
594
+ `oke dev --docker: docker compose exited ${code}` + (detail ? `\n${detail}` : ""),
595
+ );
455
596
  }
456
597
  });
598
+ bootProgress.set("compose", formatStatusLine("docker compose up…", undefined, "pending"));
457
599
  // Compose files live under docker/; cwd for compose is that directory.
458
600
  await up(composeFiles, dockerOut);
601
+ bootProgress.set("compose", formatStatusLine("docker compose up", undefined, "ready"));
459
602
  // Remember for SIGINT / terminal close — `stop` keeps volumes (not `down -v`).
460
603
  dockerStarted = {
461
604
  files: composeFiles,
@@ -467,6 +610,7 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
467
610
  // least-privilege app token to the app + Console (never root).
468
611
  const vaultSpec = derived.specs.find((s) => s.role === "vault");
469
612
  if (vaultSpec) {
613
+ bootProgress.set("vault", formatStatusLine("vault unseal…", undefined, "pending"));
470
614
  const { ensureOpenBao, openbaoStackEnv } = await import("./openbao-bootstrap.ts");
471
615
  const vaultNames = await tryLoadVaultSecretNames(cwd);
472
616
  const boot = await ensureOpenBao({
@@ -476,49 +620,139 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
476
620
  });
477
621
  Object.assign(stackEnv!, openbaoStackEnv(boot));
478
622
  Object.assign(dockerStarted.env, openbaoStackEnv(boot));
623
+ // Rewrite .env.docker with the full stack env (including token) so a
624
+ // later ensureDockerStack / writeDerivedFiles does not drop it.
479
625
  const envPath = resolve(dockerOut, ".env.docker");
480
- const current = await Bun.file(envPath).text();
481
- const tokenLine = `OKE_VAULT_TOKEN=${boot.appToken}`;
482
- const next = /OKE_VAULT_TOKEN=.*/.test(current)
483
- ? current.replace(/OKE_VAULT_TOKEN=.*/, tokenLine)
484
- : `${current.trimEnd()}\nOKE_VAULT_TOKEN=${boot.appToken}\nOKE_VAULT_MOUNT=secret\n`;
485
- await Bun.write(envPath, next);
626
+ await Bun.write(envPath, formatStackEnv(stackEnv!));
627
+ bootProgress.set("vault", formatStatusLine("vault ready", undefined, "ready"));
628
+ }
629
+
630
+ // Stream compose health until infra is up (AI may stay pending — model load).
631
+ const aiServiceName = derived.specs.find((s) => s.role === "ai")?.serviceName;
632
+ const labelForService = (serviceName: string): string => {
633
+ const spec = derived.specs.find((s) => s.serviceName === serviceName);
634
+ return spec ? roleLabel(spec.role) : serviceName;
635
+ };
636
+ const onHealthUpdate = (service: string, status: DevStatus) => {
637
+ const label = labelForService(service);
638
+ const text =
639
+ status === "ready"
640
+ ? `${label} ready`
641
+ : status === "error"
642
+ ? `${label} unhealthy`
643
+ : `${label} starting…`;
644
+ bootProgress.set(`svc:${service}`, formatStatusLine(text, undefined, status));
645
+ };
646
+ const healthByService = options.composeHealth
647
+ ? await options.composeHealth({
648
+ files: composeFiles,
649
+ cwd: dockerOut,
650
+ env: dockerStarted.env,
651
+ onUpdate: onHealthUpdate,
652
+ })
653
+ : await (
654
+ await import("../docker/compose-health.ts")
655
+ ).watchComposeHealth({
656
+ files: composeFiles,
657
+ cwd: dockerOut,
658
+ env: dockerStarted.env,
659
+ timeoutMs: 20_000,
660
+ isDone: (map) => {
661
+ // Empty ps (compose just-created / injectable gap): do not spin 20s.
662
+ if (map.size === 0) return true;
663
+ return [...map.entries()].every(
664
+ ([name, s]) => name === aiServiceName || s === "ready" || s === "error",
665
+ );
666
+ },
667
+ onUpdate: onHealthUpdate,
668
+ });
669
+ liveComposeHealth = healthByService;
670
+ if (pendingStackSummary) {
671
+ pendingStackSummary = {
672
+ ...pendingStackSummary,
673
+ services: pendingStackSummary.services.map((svc) => ({
674
+ ...svc,
675
+ status: svc.serviceName
676
+ ? (healthByService.get(svc.serviceName) ?? svc.status ?? "pending")
677
+ : (svc.status ?? "pending"),
678
+ })),
679
+ };
680
+ }
681
+
682
+ // AI model: Ollama pulls via HTTP (blocking with progress). OpenAI-compatible
683
+ // servers (llama.cpp / vLLM / SGLang) load in-container — poll readiness in
684
+ // the background. Never treat compose "healthy" as model-ready.
685
+ const aiSpec = derived.specs.find((s) => s.role === "ai");
686
+ if (aiSpec) {
687
+ const { recipeFor } = await import("../docker/recipes/index.ts");
688
+ const recipeId = recipeFor(aiSpec.image).id;
689
+ const model =
690
+ stackEnv!.OKE_AI_MODEL?.trim() ||
691
+ process.env.OKE_AI_MODEL?.trim() ||
692
+ (recipeId === "ollama" ? "qwen3.5:9b" : "smollm2");
693
+ const url = `http://127.0.0.1:${aiSpec.hostPort}`;
694
+ const paintAi = (status: DevStatus, detail?: string) => {
695
+ heroAiStatus = status;
696
+ if (!pendingStackSummary) return;
697
+ pendingStackSummary = {
698
+ ...pendingStackSummary,
699
+ services: pendingStackSummary.services.map((svc) =>
700
+ svc.serviceName === aiSpec.serviceName
701
+ ? {
702
+ ...svc,
703
+ status,
704
+ detail: detail ?? svc.detail,
705
+ }
706
+ : svc,
707
+ ),
708
+ };
709
+ };
710
+ if (recipeId === "ollama") {
711
+ bootProgress.set(
712
+ "ai-model",
713
+ formatStatusLine(`AI ${model} — ensuring…`, undefined, "pending"),
714
+ );
715
+ const { ensureOllamaModel } = await import("../docker/ollama-pull.ts");
716
+ await ensureOllamaModel({
717
+ url,
718
+ model,
719
+ onStatus: (line) =>
720
+ bootProgress.set("ai-model", formatStatusLine(line, undefined, "pending")),
721
+ });
722
+ pendingAiModelWatch = { url, model, kind: "ollama" };
723
+ paintAi("ready", model);
724
+ } else if (recipeId === "llama-cpp" || recipeId === "vllm" || recipeId === "sglang") {
725
+ pendingAiModelWatch = {
726
+ url: `${url}/v1`,
727
+ model,
728
+ kind: "openai-compatible",
729
+ };
730
+ bootProgress.set(
731
+ "ai-model",
732
+ formatStatusLine(`AI ${model} — probing…`, undefined, "pending"),
733
+ );
734
+ const { probeAiModelStatus, formatAiModelStatusMessage } =
735
+ await import("../docker/ai-model-status.ts");
736
+ const snap = await probeAiModelStatus({
737
+ url: `${url}/v1`,
738
+ model,
739
+ kind: "openai-compatible",
740
+ });
741
+ const status = devStatusFromAiPhase(snap.phase);
742
+ paintAi(status, snap.phase === "ready" ? model : `${model} · ${snap.phase}`);
743
+ bootProgress.set(
744
+ "ai-model",
745
+ formatStatusLine(formatAiModelStatusMessage(snap), undefined, status),
746
+ );
747
+ }
486
748
  }
487
749
  } catch (err) {
488
750
  const msg = err instanceof Error ? err.message : String(err);
489
751
  console.error(msg);
490
752
  console.error("oke dev: docker mode failed — fix compose, or run `oke mode local`");
753
+ restoreWarn();
491
754
  return { code: 1 };
492
755
  }
493
- const roleLabel = (role: string): string => {
494
- if (role === "store.sql") return "postgres";
495
- if (role === "store.kv") return "redis";
496
- if (role === "store.files") return "files";
497
- if (role === "channel.email") return "mail";
498
- return role;
499
- };
500
- const extraLabel = (role: string, containerPort: number): string => {
501
- if (role === "channel.email" && containerPort === 8025) return "mail-ui";
502
- if (role === "store.files" && containerPort === 9001) return "files-ui";
503
- return `${roleLabel(role)}+${containerPort}`;
504
- };
505
- const appDrivers = [
506
- ...(stackRoles.includes("store.sql") ? [stackSqlDriver] : []),
507
- ...(stackRoles.includes("store.kv") ? [stackKvDriver] : []),
508
- ];
509
- write(
510
- formatStackSummary({
511
- project: `oke-${appSlug}`,
512
- services: derived.specs.flatMap((spec) => [
513
- { label: roleLabel(spec.role), hostPort: spec.hostPort },
514
- ...resolveExtraPorts(spec, { images, instanceId }).map((p) => ({
515
- label: extraLabel(spec.role, p.containerPort),
516
- hostPort: p.hostPort,
517
- })),
518
- ]),
519
- appDrivers,
520
- }),
521
- );
522
756
  }
523
757
  }
524
758
 
@@ -533,36 +767,224 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
533
767
  stackEnv,
534
768
  };
535
769
 
536
- let okeVersion = "0.0.0";
537
- try {
538
- const pkg = (await Bun.file(resolve(import.meta.dir, "../../package.json")).json()) as {
539
- version?: string;
540
- };
541
- if (typeof pkg.version === "string") okeVersion = pkg.version;
542
- } catch {
543
- // shipped binary may not sit next to package.json
770
+ const heroAiModel =
771
+ stackEnv?.OKE_AI_MODEL?.trim() || process.env.OKE_AI_MODEL?.trim() || undefined;
772
+ // OpenAI-compatible model load can outlive compose health — default AI ● to pending.
773
+ if (pendingAiModelWatch && heroAiStatus === undefined) {
774
+ heroAiStatus = "pending";
544
775
  }
545
-
546
776
  const heroSnapshot = buildDevHeroSnapshot({
547
777
  config: loadedConfig,
548
778
  docker: mode === "docker",
549
779
  sqlDriver: mode === "docker" ? stackSqlDriver : undefined,
550
780
  kvDriver: mode === "docker" ? stackKvDriver : undefined,
781
+ aiModel: heroAiModel,
782
+ aiStatus: heroAiStatus,
551
783
  version: okeVersion,
552
784
  nodeEnv: "development",
553
785
  });
786
+ const aiDriverLabel = (() => {
787
+ const detail = heroSnapshot.elements.find((r) => r.element === "ai")?.detail ?? "—";
788
+ if (detail === "—" || detail === "") return detail;
789
+ return detail.split(" · ")[0] ?? detail;
790
+ })();
791
+
792
+ const worstStatus = (statuses: readonly (DevStatus | undefined)[]): DevStatus | undefined => {
793
+ const present = statuses.filter((s): s is DevStatus => s !== undefined);
794
+ if (present.length === 0) return undefined;
795
+ if (present.includes("error")) return "error";
796
+ if (present.includes("pending")) return "pending";
797
+ if (present.includes("idle")) return "idle";
798
+ return "ready";
799
+ };
554
800
 
555
- write(
556
- formatDevBanner({
557
- profile: heroSnapshot.profile,
558
- runtimeEnv: heroSnapshot.runtimeEnv,
559
- system: heroSnapshot.system,
560
- elements: heroSnapshot.elements,
561
- version: okeVersion,
562
- }),
563
- );
801
+ const elementStatusFromHealth = (
802
+ element: string,
803
+ aiStatus: DevStatus | undefined,
804
+ ): DevStatus | undefined => {
805
+ const h = liveComposeHealth;
806
+ if (h.size === 0) return element === "ai" ? aiStatus : undefined;
807
+ const one = (name: string) => h.get(name);
808
+ switch (element) {
809
+ case "ai": {
810
+ const container = one("ai");
811
+ if (container === "error") return "error";
812
+ return aiStatus ?? container;
813
+ }
814
+ case "vault":
815
+ return one("vault");
816
+ case "signal":
817
+ case "gate":
818
+ return one("store-kv");
819
+ case "channel":
820
+ return one("channel-email");
821
+ case "clock":
822
+ return one("store-sql");
823
+ case "store":
824
+ return worstStatus([
825
+ one("store-sql"),
826
+ one("store-kv"),
827
+ one("store-files"),
828
+ one("store-index"),
829
+ ]);
830
+ default:
831
+ return undefined;
832
+ }
833
+ };
834
+
835
+ const paintBootBoard = (aiStatus: DevStatus | undefined) => {
836
+ const aiSt = aiStatus ?? heroAiStatus;
837
+ const elements = heroSnapshot.elements.map((row) => {
838
+ const fromHealth = elementStatusFromHealth(row.element, aiSt);
839
+ const st =
840
+ fromHealth ?? row.status ?? (row.detail === "—" || row.detail === "" ? "idle" : "ready");
841
+ if (row.element !== "ai") {
842
+ return fromHealth ? { ...row, status: st } : row;
843
+ }
844
+ let detail = aiDriverLabel;
845
+ if (detail !== "—" && heroAiModel) {
846
+ const phase =
847
+ st === "ready" || st === "idle"
848
+ ? null
849
+ : st === "error"
850
+ ? liveComposeHealth.get("ai") === "error"
851
+ ? "stopped"
852
+ : "error"
853
+ : "loading";
854
+ detail = phase
855
+ ? `${aiDriverLabel} · ${heroAiModel} · ${phase}`
856
+ : `${aiDriverLabel} · ${heroAiModel}`;
857
+ }
858
+ return { ...row, status: st, detail };
859
+ });
860
+ const elementsBlock = formatDevHeroDetails({ elements });
861
+ const stackBlock = pendingStackSummary
862
+ ? formatStackSummary({
863
+ project: pendingStackSummary.project,
864
+ services: pendingStackSummary.services.map((svc) => {
865
+ const fromHealth = svc.serviceName ? liveComposeHealth.get(svc.serviceName) : undefined;
866
+ let status = fromHealth ?? svc.status ?? "pending";
867
+ let detail = svc.detail;
868
+ if (svc.serviceName === "ai") {
869
+ if (fromHealth === "error") {
870
+ status = "error";
871
+ detail = heroAiModel ? `${heroAiModel} · stopped` : "stopped";
872
+ } else if (aiSt !== undefined) {
873
+ status = aiSt === "ready" && fromHealth === "pending" ? "pending" : aiSt;
874
+ detail = !heroAiModel
875
+ ? detail
876
+ : status === "ready"
877
+ ? heroAiModel
878
+ : `${heroAiModel} · ${status === "error" ? "error" : "loading"}`;
879
+ }
880
+ }
881
+ return { ...svc, status, ...(detail !== undefined ? { detail } : {}) };
882
+ }),
883
+ appDrivers: pendingStackSummary.appDrivers,
884
+ })
885
+ : "";
886
+ // Breath between elements and Docker — they read as two panes, not one block.
887
+ const gap = elementsBlock && stackBlock ? "\n" : "";
888
+ return `${elementsBlock}${gap}${stackBlock}`;
889
+ };
890
+
891
+ // Drop ephemeral progress — final elements + Docker board replaces it.
892
+ bootProgress.clear();
893
+ const bootBoard = createAnchoredBoard(write);
894
+ bootBoard.paint(paintBootBoard(heroAiStatus));
895
+ // Track everything printed below so ● can rewrite in place for the session.
896
+ write = bootBoard.wrapWrite(write);
897
+
898
+ if (options.dryRun) {
899
+ bootBoard.stop();
900
+ restoreWarn();
901
+ return { code: 0, plan };
902
+ }
903
+
904
+ const repaintBoard = (aiStatus?: DevStatus): void => {
905
+ bootBoard.paint(paintBootBoard(aiStatus ?? heroAiStatus));
906
+ };
907
+
908
+ if (pendingAiModelWatch) {
909
+ const target = pendingAiModelWatch;
910
+ pendingAiModelWatch = null;
911
+ const { startAiModelWatch } = await import("../docker/ai-model-status.ts");
912
+ let resolveFirstAi: (() => void) | undefined;
913
+ const firstAiPaint = new Promise<void>((resolve) => {
914
+ resolveFirstAi = resolve;
915
+ });
916
+ let sawAiPaint = false;
917
+ stopAiModelWatch = startAiModelWatch({
918
+ url: target.url,
919
+ model: target.model,
920
+ kind: target.kind,
921
+ onStatus: (_line, status) => {
922
+ const st = devStatusFromAiPhase(status.phase);
923
+ // Container stopped wins over model-phase "loading".
924
+ if (liveComposeHealth.get("ai") === "error") {
925
+ heroAiStatus = "error";
926
+ repaintBoard("error");
927
+ if (!sawAiPaint) {
928
+ sawAiPaint = true;
929
+ resolveFirstAi?.();
930
+ }
931
+ return;
932
+ }
933
+ heroAiStatus = st;
934
+ // Status ● lives on the board above Logs — not in the log stream.
935
+ repaintBoard(st);
936
+ if (!sawAiPaint) {
937
+ sawAiPaint = true;
938
+ resolveFirstAi?.();
939
+ }
940
+ },
941
+ });
942
+ // Let the first probe repaint AI ● (yellow loading) before we print below.
943
+ await Promise.race([firstAiPaint, Bun.sleep(2_500)]);
944
+ }
945
+
946
+ // Live ● for the whole session — catch Docker Desktop stops / crashes.
947
+ // Updates the board only (no redis stopped / starting / ready log spam).
948
+ if (dockerStarted && composeFiles) {
949
+ const { startComposeHealthWatch } = await import("../docker/compose-health.ts");
950
+ const started = dockerStarted;
951
+ stopComposeHealthWatch = startComposeHealthWatch({
952
+ files: started.files,
953
+ cwd: started.cwd,
954
+ env: started.env,
955
+ intervalMs: 2_000,
956
+ onChange: (map) => {
957
+ liveComposeHealth = map;
958
+ if (map.get("ai") === "error") heroAiStatus = "error";
959
+ repaintBoard();
960
+ },
961
+ });
962
+ }
963
+
964
+ /** Snapshot keys we mutate so `stop()` can leave the parent shell clean. */
965
+ const processEnvSnapshot = new Map<string, string | undefined>();
966
+ const setProcessEnv = (key: string, value: string): void => {
967
+ if (!processEnvSnapshot.has(key)) processEnvSnapshot.set(key, process.env[key]);
968
+ process.env[key] = value;
969
+ };
970
+ const restoreProcessEnv = (): void => {
971
+ for (const [key, prev] of processEnvSnapshot) {
972
+ if (prev === undefined) delete process.env[key];
973
+ else process.env[key] = prev;
974
+ }
975
+ processEnvSnapshot.clear();
976
+ };
564
977
 
565
- if (options.dryRun) return { code: 0, plan };
978
+ // Hydrate parent process.env so schema sync / seed / Console see compose URLs
979
+ // (Backend child gets the same overlay via `env` below). Restored on stop.
980
+ if (stackEnv) {
981
+ for (const [key, value] of Object.entries(stackEnv)) {
982
+ setProcessEnv(key, value);
983
+ }
984
+ setProcessEnv("OKE_DOCKER", "1");
985
+ setProcessEnv("OKE_SQL_DRIVER", stackSqlDriver);
986
+ setProcessEnv("OKE_KV_DRIVER", stackKvDriver);
987
+ }
566
988
 
567
989
  // One-shot schema sync for the session profile (both local and docker):
568
990
  // emits schema.generated.ts for the active dialect, then pushes via
@@ -571,12 +993,24 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
571
993
  if (!options.noDbPush) {
572
994
  const { syncDevSchema } = await import("./dev-schema-sync.ts");
573
995
  try {
574
- const result = await syncDevSchema(cwd, mode, { write });
996
+ const result = await syncDevSchema(cwd, mode, {
997
+ write: chromeWrite,
998
+ quietComposeReady: mode === "docker",
999
+ });
575
1000
  write(
576
1001
  formatStatusLine(
577
1002
  `oke db push (${mode} · ${result.dialect}) ${result.code === 0 ? "ok" : "failed"}`,
578
1003
  ),
579
1004
  );
1005
+ if (result.code === 0) {
1006
+ const { maybeAskSeed } = await import("./ask-seed.ts");
1007
+ await maybeAskSeed({
1008
+ cwd,
1009
+ env: mode,
1010
+ write,
1011
+ stdinIsTTY: options.stdinIsTTY ?? process.stdin.isTTY,
1012
+ });
1013
+ }
580
1014
  } catch (err) {
581
1015
  write(
582
1016
  formatStatusLine(
@@ -612,8 +1046,10 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
612
1046
  }
613
1047
  : {}),
614
1048
  ...(meili?.overlay ?? {}),
1049
+ /** Backend child — parent Console owns process-local boot notices. */
1050
+ OKE_SUPPRESS_BOOT_WARN: "1",
615
1051
  };
616
- process.env.OKE_DEV_REQUEST_LOG = "1";
1052
+ setProcessEnv("OKE_DEV_REQUEST_LOG", "1");
617
1053
 
618
1054
  const startApp = options.startApp ?? ((entryPath, appEnv) => startAppHot(cwd, entryPath, appEnv));
619
1055
 
@@ -779,6 +1215,9 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
779
1215
  }
780
1216
 
781
1217
  write(formatDevLogSeparator());
1218
+ if (dockerStarted && (options.stdinIsTTY ?? process.stdin.isTTY)) {
1219
+ write(formatDevControlsHint());
1220
+ }
782
1221
 
783
1222
  const watchFs: DevWatchFn =
784
1223
  options.watchFs ?? ((path, watchOptions, listener) => watch(path, watchOptions, listener));
@@ -794,10 +1233,20 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
794
1233
  }
795
1234
  });
796
1235
 
1236
+ let stopDevControls: (() => void) | null = null;
1237
+
797
1238
  let stopped = false;
798
1239
  const stop = () => {
799
1240
  if (stopped) return;
800
1241
  stopped = true;
1242
+ restoreWarn();
1243
+ stopDevControls?.();
1244
+ stopDevControls = null;
1245
+ stopAiModelWatch?.();
1246
+ stopAiModelWatch = null;
1247
+ stopComposeHealthWatch?.();
1248
+ stopComposeHealthWatch = null;
1249
+ bootBoard.stop();
801
1250
  autoPushRunner.cancel();
802
1251
  watcher.close();
803
1252
  meili?.stop();
@@ -814,8 +1263,8 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
814
1263
  const args = ["compose", ...files.flatMap((f) => ["-f", f]), "stop"];
815
1264
  const result = Bun.spawnSync(["docker", ...args], {
816
1265
  cwd: dir,
817
- stdout: "inherit",
818
- stderr: "inherit",
1266
+ stdout: "pipe",
1267
+ stderr: "pipe",
819
1268
  env: { ...process.env, ...env },
820
1269
  });
821
1270
  if (result.exitCode !== 0) {
@@ -833,6 +1282,7 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
833
1282
  process.off("SIGINT", onSignal);
834
1283
  process.off("SIGTERM", onSignal);
835
1284
  process.off("SIGHUP", onSignal);
1285
+ restoreProcessEnv();
836
1286
  };
837
1287
 
838
1288
  const onSignal = () => {
@@ -866,6 +1316,97 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
866
1316
 
867
1317
  if (options.onReady) await options.onReady(session);
868
1318
 
1319
+ // Keyboard controls (docker + TTY): start/stop/restart services without leaving oke dev.
1320
+ if (keepAlive && dockerStarted && (options.stdinIsTTY ?? process.stdin.isTTY)) {
1321
+ const started = dockerStarted;
1322
+ const syncComposeBoard = async (): Promise<void> => {
1323
+ const { readComposeHealth } = await import("../docker/compose-health.ts");
1324
+ liveComposeHealth = await readComposeHealth({
1325
+ files: started.files,
1326
+ cwd: started.cwd,
1327
+ env: started.env,
1328
+ });
1329
+ if (liveComposeHealth.get("ai") === "error") heroAiStatus = "error";
1330
+ repaintBoard();
1331
+ };
1332
+ const refreshChrome = async (): Promise<void> => {
1333
+ await syncComposeBoard();
1334
+ // Clear log noise; reprint head + latest ● board + surfaces + empty Logs.
1335
+ write(clearTerminalScreen());
1336
+ bootBoard.reset();
1337
+ write(
1338
+ formatDevBanner({
1339
+ profile: earlyProfile,
1340
+ runtimeEnv: resolveDevRuntimeEnv(earlyProfile),
1341
+ system: formatHeroSystemLine(),
1342
+ version: okeVersion,
1343
+ }),
1344
+ );
1345
+ bootBoard.paint(paintBootBoard(heroAiStatus));
1346
+ write(formatAppReadyLine(`http://127.0.0.1:${boundAppPort}`));
1347
+ write(formatServiceLine("Console", `http://127.0.0.1:${boundConsolePort}`));
1348
+ if (mcpServer) {
1349
+ write(formatServiceLine("MCP", `http://127.0.0.1:${boundMcpPort}`));
1350
+ }
1351
+ if (docsMcpServer) {
1352
+ write(formatServiceLine("Docs MCP", `http://127.0.0.1:${boundDocsMcpPort}`));
1353
+ }
1354
+ write(formatDevLogSeparator());
1355
+ write(formatDevControlsHint());
1356
+ };
1357
+ const controls = startDevControls({
1358
+ write,
1359
+ isTTY: options.stdinIsTTY ?? process.stdin.isTTY,
1360
+ onQuit: () => {
1361
+ stop();
1362
+ process.exit(0);
1363
+ },
1364
+ onRefresh: () => refreshChrome(),
1365
+ // Refresh first so help/services never sit in the middle of request logs.
1366
+ onShowPanel: async (body) => {
1367
+ await refreshChrome();
1368
+ write(body);
1369
+ },
1370
+ onComposeSettled: () => syncComposeBoard(),
1371
+ services: () => controlServicesFromStack(pendingStackSummary?.services ?? []),
1372
+ statusOf: (serviceName) => liveComposeHealth.get(serviceName),
1373
+ composeAction: async (action: DevComposeControlAction, serviceNames) => {
1374
+ const files = started.files;
1375
+ const finalArgs =
1376
+ action === "up" && serviceNames.length === 0
1377
+ ? ["compose", ...files.flatMap((f) => ["-f", f]), "up", "-d"]
1378
+ : action === "up"
1379
+ ? [
1380
+ "compose",
1381
+ ...files.flatMap((f) => ["-f", f]),
1382
+ "up",
1383
+ "-d",
1384
+ "--no-deps",
1385
+ ...serviceNames,
1386
+ ]
1387
+ : ["compose", ...files.flatMap((f) => ["-f", f]), action, ...serviceNames];
1388
+ const proc = Bun.spawn(["docker", ...finalArgs], {
1389
+ cwd: started.cwd,
1390
+ stdout: "pipe",
1391
+ stderr: "pipe",
1392
+ env: { ...process.env, ...started.env },
1393
+ });
1394
+ const [stdout, stderr, code] = await Promise.all([
1395
+ new Response(proc.stdout).text(),
1396
+ new Response(proc.stderr).text(),
1397
+ proc.exited,
1398
+ ]);
1399
+ if (code !== 0) {
1400
+ const detail = [stdout.trim(), stderr.trim()].filter(Boolean).join("\n");
1401
+ throw new Error(
1402
+ `docker compose ${action} exited ${code}` + (detail ? `\n${detail}` : ""),
1403
+ );
1404
+ }
1405
+ },
1406
+ });
1407
+ stopDevControls = () => controls.stop();
1408
+ }
1409
+
869
1410
  if (!keepAlive) {
870
1411
  return { code: 0, plan: session.plan, session };
871
1412
  }
@@ -952,11 +1493,11 @@ async function startAppHot(
952
1493
  OKE_HOSTNAME: hostname,
953
1494
  OKE_READY_PATH: readyPath,
954
1495
  OKE_DEV_REQUEST_LOG: "1",
955
- OKE_DEV_SURFACE: "App",
1496
+ OKE_DEV_SURFACE: "Backend",
956
1497
  };
957
1498
 
958
1499
  // `--no-clear-screen`: Bun must not wipe the hero; the runner clears only
959
- // request logs on soft reload and reprints App / Console / MCP URLs.
1500
+ // request logs on soft reload and reprints Backend / Console / MCP URLs.
960
1501
  const proc = Bun.spawn(["bun", "--hot", "--no-clear-screen", runner], {
961
1502
  cwd,
962
1503
  env,