okengine 0.9.1 → 0.10.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 (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 +97 -9
  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/providers/azure-redis.mdx +85 -0
  14. package/site/content/docs/providers/cockroachdb.mdx +86 -0
  15. package/site/content/docs/providers/digitalocean-caching.mdx +88 -0
  16. package/site/content/docs/providers/dragonfly-cloud.mdx +82 -0
  17. package/site/content/docs/providers/elasticache.mdx +92 -0
  18. package/site/content/docs/providers/index.mdx +101 -0
  19. package/site/content/docs/providers/memorystore.mdx +89 -0
  20. package/site/content/docs/providers/meta.json +20 -0
  21. package/site/content/docs/providers/neon.mdx +115 -0
  22. package/site/content/docs/providers/redis-cloud.mdx +93 -0
  23. package/site/content/docs/providers/supabase.mdx +117 -0
  24. package/site/content/docs/providers/upstash.mdx +91 -0
  25. package/site/content/docs/providers/yugabytedb.mdx +95 -0
  26. package/site/content/docs/recipes/caddy.mdx +128 -0
  27. package/site/content/docs/recipes/cockroachdb.mdx +127 -0
  28. package/site/content/docs/recipes/dragonfly.mdx +125 -0
  29. package/site/content/docs/recipes/index.mdx +146 -0
  30. package/site/content/docs/recipes/llama-cpp.mdx +153 -0
  31. package/site/content/docs/recipes/mailpit.mdx +136 -0
  32. package/site/content/docs/recipes/meilisearch.mdx +139 -0
  33. package/site/content/docs/recipes/meta.json +31 -0
  34. package/site/content/docs/recipes/ollama.mdx +142 -0
  35. package/site/content/docs/recipes/openbao.mdx +141 -0
  36. package/site/content/docs/recipes/pgdog.mdx +137 -0
  37. package/site/content/docs/recipes/postgres.mdx +136 -0
  38. package/site/content/docs/recipes/redis.mdx +130 -0
  39. package/site/content/docs/recipes/rustfs.mdx +148 -0
  40. package/site/content/docs/recipes/sglang.mdx +105 -0
  41. package/site/content/docs/recipes/supabase-docker.mdx +145 -0
  42. package/site/content/docs/recipes/timescale.mdx +129 -0
  43. package/site/content/docs/recipes/traefik.mdx +136 -0
  44. package/site/content/docs/recipes/valkey.mdx +121 -0
  45. package/site/content/docs/recipes/vllm.mdx +106 -0
  46. package/site/content/docs/recipes/yugabytedb.mdx +135 -0
  47. package/site/content/docs/reference/cli.md +42 -6
  48. package/site/content/docs/reference/configuration.mdx +5 -3
  49. package/site/content/docs/reference/environment-variables.mdx +12 -12
  50. package/site/content/docs/reference/fx.mdx +38 -4
  51. package/src/cli/ai-setup/ai-setup.test.ts +32 -12
  52. package/src/cli/ai-setup/apply.ts +65 -23
  53. package/src/cli/ai-setup/catalog.ts +1316 -35
  54. package/src/cli/ai-setup/detect-ollama.ts +47 -0
  55. package/src/cli/ai-setup/index.ts +44 -7
  56. package/src/cli/ai-setup/prompts.ts +430 -486
  57. package/src/cli/ai-setup/recommend.test.ts +150 -50
  58. package/src/cli/ai-setup/recommend.ts +118 -101
  59. package/src/cli/ask-seed.test.ts +68 -0
  60. package/src/cli/ask-seed.ts +79 -0
  61. package/src/cli/db-seed-redact.test.ts +25 -0
  62. package/src/cli/db-seed.ts +36 -7
  63. package/src/cli/db.test.ts +1 -1
  64. package/src/cli/db.ts +2 -2
  65. package/src/cli/dev-app-runner.ts +6 -0
  66. package/src/cli/dev-controls.test.ts +107 -0
  67. package/src/cli/dev-controls.ts +354 -0
  68. package/src/cli/dev-schema-sync.ts +12 -4
  69. package/src/cli/dev.test.ts +50 -30
  70. package/src/cli/dev.ts +633 -66
  71. package/src/cli/docker-cli.test.ts +15 -0
  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/cli/start.ts +12 -1
  82. package/src/compiler/effects-infer.ts +37 -8
  83. package/src/compiler/extract.test.ts +125 -0
  84. package/src/compiler/extract.ts +31 -6
  85. package/src/config/index.ts +3 -3
  86. package/src/console/server/claim.ts +3 -1
  87. package/src/console/ui/dist/assets/{index-ClX0ZCe5.js → index-D-9KFce5.js} +2 -2
  88. package/src/console/ui/dist/assets/panel-overview-CW0CQEc2.js +1 -0
  89. package/src/console/ui/dist/assets/panel-runs-BxuyDI3x.js +1 -0
  90. package/src/console/ui/dist/assets/{panel-signals-Dsxdu_AR.js → panel-signals-BXofbj7v.js} +1 -1
  91. package/src/console/ui/dist/assets/{panel-store-hy7O8HOs.js → panel-store-B8mH-arU.js} +1 -1
  92. package/src/console/ui/dist/assets/{panel-traces-CBiAe5go.js → panel-traces-DSA-ZWnf.js} +1 -1
  93. package/src/console/ui/dist/index.html +1 -1
  94. package/src/console/ui/overview/slo.ts +31 -3
  95. package/src/console/ui/overview/types.ts +6 -0
  96. package/src/console/ui/runs/errors.test.ts +53 -0
  97. package/src/console/ui/runs/errors.ts +88 -0
  98. package/src/console/ui/runs/index.ts +8 -0
  99. package/src/console/ui/runs/search.ts +16 -0
  100. package/src/console/ui/shell/panels/runs/RunsPanel.tsx +72 -1
  101. package/src/docker/ai-model-status.test.ts +101 -0
  102. package/src/docker/ai-model-status.ts +276 -0
  103. package/src/docker/compose-health.test.ts +115 -0
  104. package/src/docker/compose-health.ts +234 -0
  105. package/src/docker/compose.ts +24 -8
  106. package/src/docker/derive.ts +35 -9
  107. package/src/docker/docker.test.ts +304 -12
  108. package/src/docker/helpers.ts +56 -0
  109. package/src/docker/index.ts +52 -0
  110. package/src/docker/ollama-pull.ts +232 -0
  111. package/src/docker/recipes/cockroach.ts +24 -0
  112. package/src/docker/recipes/index.ts +32 -0
  113. package/src/docker/recipes/llama-cpp.ts +273 -0
  114. package/src/docker/recipes/ollama.ts +19 -23
  115. package/src/docker/recipes/pgdog.ts +4 -1
  116. package/src/docker/recipes/postgres.ts +5 -18
  117. package/src/docker/recipes/sglang.ts +55 -0
  118. package/src/docker/recipes/supabase.ts +21 -0
  119. package/src/docker/recipes/timescale.ts +18 -0
  120. package/src/docker/recipes/vllm.ts +44 -0
  121. package/src/docker/recipes/yugabyte.ts +23 -0
  122. package/src/docker/stack-id.test.ts +22 -0
  123. package/src/docker/stack-id.ts +38 -4
  124. package/src/docker/types.ts +15 -0
  125. package/src/drivers/ai-ollama.integration.test.ts +7 -4
  126. package/src/drivers/signal-engine.ts +10 -0
  127. package/src/drivers/signal-postgres.ts +24 -2
  128. package/src/drivers/signal-types.ts +10 -0
  129. package/src/elements/channel/fallback.test.ts +91 -0
  130. package/src/elements/channel/hard-bounce.test.ts +82 -0
  131. package/src/elements/channel/injection.test.ts +153 -0
  132. package/src/elements/channel/locale-ar.test.ts +102 -0
  133. package/src/elements/channel/otp-delivery.ts +5 -2
  134. package/src/elements/channel/receipts-ledger.test.ts +100 -0
  135. package/src/elements/channel/runtime.ts +24 -1
  136. package/src/elements/channel/test-helpers.ts +60 -0
  137. package/src/elements/channel/whatsapp-compliance.test.ts +124 -0
  138. package/src/elements/store/cache.test.ts +28 -0
  139. package/src/elements/store/cache.ts +1 -1
  140. package/src/elements/store/emit-drizzle.ts +43 -9
  141. package/src/elements/store/runtime.ts +1 -1
  142. package/src/elements/store/schema-decl.test.ts +2 -0
  143. package/src/elements/store/schema-decl.ts +15 -0
  144. package/src/elements/store/seed.ts +1 -1
  145. package/src/index.ts +1 -0
  146. package/src/kernel/app.ts +131 -10
  147. package/src/kernel/boot-bind/signal.ts +6 -2
  148. package/src/kernel/boot-bind/store.ts +2 -1
  149. package/src/kernel/boot.test.ts +6 -5
  150. package/src/kernel/boot.ts +113 -10
  151. package/src/kernel/compensate.test.ts +131 -0
  152. package/src/kernel/correlation.test.ts +108 -0
  153. package/src/kernel/effect-timing.test.ts +82 -0
  154. package/src/kernel/effects-stamping.test.ts +207 -0
  155. package/src/kernel/errors.ts +11 -0
  156. package/src/kernel/flow.ts +38 -0
  157. package/src/kernel/fx-runs.test.ts +49 -0
  158. package/src/kernel/fx.test.ts +14 -0
  159. package/src/kernel/fx.ts +153 -14
  160. package/src/manifest/sql-resource.ts +41 -0
  161. package/src/manifest/types.ts +8 -2
  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,32 @@ 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>>;
236
+ /**
237
+ * Injectable `docker compose ps` runner for live health polls
238
+ * ({@link startComposeHealthWatch} / keyboard refresh). Default: real
239
+ * `docker`. When {@link DevOptions.composeHealth} is set and this is
240
+ * omitted, live polls return empty (unit tests never shell out).
241
+ *
242
+ * @param args - Args after `docker` (e.g. `compose -f … ps -a …`)
243
+ * @param cwd - Compose directory
244
+ * @param env - Stack env merged into the process env
245
+ */
246
+ readonly composeHealthRun?: (
247
+ args: readonly string[],
248
+ cwd: string,
249
+ env: Record<string, string>,
250
+ ) => Promise<string>;
201
251
  /**
202
252
  * Regenerate client types (injectable).
203
253
  *
@@ -260,7 +310,29 @@ export interface DevResult {
260
310
  * @param options - Flags
261
311
  */
262
312
  export async function runDev(options: DevOptions = {}): Promise<DevResult> {
263
- const write = options.write ?? ((t) => process.stdout.write(t));
313
+ /** Reassigned to {@link createAnchoredBoard} wrap after the status board paints. */
314
+ let write: (text: string) => void = options.write ?? ((t) => process.stdout.write(t));
315
+ const chromeWrite = (t: string) => write(formatCliChrome(t));
316
+ /**
317
+ * `docker compose ps` runner for live / default health polls.
318
+ * Explicit inject wins; otherwise a `composeHealth` inject implies tests —
319
+ * never shell out to a real `docker` binary.
320
+ */
321
+ const composeHealthRun =
322
+ options.composeHealthRun ??
323
+ (options.composeHealth ? async (): Promise<string> => "[]" : undefined);
324
+ const previousWarn = console.warn;
325
+ console.warn = (...args: unknown[]) => {
326
+ const msg = args.map(String).join(" ");
327
+ if (/^oke boot:/i.test(msg.trim())) {
328
+ write(formatBootWarn(msg.trim()));
329
+ return;
330
+ }
331
+ previousWarn.apply(console, args as Parameters<typeof console.warn>);
332
+ };
333
+ const restoreWarn = () => {
334
+ console.warn = previousWarn;
335
+ };
264
336
  const cwd = options.cwd ?? process.cwd();
265
337
  const preferredApp = options.appPort ?? Number(Bun.env.PORT ?? APP_PORT);
266
338
  const preferredConsole = options.consolePort ?? CONSOLE_PORT;
@@ -301,6 +373,7 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
301
373
  }
302
374
  if (!entry) {
303
375
  console.error("oke dev: no entry found (src/app.ts)");
376
+ restoreWarn();
304
377
  return { code: 1 };
305
378
  }
306
379
 
@@ -308,6 +381,7 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
308
381
  const explicitDocker = options.docker === true || Array.isArray(options.docker);
309
382
  if (explicitLocal && explicitDocker) {
310
383
  console.error("oke dev: use either --local or --docker, not both");
384
+ restoreWarn();
311
385
  return { code: 1 };
312
386
  }
313
387
 
@@ -331,6 +405,7 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
331
405
  const chosen = await ask();
332
406
  if (chosen === null) {
333
407
  console.error("oke dev: cancelled");
408
+ restoreWarn();
334
409
  return { code: 1 };
335
410
  }
336
411
  mode = chosen;
@@ -340,6 +415,28 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
340
415
  mode = "local";
341
416
  }
