pi-async 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,272 @@
1
+ import type { Condition, WatchSpec } from "./task.ts";
2
+
3
+ const compiledRegexes = new WeakMap<Condition, RegExp>();
4
+
5
+ export function validateWatchSpec(spec: WatchSpec): { ok: true } | { ok: false; error: string } {
6
+ const invalid = (error: string) => ({ ok: false as const, error });
7
+ if (!spec || typeof spec !== "object") return invalid("watch must be an object");
8
+ if (!Number.isFinite(spec.intervalMs) || !Number.isInteger(spec.intervalMs) || spec.intervalMs < 1_000) {
9
+ return invalid("intervalMs must be an integer greater than or equal to 1000");
10
+ }
11
+ if (!Number.isFinite(spec.timeoutMs) || !Number.isInteger(spec.timeoutMs) || spec.timeoutMs <= 0) {
12
+ return invalid("timeoutMs must be a positive finite integer");
13
+ }
14
+ if (spec.blind_checks !== undefined &&
15
+ (!Number.isFinite(spec.blind_checks) || !Number.isInteger(spec.blind_checks) || spec.blind_checks < 1)) {
16
+ return invalid("blind_checks must be a positive integer");
17
+ }
18
+ if (!Array.isArray(spec.success_when) || !Array.isArray(spec.failure_when)) {
19
+ return invalid("success_when and failure_when must be arrays");
20
+ }
21
+
22
+ for (const [group, conditions] of [
23
+ ["success_when", spec.success_when],
24
+ ["failure_when", spec.failure_when],
25
+ ] as const) {
26
+ for (const [index, condition] of conditions.entries()) {
27
+ const location = `${group}[${index}]`;
28
+ if (!condition || typeof condition !== "object" || !("type" in condition)) {
29
+ return invalid(`${location} is invalid`);
30
+ }
31
+ switch (condition.type) {
32
+ case "exit_code":
33
+ if (!Number.isFinite(condition.equals)) return invalid(`${location}: equals must be finite`);
34
+ break;
35
+ case "stdout_contains":
36
+ case "stderr_contains":
37
+ if (typeof condition.value !== "string") return invalid(`${location}: value must be a string`);
38
+ break;
39
+ case "json_path_exists":
40
+ if (typeof condition.path !== "string" || parseJsonPath(condition.path) === undefined) {
41
+ return invalid(`${location}: invalid JSON path`);
42
+ }
43
+ break;
44
+ case "output_matches":
45
+ if (typeof condition.regex !== "string") return invalid(`${location}: regex must be a string`);
46
+ try {
47
+ compiledRegexes.set(condition, new RegExp(condition.regex));
48
+ } catch {
49
+ return invalid(`${location}: invalid regex`);
50
+ }
51
+ break;
52
+ default:
53
+ return invalid(`${location}: unknown condition type`);
54
+ }
55
+ }
56
+ }
57
+ return { ok: true };
58
+ }
59
+
60
+ export function evalConditions(conds: Condition[], ctx: { output: string; exitCode?: number }): boolean {
61
+ const result = evaluateConditionsDetailed(conds, ctx);
62
+ return result.status === "matched" && result.matched;
63
+ }
64
+
65
+ export interface WatchLoopDeps {
66
+ taskId: string;
67
+ spec: WatchSpec;
68
+ readOutput: () => string;
69
+ getExitCode: () => number | undefined;
70
+ onSuccess: (detail: string) => void;
71
+ onFailure: () => void;
72
+ onBlindExhausted: () => void;
73
+ onWatchTimeout: () => void;
74
+ }
75
+
76
+ export class WatchLoop {
77
+ private timer: ReturnType<typeof setTimeout> | undefined;
78
+ private stopped = true;
79
+ private finished = false;
80
+ private deadline = 0;
81
+ private blindCount = 0;
82
+ private readonly deps: WatchLoopDeps;
83
+
84
+ constructor(deps: WatchLoopDeps) {
85
+ this.deps = deps;
86
+ }
87
+
88
+ start(): void {
89
+ if (!this.stopped || this.finished) return;
90
+ this.stopped = false;
91
+ this.blindCount = 0;
92
+ this.deadline = Date.now() + this.deps.spec.timeoutMs;
93
+ this.check();
94
+ }
95
+
96
+ stop(): void {
97
+ this.stopped = true;
98
+ if (this.timer !== undefined) {
99
+ clearTimeout(this.timer);
100
+ this.timer = undefined;
101
+ }
102
+ }
103
+
104
+ private check(): void {
105
+ if (this.stopped) return;
106
+ if (Date.now() >= this.deadline) {
107
+ this.finish(this.deps.onWatchTimeout);
108
+ return;
109
+ }
110
+
111
+ const conditions = [...this.deps.spec.failure_when, ...this.deps.spec.success_when];
112
+ const needsOutput = conditions.some((condition) => condition.type !== "exit_code");
113
+ const needsExitCode = conditions.some((condition) => condition.type === "exit_code");
114
+ let output = "";
115
+ let exitCode: number | undefined;
116
+ try {
117
+ if (needsOutput) output = this.deps.readOutput();
118
+ if (needsExitCode) exitCode = this.deps.getExitCode();
119
+ } catch {
120
+ this.countBlindCheck();
121
+ return;
122
+ }
123
+
124
+ const ctx = { output, exitCode };
125
+ const failure = evaluateConditionsDetailed(this.deps.spec.failure_when, ctx);
126
+ if (failure.status === "matched" && failure.matched) {
127
+ this.finish(this.deps.onFailure);
128
+ return;
129
+ }
130
+ const success = evaluateConditionsDetailed(this.deps.spec.success_when, ctx);
131
+ if (success.status === "matched" && success.matched) {
132
+ const matched = success.condition;
133
+ this.finish(() => this.deps.onSuccess(`success_when matched: ${JSON.stringify(matched)}`));
134
+ return;
135
+ }
136
+
137
+ // Pending observations (e.g. a process still running or incomplete JSON output)
138
+ // are not infrastructure failures and must not consume the blind-check budget.
139
+ if (failure.status === "error" || success.status === "error") {
140
+ this.countBlindCheck();
141
+ } else {
142
+ this.blindCount = 0;
143
+ this.scheduleNext();
144
+ }
145
+ }
146
+
147
+ private countBlindCheck(): void {
148
+ this.blindCount++;
149
+ if (this.blindCount >= (this.deps.spec.blind_checks ?? 3)) {
150
+ this.finish(this.deps.onBlindExhausted);
151
+ return;
152
+ }
153
+ this.scheduleNext();
154
+ }
155
+
156
+ private scheduleNext(): void {
157
+ if (this.stopped) return;
158
+ const remaining = this.deadline - Date.now();
159
+ if (remaining <= 0) {
160
+ this.finish(this.deps.onWatchTimeout);
161
+ return;
162
+ }
163
+ this.timer = setTimeout(() => {
164
+ this.timer = undefined;
165
+ this.check();
166
+ }, Math.min(this.deps.spec.intervalMs, remaining));
167
+ }
168
+
169
+ private finish(callback: () => void): void {
170
+ this.finished = true;
171
+ this.stop();
172
+ callback();
173
+ }
174
+ }
175
+
176
+ type ConditionEvaluation =
177
+ | { status: "matched"; matched: boolean; condition?: Condition }
178
+ | { status: "pending" }
179
+ | { status: "error" };
180
+
181
+ function evaluateConditionsDetailed(conditions: Condition[], ctx: { output: string; exitCode?: number }): ConditionEvaluation {
182
+ let pending = false;
183
+ let error = false;
184
+ for (const condition of conditions) {
185
+ const result = evaluateCondition(condition, ctx);
186
+ if (result.status === "matched" && result.matched) return { status: "matched", matched: true, condition };
187
+ if (result.status === "pending") pending = true;
188
+ if (result.status === "error") error = true;
189
+ }
190
+ if (error) return { status: "error" };
191
+ if (pending) return { status: "pending" };
192
+ return { status: "matched", matched: false };
193
+ }
194
+
195
+ function evaluateCondition(condition: Condition, ctx: { output: string; exitCode?: number }): ConditionEvaluation {
196
+ switch (condition.type) {
197
+ case "exit_code":
198
+ return ctx.exitCode === undefined
199
+ ? { status: "pending" }
200
+ : { status: "matched", matched: ctx.exitCode === condition.equals };
201
+ case "stdout_contains":
202
+ case "stderr_contains":
203
+ // The frozen WatchLoop context exposes the captured merged output stream.
204
+ return { status: "matched", matched: ctx.output.includes(condition.value) };
205
+ case "output_matches": {
206
+ let regex = compiledRegexes.get(condition);
207
+ if (!regex) {
208
+ try {
209
+ regex = new RegExp(condition.regex);
210
+ compiledRegexes.set(condition, regex);
211
+ } catch {
212
+ return { status: "error" };
213
+ }
214
+ }
215
+ // Global/sticky expressions otherwise retain lastIndex between watch polls.
216
+ regex.lastIndex = 0;
217
+ return { status: "matched", matched: regex.test(ctx.output) };
218
+ }
219
+ case "json_path_exists": {
220
+ let root: unknown;
221
+ try {
222
+ root = JSON.parse(ctx.output);
223
+ } catch {
224
+ return { status: "pending" };
225
+ }
226
+ const tokens = parseJsonPath(condition.path);
227
+ if (tokens === undefined) return { status: "error" };
228
+ let current = root;
229
+ for (const token of tokens) {
230
+ if (typeof token === "number") {
231
+ if (!Array.isArray(current) || token >= current.length) return { status: "matched", matched: false };
232
+ current = current[token];
233
+ } else {
234
+ if (current === null || typeof current !== "object" || !Object.hasOwn(current, token)) {
235
+ return { status: "matched", matched: false };
236
+ }
237
+ current = (current as Record<string, unknown>)[token];
238
+ }
239
+ }
240
+ return { status: "matched", matched: true };
241
+ }
242
+ }
243
+ }
244
+
245
+ function parseJsonPath(path: string): Array<string | number> | undefined {
246
+ if (!path.startsWith("$") || path.length === 0) return undefined;
247
+ const tokens: Array<string | number> = [];
248
+ let index = 1;
249
+ while (index < path.length) {
250
+ if (path[index] === ".") {
251
+ const start = ++index;
252
+ if (index >= path.length || !/[A-Za-z_$]/.test(path[index]!)) return undefined;
253
+ index++;
254
+ while (index < path.length && /[A-Za-z0-9_$-]/.test(path[index]!)) index++;
255
+ tokens.push(path.slice(start, index));
256
+ continue;
257
+ }
258
+ if (path[index] === "[") {
259
+ const end = path.indexOf("]", index + 1);
260
+ if (end < 0) return undefined;
261
+ const raw = path.slice(index + 1, end);
262
+ if (!/^\d+$/.test(raw)) return undefined;
263
+ const arrayIndex = Number(raw);
264
+ if (!Number.isSafeInteger(arrayIndex)) return undefined;
265
+ tokens.push(arrayIndex);
266
+ index = end + 1;
267
+ continue;
268
+ }
269
+ return undefined;
270
+ }
271
+ return tokens;
272
+ }
@@ -0,0 +1,78 @@
1
+ import type { PiCtx } from "../core/notify.ts";
2
+ import type { TaskRegistry } from "../core/registry.ts";
3
+
4
+ export class SettleGuard {
5
+ private readonly maxReminders: number;
6
+ private readonly heartbeatMs: number;
7
+ private reminders = 0;
8
+ private heartbeat?: ReturnType<typeof setInterval>;
9
+ private removeHooks: Array<() => void> = [];
10
+ private readonly pi: PiCtx;
11
+ private readonly registry: TaskRegistry;
12
+
13
+ constructor(pi: PiCtx, registry: TaskRegistry, opts?: { maxReminders?: number; heartbeatMs?: number }) {
14
+ this.pi = pi;
15
+ this.registry = registry;
16
+ this.maxReminders = opts?.maxReminders ?? 3;
17
+ this.heartbeatMs = opts?.heartbeatMs ?? finiteEnvNumber("PI_ASYNC_HEARTBEAT_MS", 60_000);
18
+ }
19
+
20
+ register(): void {
21
+ // SAFETY: PiCtx hooks vary by runtime; these optional operations are only invoked after presence checks.
22
+ const hooks = this.pi as unknown as {
23
+ on?: (event: string, handler: (...args: unknown[]) => unknown) => void;
24
+ off?: (event: string, handler: (...args: unknown[]) => unknown) => void;
25
+ appendEntry?: (customType: string, data: unknown) => void;
26
+ };
27
+ const beforeSettle = () => {
28
+ const active = this.registry.list({ includeHistory: false });
29
+ if (active.length === 0) { this.reminders = 0; this.reportActivity(); return; }
30
+ this.reportActivity();
31
+ if (this.reminders >= this.maxReminders) {
32
+ hooks.appendEntry?.("async-task-unresolved", { message: `${active.length} async task(s) remain unresolved: ${active.map((task) => task.taskId).join(", ")}` });
33
+ return;
34
+ }
35
+ this.reminders++;
36
+ hooks.appendEntry?.("async-task-settle-reminder", { message: `${active.length} async task(s) not collected (${active.map((task) => task.taskId).join(", ")}); use async_wait/async_status before reporting verdict.` });
37
+ return { continue: true };
38
+ };
39
+ const beforeRun = () => { this.reminders = 0; };
40
+ // SAFETY: PI_LEGION_NODE_ID is the documented legion-child marker; presence opts into settle reminders.
41
+ const legionChild = process.env.PI_LEGION_NODE_ID !== undefined;
42
+ if (legionChild) hooks.on?.("agent_before_settle", beforeSettle);
43
+ hooks.on?.("agent_start", beforeRun);
44
+ const cleanup = () => {
45
+ if (this.heartbeat) clearInterval(this.heartbeat);
46
+ if (legionChild) hooks.off?.("agent_before_settle", beforeSettle);
47
+ hooks.off?.("agent_start", beforeRun);
48
+ this.removeHooks = [];
49
+ };
50
+ hooks.on?.("session_shutdown", () => { void cleanup(); });
51
+ this.removeHooks.push(cleanup);
52
+ this.startHeartbeat(hooks);
53
+ this.reportActivity();
54
+ }
55
+
56
+ private reportActivity(): void {
57
+ // SAFETY: setActivity is feature-detected because older Pi hosts do not implement the optional API.
58
+ const ui = (this.pi as unknown as { ui?: { setActivity?: (id: string, value: { message: string } | undefined) => void } }).ui;
59
+ if (typeof ui?.setActivity !== "function") return;
60
+ const count = this.registry.activeCount();
61
+ const total = count.running + count.pending;
62
+ ui.setActivity("pi-async", total ? { message: `${total} async tasks` } : undefined);
63
+ }
64
+
65
+ private startHeartbeat(hooks: { appendEntry?: (customType: string, data: unknown) => void }): void {
66
+ if (this.heartbeat || this.heartbeatMs <= 0) return;
67
+ this.heartbeat = setInterval(() => {
68
+ if (!this.registry.hasActiveWork()) return;
69
+ hooks.appendEntry?.("async-task-heartbeat", { timestamp: Date.now(), active: this.registry.activeCount() });
70
+ }, this.heartbeatMs);
71
+ this.heartbeat.unref?.();
72
+ }
73
+ }
74
+
75
+ function finiteEnvNumber(name: string, fallback: number): number {
76
+ const parsed = Number(process.env[name] ?? fallback);
77
+ return Number.isFinite(parsed) ? parsed : fallback;
78
+ }
@@ -0,0 +1,92 @@
1
+ import type { PiCtx } from "../core/notify.ts";
2
+ import type { TaskRegistry } from "../core/registry.ts";
3
+ import type { WatchSpec } from "../core/task.ts";
4
+
5
+ /**
6
+ * Wrap an extension-tool return value into pi's ToolResultMessage shape.
7
+ *
8
+ * pi 1.2.0 does NOT auto-wrap extension tool return values (the executor
9
+ * passes `await tool.execute(...)` through verbatim; only builtin tools
10
+ * return `{content:[{type:"text",...}]}`). A bare object without `content`
11
+ * crashes the TUI's ToolExecutionComponent.getTextOutput
12
+ * (`result.content.filter` on undefined) and persists as content:[] —
13
+ * see the user-reported uncaughtException on 2026-10-15.
14
+ *
15
+ * Every async_* tool MUST route its return value through this helper so the
16
+ * result satisfies the ToolResultMessage contract: content is an array of
17
+ * typed blocks, and the original payload rides along in `details` for tests
18
+ * and structured consumers.
19
+ */
20
+ function toolResult(payload: unknown, isError = false): { content: Array<{ type: "text"; text: string }>; details: unknown; isError: boolean } {
21
+ return { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }], details: { payload }, isError };
22
+ }
23
+
24
+ const promptGuidelines = "Check commands must not swallow errors with || true; map unknown states to failure; prefer structured output and jq -e; anchor output_matches regexes to avoid premature matches.";
25
+ const MAX_LOG_BYTES = 50 * 1024;
26
+ const schema = (properties: Record<string, unknown>, required: string[] = [], allOf: unknown[] = []) => ({ type: "object", properties, required, additionalProperties: false, ...(allOf.length ? { allOf } : {}) });
27
+ const stringField = { type: "string" };
28
+ const timeoutField = { type: "number", minimum: 0 };
29
+ const conditionSchema = {
30
+ oneOf: [
31
+ { type: "object", properties: { type: { const: "exit_code" }, equals: { type: "number" } }, required: ["type", "equals"], additionalProperties: false },
32
+ ...["stdout_contains", "stderr_contains"].map((type) => ({ type: "object", properties: { type: { const: type }, value: stringField }, required: ["type", "value"], additionalProperties: false })),
33
+ { type: "object", properties: { type: { const: "json_path_exists" }, path: stringField }, required: ["type", "path"], additionalProperties: false },
34
+ { type: "object", properties: { type: { const: "output_matches" }, regex: stringField }, required: ["type", "regex"], additionalProperties: false },
35
+ ],
36
+ };
37
+ const watchSchema = {
38
+ type: "object", properties: {
39
+ intervalMs: { type: "number", minimum: 1000 }, timeoutMs: { type: "number", minimum: 1 },
40
+ success_when: { type: "array", items: conditionSchema }, failure_when: { type: "array", items: conditionSchema },
41
+ blind_checks: { type: "number", minimum: 1 },
42
+ }, required: ["intervalMs", "timeoutMs", "success_when", "failure_when"], additionalProperties: false,
43
+ };
44
+
45
+ /** Register the frozen five-tool surface. */
46
+ export function registerTools(pi: PiCtx, registry: TaskRegistry): void {
47
+ type ToolDef = { name: string; label: string; description: string; parameters: unknown; execute: (toolCallId: string, params: Record<string, unknown>, signal?: AbortSignal) => Promise<unknown> };
48
+ // SAFETY: ExtensionAPI exposes registerTool at runtime; this local structural view keeps schemas dependency-free.
49
+ const api = pi as unknown as { registerTool?: (tool: ToolDef) => void };
50
+ if (!api.registerTool) throw new Error("Pi host does not expose registerTool");
51
+ api.registerTool({
52
+ name: "async_run", label: "Run async command", description: `Run a background, delayed, or synchronous shell command. ${promptGuidelines}`,
53
+ parameters: schema({ command: stringField, name: stringField, mode: { type: "string", enum: ["background", "sync", "delayed"], default: "background" }, timeoutMs: timeoutField, delayMs: timeoutField, taskTimeoutMs: timeoutField, onTimeout: { type: "string", enum: ["promote", "abort"], default: "promote" }, notifyOnCompletion: { type: "boolean" }, triggerOnCompletion: { type: "boolean" }, env: { type: "object", additionalProperties: { type: "string" } }, watch: watchSchema }, ["command"], [
54
+ { if: { properties: { mode: { const: "sync" } }, required: ["mode"] }, then: { required: ["timeoutMs"] } },
55
+ { if: { properties: { mode: { const: "delayed" } }, required: ["mode"] }, then: { required: ["delayMs"] } },
56
+ { if: { properties: { mode: { const: "sync" } }, required: ["mode"] }, then: { not: { required: ["watch"] } } },
57
+ ]),
58
+ execute: async (_id, params, signal) => {
59
+ const mode = String(params.mode ?? "background");
60
+ if (mode !== "background" && mode !== "sync" && mode !== "delayed") throw new Error(`Unsupported async_run mode: ${mode}`);
61
+ const command = String(params.command ?? "");
62
+ if (mode === "sync" && params.timeoutMs === undefined) throw new Error("timeoutMs is required for sync mode");
63
+ if (mode === "delayed" && params.delayMs === undefined) throw new Error("delayMs is required for delayed mode");
64
+ if (params.watch !== undefined && mode === "sync") throw new Error("watch is only supported for background and delayed modes");
65
+ const options = {
66
+ command,
67
+ ...(typeof params.name === "string" ? { name: params.name } : {}),
68
+ ...(typeof params.taskTimeoutMs === "number" ? { taskTimeoutMs: params.taskTimeoutMs } : {}),
69
+ ...(typeof params.notifyOnCompletion === "boolean" ? { notifyOnCompletion: params.notifyOnCompletion } : {}),
70
+ ...(typeof params.triggerOnCompletion === "boolean" ? { triggerOnCompletion: params.triggerOnCompletion } : {}),
71
+ ...(params.env && typeof params.env === "object" ? { env: params.env as Record<string, string> } : {}),
72
+ ...(params.watch && typeof params.watch === "object" ? { watch: params.watch as WatchSpec } : {}),
73
+ };
74
+ if (mode === "sync") { const r = await registry.runSync({ ...options, mode: "sync" }, Number(params.timeoutMs), params.onTimeout === "abort" ? "abort" : "promote", signal); return toolResult(r); }
75
+ const task = mode === "delayed"
76
+ ? registry.scheduleDelayed({ ...options, mode: "delayed" }, Number(params.delayMs))
77
+ : registry.submit({ ...options, mode: "background" });
78
+ return toolResult({ taskId: task.taskId, name: task.name, status: task.status, mode: task.mode, outputHint: `Use async_logs with taskId ${task.taskId} to inspect early output.` });
79
+ },
80
+ });
81
+ api.registerTool({ name: "async_wait", label: "Wait for async tasks", description: "Wait for tasks to finish or return notReady; waiting does not kill tasks.", parameters: schema({ taskIds: { type: "array", items: stringField }, timeoutMs: timeoutField }, ["taskIds", "timeoutMs"]), execute: async (_id, params, signal) => {
82
+ if (params.timeoutMs === undefined) throw new Error("timeoutMs is required");
83
+ return toolResult(await registry.wait(params.taskIds as string[], Number(params.timeoutMs), signal));
84
+ } });
85
+ api.registerTool({ name: "async_logs", label: "Read async output", description: "Read bounded, ANSI-stripped task output.", parameters: schema({ taskId: stringField, maxBytes: { type: "number", minimum: 0, maximum: MAX_LOG_BYTES }, tail: { type: "boolean" } }, ["taskId"]), execute: async (_id, params) => {
86
+ const maxBytes = params.maxBytes === undefined ? MAX_LOG_BYTES : Number(params.maxBytes);
87
+ if (maxBytes > MAX_LOG_BYTES) throw new Error("maxBytes must be at most 50 KiB");
88
+ return toolResult(registry.logs(String(params.taskId), { maxBytes, tail: params.tail !== false }));
89
+ } });
90
+ api.registerTool({ name: "async_status", label: "Async task status", description: "List async tasks and current status.", parameters: schema({ includeHistory: { type: "boolean" } }), execute: async (_id, params) => toolResult(registry.list({ includeHistory: params.includeHistory !== false })) });
91
+ api.registerTool({ name: "async_kill", label: "Kill async task", description: "Terminate an async task and its process group.", parameters: schema({ taskId: stringField }, ["taskId"]), execute: async (_id, params) => { await registry.kill(String(params.taskId), "user"); return toolResult({ taskId: params.taskId, status: registry.get(String(params.taskId))?.status }); } });
92
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Bound TUI host context.
3
+ *
4
+ * pi's factory-time ExtensionAPI exposes neither `mode` nor `ui` — those live
5
+ * on event-handler contexts (`pi.on(event, (event, ctx) => ...)`), and `ctx.ui`
6
+ * is the only object whose setWidget/setStatus/custom actually work. All TUI
7
+ * surfaces therefore bind lazily from the first event context whose
8
+ * `mode === "tui"`, and read `tuiHost()` afterwards.
9
+ */
10
+ export type TuiHost = { mode: string; ui: Record<string, unknown> };
11
+
12
+ let bound: TuiHost | undefined;
13
+
14
+ /** Bind the first TUI-capable event context. Idempotent; only the first wins. */
15
+ export function bindTuiHost(ctx: unknown): boolean {
16
+ const c = ctx as { mode?: string; ui?: Record<string, unknown> } | undefined;
17
+ if (bound || c?.mode !== "tui" || !c.ui) return false;
18
+ bound = { mode: c.mode, ui: c.ui };
19
+ return true;
20
+ }
21
+
22
+ /** The bound TUI host, or undefined when no TUI context has been seen. */
23
+ export function tuiHost(): TuiHost | undefined {
24
+ return bound;
25
+ }
26
+
27
+ /** Test-only: forget the bound host so isolated unit tests can re-bind. */
28
+ export function resetTuiHostForTests(): void {
29
+ bound = undefined;
30
+ }