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/term.test.ts CHANGED
@@ -5,7 +5,9 @@
5
5
  import { describe, expect, test } from "bun:test";
6
6
  import {
7
7
  formatAppReadyLine,
8
+ formatBootWarn,
8
9
  formatClaimNote,
10
+ formatCliChrome,
9
11
  formatDevBanner,
10
12
  formatDevHero,
11
13
  formatDevLogSeparator,
@@ -13,6 +15,11 @@ import {
13
15
  formatServiceLine,
14
16
  formatRequestLine,
15
17
  formatStackSummary,
18
+ countTermLines,
19
+ createAnchoredBoard,
20
+ createBootProgress,
21
+ createRewritableBlock,
22
+ formatStatusDot,
16
23
  formatStatusLine,
17
24
  termStyle,
18
25
  } from "./term.ts";
@@ -38,9 +45,10 @@ describe("term", () => {
38
45
  runtimeEnv: "local",
39
46
  system: "darwin 25.4.0 · bun 1.3.14",
40
47
  elements: [
41
- { element: "flow", detail: "" },
42
- { element: "store", detail: "sql postgres · kv redis" },
43
- { element: "signal", detail: "memory" },
48
+ { element: "flow", detail: "", status: "ready" },
49
+ { element: "store", detail: "sql postgres · kv redis", status: "ready" },
50
+ { element: "signal", detail: "memory", status: "ready" },
51
+ { element: "ai", detail: "openai-compatible · smollm2", status: "pending" },
44
52
  ],
45
53
  });
46
54
  expect(out).toContain("oke dev v0.2.4");
@@ -53,11 +61,65 @@ describe("term", () => {
53
61
  expect(out).toContain("elements");
54
62
  expect(out).toContain("store");
55
63
  expect(out).toContain("postgres");
64
+ expect(out).toContain("● flow");
65
+ expect(out).toContain("● ai");
56
66
  expect(out).not.toContain("on(Trigger)");
57
67
  expect(out).not.toContain("O·K·E");
58
68
  expect(out).not.toMatch(/\u001b\[/);
59
69
  });
60
70
 
71
+ test("formatStatusDot colors by status", () => {
72
+ const s = termStyle(true);
73
+ expect(formatStatusDot("ready", true)).toBe(`${s.green}●${s.reset}`);
74
+ expect(formatStatusDot("pending", true)).toBe(`${s.yellow}●${s.reset}`);
75
+ expect(formatStatusDot("error", true)).toBe(`${s.red}●${s.reset}`);
76
+ expect(formatStatusDot("idle", true)).toBe(`${s.dim}●${s.reset}`);
77
+ expect(formatStatusDot("ready", false)).toBe("●");
78
+ });
79
+
80
+ test("createRewritableBlock appends when disabled", () => {
81
+ let out = "";
82
+ const block = createRewritableBlock((t) => {
83
+ out += t;
84
+ }, false);
85
+ block.paint("a\nb\n");
86
+ block.paint("c\n");
87
+ expect(out).toBe("a\nb\nc\n");
88
+ expect(countTermLines("a\nb\n")).toBe(2);
89
+ });
90
+
91
+ test("createBootProgress replaces keyed rows then clears", () => {
92
+ let out = "";
93
+ const progress = createBootProgress((t) => {
94
+ out += t;
95
+ }, false);
96
+ progress.set("compose", "compose…\n");
97
+ progress.set("compose", "compose ok\n");
98
+ progress.set("vault", "vault ok\n");
99
+ // Non-TTY appends each set (including replacements).
100
+ expect(out).toContain("compose ok\n");
101
+ expect(out).toContain("vault ok\n");
102
+ progress.clear();
103
+ // clear is a no-op on the stream when rewrite is disabled
104
+ expect(out).toContain("vault ok\n");
105
+ });
106
+
107
+ test("createAnchoredBoard wrapWrite tracks below output", () => {
108
+ let out = "";
109
+ const board = createAnchoredBoard((t) => {
110
+ out += t;
111
+ }, false);
112
+ board.paint("board\n");
113
+ const w = board.wrapWrite((t) => {
114
+ out += t;
115
+ });
116
+ w("below\n");
117
+ board.paint("board2\n"); // non-TTY: no rewrite append
118
+ expect(out).toContain("board\n");
119
+ expect(out).toContain("below\n");
120
+ board.stop();
121
+ });
122
+
61
123
  test("formatServiceLine / formatAppReadyLine include URL", () => {
62
124
  expect(formatServiceLine("Console", "http://127.0.0.1:6533", false)).toContain(
63
125
  "http://127.0.0.1:6533",
@@ -65,7 +127,7 @@ describe("term", () => {
65
127
  expect(formatAppReadyLine("http://127.0.0.1:6530", false)).toContain("http://127.0.0.1:6530");
66
128
  });
67
129
 
68
- test("formatDevHero keeps App Console MCP URLs", () => {
130
+ test("formatDevHero keeps Backend Console MCP URLs", () => {
69
131
  const out = formatDevHero({
70
132
  appUrl: "http://127.0.0.1:6530",
71
133
  consoleUrl: "http://127.0.0.1:6533",
@@ -73,12 +135,12 @@ describe("term", () => {
73
135
  profile: "local",
74
136
  runtimeEnv: "local",
75
137
  system: "darwin 25.4.0 · bun 1.3.14",
76
- elements: [{ element: "flow", detail: "" }],
138
+ elements: [{ element: "flow", detail: "", status: "ready" }],
77
139
  color: false,
78
140
  });
79
141
  expect(out).toContain("oke dev");
80
142
  expect(out).toContain("██");
81
- expect(out).toContain("App");
143
+ expect(out).toContain("Backend");
82
144
  expect(out).toContain("Console");
83
145
  expect(out).toContain("MCP");
84
146
  expect(out).toContain("http://127.0.0.1:6530");
@@ -106,10 +168,27 @@ describe("term", () => {
106
168
  expect(formatStatusLine("stack up (sql)", false)).toContain("stack up");
107
169
  });
108
170
 
171
+ test("formatBootWarn wraps under Notice chrome", () => {
172
+ const out = formatBootWarn(
173
+ "oke boot: Channel suppression defaults to process-local memory for multi-instance",
174
+ false,
175
+ );
176
+ expect(out).toContain("Notice");
177
+ expect(out).toContain("process-local");
178
+ expect(out).not.toContain("oke boot:");
179
+ });
180
+
181
+ test("formatCliChrome routes boot and status lines", () => {
182
+ const out = formatCliChrome("oke db seed: ok\noke boot: hello world\n", false);
183
+ expect(out).toContain("oke db seed: ok");
184
+ expect(out).toContain("Notice");
185
+ expect(out).toContain("hello world");
186
+ });
187
+
109
188
  test("formatRequestLine shows date, time, surface, flow, ms, status", () => {
110
189
  const at = new Date(2026, 6, 26, 3, 11, 42);
111
190
  const out = formatRequestLine({
112
- surface: "App",
191
+ surface: "Backend",
113
192
  method: "GET",
114
193
  path: "/health",
115
194
  flow: "main.health",
@@ -120,7 +199,7 @@ describe("term", () => {
120
199
  });
121
200
  expect(out).toContain("2026-07-26");
122
201
  expect(out).toContain("03:11:42");
123
- expect(out).toContain("App");
202
+ expect(out).toContain("Backend");
124
203
  expect(out).toContain("200");
125
204
  expect(out).not.toMatch(/\u001b\[/);
126
205
  });
@@ -144,14 +223,20 @@ describe("term", () => {
144
223
  const out = formatStackSummary({
145
224
  project: "oke-dev-a3f791",
146
225
  services: [
147
- { label: "postgres", hostPort: 15975 },
148
- { label: "redis", hostPort: 16975 },
226
+ { label: "postgres", hostPort: 15975, status: "ready" },
227
+ { label: "redis", hostPort: 16975, status: "ready" },
228
+ { label: "ai", hostPort: 23975, detail: "gemma4:e4b-q4_K_M", status: "pending" },
149
229
  ],
150
230
  appDrivers: ["postgres", "redis"],
151
231
  color: false,
152
232
  });
153
233
  expect(out).toContain("Docker");
154
234
  expect(out).toContain(":15975");
235
+ expect(out).toContain("gemma4:e4b-q4_K_M");
236
+ expect(out).toContain("● postgres");
237
+ expect(out).toContain("● ai");
238
+ // Blank │ row separates the Docker header from whatever sits above.
239
+ expect(out).toMatch(/│\n◇ {2}Docker/);
155
240
  expect(out).not.toMatch(/\u001b\[/);
156
241
  });
157
242
  });
package/src/term.ts CHANGED
@@ -60,10 +60,56 @@ export function termStyle(color: boolean = termColorEnabled()): TermStyle {
60
60
  };
61
61
  }
62
62
 
63
+ /**
64
+ * Readiness for hero / Docker status dots.
65
+ *
66
+ * - `ready` — green ●
67
+ * - `pending` — yellow ● (starting / loading)
68
+ * - `error` — red ●
69
+ * - `idle` — dim ● (unbound)
70
+ */
71
+ export type DevStatus = "ready" | "pending" | "error" | "idle";
72
+
73
+ /**
74
+ * Colored ● for {@link DevStatus}.
75
+ *
76
+ * @param status - Readiness
77
+ * @param color - Color on/off
78
+ */
79
+ export function formatStatusDot(status: DevStatus, color: boolean = termColorEnabled()): string {
80
+ const s = termStyle(color);
81
+ switch (status) {
82
+ case "ready":
83
+ return `${s.green}●${s.reset}`;
84
+ case "pending":
85
+ return `${s.yellow}●${s.reset}`;
86
+ case "error":
87
+ return `${s.red}●${s.reset}`;
88
+ case "idle":
89
+ return `${s.dim}●${s.reset}`;
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Map AI model phase → {@link DevStatus}.
95
+ *
96
+ * @param phase - Probe phase string
97
+ */
98
+ export function devStatusFromAiPhase(
99
+ phase: "unreachable" | "starting" | "loading" | "ready" | "error" | string,
100
+ ): DevStatus {
101
+ if (phase === "ready") return "ready";
102
+ if (phase === "error") return "error";
103
+ if (phase === "unreachable" || phase === "starting" || phase === "loading") return "pending";
104
+ return "pending";
105
+ }
106
+
63
107
  /** One eight-element row in the hero. */
64
108
  export type DevHeroElement = {
65
109
  readonly element: string;
66
110
  readonly detail: string;
111
+ /** Status dot (default: idle when detail is `—`, else ready). */
112
+ readonly status?: DevStatus;
67
113
  };
68
114
 
69
115
  /** Shared options for the `oke dev` hero / banner. */
@@ -124,10 +170,18 @@ export function formatDevHeroDetails(options: DevHeroMeta = {}): string {
124
170
  const elements = options.elements ?? [];
125
171
  if (elements.length > 0) {
126
172
  lines.push(`${bar} ${s.dim}elements${s.reset}`);
173
+ const color = options.color ?? termColorEnabled();
127
174
  for (const row of elements) {
128
- const idle = row.detail === "—";
129
- const detail = idle ? `${s.dim}—${s.reset}` : `${s.cyan}${row.detail}${s.reset}`;
130
- lines.push(`${bar} ${s.dim}${row.element.padEnd(9)}${s.reset} ${detail}`);
175
+ const idle = row.detail === "—" || row.detail === "";
176
+ const status = row.status ?? (idle ? "idle" : "ready");
177
+ const dot = formatStatusDot(status, color);
178
+ const detail = idle
179
+ ? row.detail === ""
180
+ ? ""
181
+ : `${s.dim}—${s.reset}`
182
+ : `${s.cyan}${row.detail}${s.reset}`;
183
+ const detailPart = detail ? ` ${detail}` : "";
184
+ lines.push(`${bar} ${dot} ${s.dim}${row.element.padEnd(9)}${s.reset}${detailPart}`);
131
185
  }
132
186
  }
133
187
  return lines.length > 0 ? `${lines.join("\n")}\n` : "";
@@ -152,11 +206,220 @@ export function formatDevBanner(options: DevHeroMeta = {}): string {
152
206
  if (options.watching !== false) {
153
207
  lines.push(`${bar} ${s.dim}watching — client types regenerate on save${s.reset}`);
154
208
  }
155
- lines.push(formatDevHeroDetails(options).trimEnd());
209
+ const details = formatDevHeroDetails(options).trimEnd();
210
+ if (details) lines.push(details);
156
211
  lines.push(bar);
157
212
  return `${lines.join("\n")}\n`;
158
213
  }
159
214
 
215
+ /**
216
+ * Count visual lines in a terminal string (trailing newline optional).
217
+ *
218
+ * @param text - Block text
219
+ */
220
+ export function countTermLines(text: string): number {
221
+ if (text.length === 0) return 0;
222
+ const normalized = text.endsWith("\n") ? text.slice(0, -1) : text;
223
+ if (normalized.length === 0) return 0;
224
+ return normalized.split("\n").length;
225
+ }
226
+
227
+ /** Handle for a bottom-of-TTY block that can be repainted until frozen. */
228
+ export type RewritableBlock = {
229
+ /** Paint / repaint the block (must still be the last output). */
230
+ readonly paint: (text: string) => void;
231
+ /** Erase the block in place (no-op when frozen or non-TTY). */
232
+ readonly clear: () => void;
233
+ /** Stop rewriting — further {@link paint} calls append instead. */
234
+ readonly freeze: () => void;
235
+ readonly frozen: () => boolean;
236
+ };
237
+
238
+ /**
239
+ * Erase `lineCount` lines above the cursor (cursor ends on the first cleared row).
240
+ *
241
+ * @param write - stdout writer
242
+ * @param lineCount - Rows to clear
243
+ */
244
+ function eraseTermLines(write: (text: string) => void, lineCount: number): void {
245
+ if (lineCount <= 0) return;
246
+ write(`\x1b[${lineCount}A`);
247
+ for (let i = 0; i < lineCount; i++) {
248
+ write("\x1b[2K");
249
+ if (i < lineCount - 1) write("\x1b[1B");
250
+ }
251
+ if (lineCount > 1) write(`\x1b[${lineCount - 1}A`);
252
+ }
253
+
254
+ /**
255
+ * Bottom-anchored live block for hero elements / Docker summary.
256
+ *
257
+ * Repaints in place with ANSI cursor moves while at the bottom of the TTY.
258
+ * Call {@link RewritableBlock.freeze} before printing anything below it.
259
+ *
260
+ * @param write - stdout writer
261
+ * @param enabled - When false (non-TTY / tests), every paint appends
262
+ */
263
+ export function createRewritableBlock(
264
+ write: (text: string) => void,
265
+ enabled: boolean = termColorEnabled() && process.stdout.isTTY === true,
266
+ ): RewritableBlock {
267
+ let lineCount = 0;
268
+ let frozen = false;
269
+ return {
270
+ paint(text: string) {
271
+ if (text.length === 0) {
272
+ this.clear();
273
+ return;
274
+ }
275
+ const body = text.endsWith("\n") ? text : `${text}\n`;
276
+ const nextLines = countTermLines(body);
277
+ if (!frozen && enabled && lineCount > 0) {
278
+ eraseTermLines(write, lineCount);
279
+ }
280
+ write(body);
281
+ if (!frozen && enabled) lineCount = nextLines;
282
+ else lineCount = 0;
283
+ },
284
+ clear() {
285
+ if (frozen) return;
286
+ if (enabled && lineCount > 0) eraseTermLines(write, lineCount);
287
+ lineCount = 0;
288
+ },
289
+ freeze() {
290
+ frozen = true;
291
+ lineCount = 0;
292
+ },
293
+ frozen: () => frozen,
294
+ };
295
+ }
296
+
297
+ /** Keyed ephemeral boot-progress lines (compose / vault / health / AI). */
298
+ export type BootProgress = {
299
+ /** Set or replace a progress row by key, then repaint. */
300
+ readonly set: (key: string, line: string) => void;
301
+ /** Erase all progress rows (TTY) before the final board. */
302
+ readonly clear: () => void;
303
+ };
304
+
305
+ /**
306
+ * Live boot-progress pane — keyed rows replace in place, then {@link BootProgress.clear}.
307
+ *
308
+ * @param write - stdout writer
309
+ * @param enabled - TTY rewrite on/off
310
+ */
311
+ export function createBootProgress(
312
+ write: (text: string) => void,
313
+ enabled: boolean = termColorEnabled() && process.stdout.isTTY === true,
314
+ ): BootProgress {
315
+ const block = createRewritableBlock(write, enabled);
316
+ const order: string[] = [];
317
+ const lines = new Map<string, string>();
318
+ const repaint = (): void => {
319
+ if (order.length === 0) {
320
+ block.clear();
321
+ return;
322
+ }
323
+ block.paint(order.map((key) => lines.get(key) ?? "").join(""));
324
+ };
325
+ return {
326
+ set(key: string, line: string) {
327
+ const body = line.endsWith("\n") ? line : `${line}\n`;
328
+ if (!lines.has(key)) order.push(key);
329
+ lines.set(key, body);
330
+ if (!enabled) {
331
+ write(body);
332
+ return;
333
+ }
334
+ repaint();
335
+ },
336
+ clear() {
337
+ order.length = 0;
338
+ lines.clear();
339
+ block.clear();
340
+ },
341
+ };
342
+ }
343
+
344
+ /** Live elements/Docker board that can rewrite after content is printed below. */
345
+ export type AnchoredBoard = {
346
+ /** Paint or rewrite the board (same height preferred). */
347
+ readonly paint: (text: string) => void;
348
+ /** Wrap a writer so post-board output is tracked for cursor math. */
349
+ readonly wrapWrite: (inner: (text: string) => void) => (text: string) => void;
350
+ /**
351
+ * Forget board geometry (after a full-screen clear) so the next
352
+ * {@link paint} writes fresh at the cursor.
353
+ */
354
+ readonly reset: () => void;
355
+ /** Disable further in-place rewrites (session end). */
356
+ readonly stop: () => void;
357
+ };
358
+
359
+ /**
360
+ * Anchored status board — rewrites in place even after Backend / Logs lines.
361
+ *
362
+ * Uses saved cursor + line offsets. Soft-reload full-screen clears can desync;
363
+ * later paints then append instead.
364
+ *
365
+ * @param write - stdout writer
366
+ * @param enabled - TTY rewrite on/off
367
+ */
368
+ export function createAnchoredBoard(
369
+ write: (text: string) => void,
370
+ enabled: boolean = termColorEnabled() && process.stdout.isTTY === true,
371
+ ): AnchoredBoard {
372
+ let boardLines = 0;
373
+ let belowLines = 0;
374
+ let stopped = false;
375
+ let lastBody = "";
376
+
377
+ return {
378
+ paint(text: string) {
379
+ if (stopped) return;
380
+ const body = text.endsWith("\n") ? text : `${text}\n`;
381
+ const nextLines = countTermLines(body);
382
+ if (!enabled) {
383
+ if (boardLines === 0) write(body);
384
+ boardLines = nextLines;
385
+ lastBody = body;
386
+ return;
387
+ }
388
+ if (boardLines === 0) {
389
+ write(body);
390
+ boardLines = nextLines;
391
+ lastBody = body;
392
+ return;
393
+ }
394
+ if (body === lastBody) return;
395
+ // Move to board start, replace rows, restore cursor.
396
+ write("\x1b[s");
397
+ write(`\x1b[${belowLines + boardLines}A`);
398
+ eraseTermLines(write, boardLines);
399
+ write(body);
400
+ boardLines = nextLines;
401
+ lastBody = body;
402
+ write("\x1b[u");
403
+ },
404
+ wrapWrite(inner: (text: string) => void) {
405
+ return (text: string) => {
406
+ if (!stopped && enabled && boardLines > 0) {
407
+ belowLines += countTermLines(text);
408
+ }
409
+ inner(text);
410
+ };
411
+ },
412
+ reset() {
413
+ boardLines = 0;
414
+ belowLines = 0;
415
+ lastBody = "";
416
+ },
417
+ stop() {
418
+ stopped = true;
419
+ },
420
+ };
421
+ }
422
+
160
423
  /**
161
424
  * One service URL line (Console / MCP after bind).
162
425
  *
@@ -175,14 +438,14 @@ export function formatServiceLine(
175
438
  }
176
439
 
177
440
  /**
178
- * App child ready line (`bun --hot` soft reload).
441
+ * Backend child ready line (`bun --hot` soft reload).
179
442
  *
180
- * @param url - App base URL
443
+ * @param url - Backend base URL
181
444
  * @param color - Color on/off
182
445
  */
183
446
  export function formatAppReadyLine(url: string, color: boolean = termColorEnabled()): string {
184
447
  const s = termStyle(color);
185
- return `${s.green}●${s.reset} ${s.dim}App${s.reset} ${s.cyan}${url}${s.reset}\n`;
448
+ return `${s.green}●${s.reset} ${s.dim}${"Backend".padEnd(7)}${s.reset} ${s.cyan}${url}${s.reset}\n`;
186
449
  }
187
450
 
188
451
  /**
@@ -241,10 +504,87 @@ export function formatDevLogSeparator(color: boolean = termColorEnabled()): stri
241
504
  *
242
505
  * @param message - Status text
243
506
  * @param color - Color on/off
507
+ * @param status - Optional colored ● prefix
508
+ */
509
+ export function formatStatusLine(
510
+ message: string,
511
+ color: boolean = termColorEnabled(),
512
+ status?: DevStatus,
513
+ ): string {
514
+ const s = termStyle(color);
515
+ const dot = status ? `${formatStatusDot(status, color)} ` : "";
516
+ return `${s.dim}│${s.reset} ${dot}${s.dim}${message}${s.reset}\n`;
517
+ }
518
+
519
+ /**
520
+ * Wrap a long message to `width` columns (word-aware).
521
+ *
522
+ * @param text - Source text
523
+ * @param width - Max columns per line
524
+ */
525
+ function wrapWords(text: string, width: number): string[] {
526
+ const words = text.split(/\s+/).filter((w) => w.length > 0);
527
+ if (words.length === 0) return [];
528
+ const lines: string[] = [];
529
+ let cur = "";
530
+ for (const word of words) {
531
+ if (cur.length === 0) {
532
+ cur = word;
533
+ continue;
534
+ }
535
+ if (`${cur} ${word}`.length <= width) {
536
+ cur = `${cur} ${word}`;
537
+ continue;
538
+ }
539
+ lines.push(cur);
540
+ cur = word;
541
+ }
542
+ if (cur.length > 0) lines.push(cur);
543
+ return lines;
544
+ }
545
+
546
+ /**
547
+ * Boot honesty notice — Clack-like column under the `oke dev` hero.
548
+ *
549
+ * @param message - Body (with or without `oke boot:` prefix)
550
+ * @param color - Color on/off
244
551
  */
245
- export function formatStatusLine(message: string, color: boolean = termColorEnabled()): string {
552
+ export function formatBootWarn(message: string, color: boolean = termColorEnabled()): string {
246
553
  const s = termStyle(color);
247
- return `${s.dim}│${s.reset} ${s.dim}${message}${s.reset}\n`;
554
+ const bar = `${s.dim}│${s.reset}`;
555
+ const body = message.replace(/^oke boot:\s*/i, "").trim();
556
+ const width = Math.max(40, Math.min(72, (process.stdout.columns ?? 80) - 6));
557
+ const wrapped = wrapWords(body, width);
558
+ const lines = [`${s.yellow}◇${s.reset} ${s.dim}Notice${s.reset}`];
559
+ for (const line of wrapped) {
560
+ lines.push(`${bar} ${s.dim}${line}${s.reset}`);
561
+ }
562
+ lines.push(bar);
563
+ return `${lines.join("\n")}\n`;
564
+ }
565
+
566
+ /**
567
+ * Route raw CLI / boot lines into hero chrome (`│ …` or Notice boxes).
568
+ *
569
+ * @param text - Chunk that may contain newlines
570
+ * @param color - Color on/off
571
+ */
572
+ export function formatCliChrome(text: string, color: boolean = termColorEnabled()): string {
573
+ const parts = text.split("\n");
574
+ const out: string[] = [];
575
+ for (let i = 0; i < parts.length; i++) {
576
+ const line = parts[i]!;
577
+ const isLastEmpty = i === parts.length - 1 && line.length === 0;
578
+ if (isLastEmpty) continue;
579
+ const trimmed = line.trimEnd();
580
+ if (trimmed.length === 0) continue;
581
+ if (/^oke boot:/i.test(trimmed)) {
582
+ out.push(formatBootWarn(trimmed, color));
583
+ continue;
584
+ }
585
+ out.push(formatStatusLine(trimmed, color));
586
+ }
587
+ return out.join("");
248
588
  }
249
589
 
250
590
  /** One infra service row for {@link formatStackSummary}. */
@@ -253,10 +593,16 @@ export type StackSummaryService = {
253
593
  readonly label: string;
254
594
  /** Published host port. */
255
595
  readonly hostPort: number;
596
+ /** Optional trailing detail (e.g. AI model id). */
597
+ readonly detail?: string;
598
+ /** Status dot (default `pending` until probed). */
599
+ readonly status?: DevStatus;
600
+ /** Compose service name for health lookup (`ai`, `store-sql`). */
601
+ readonly serviceName?: string;
256
602
  };
257
603
 
258
604
  /** Surfaces that emit request lines during `oke dev`. */
259
- export type DevLogSurface = "App" | "Console" | "MCP";
605
+ export type DevLogSurface = "Backend" | "Console" | "MCP";
260
606
 
261
607
  /**
262
608
  * Local calendar date `YYYY-MM-DD` (no time).
@@ -286,7 +632,7 @@ export function formatDevLogTime(at: Date = new Date()): string {
286
632
  * One HTTP/RPC request line for the `oke dev` TTY.
287
633
  *
288
634
  * @example
289
- * `● App GET /health main.health 12ms 200 2026-07-26 03:11:42`
635
+ * `● Backend GET /health main.health 12ms 200 2026-07-26 03:11:42`
290
636
  *
291
637
  * @param options - Surface, method, path, flow, timing, status
292
638
  */
@@ -308,7 +654,7 @@ export function formatRequestLine(options: {
308
654
  const date = formatDevLogDate(at);
309
655
  const time = formatDevLogTime(at);
310
656
  const surfaceColor =
311
- options.surface === "App" ? s.green : options.surface === "Console" ? s.magenta : s.cyan;
657
+ options.surface === "Backend" ? s.green : options.surface === "Console" ? s.magenta : s.cyan;
312
658
  const statusColor = options.status >= 500 ? s.red : options.status >= 400 ? s.yellow : s.green;
313
659
  const methodRaw = options.method.toUpperCase();
314
660
  const methodColor =
@@ -356,15 +702,24 @@ export function formatStackSummary(options: {
356
702
  const s = termStyle(options.color ?? termColorEnabled());
357
703
  const bar = `${s.dim}│${s.reset}`;
358
704
  const pad = (label: string) => label.padEnd(8);
705
+ const color = options.color ?? termColorEnabled();
359
706
  const lines: string[] = [
707
+ `${s.dim}│${s.reset}`,
360
708
  `${s.green}◇${s.reset} ${s.bold}Docker${s.reset} ${s.cyan}${options.project}${s.reset}`,
361
709
  ];
362
710
  for (const svc of options.services) {
363
- lines.push(`${bar} ${s.dim}${pad(svc.label)}${s.reset} ${s.cyan}:${svc.hostPort}${s.reset}`);
711
+ const detail = svc.detail?.trim();
712
+ const status = svc.status ?? "pending";
713
+ const dot = formatStatusDot(status, color);
714
+ lines.push(
715
+ `${bar} ${dot} ${s.dim}${pad(svc.label)}${s.reset} ${s.cyan}:${svc.hostPort}${s.reset}` +
716
+ (detail ? ` ${s.dim}${detail}${s.reset}` : ""),
717
+ );
364
718
  }
365
719
  const drivers = options.appDrivers ?? [];
366
720
  const appDetail = drivers.length > 0 ? `host Bun · ${drivers.join(" + ")}` : "host Bun";
367
- lines.push(`${bar} ${s.dim}${pad("app")}${s.reset} ${s.dim}${appDetail}${s.reset}`);
721
+ const appDot = formatStatusDot("ready", color);
722
+ lines.push(`${bar} ${appDot} ${s.dim}${pad("app")}${s.reset} ${s.dim}${appDetail}${s.reset}`);
368
723
  lines.push(bar);
369
724
  return `${lines.join("\n")}\n`;
370
725
  }