342
417
 
418
+ // Paint chrome immediately so docker/vault work is never a blank 6s wait.
419
+ let okeVersion = "0.0.0";
420
+ try {
421
+ const pkg = (await Bun.file(resolve(import.meta.dir, "../../package.json")).json()) as {
422
+ version?: string;
423
+ };
424
+ if (typeof pkg.version === "string") okeVersion = pkg.version;
425
+ } catch {
426
+ // shipped binary may not sit next to package.json
427
+ }
428
+ const earlyProfile = resolveDevProfile({ docker: mode === "docker", nodeEnv: "development" });
429
+ write(
430
+ formatDevBanner({
431
+ profile: earlyProfile,
432
+ runtimeEnv: resolveDevRuntimeEnv(earlyProfile),
433
+ system: formatHeroSystemLine(),
434
+ version: okeVersion,
435
+ }),
436
+ );
437
+ /** Ephemeral compose/vault/health/AI lines — cleared before the final board. */
438
+ const bootProgress: BootProgress = createBootProgress(write);
439
+
343
440
  let stackRoles: string[] | null = null;
344
441
  let composeFiles: string[] | null = null;
345
442
  let stackEnv: Record<string, string> | null = null;
@@ -351,6 +448,26 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
351
448
  } | null = null;
352
449
  let stackSqlDriver = "postgres";
