omkit 0.2.2 → 0.4.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 (56) hide show
  1. package/README.md +66 -43
  2. package/dist/actions/browser.d.ts +28 -0
  3. package/dist/actions/browser.js +36 -0
  4. package/dist/actions/command.d.ts +11 -7
  5. package/dist/actions/command.js +16 -10
  6. package/dist/actions/index.d.ts +2 -0
  7. package/dist/actions/index.js +1 -0
  8. package/dist/actions/prompt.js +43 -17
  9. package/dist/cli/client/channel.d.ts +14 -0
  10. package/dist/cli/client/channel.js +48 -0
  11. package/dist/cli/client/check.d.ts +3 -0
  12. package/dist/cli/client/check.js +32 -0
  13. package/dist/cli/client/discovery.d.ts +8 -0
  14. package/dist/cli/client/discovery.js +163 -0
  15. package/dist/cli/client/index.d.ts +9 -0
  16. package/dist/cli/client/index.js +11 -0
  17. package/dist/cli/client/registry.d.ts +29 -0
  18. package/dist/cli/client/registry.js +1 -0
  19. package/dist/cli/client/runner.d.ts +15 -0
  20. package/dist/cli/client/runner.js +61 -0
  21. package/dist/cli/client/types.d.ts +53 -0
  22. package/dist/cli/client/types.js +1 -0
  23. package/dist/cli/commands/check.d.ts +6 -0
  24. package/dist/cli/commands/check.js +11 -0
  25. package/dist/cli/commands/init.d.ts +5 -0
  26. package/dist/cli/commands/init.js +54 -0
  27. package/dist/cli/commands/ls.d.ts +5 -0
  28. package/dist/cli/commands/ls.js +25 -0
  29. package/dist/cli/commands/run.d.ts +10 -0
  30. package/dist/cli/commands/run.js +12 -0
  31. package/dist/cli/commands/ui.d.ts +11 -0
  32. package/dist/cli/commands/ui.js +37 -0
  33. package/dist/cli/index.d.ts +9 -0
  34. package/dist/cli/index.js +110 -0
  35. package/dist/cli/ui/Spinner.d.ts +10 -0
  36. package/dist/cli/ui/Spinner.js +20 -0
  37. package/dist/cli/ui/app.d.ts +12 -0
  38. package/dist/cli/ui/app.js +95 -0
  39. package/dist/cli/ui/views/OmList.d.ts +9 -0
  40. package/dist/cli/ui/views/OmList.js +36 -0
  41. package/dist/cli/ui/views/RunView.d.ts +14 -0
  42. package/dist/cli/ui/views/RunView.js +47 -0
  43. package/dist/core/ActionRun.d.ts +2 -4
  44. package/dist/core/ActionRun.js +32 -36
  45. package/dist/core/ExecutionTree.d.ts +1 -0
  46. package/dist/core/ExecutionTree.js +25 -4
  47. package/dist/core/interaction.d.ts +63 -0
  48. package/dist/core/interaction.js +82 -0
  49. package/dist/core/step.js +2 -7
  50. package/dist/core/types.d.ts +10 -20
  51. package/dist/index.d.ts +1 -1
  52. package/dist/output/LiveRenderer.d.ts +7 -17
  53. package/dist/output/LiveRenderer.js +8 -82
  54. package/dist/output/milestones.d.ts +8 -0
  55. package/dist/output/milestones.js +39 -0
  56. package/package.json +9 -1