353
450
  let stackKvDriver = "redis";
451
+ /** Printed after the hero so Docker ports sit under the Starting column. */
452
+ let pendingStackSummary: {
453
+ readonly project: string;
454
+ readonly services: StackSummaryService[];
455
+ readonly appDrivers: readonly string[];
456
+ } | null = null;
457
+ /** Background AI model poller — cancelled in `stop()`. */
458
+ let stopAiModelWatch: (() => void) | null = null;
459
+ /** Session compose health poller — cancelled in `stop()`. */
460
+ let stopComposeHealthWatch: (() => void) | null = null;
461
+ /** Latest compose ps -a statuses (service name → ●). */
462
+ let liveComposeHealth = new Map<string, DevStatus>();
463
+ /** Start after hero/stack chrome so status lines land in the right place. */
464
+ let pendingAiModelWatch: {
465
+ readonly url: string;
466
+ readonly model: string;
467
+ readonly kind: "openai-compatible" | "ollama";
468
+ } | null = null;
469
+ /** Hero AI status dot (yellow while model loads). */
470
+ let heroAiStatus: DevStatus | undefined;
354
471
  let loadedConfig: Awaited<ReturnType<typeof loadOkeConfig>>["config"] | null = null;
355
472
  try {
356
473
  loadedConfig = (await loadOkeConfig(cwd)).config;
@@ -371,6 +488,7 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
371
488
  write(formatStatusLine(`meilisearch local server on ${meili.url}`));
372
489
  } catch (err) {
373
490
  console.error(err instanceof Error ? err.message : String(err));
491
+ restoreWarn();
374
492
  return { code: 1 };
375
493
  }
376
494
  }
@@ -386,6 +504,7 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
386
504
  images = resolveImages(loaded.config);
387
505
  } catch (err) {
388
506
  console.error(err instanceof Error ? err.message : String(err));
507
+ restoreWarn();
389
508
  return { code: 1 };
390
509
  }
391
510
  } else {
@@ -434,6 +553,44 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
434
553
  );
435
554
  stackEnv = { ...derived.stackEnv };
436
555
 