@@ -0,0 +1,10 @@
1
+ import { type ReactElement } from "react";
2
+ /**
3
+ * A tiny animated spinner — cycles braille frames on a timer. Self-contained (no `ink-spinner`
4
+ * dependency). The interval is unref'd so it never keeps the process alive on its own: it animates
5
+ * only while something else (the live run's IPC channel) holds the event loop open, and stops the
6
+ * instant the run is disposed — so it can't block the app's exit.
7
+ */
8
+ export declare function Spinner({ label }: {
9
+ label?: string;
10
+ }): ReactElement;
@@ -0,0 +1,20 @@
1
+ import { jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useState } from "react";
3
+ import { Text } from "ink";
4
+ // Braille dots — a smooth ten-frame cycle that reads as motion in a terminal.
5
+ const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
6
+ /**
7
+ * A tiny animated spinner — cycles braille frames on a timer. Self-contained (no `ink-spinner`
8
+ * dependency). The interval is unref'd so it never keeps the process alive on its own: it animates
9
+ * only while something else (the live run's IPC channel) holds the event loop open, and stops the
10
+ * instant the run is disposed — so it can't block the app's exit.
11
+ */
12
+ export function Spinner({ label }) {
13
+ const [frame, setFrame] = useState(0);
14
+ useEffect(() => {
15
+ const id = setInterval(() => setFrame((f) => (f + 1) % FRAMES.length), 80);
16
+ id.unref?.();
17
+ return () => clearInterval(id);
18
+ }, []);
19
+ return (_jsxs(Text, { dimColor: true, children: [FRAMES[frame], label ? ` ${label}` : ""] }));
20
+ }
@@ -0,0 +1,12 @@
1
+ import { type ReactElement } from "react";
2
+ import type { OmkitClient, Verdict } from "../client/types.ts";
3
+ import type { DiscoveredOm } from "../client/registry.ts";
4
+ /** The interactive app: discover → list/search → run → live milestones + prompts. */
5
+ export declare function App({ client, onExit, oms: initialOms, }: {
6
+ client: OmkitClient;
7
+ /** Called with the run's verdict (if any) just before Ink unmounts, so the caller can print
8
+ * a durable summary — Ink erases its own frame on exit. */
9
+ onExit?: (verdict: Verdict | undefined) => void;
10
+ /** Oms discovered by the caller (pre-flight). When given, the app skips its own discovery. */
11
+ oms?: DiscoveredOm[];
12
+ }): ReactElement;
@@ -0,0 +1,95 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useRef, useState } from "react";
3
+ import { useApp, useInput } from "ink";
4
+ import { format } from "../../output/milestones.js";
5
+ import { OmList } from "./views/OmList.js";
6
+ import { RunView } from "./views/RunView.js";
7
+ import { Spinner } from "./Spinner.js";
8
+ /** The interactive app: discover → list/search → run → live milestones + prompts. */
9
+ export function App({ client, onExit, oms: initialOms, }) {
10
+ const { exit } = useApp();
11
+ const [oms, setOms] = useState(initialOms ?? []);
12
+ const [session, setSession] = useState(null);
13
+ const [lines, setLines] = useState([]);
14
+ const [prompt, setPrompt] = useState();
15
+ const [verdict, setVerdict] = useState();
16
+ const [tearing, setTearing] = useState(false);
17
+ // Refs so the Ctrl+C handler always sees the live session/verdict, not a stale render closure.
18
+ const sessionRef = useRef(null);
19
+ const verdictRef = useRef(undefined);
20
+ const tearingRef = useRef(false);
21
+ useEffect(() => {
22
+ if (initialOms)
23
+ return; // provided by the caller's pre-flight discovery
24
+ let live = true;
25
+ void client.discover().then((r) => {
26
+ if (live)
27
+ setOms(r.oms);
28
+ });
29
+ return () => {
30
+ live = false;
31
+ };
32
+ }, [client, initialOms]);
33
+ const run = (om) => {
34
+ const s = client.run(om.file);
35
+ sessionRef.current = s;
36
+ s.on("log", (entry) => {
37
+ const rendered = format(entry);
38
+ if (rendered)
39
+ setLines((prev) => [...prev, rendered.text]);
40
+ });
41
+ s.on("prompt", (req) => setPrompt(req));
42
+ s.on("settled", (v) => {
43
+ setVerdict(v);
44
+ setPrompt(undefined);
45
+ });
46
+ // The single exit point. `result` resolves when the run ends — a natural completion (a build
47
+ // that settles on its own), a teardown after Ctrl+C, or a crash that closes the channel — and
48
+ // stays pending for a live daemon stack until Ctrl+C. Record the verdict, hand it to the caller
49
+ // so it can print the durable summary block, and unmount. Without this a self-completing run
50
+ // would show its verdict line but never exit or render the summary.
51
+ void s.result.then((v) => {
52
+ verdictRef.current = v;
53
+ setVerdict(v);
54
+ onExit?.(v);
55
+ exit();
56
+ });
57
+ setSession(s);
58
+ };
59
+ const answer = (value) => {
60
+ if (session && prompt)
61
+ session.answer(prompt.id, value);
62
+ setPrompt(undefined);
63
+ };
64
+ // Ctrl+C: in raw mode this is a keystroke, not a signal. Cancel the run and wait for the child
65
+ // to tear down and report its verdict, then quit — leaving the durable summary to `onExit`.
66
+ //
67
+ // Repeated presses are a deliberate no-op (see the `tearing` guard below): the child owns the
68
+ // teardown and is guaranteed to report `settled` within its grace window, so we let it finish
69
+ // rather than force-exiting. This mirrors the normal om run, where SIGINT is caught with
70
+ // `process.on` and smashing Ctrl+C just re-triggers an idempotent teardown — the process never
71
+ // exits before the tree is reaped. Force-exiting here (the old second-press escape hatch) killed
72
+ // the parent mid-teardown and orphaned the child's process tree (vite, watchers, Chrome).
73
+ useInput((input, key) => {
74
+ if (!(key.ctrl && input === "c"))
75
+ return;
76
+ const s = sessionRef.current;
77
+ // Nothing running or already settled → quit now.
78
+ if (!s || verdictRef.current) {
79
+ onExit?.(verdictRef.current);
80
+ exit();
81
+ return;
82
+ }
83
+ // Already tearing down → ignore. The child is reaping its tree and will report `settled` (or
84
+ // the channel closes and resolves a failed verdict); either way `result` resolves and the
85
+ // handler wired in `run()` records the verdict and unmounts. Just request the teardown here.
86
+ if (tearingRef.current)
87
+ return;
88
+ tearingRef.current = true;
89
+ setTearing(true);
90
+ s.cancel();
91
+ });
92
+ if (!session)
93
+ return _jsx(OmList, { oms: oms, onSelect: run });
94
+ return (_jsxs(_Fragment, { children: [_jsx(RunView, { lines: lines, prompt: prompt, verdict: verdict, onAnswer: answer }), !verdict && tearing ? _jsx(Spinner, { label: "tearing down\u2026 (stopping the run cleanly)" }) : null, !verdict && !tearing && !prompt ? _jsx(Spinner, { label: "running\u2026" }) : null] }));
95
+ }
@@ -0,0 +1,9 @@
1
+ import { type ReactElement } from "react";
2
+ import type { DiscoveredOm } from "../../client/registry.ts";
3
+ /** Case-insensitive substring filter on om names. */
4
+ export declare function filterOms(oms: DiscoveredOm[], query: string): DiscoveredOm[];
5
+ /** A searchable, arrow-navigable list of oms; Enter selects the highlighted one. */
6
+ export declare function OmList({ oms, onSelect, }: {
7
+ oms: DiscoveredOm[];
8
+ onSelect: (om: DiscoveredOm) => void;
9
+ }): ReactElement;
@@ -0,0 +1,36 @@
1
+ import { jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState } from "react";
3
+ import { Box, Text, useInput } from "ink";
4
+ /** Case-insensitive substring filter on om names. */
5
+ export function filterOms(oms, query) {
6
+ const q = query.toLowerCase();
7
+ return oms.filter((o) => o.name.toLowerCase().includes(q));
8
+ }
9
+ /** A searchable, arrow-navigable list of oms; Enter selects the highlighted one. */
10
+ export function OmList({ oms, onSelect, }) {
11
+ const [query, setQuery] = useState("");
12
+ const [index, setIndex] = useState(0);
13
+ const filtered = filterOms(oms, query);
14
+ useInput((input, key) => {
15
+ if (key.upArrow) {
16
+ setIndex((i) => Math.max(0, i - 1));
17
+ }
18
+ else if (key.downArrow) {
19
+ setIndex((i) => Math.min(filtered.length - 1, i + 1));
20
+ }
21
+ else if (key.return) {
22
+ const picked = filtered[index];
23
+ if (picked)
24
+ onSelect(picked);
25
+ }
26
+ else if (key.backspace || key.delete) {
27
+ setQuery((q) => q.slice(0, -1));
28
+ setIndex(0);
29
+ }
30
+ else if (input && !key.ctrl && !key.meta) {
31
+ setQuery((q) => q + input);
32
+ setIndex(0);
33
+ }
34
+ });
35
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: ["search: ", query] }), filtered.map((o, i) => (_jsxs(Text, { inverse: i === index, children: [o.name, " ", o.file] }, o.name)))] }));
36
+ }
@@ -0,0 +1,14 @@
1
+ import { type ReactElement } from "react";
2
+ import type { PromptRequest, Verdict } from "../../client/types.ts";
3
+ /** Answer one pending prompt — free-text input, or arrow-navigable choices. */
4
+ export declare function PromptView({ request, onAnswer, }: {
5
+ request: PromptRequest;
6
+ onAnswer: (value: string) => void;
7
+ }): ReactElement;
8
+ /** The live run surface: milestone lines, a pending prompt, and the final verdict. */
9
+ export declare function RunView({ lines, prompt, verdict, onAnswer, }: {
10
+ lines: string[];
11
+ prompt?: PromptRequest;
12
+ verdict?: Verdict;
13
+ onAnswer: (value: string) => void;
14
+ }): ReactElement;
@@ -0,0 +1,47 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useRef, useState } from "react";
3
+ import { Box, Text, useInput } from "ink";
4
+ /** Answer one pending prompt — free-text input, or arrow-navigable choices. */
5
+ export function PromptView({ request, onAnswer, }) {
6
+ const { spec } = request;
7
+ const choices = spec.choices ?? [];
8
+ // Refs hold the authoritative value: Ink can deliver several key events before React
9
+ // re-renders, so the Enter handler must read a ref, not a possibly-stale render closure.
10
+ // State mirrors the ref only to drive the display.
11
+ const textRef = useRef("");
12
+ const indexRef = useRef(0);
13
+ const [text, setText] = useState("");
14
+ const [index, setIndex] = useState(0);
15
+ const onChoiceKey = (key) => {
16
+ if (key.upArrow) {
17
+ indexRef.current = Math.max(0, indexRef.current - 1);
18
+ setIndex(indexRef.current);
19
+ }
20
+ else if (key.downArrow) {
21
+ indexRef.current = Math.min(choices.length - 1, indexRef.current + 1);
22
+ setIndex(indexRef.current);
23
+ }
24
+ else if (key.return) {
25
+ onAnswer(choices[indexRef.current]?.value ?? spec.default);
26
+ }
27
+ };
28
+ const onInputKey = (input, key) => {
29
+ if (key.return) {
30
+ onAnswer(textRef.current === "" ? spec.default : textRef.current);
31
+ }
32
+ else if (key.backspace || key.delete) {
33
+ textRef.current = textRef.current.slice(0, -1);
34
+ setText(textRef.current);
35
+ }
36
+ else if (input && !key.ctrl && !key.meta) {
37
+ textRef.current += input;
38
+ setText(textRef.current);
39
+ }
40
+ };
41
+ useInput((input, key) => (spec.kind === "choice" ? onChoiceKey(key) : onInputKey(input, key)));
42
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", paddingX: 1, children: [_jsx(Text, { children: spec.message }), spec.kind === "choice" ? (choices.map((c, i) => (_jsxs(Text, { inverse: i === index, children: [c.value === spec.default ? "* " : " ", c.label] }, c.value)))) : (_jsxs(Text, { children: ["> ", text] }))] }));
43
+ }
44
+ /** The live run surface: milestone lines, a pending prompt, and the final verdict. */
45
+ export function RunView({ lines, prompt, verdict, onAnswer, }) {
46
+ return (_jsxs(Box, { flexDirection: "column", children: [lines.map((line, i) => (_jsx(Text, { children: line }, i))), prompt ? _jsx(PromptView, { request: prompt, onAnswer: onAnswer }) : null, verdict ? (_jsxs(Text, { color: verdict.ok ? "green" : "red", children: [verdict.ok ? "✓ ok" : "✗ failed", " \u00B7 ", verdict.folder] })) : null] }));
47
+ }
@@ -2,7 +2,7 @@ import { type EventHandler } from "../foundation/events.ts";
2
2
  import type { ActionRef } from "../foundation/ActionRef.ts";
3
3
  import type { LogStore } from "../output/log/LogStore.ts";
4
4
  import type { SnapshotStore } from "../output/snapshot/SnapshotStore.ts";
5
- import type { Activity, Exec, InstanceEvents, NodeStatus, Outcome } from "./types.ts";
5
+ import type { Activity, Exec, InstanceEvents, NodeStatus } from "./types.ts";
6
6
  /** Everything a node needs to exist, minus its behavior. */
7
7
  export interface NodeInit {
8
8
  readonly store: LogStore;
@@ -61,7 +61,6 @@ export declare class ActionRun<Result = unknown, Events extends object = object,
61
61
  private readonly emitter;
62
62
  private readonly settled;
63
63
  private readonly handle;
64
- private failureHandler;
65
64
  private attached;
66
65
  private observed;
67
66
  private refObserved;
@@ -75,14 +74,13 @@ export declare class ActionRun<Result = unknown, Events extends object = object,
75
74
  toRef(): ActionRef;
76
75
  /** This node's abort signal — handed to the exec as `ctx.signal`. */
77
76
  get signal(): AbortSignal;
78
- get result(): Promise<Outcome<Result>>;
77
+ get result(): Promise<Result>;
79
78
  /** The attached handle; rejects on failure/cancel. */
80
79
  get ref(): Promise<Handle>;
81
80
  on<K extends keyof InstanceEvents<Events, Result>>(key: K, handler: EventHandler<InstanceEvents<Events, Result>[K]>): void;
82
81
  once<K extends keyof InstanceEvents<Events, Result>>(key: K): Promise<InstanceEvents<Events, Result>[K] | undefined>;
83
82
  tag(name: string): this;
84
83
  withCache(...paths: string[]): Activity<Result | undefined, Events, Handle>;
85
- handleFailure(handler: (error: unknown) => void): this;
86
84
  cancel(): void;
87
85
  /**
88
86
  * Log a cancellation milestone on this node (once) and bubble it to the parent, so a
@@ -37,7 +37,6 @@ export class ActionRun {
37
37
  emitter = events();
38
38
  settled = defer();
39
39
  handle = defer();
40
- failureHandler;
41
40
  attached = false;
42
41
  observed = false;
43
42
  refObserved = false;
@@ -95,7 +94,10 @@ export class ActionRun {
95
94
  // ── Run handle surface ─────────────────────────────────────────────────────
96
95
  get result() {
97
96
  this.observed = true;
98
- return this.settled.promise.then((value) => ({ ok: true, value }), (error) => ({ ok: false, error }));
97
+ // The outcome itself: resolves the value, rejects with the error (CancelledError on cancel).
98
+ // Reading it marks the failure observed, so a `.result.catch(fn)` handles a fire-and-forget
99
+ // crash without tearing the run down.
100
+ return this.settled.promise;
99
101
  }
100
102
  /** The attached handle; rejects on failure/cancel. */
101
103
  get ref() {
@@ -111,26 +113,32 @@ export class ActionRun {
111
113
  this.emitter.listen(key, handler);
112
114
  }
113
115
  once(key) {
114
- if (key === "error")
115
- this.observed = true;
116
- if (key === "done") {
117
- // Awaiting completion IS observing the outcome: deliver a failure to the awaiter
118
- // (reject, like `.ref`) instead of resolving `undefined` and letting the body
119
- // sail past a failed step while the unobserved-failure teardown races it.
120
- this.observed = true;
121
- return this.settled.promise;
122
- }
123
- return new Promise((resolve) => {
116
+ // `done` is the outcome itself — identical to `.result` (resolve the value, reject on failure).
117
+ if (key === "done")
118
+ return this.result;
119
+ // Awaiting an event observes the activity: resolve the payload when it fires, but if the
120
+ // activity settles first, resolve `undefined` on success and **reject** on failure/cancel —
121
+ // so an event gate surfaces a failure instead of hanging (or, for `error`, hand back the error).
122
+ this.observed = true;
123
+ return new Promise((resolve, reject) => {
124
124
  let done = false;
125
- const settle = (v) => {
126
- if (!done) {
127
- done = true;
128
- resolve(v);
129
- }
130
- };
131
- const doneKey = "done";
132
- this.emitter.listenOnce(key, (p) => settle(p));
133
- this.emitter.listenOnce(doneKey, () => settle(undefined));
125
+ const win = () => (done ? false : (done = true));
126
+ this.emitter.listenOnce(key, (p) => {
127
+ if (win())
128
+ resolve(p);
129
+ });
130
+ this.settled.promise.then(() => {
131
+ if (win())
132
+ resolve(undefined);
133
+ }, (error) => {
134
+ if (!win())
135
+ return;
136
+ // Awaiting the `error` event hands back the error; any other gate rejects on failure.
137
+ if (key === "error")
138
+ resolve(error);
139
+ else
140
+ reject(error);
141
+ });
134
142
  });
135
143
  }
136
144
  tag(name) {
@@ -150,16 +158,6 @@ export class ActionRun {
150
158
  this.armedExec = cacheGate(inner, targets);
151
159
  return this;
152
160
  }
153
- handleFailure(handler) {
154
- this.failureHandler = handler;
155
- this.observed = true;
156
- // Attached after an `await` on a body that has already failed: deliver the
157
- // already-recorded failure now (it won't fail again). Cancellation is status
158
- // "cancelled", not "failed", so a cancelled node never runs the handler.
159
- if (this.status === "failed")
160
- handler(this.error);
161
- return this;
162
- }
163
161
  cancel() {
164
162
  this.noteCancelled(); // a direct cancel of this activity
165
163
  this.controller.abort();
@@ -295,11 +293,9 @@ export class ActionRun {
295
293
  // signal.aborted, which is why the check below uses `cascadeCancelled`, not
296
294
  // `signal.aborted`, to tell an unhandled fault from a run/ancestor teardown.
297
295
  this.controller.abort();
298
- // A registered handler owns a fire-and-forget failure (side effect only; not on cancel).
299
- this.failureHandler?.(error);
300
- // If, one microtask on, no one is positioned to receive this error (no `.result`/`.ref`
301
- // read, no `on("error")`) and we weren't caught in an ancestor's teardown, it's
302
- // unhandled — tell the tree to tear the run down and record the fault.
296
+ // If, one microtask on, no one is positioned to receive this error (no `.result`/`.ref`/
297
+ // `.once` read, no `on("error")`, no `.result.catch`) and we weren't caught in an ancestor's
298
+ // teardown, it's unhandled — tell the tree to tear the run down and record the fault.
303
299
  // `sysEmit("error")` above already ran, so a live error handler has set `observed`.
304
300
  queueMicrotask(() => {
305
301
  const observed = this.observed || (this.refObserved && !this.attached);
@@ -28,6 +28,7 @@ export declare class ExecutionTree {
28
28
  private readonly originalLog;
29
29
  private streaming;
30
30
  private liveRendering;
31
+ private forwarding;
31
32
  private assertionsPassed;
32
33
  private assertionsFailed;
33
34
  private readonly faults;
@@ -15,6 +15,7 @@ import { writeResult } from "../output/writers/ResultWriter.js";
15
15
  import { ActionRun } from "./ActionRun.js";
16
16
  import { procRegistry } from "../system/proc.js";
17
17
  import { currentNode } from "./context.js";
18
+ import { activeSupervisor } from "./interaction.js";
18
19
  /**
19
20
  * The run: a module singleton that owns the log store, the run folder, the node
20
21
  * registry, and the process-wide teardown. `launch` creates a child node under the
@@ -47,6 +48,7 @@ export class ExecutionTree {
47
48
  originalLog = console.log.bind(console);
48
49
  streaming;
49
50
  liveRendering;
51
+ forwarding;
50
52
  assertionsPassed = 0;
51
53
  assertionsFailed = 0;
52
54
  faults = [];
@@ -202,9 +204,21 @@ export class ExecutionTree {
202
204
  process.on("uncaughtException", this.onUncaught);
203
205
  process.on("unhandledRejection", this.onUnhandled);
204
206
  this.streaming = this.rawStream.run(this.store);
205
- // Live, curated milestones to the terminal — a single in-place status line on a TTY.
206
- // Straight to process.stdout, which patch-console doesn't intercept (no capture feedback).
207
- this.liveRendering = new LiveRenderer(process.stdout).run(this.store);
207
+ const supervisor = activeSupervisor();
208
+ if (supervisor) {
209
+ // Supervised: the supervisor owns the terminal. Forward every entry up the channel
210
+ // instead of rendering, and tear down on its cancel request.
211
+ supervisor.onCancel(() => this.cancel());
212
+ this.forwarding = (async () => {
213
+ for await (const entry of this.store.subscribe({ replay: true }))
214
+ supervisor.log(entry);
215
+ })();
216
+ }
217
+ else {
218
+ // Bare: live, curated milestones to the terminal — a single in-place status line on a TTY.
219
+ // Straight to process.stdout, which patch-console doesn't intercept (no capture feedback).
220
+ this.liveRendering = new LiveRenderer(process.stdout).run(this.store);
221
+ }
208
222
  const rootPromise = currentNode.run(this.root, () => this.root.run(body, []));
209
223
  this.trackNode(rootPromise);
210
224
  await rootPromise;
@@ -252,6 +266,7 @@ export class ExecutionTree {
252
266
  this.store.close();
253
267
  await this.streaming;
254
268
  await this.liveRendering;
269
+ await this.forwarding;
255
270
  const flat = this.registry.map((node) => this.nodeView(node));
256
271
  const entries = this.store.entries();
257
272
  const at = (name) => path.join(this.folder.path(), name);
@@ -269,7 +284,13 @@ export class ExecutionTree {
269
284
  process.off("unhandledRejection", this.onUnhandled);
270
285
  this.consoleCapture.uninstall();
271
286
  ExecutionTree.current = null;
272
- this.printSummary(summary);
287
+ const supervisor = activeSupervisor();
288
+ // Forward the same recap block the bare run prints to its terminal, so a supervising
289
+ // frontend (the ink console) can show it verbatim — see printSummary.
290
+ if (supervisor)
291
+ supervisor.settled(this.verdictOk(), this.folder.path(), summary);
292
+ else
293
+ this.printSummary(summary);
273
294
  if (!this.verdictOk())
274
295
  process.exitCode = 1;
275
296
  }
@@ -0,0 +1,63 @@
1
+ import type { LogEntry } from "../foundation/LogEntry.ts";
2
+ /**
3
+ * A normalized prompt request the supervisor renders. The `prompt` action builds it;
4
+ * `interaction.ts` treats it as an opaque payload (no dependency on the action layer,
5
+ * so there is no core→actions import cycle).
6
+ */
7
+ export interface PromptSpec {
8
+ kind: "input" | "choice";
9
+ message: string;
10
+ default: string;
11
+ choices?: {
12
+ label: string;
13
+ value: string;
14
+ }[];
15
+ }
16
+ export type ChildMessage = {
17
+ kind: "prompt";
18
+ id: string;
19
+ spec: PromptSpec;
20
+ } | {
21
+ kind: "log";
22
+ entry: LogEntry;
23
+ } | {
24
+ kind: "settled";
25
+ ok: boolean;
26
+ folder: string;
27
+ summary: string[];
28
+ };
29
+ export type AnswerMessage = {
30
+ kind: "answer";
31
+ id: string;
32
+ value: string;
33
+ via: string;
34
+ };
35
+ export type SupervisorMessage = AnswerMessage | {
36
+ kind: "cancel";
37
+ };
38
+ type Send = (message: ChildMessage) => void;
39
+ type OnMessage = (handler: (message: SupervisorMessage) => void) => void;
40
+ /**
41
+ * The child-side handle to an out-of-process supervisor. Sends prompt requests, live log
42
+ * entries, and the final verdict up the channel; resolves prompts when the matching answer
43
+ * comes back; and invokes a cancel handler on a `cancel` message. Purely a transport — it
44
+ * decides nothing (the child keeps its own prompt timeout; see the `prompt` action).
45
+ */
46
+ export declare class Supervisor {
47
+ private readonly send;
48
+ private seq;
49
+ private readonly pending;
50
+ private cancelHandler;
51
+ constructor(send: Send, onMessage: OnMessage);
52
+ request(spec: PromptSpec, signal: AbortSignal): Promise<AnswerMessage>;
53
+ log(entry: LogEntry): void;
54
+ settled(ok: boolean, folder: string, summary: string[]): void;
55
+ onCancel(handler: () => void): void;
56
+ }
57
+ /** Build a supervisor over an arbitrary channel — used in tests and by {@link detect}. */
58
+ export declare function createSupervisor(send: Send, onMessage: OnMessage): Supervisor;
59
+ /** The active supervisor, or `null` when running bare. */
60
+ export declare function activeSupervisor(): Supervisor | null;
61
+ /** Test seam: override the active supervisor (pass `null` to restore bare mode). */
62
+ export declare function installSupervisor(supervisor: Supervisor | null): void;
63
+ export {};
@@ -0,0 +1,82 @@
1
+ /**
2
+ * The child-side handle to an out-of-process supervisor. Sends prompt requests, live log
3
+ * entries, and the final verdict up the channel; resolves prompts when the matching answer
4
+ * comes back; and invokes a cancel handler on a `cancel` message. Purely a transport — it
5
+ * decides nothing (the child keeps its own prompt timeout; see the `prompt` action).
6
+ */
7
+ export class Supervisor {
8
+ send;
9
+ seq = 0;
10
+ pending = new Map();
11
+ cancelHandler;
12
+ constructor(send, onMessage) {
13
+ this.send = send;
14
+ onMessage((message) => {
15
+ if (message.kind === "answer") {
16
+ const resolve = this.pending.get(message.id);
17
+ if (resolve) {
18
+ this.pending.delete(message.id);
19
+ resolve(message);
20
+ }
21
+ }
22
+ else if (message.kind === "cancel") {
23
+ this.cancelHandler?.();
24
+ }
25
+ });
26
+ }
27
+ request(spec, signal) {
28
+ const id = `p${++this.seq}`;
29
+ return new Promise((resolve, reject) => {
30
+ const onAbort = () => {
31
+ this.pending.delete(id);
32
+ reject(new Error("prompt aborted"));
33
+ };
34
+ if (signal.aborted)
35
+ return onAbort();
36
+ signal.addEventListener("abort", onAbort, { once: true });
37
+ this.pending.set(id, (answer) => {
38
+ signal.removeEventListener("abort", onAbort);
39
+ resolve(answer);
40
+ });
41
+ this.send({ kind: "prompt", id, spec });
42
+ });
43
+ }
44
+ log(entry) {
45
+ this.send({ kind: "log", entry });
46
+ }
47
+ settled(ok, folder, summary) {
48
+ this.send({ kind: "settled", ok, folder, summary });
49
+ }
50
+ onCancel(handler) {
51
+ this.cancelHandler = handler;
52
+ }
53
+ }
54
+ /** Build a supervisor over an arbitrary channel — used in tests and by {@link detect}. */
55
+ export function createSupervisor(send, onMessage) {
56
+ return new Supervisor(send, onMessage);
57
+ }
58
+ /**
59
+ * The real supervisor when this process was `fork`ed with an IPC channel and marked
60
+ * supervised (`OMKIT_SUPERVISED=1`), else `null`. Detected once at import.
61
+ */
62
+ function detect() {
63
+ const supervised = process.env.OMKIT_SUPERVISED === "1" && typeof process.send === "function";
64
+ // Read the flag once, then clear it from the environment so any subprocess this run spawns
65
+ // (a shell command, a nested `npm test`, …) does not inherit it and spuriously enter
66
+ // supervised mode. Each omkit child's status is set explicitly by whoever forks it.
67
+ delete process.env.OMKIT_SUPERVISED;
68
+ if (!supervised)
69
+ return null;
70
+ const send = (message) => void process.send(message);
71
+ const onMessage = (handler) => process.on("message", (m) => handler(m));
72
+ return createSupervisor(send, onMessage);
73
+ }
74
+ let current = detect();
75
+ /** The active supervisor, or `null` when running bare. */
76
+ export function activeSupervisor() {
77
+ return current;
78
+ }
79
+ /** Test seam: override the active supervisor (pass `null` to restore bare mode). */
80
+ export function installSupervisor(supervisor) {
81
+ current = supervisor;
82
+ }
package/dist/core/step.js CHANGED
@@ -7,11 +7,6 @@ import { action } from "./action.js";
7
7
  * units use `action(...)`.
8
8
  */
9
9
  export function step(name, fn) {
10
- return action(name)
11
- .run(fn)()
12
- .result.then((r) => {
13
- if (!r.ok)
14
- throw r.error;
15
- return r.value;
16
- });
10
+ // `.result` already resolves the value and rejects on failure/cancel — exactly step's contract.
11
+ return action(name).run(fn)().result;
17
12
  }