556
+ const roleLabel = (role: string): string => {
557
+ if (role === "store.sql") return "postgres";
558
+ if (role === "store.kv") return "redis";
559
+ if (role === "store.files") return "files";
560
+ if (role === "channel.email") return "mail";
561
+ return role;
562
+ };
563
+ const extraLabel = (role: string, containerPort: number): string => {
564
+ if (role === "channel.email" && containerPort === 8025) return "mail-ui";
565
+ if (role === "store.files" && containerPort === 9001) return "files-ui";
566
+ return `${roleLabel(role)}+${containerPort}`;
567
+ };
568
+ const appDrivers = [
569
+ ...(stackRoles.includes("store.sql") ? [stackSqlDriver] : []),
570
+ ...(stackRoles.includes("store.kv") ? [stackKvDriver] : []),
571
+ ];
572
+ const stackAiModel =
573
+ stackEnv.OKE_AI_MODEL?.trim() || process.env.OKE_AI_MODEL?.trim() || undefined;
574
+ pendingStackSummary = {
575
+ project: `oke-${appSlug}`,
576
+ services: derived.specs.flatMap((spec) => [
577
+ {
578
+ label: roleLabel(spec.role),
579
+ hostPort: spec.hostPort,
580
+ serviceName: spec.serviceName,
581
+ status: "pending" as const,
582
+ ...(spec.role === "ai" && stackAiModel ? { detail: stackAiModel } : {}),
583
+ },
584
+ ...resolveExtraPorts(spec, { images, instanceId }).map((p) => ({
585
+ label: extraLabel(spec.role, p.containerPort),
586
+ hostPort: p.hostPort,
587
+ serviceName: spec.serviceName,
588
+ status: "pending" as const,
589
+ })),
590
+ ]),
591
+ appDrivers,
592
+ };
593
+
437
594
  if (!options.dryRun) {
438
595
  try {
439
596
  await writeDerivedFiles(derived, dockerOut, {
@@ -445,17 +602,26 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
445
602
  const args = ["compose", ...files.flatMap((f) => ["-f", f]), "up", "-d"];
446
603
  const proc = Bun.spawn(["docker", ...args], {
447
604
  cwd: dir,
448
- stdout: "inherit",
449
- stderr: "inherit",
605
+ stdout: "pipe",
606
+ stderr: "pipe",
450
607
  env: { ...process.env, ...stackEnv! },
451
608
  });
452
- const code = await proc.exited;
609
+ const [stdout, stderr, code] = await Promise.all([
610
+ new Response(proc.stdout).text(),
611
+ new Response(proc.stderr).text(),
612
+ proc.exited,
613
+ ]);
453
614
  if (code !== 0) {
454
- throw new Error(`oke dev --docker: docker compose exited ${code}`);
615
+ const detail = [stdout.trim(), stderr.trim()].filter(Boolean).join("\n");
616
+ throw new Error(
617
+ `oke dev --docker: docker compose exited ${code}` + (detail ? `\n${detail}` : ""),
618
+ );
455
619
  }
456
620
  });
621
+ bootProgress.set("compose", formatStatusLine("docker compose up…", undefined, "pending"));
457
622
  // Compose files live under docker/; cwd for compose is that directory.
458
623
  await up(composeFiles, dockerOut);
624
+ bootProgress.set("compose", formatStatusLine("docker compose up", undefined, "ready"));
459
625
  // Remember for SIGINT / terminal close — `stop` keeps volumes (not `down -v`).
460
626
  dockerStarted = {
461
627
  files: composeFiles,
@@ -467,6 +633,7 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
467
633
  // least-privilege app token to the app + Console (never root).
468
634
  const vaultSpec = derived.specs.find((s) => s.role === "vault");
469
635
  if (vaultSpec) {
636
+ bootProgress.set("vault", formatStatusLine("vault unseal…", undefined, "pending"));
470
637
  const { ensureOpenBao, openbaoStackEnv } = await import("./openbao-bootstrap.ts");
471
638
  const vaultNames = await tryLoadVaultSecretNames(cwd);
472
639
  const boot = await ensureOpenBao({
@@ -476,49 +643,140 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
476
643
  });
477
644
  Object.assign(stackEnv!, openbaoStackEnv(boot));
478
645
  Object.assign(dockerStarted.env, openbaoStackEnv(boot));
646
+ // Rewrite .env.docker with the full stack env (including token) so a
647
+ // later ensureDockerStack / writeDerivedFiles does not drop it.
479
648
  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);
649
+ await Bun.write(envPath, formatStackEnv(stackEnv!));
650
+ bootProgress.set("vault", formatStatusLine("vault ready", undefined, "ready"));
651
+ }
652
+
653
+ // Stream compose health until infra is up (AI may stay pending — model load).
654
+ const aiServiceName = derived.specs.find((s) => s.role === "ai")?.serviceName;
655
+ const labelForService = (serviceName: string): string => {
656
+ const spec = derived.specs.find((s) => s.serviceName === serviceName);
657
+ return spec ? roleLabel(spec.role) : serviceName;
658
+ };
659
+ const onHealthUpdate = (service: string, status: DevStatus) => {
660
+ const label = labelForService(service);
661
+ const text =
662
+ status === "ready"
663
+ ? `${label} ready`
664
+ : status === "error"
665
+ ? `${label} unhealthy`
666
+ : `${label} starting…`;
667
+ bootProgress.set(`svc:${service}`, formatStatusLine(text, undefined, status));
668
+ };
669
+ const healthByService = options.composeHealth
670
+ ? await options.composeHealth({
671
+ files: composeFiles,
672
+ cwd: dockerOut,
673
+ env: dockerStarted.env,
674
+ onUpdate: onHealthUpdate,
675
+ })
676
+ : await (
677
+ await import("../docker/compose-health.ts")
678
+ ).watchComposeHealth({
679
+ files: composeFiles,
680
+ cwd: dockerOut,
681
+ env: dockerStarted.env,
682
+ run: composeHealthRun,
683
+ timeoutMs: 20_000,
684
+ isDone: (map) => {
685
+ // Empty ps (compose just-created / injectable gap): do not spin 20s.
686
+ if (map.size === 0) return true;
687
+ return [...map.entries()].every(
688
+ ([name, s]) => name === aiServiceName || s === "ready" || s === "error",
689
+ );
690
+ },
691
+ onUpdate: onHealthUpdate,
692
+ });
693
+ liveComposeHealth = healthByService;
694
+ if (pendingStackSummary) {
695
+ pendingStackSummary = {
696
+ ...pendingStackSummary,
697
+ services: pendingStackSummary.services.map((svc) => ({
698
+ ...svc,
699
+ status: svc.serviceName
700
+ ? (healthByService.get(svc.serviceName) ?? svc.status ?? "pending")
701
+ : (svc.status ?? "pending"),
702
+ })),
703
+ };
704
+ }
705
+
706
+ // AI model: Ollama pulls via HTTP (blocking with progress). OpenAI-compatible
707
+ // servers (llama.cpp / vLLM / SGLang) load in-container — poll readiness in
708
+ // the background. Never treat compose "healthy" as model-ready.
709
+ const aiSpec = derived.specs.find((s) => s.role === "ai");
710
+ if (aiSpec) {
711
+ const { recipeFor } = await import("../docker/recipes/index.ts");
712
+ const recipeId = recipeFor(aiSpec.image).id;
713
+ const model =
714
+ stackEnv!.OKE_AI_MODEL?.trim() ||
715
+ process.env.OKE_AI_MODEL?.trim() ||
716
+ (recipeId === "ollama" ? "qwen3.5:9b" : "smollm2");
717
+ const url = `http://127.0.0.1:${aiSpec.hostPort}`;
718
+ const paintAi = (status: DevStatus, detail?: string) => {
719
+ heroAiStatus = status;
720
+ if (!pendingStackSummary) return;
721
+ pendingStackSummary = {
722
+ ...pendingStackSummary,
723
+ services: pendingStackSummary.services.map((svc) =>
724
+ svc.serviceName === aiSpec.serviceName
725
+ ? {
726
+ ...svc,
727
+ status,
728
+ detail: detail ?? svc.detail,
729
+ }
730
+ : svc,
731
+ ),
732
+ };
733
+ };
734
+ if (recipeId === "ollama") {
735
+ bootProgress.set(
736
+ "ai-model",
737
+ formatStatusLine(`AI ${model} — ensuring…`, undefined, "pending"),
738
+ );
739
+ const { ensureOllamaModel } = await import("../docker/ollama-pull.ts");
740
+ await ensureOllamaModel({
741
+ url,
742
+ model,
743
+ onStatus: (line) =>
744
+ bootProgress.set("ai-model", formatStatusLine(line, undefined, "pending")),
745
+ });
746
+ pendingAiModelWatch = { url, model, kind: "ollama" };
747
+ paintAi("ready", model);
748
+ } else if (recipeId === "llama-cpp" || recipeId === "vllm" || recipeId === "sglang") {
749
+ pendingAiModelWatch = {
750
+ url: `${url}/v1`,
751
+ model,
752
+ kind: "openai-compatible",
753
+ };
754
+ bootProgress.set(
755
+ "ai-model",
756
+ formatStatusLine(`AI ${model} — probing…`, undefined, "pending"),
757
+ );
758
+ const { probeAiModelStatus, formatAiModelStatusMessage } =
759
+ await import("../docker/ai-model-status.ts");
760
+ const snap = await probeAiModelStatus({
761
+ url: `${url}/v1`,
762
+ model,
763
+ kind: "openai-compatible",
764
+ });
765
+ const status = devStatusFromAiPhase(snap.phase);
766
+ paintAi(status, snap.phase === "ready" ? model : `${model} · ${snap.phase}`);
767
+ bootProgress.set(
768
+ "ai-model",
769
+ formatStatusLine(formatAiModelStatusMessage(snap), undefined, status),
770
+ );
771
+ }
486
772
  }
487
773
  } catch (err) {
488
774
  const msg = err instanceof Error ? err.message : String(err);
489
775
  console.error(msg);
490
776
  console.error("oke dev: docker mode failed — fix compose, or run `oke mode local`");
777
+ restoreWarn();
491
778
  return { code: 1 };
492
779
  }
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
780
  }
523
781
  }
524
782
 
@@ -533,36 +791,225 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
533
791
  stackEnv,
534
792
  };
535
793
 
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
794
+ const heroAiModel =
795
+ stackEnv?.OKE_AI_MODEL?.trim() || process.env.OKE_AI_MODEL?.trim() || undefined;
796
+ // OpenAI-compatible model load can outlive compose health — default AI ● to pending.
797
+ if (pendingAiModelWatch && heroAiStatus === undefined) {
798
+ heroAiStatus = "pending";
544
799
  }
545
-
546
800
  const heroSnapshot = buildDevHeroSnapshot({
547
801
  config: loadedConfig,
548
802
  docker: mode === "docker",
549
803
  sqlDriver: mode === "docker" ? stackSqlDriver : undefined,
550
804
  kvDriver: mode === "docker" ? stackKvDriver : undefined,
805
+ aiModel: heroAiModel,
806
+ aiStatus: heroAiStatus,
551
807
  version: okeVersion,
552
808
  nodeEnv: "development",
553
809
  });
810
+ const aiDriverLabel = (() => {
811
+ const detail = heroSnapshot.elements.find((r) => r.element === "ai")?.detail ?? "—";
812
+ if (detail === "—" || detail === "") return detail;
813
+ return detail.split(" · ")[0] ?? detail;
814
+ })();
815
+
816
+ const worstStatus = (statuses: readonly (DevStatus | undefined)[]): DevStatus | undefined => {
817
+ const present = statuses.filter((s): s is DevStatus => s !== undefined);
818
+ if (present.length === 0) return undefined;
819
+ if (present.includes("error")) return "error";
820
+ if (present.includes("pending")) return "pending";
821
+ if (present.includes("idle")) return "idle";
822
+ return "ready";
823
+ };
554
824
 
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
- );
825
+ const elementStatusFromHealth = (
826
+ element: string,
827
+ aiStatus: DevStatus | undefined,
828
+ ): DevStatus | undefined => {
829
+ const h = liveComposeHealth;
830
+ if (h.size === 0) return element === "ai" ? aiStatus : undefined;
831
+ const one = (name: string) => h.get(name);
832
+ switch (element) {
833
+ case "ai": {
834
+ const container = one("ai");
835
+ if (container === "error") return "error";
836
+ return aiStatus ?? container;
837
+ }
838
+ case "vault":
839
+ return one("vault");
840
+ case "signal":
841
+ case "gate":
842
+ return one("store-kv");
843
+ case "channel":
844
+ return one("channel-email");
845
+ case "clock":
846
+ return one("store-sql");
847
+ case "store":
848
+ return worstStatus([
849
+ one("store-sql"),
850
+ one("store-kv"),
851
+ one("store-files"),
852
+ one("store-index"),
853
+ ]);
854
+ default:
855
+ return undefined;
856
+ }
857
+ };
564
858
 
565
- if (options.dryRun) return { code: 0, plan };
859
+ const paintBootBoard = (aiStatus: DevStatus | undefined) => {
860
+ const aiSt = aiStatus ?? heroAiStatus;
861
+ const elements = heroSnapshot.elements.map((row) => {
862
+ const fromHealth = elementStatusFromHealth(row.element, aiSt);
863
+ const st =
864
+ fromHealth ?? row.status ?? (row.detail === "—" || row.detail === "" ? "idle" : "ready");
865
+ if (row.element !== "ai") {
866
+ return fromHealth ? { ...row, status: st } : row;
867
+ }
868
+ let detail = aiDriverLabel;
869
+ if (detail !== "—" && heroAiModel) {
870
+ const phase =
871
+ st === "ready" || st === "idle"
872
+ ? null
873
+ : st === "error"
874
+ ? liveComposeHealth.get("ai") === "error"
875
+ ? "stopped"
876
+ : "error"
877
+ : "loading";
878
+ detail = phase
879
+ ? `${aiDriverLabel} · ${heroAiModel} · ${phase}`
880
+ : `${aiDriverLabel} · ${heroAiModel}`;
881
+ }
882
+ return { ...row, status: st, detail };
883
+ });
884
+ const elementsBlock = formatDevHeroDetails({ elements });
885
+ const stackBlock = pendingStackSummary
886
+ ? formatStackSummary({
887
+ project: pendingStackSummary.project,
888
+ services: pendingStackSummary.services.map((svc) => {
889
+ const fromHealth = svc.serviceName ? liveComposeHealth.get(svc.serviceName) : undefined;
890
+ let status = fromHealth ?? svc.status ?? "pending";
891
+ let detail = svc.detail;
892
+ if (svc.serviceName === "ai") {
893
+ if (fromHealth === "error") {
894
+ status = "error";
895
+ detail = heroAiModel ? `${heroAiModel} · stopped` : "stopped";
896
+ } else if (aiSt !== undefined) {
897
+ status = aiSt === "ready" && fromHealth === "pending" ? "pending" : aiSt;
898
+ detail = !heroAiModel
899
+ ? detail
900
+ : status === "ready"
901
+ ? heroAiModel
902
+ : `${heroAiModel} · ${status === "error" ? "error" : "loading"}`;
903
+ }
904
+ }
905
+ return { ...svc, status, ...(detail !== undefined ? { detail } : {}) };
906
+ }),
907
+ appDrivers: pendingStackSummary.appDrivers,
908
+ })
909
+ : "";
910
+ // Breath between elements and Docker — they read as two panes, not one block.
911
+ const gap = elementsBlock && stackBlock ? "\n" : "";
912
+ return `${elementsBlock}${gap}${stackBlock}`;
913
+ };
914
+
915
+ // Drop ephemeral progress — final elements + Docker board replaces it.
916
+ bootProgress.clear();
917
+ const bootBoard = createAnchoredBoard(write);
918
+ bootBoard.paint(paintBootBoard(heroAiStatus));
919
+ // Track everything printed below so ● can rewrite in place for the session.
920
+ write = bootBoard.wrapWrite(write);
921
+
922
+ if (options.dryRun) {
923
+ bootBoard.stop();
924
+ restoreWarn();
925
+ return { code: 0, plan };
926
+ }
927
+
928
+ const repaintBoard = (aiStatus?: DevStatus): void => {
929
+ bootBoard.paint(paintBootBoard(aiStatus ?? heroAiStatus));
930
+ };
931
+
932
+ if (pendingAiModelWatch) {
933
+ const target = pendingAiModelWatch;
934
+ pendingAiModelWatch = null;
935
+ const { startAiModelWatch } = await import("../docker/ai-model-status.ts");
936
+ let resolveFirstAi: (() => void) | undefined;
937
+ const firstAiPaint = new Promise<void>((resolve) => {
938
+ resolveFirstAi = resolve;
939
+ });
940
+ let sawAiPaint = false;
941
+ stopAiModelWatch = startAiModelWatch({
942
+ url: target.url,
943
+ model: target.model,
944
+ kind: target.kind,
945
+ onStatus: (_line, status) => {
946
+ const st = devStatusFromAiPhase(status.phase);
947
+ // Container stopped wins over model-phase "loading".
948
+ if (liveComposeHealth.get("ai") === "error") {
949
+ heroAiStatus = "error";
950
+ repaintBoard("error");
951
+ if (!sawAiPaint) {
952
+ sawAiPaint = true;
953
+ resolveFirstAi?.();
954
+ }
955
+ return;
956
+ }
957
+ heroAiStatus = st;
958
+ // Status ● lives on the board above Logs — not in the log stream.
959
+ repaintBoard(st);
960
+ if (!sawAiPaint) {
961
+ sawAiPaint = true;
962
+ resolveFirstAi?.();
963
+ }
964
+ },
965
+ });
966
+ // Let the first probe repaint AI ● (yellow loading) before we print below.
967
+ await Promise.race([firstAiPaint, Bun.sleep(2_500)]);
968
+ }
969
+
970
+ // Live ● for the whole session — catch Docker Desktop stops / crashes.
971
+ // Updates the board only (no redis stopped / starting / ready log spam).
972
+ if (dockerStarted && composeFiles) {
973
+ const { startComposeHealthWatch } = await import("../docker/compose-health.ts");
974
+ const started = dockerStarted;
975
+ stopComposeHealthWatch = startComposeHealthWatch({
976
+ files: started.files,
977
+ cwd: started.cwd,
978
+ env: started.env,
979
+ run: composeHealthRun,
980
+ intervalMs: 2_000,
981
+ onChange: (map) => {
982
+ liveComposeHealth = map;
983
+ if (map.get("ai") === "error") heroAiStatus = "error";
984
+ repaintBoard();
985
+ },
986
+ });
987
+ }
988
+
989
+ /** Snapshot keys we mutate so `stop()` can leave the parent shell clean. */
990
+ const processEnvSnapshot = new Map<string, string | undefined>();
991
+ const setProcessEnv = (key: string, value: string): void => {
992
+ if (!processEnvSnapshot.has(key)) processEnvSnapshot.set(key, process.env[key]);
993
+ process.env[key] = value;
994
+ };
995
+ const restoreProcessEnv = (): void => {
996
+ for (const [key, prev] of processEnvSnapshot) {
997
+ if (prev === undefined) delete process.env[key];
998
+ else process.env[key] = prev;
999
+ }
1000
+ processEnvSnapshot.clear();
1001
+ };
1002
+
1003
+ // Hydrate parent process.env so schema sync / seed / Console see compose URLs
1004
+ // (Backend child gets the same overlay via `env` below). Restored on stop.
1005
+ if (stackEnv) {
1006
+ for (const [key, value] of Object.entries(stackEnv)) {
1007
+ setProcessEnv(key, value);
1008
+ }
1009
+ setProcessEnv("OKE_DOCKER", "1");
1010
+ setProcessEnv("OKE_SQL_DRIVER", stackSqlDriver);
1011
+ setProcessEnv("OKE_KV_DRIVER", stackKvDriver);
1012
+ }
566
1013
 
567
1014
  // One-shot schema sync for the session profile (both local and docker):
568
1015
  // emits schema.generated.ts for the active dialect, then pushes via
@@ -571,12 +1018,24 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
571
1018
  if (!options.noDbPush) {
572
1019
  const { syncDevSchema } = await import("./dev-schema-sync.ts");
573
1020
  try {
574
- const result = await syncDevSchema(cwd, mode, { write });
1021
+ const result = await syncDevSchema(cwd, mode, {
1022
+ write: chromeWrite,
1023
+ quietComposeReady: mode === "docker",
1024
+ });
575
1025
  write(
576
1026
  formatStatusLine(
577
1027
  `oke db push (${mode} · ${result.dialect}) ${result.code === 0 ? "ok" : "failed"}`,
578
1028
  ),
579
1029
  );
1030
+ if (result.code === 0) {
1031
+ const { maybeAskSeed } = await import("./ask-seed.ts");
1032
+ await maybeAskSeed({
1033
+ cwd,
1034
+ env: mode,
1035
+ write,
1036
+ stdinIsTTY: options.stdinIsTTY ?? process.stdin.isTTY,
1037
+ });
1038
+ }
580
1039
  } catch (err) {
581
1040
  write(
582
1041
  formatStatusLine(
@@ -612,8 +1071,10 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
612
1071
  }
613
1072
  : {}),
614
1073
  ...(meili?.overlay ?? {}),
1074
+ /** Backend child — parent Console owns process-local boot notices. */
1075
+ OKE_SUPPRESS_BOOT_WARN: "1",
615
1076
  };
616
- process.env.OKE_DEV_REQUEST_LOG = "1";
1077
+ setProcessEnv("OKE_DEV_REQUEST_LOG", "1");
617
1078
 
618
1079
  const startApp = options.startApp ?? ((entryPath, appEnv) => startAppHot(cwd, entryPath, appEnv));
619
1080
 
@@ -779,6 +1240,9 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
779
1240
  }
780
1241
 
781
1242
  write(formatDevLogSeparator());
1243
+ if (dockerStarted && (options.stdinIsTTY ?? process.stdin.isTTY)) {
1244
+ write(formatDevControlsHint());
1245
+ }
782
1246
 
783
1247
  const watchFs: DevWatchFn =
784
1248
  options.watchFs ?? ((path, watchOptions, listener) => watch(path, watchOptions, listener));
@@ -794,10 +1258,20 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
794
1258
  }
795
1259
  });
796
1260
 
1261
+ let stopDevControls: (() => void) | null = null;
1262
+
797
1263
  let stopped = false;
798
1264
  const stop = () => {
799
1265
  if (stopped) return;
800
1266
  stopped = true;
1267
+ restoreWarn();
1268
+ stopDevControls?.();
1269
+ stopDevControls = null;
1270
+ stopAiModelWatch?.();
1271
+ stopAiModelWatch = null;
1272
+ stopComposeHealthWatch?.();
1273
+ stopComposeHealthWatch = null;
1274
+ bootBoard.stop();
801
1275
  autoPushRunner.cancel();
802
1276
  watcher.close();
803
1277
  meili?.stop();
@@ -814,8 +1288,8 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
814
1288
  const args = ["compose", ...files.flatMap((f) => ["-f", f]), "stop"];
815
1289
  const result = Bun.spawnSync(["docker", ...args], {
816
1290
  cwd: dir,
817
- stdout: "inherit",
818
- stderr: "inherit",
1291
+ stdout: "pipe",
1292
+ stderr: "pipe",
819
1293
  env: { ...process.env, ...env },
820
1294
  });
821
1295
  if (result.exitCode !== 0) {
@@ -833,6 +1307,7 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
833
1307
  process.off("SIGINT", onSignal);
834
1308
  process.off("SIGTERM", onSignal);
835
1309
  process.off("SIGHUP", onSignal);
1310
+ restoreProcessEnv();
836
1311
  };
837
1312
 
838
1313
  const onSignal = () => {
@@ -866,6 +1341,98 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
866
1341
 
867
1342
  if (options.onReady) await options.onReady(session);
868
1343
 
1344
+ // Keyboard controls (docker + TTY): start/stop/restart services without leaving oke dev.
1345
+ if (keepAlive && dockerStarted && (options.stdinIsTTY ?? process.stdin.isTTY)) {
1346
+ const started = dockerStarted;
1347
+ const syncComposeBoard = async (): Promise<void> => {
1348
+ const { readComposeHealth } = await import("../docker/compose-health.ts");
1349
+ liveComposeHealth = await readComposeHealth({
1350
+ files: started.files,
1351
+ cwd: started.cwd,
1352
+ env: started.env,
1353
+ run: composeHealthRun,
1354
+ });
1355
+ if (liveComposeHealth.get("ai") === "error") heroAiStatus = "error";
1356
+ repaintBoard();
1357
+ };
1358
+ const refreshChrome = async (): Promise<void> => {
1359
+ await syncComposeBoard();
1360
+ // Clear log noise; reprint head + latest ● board + surfaces + empty Logs.
1361
+ write(clearTerminalScreen());
1362
+ bootBoard.reset();
1363
+ write(
1364
+ formatDevBanner({
1365
+ profile: earlyProfile,
1366
+ runtimeEnv: resolveDevRuntimeEnv(earlyProfile),
1367
+ system: formatHeroSystemLine(),
1368
+ version: okeVersion,
1369
+ }),
1370
+ );
1371
+ bootBoard.paint(paintBootBoard(heroAiStatus));
1372
+ write(formatAppReadyLine(`http://127.0.0.1:${boundAppPort}`));
1373
+ write(formatServiceLine("Console", `http://127.0.0.1:${boundConsolePort}`));
1374
+ if (mcpServer) {
1375
+ write(formatServiceLine("MCP", `http://127.0.0.1:${boundMcpPort}`));
1376
+ }
1377
+ if (docsMcpServer) {
1378
+ write(formatServiceLine("Docs MCP", `http://127.0.0.1:${boundDocsMcpPort}`));
1379
+ }
1380
+ write(formatDevLogSeparator());
1381
+ write(formatDevControlsHint());
1382
+ };
1383
+ const controls = startDevControls({
1384
+ write,
1385
+ isTTY: options.stdinIsTTY ?? process.stdin.isTTY,
1386
+ onQuit: () => {
1387
+ stop();
1388
+ process.exit(0);
1389
+ },
1390
+ onRefresh: () => refreshChrome(),
1391
+ // Refresh first so help/services never sit in the middle of request logs.
1392
+ onShowPanel: async (body) => {
1393
+ await refreshChrome();
1394
+ write(body);
1395
+ },
1396
+ onComposeSettled: () => syncComposeBoard(),
1397
+ services: () => controlServicesFromStack(pendingStackSummary?.services ?? []),
1398
+ statusOf: (serviceName) => liveComposeHealth.get(serviceName),
1399
+ composeAction: async (action: DevComposeControlAction, serviceNames) => {
1400
+ const files = started.files;
1401
+ const finalArgs =
1402
+ action === "up" && serviceNames.length === 0
1403
+ ? ["compose", ...files.flatMap((f) => ["-f", f]), "up", "-d"]
1404
+ : action === "up"
1405
+ ? [
1406
+ "compose",
1407
+ ...files.flatMap((f) => ["-f", f]),
1408
+ "up",
1409
+ "-d",
1410
+ "--no-deps",
1411
+ ...serviceNames,
1412
+ ]
1413
+ : ["compose", ...files.flatMap((f) => ["-f", f]), action, ...serviceNames];
1414
+ const proc = Bun.spawn(["docker", ...finalArgs], {
1415
+ cwd: started.cwd,
1416
+ stdout: "pipe",
1417
+ stderr: "pipe",
1418
+ env: { ...process.env, ...started.env },
1419
+ });
1420
+ const [stdout, stderr, code] = await Promise.all([
1421
+ new Response(proc.stdout).text(),
1422
+ new Response(proc.stderr).text(),
1423
+ proc.exited,
1424
+ ]);
1425
+ if (code !== 0) {
1426
+ const detail = [stdout.trim(), stderr.trim()].filter(Boolean).join("\n");
1427
+ throw new Error(
1428
+ `docker compose ${action} exited ${code}` + (detail ? `\n${detail}` : ""),
1429
+ );
1430
+ }
1431
+ },
1432
+ });
1433
+ stopDevControls = () => controls.stop();
1434
+ }
1435
+
869
1436
  if (!keepAlive) {
870
1437
  return { code: 0, plan: session.plan, session };
871
1438
  }
@@ -952,11 +1519,11 @@ async function startAppHot(
952
1519
  OKE_HOSTNAME: hostname,
953
1520
  OKE_READY_PATH: readyPath,
954
1521
  OKE_DEV_REQUEST_LOG: "1",
955
- OKE_DEV_SURFACE: "App",
1522
+ OKE_DEV_SURFACE: "Backend",
956
1523
  };
957
1524
 
958
1525
  // `--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.
1526
+ // request logs on soft reload and reprints Backend / Console / MCP URLs.
960
1527
  const proc = Bun.spawn(["bun", "--hot", "--no-clear-screen", runner], {
961
1528
  cwd,
962
1529
  env,