pi-freeflow 1.5.0 → 1.7.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.
@@ -1,271 +1,307 @@
1
- /**
2
- * Upstream SSE streaming pipeline with thinking sniffing and exception isolation.
3
- *
4
- * Prevents upstream stream aborts or timeouts from crashing the host process.
5
- * Sniffs thinking and reasoning chunks for debug trace logging without payload mutation.
6
- */
7
-
8
- import { randomUUID } from "node:crypto";
9
- import type * as http from "node:http";
10
- import type { Readable } from "node:stream";
11
- import { isDebugEnabled, log } from "./logger.ts";
12
- import { markRelayFailure } from "./relay-state.ts";
13
- /**
14
- * Pipes an upstream readable stream to a client HTTP response.
15
- *
16
- * - Flushes HTTP headers immediately for low Time-to-First-Byte (TTFB).
17
- * - Detects thinking/reasoning tokens in chunks for diagnostic logs.
18
- * - Handles upstream errors (returns 502 if headers unsent) and ends response.
19
- * - Cleans up resources and destroys upstream stream on client disconnect/abort.
20
- */
21
- export function pipeUpstreamStream(
22
- nodeStream: Readable,
23
- res: http.ServerResponse,
24
- req: http.IncomingMessage,
25
- reqId?: string,
26
- relayUrl?: string,
27
- ): void {
28
- const rid = reqId || randomUUID().slice(0, 8);
29
- let totalChunks = 0;
30
- let totalBytes = 0;
31
- let thinkingChunks = 0;
32
- let thinkingBytes = 0;
33
- let firstChunkAt: number | null = null;
34
- const startAt = Date.now();
35
- const isResponsesApi = req.url?.includes("/responses") ?? false;
36
- let hasTerminalEvent = false;
37
- // Abort-cause tracking: only genuine upstream-side truncation may penalize
38
- // relay health. Client disconnects and clean upstream ends must not.
39
- let upstreamEnded = false;
40
- let clientAborted = false;
41
- // Terminal-marker scan carry: holds the tail of the previously scanned
42
- // view so a marker split across adjacent chunks ("[DO" | "NE]") is still
43
- // recognized. Longest marker is "response.completed" (18 bytes); 32
44
- // gives comfortable headroom.
45
- let terminalScanCarry = Buffer.alloc(0);
46
-
47
- const sniffThinking = (chunk: Buffer | string): boolean => {
48
- const s =
49
- typeof chunk === "string"
50
- ? chunk
51
- : chunk.toString("utf8", 0, Math.min(chunk.length, 4000));
52
- return (
53
- s.includes("reasoning") ||
54
- s.includes("thinking") ||
55
- s.includes("<think>") ||
56
- s.includes("reasoning_content") ||
57
- s.includes('"type":"thinking"') ||
58
- s.includes("thinking_delta")
59
- );
60
- };
61
-
62
- const checkTerminalEvent = (s: string): boolean => {
63
- return (
64
- s.includes("response.completed") ||
65
- s.includes("response.done") ||
66
- s.includes("response.failed") ||
67
- s.includes("response.incomplete") ||
68
- s.includes("[DONE]")
69
- );
70
- };
71
- const ensureTerminalEvent = (
72
- isError = false,
73
- errorMsg?: string,
74
- penalizeRelay = true,
75
- ) => {
76
- if (hasTerminalEvent || res.writableEnded) return;
77
- // Only genuine upstream-side truncation penalizes relay health.
78
- // Client disconnects and clean upstream ends leave it untouched.
79
- if (penalizeRelay && relayUrl && relayUrl !== "direct") {
80
- markRelayFailure(relayUrl, 0, errorMsg || "stream truncated prematurely");
81
- }
82
- if (isResponsesApi && totalChunks > 0) {
83
- try {
84
- if (isError) {
85
- res.write(
86
- `\nevent: response.failed\ndata: {"type":"response.failed","response":{"status":"failed","error":{"code":"stream_error","message":${JSON.stringify(errorMsg || "Upstream stream disconnected unexpectedly")}}}}\n\n`,
87
- );
88
- } else {
89
- res.write(
90
- `\nevent: response.incomplete\ndata: {"type":"response.incomplete","response":{"status":"incomplete","incomplete_details":{"reason":"cancelled"}}}\n\n`,
91
- );
92
- }
93
- hasTerminalEvent = true;
94
- log(
95
- "warn",
96
- `injected synthetic response.${isError ? "failed" : "incomplete"} for prematurely truncated stream`,
97
- { totalChunks, totalBytes, isError, errorMsg },
98
- rid,
99
- );
100
- } catch {}
101
- } else if (!isResponsesApi && totalChunks > 0) {
102
- try {
103
- res.write("\ndata: [DONE]\n\n");
104
- hasTerminalEvent = true;
105
- } catch {}
106
- }
107
- };
108
-
109
- try {
110
- if (typeof res.flushHeaders === "function") {
111
- res.flushHeaders();
112
- }
113
- } catch {}
114
- nodeStream.on("data", (chunk: Buffer | string) => {
115
- try {
116
- if (firstChunkAt === null) {
117
- firstChunkAt = Date.now();
118
- const ttfb = firstChunkAt - startAt;
119
- log("debug", `stream first chunk in ${ttfb}ms`, undefined, rid);
120
- }
121
- totalChunks++;
122
- const chunkSize =
123
- typeof chunk === "string" ? Buffer.byteLength(chunk) : chunk.length;
124
- totalBytes += chunkSize;
125
-
126
- if (sniffThinking(chunk)) {
127
- thinkingChunks++;
128
- thinkingBytes += chunkSize;
129
- if (isDebugEnabled() && thinkingChunks <= 3) {
130
- const preview =
131
- typeof chunk === "string"
132
- ? chunk.slice(0, 600)
133
- : chunk.toString("utf8", 0, 600);
134
- log(
135
- "debug",
136
- `thinking chunk #${thinkingChunks}`,
137
- { preview: preview.slice(0, 400) },
138
- rid,
139
- );
140
- }
141
- }
142
- // Always scan the FULL chunk plus the small carry from the previous
143
- // view. SSE chunks are KBs at most, so includes() over everything
144
- // is negligible against correctness — the removed head/tail windows
145
- // are exactly what let markers buried mid-chunk or split across
146
- // adjacent chunks escape and cause duplicate synthetic terminals.
147
- const buf =
148
- typeof chunk === "string" ? Buffer.from(chunk, "utf8") : chunk;
149
- const scanBuf =
150
- terminalScanCarry.length > 0
151
- ? Buffer.concat([terminalScanCarry, buf])
152
- : buf;
153
- if (!hasTerminalEvent && checkTerminalEvent(scanBuf.toString("utf8"))) {
154
- hasTerminalEvent = true;
155
- }
156
- terminalScanCarry = Buffer.from(scanBuf.subarray(Math.max(0, scanBuf.length - 32)));
157
-
158
- res.write(chunk);
159
- const maybeFlush = res as unknown as { flush?: () => void };
160
- if (typeof maybeFlush.flush === "function") {
161
- maybeFlush.flush();
162
- }
163
- } catch {}
164
- });
165
-
166
- nodeStream.on("error", (e: unknown) => {
167
- const errorMsg = (e as Error)?.message || String(e);
168
- log(
169
- "error",
170
- "upstream stream error",
171
- { error: errorMsg, totalChunks, thinkingChunks, hasTerminalEvent },
172
- rid,
173
- );
174
- try {
175
- if (!res.headersSent) {
176
- res.writeHead(502, { "content-type": "application/json" });
177
- } else {
178
- const isSubstantial = totalChunks > 50 && totalBytes > 100 * 1024;
179
- ensureTerminalEvent(!isSubstantial, errorMsg, true);
180
- }
181
- if (!res.writableEnded) {
182
- res.end();
183
- }
184
- } catch {}
185
- });
186
-
187
- nodeStream.on("end", () => {
188
- upstreamEnded = true;
189
- const elapsed = ((Date.now() - startAt) / 1000).toFixed(1);
190
- if (!hasTerminalEvent && totalChunks > 0) {
191
- // Clean upstream end without a detectable marker: keep the host
192
- // contract (synthetic terminal) but never blame the relay.
193
- ensureTerminalEvent(
194
- false,
195
- "upstream ended without terminal marker",
196
- false,
197
- );
198
- }
199
- if (thinkingChunks > 0) {
200
- log(
201
- "info",
202
- `stream ended in ${elapsed}s — ${totalChunks} chunks (${(totalBytes / 1024).toFixed(1)}KB), thinking: ${thinkingChunks} chunks (${(thinkingBytes / 1024).toFixed(1)}KB)`,
203
- undefined,
204
- rid,
205
- );
206
- } else if (isDebugEnabled()) {
207
- log(
208
- "debug",
209
- `stream ended in ${elapsed}s — ${totalChunks} chunks (${(totalBytes / 1024).toFixed(1)}KB), no thinking detected`,
210
- undefined,
211
- rid,
212
- );
213
- }
214
- try {
215
- if (!res.writableEnded) res.end();
216
- } catch {}
217
- });
218
-
219
- nodeStream.on("close", () => {
220
- try {
221
- if (!hasTerminalEvent && totalChunks > 0) {
222
- if (clientAborted || upstreamEnded) {
223
- // Client disconnect teardown or post-end cleanup — the relay
224
- // is not at fault; still give the host a terminal event.
225
- ensureTerminalEvent(false, "stream interrupted by client", false);
226
- } else {
227
- // Upstream socket died mid-stream with no error event.
228
- // For muse-spark large payloads: raxtant 514KB failed but feoni 802KB
229
- // succeeded with same 2.6MB in — so this is edge-specific, not pure
230
- // provider token limit. Keep penalize=true to rotate failing relay,
231
- // but inject incomplete (not failed) for substantial to avoid alarming
232
- // stream_error. Small premature (<50 chunks) stays failed+penalize.
233
- const isSubstantial = totalChunks > 50 && totalBytes > 100 * 1024;
234
- ensureTerminalEvent(!isSubstantial, "stream closed prematurely", true);
235
- }
236
- }
237
- if (!res.writableEnded) res.end();
238
- } catch {}
239
- });
240
-
241
- req.on("aborted", () => {
242
- clientAborted = true;
243
- log("warn", "client aborted — destroying upstream", { totalChunks }, rid);
244
- if (!nodeStream.destroyed) nodeStream.destroy();
245
- });
246
-
247
- req.on("close", () => {
248
- if (!upstreamEnded && !nodeStream.destroyed) {
249
- clientAborted = true;
250
- nodeStream.destroy();
251
- }
252
- });
253
-
254
- res.on("close", () => {
255
- if (
256
- !upstreamEnded &&
257
- !res.writableEnded &&
258
- !nodeStream.destroyed
259
- ) {
260
- clientAborted = true;
261
- nodeStream.destroy();
262
- }
263
- });
264
-
265
- res.on("error", () => {
266
- if (!upstreamEnded && !nodeStream.destroyed) {
267
- clientAborted = true;
268
- nodeStream.destroy();
269
- }
270
- });
271
- }
1
+ /**
2
+ * Upstream SSE streaming pipeline with thinking sniffing and exception isolation.
3
+ *
4
+ * Prevents upstream stream aborts or timeouts from crashing the host process.
5
+ * Sniffs thinking and reasoning chunks for debug trace logging without payload mutation.
6
+ */
7
+
8
+ import { randomUUID } from "node:crypto";
9
+ import type * as http from "node:http";
10
+ import type { Readable } from "node:stream";
11
+ import { isDebugEnabled, log } from "./logger.ts";
12
+ import { markRelayFailure } from "./relay-state.ts";
13
+
14
+ /**
15
+ * A premature stream end counts as "substantial" only when BOTH thresholds
16
+ * are strictly exceeded: more than 50 chunks AND more than 100KB. Substantial
17
+ * truncations are reported as incomplete; smaller drops stay failed.
18
+ */
19
+ export const SUBSTANTIAL_MIN_CHUNKS = 50;
20
+ export const SUBSTANTIAL_MIN_BYTES = 100 * 1024;
21
+
22
+ export function isSubstantial(chunks: number, bytes: number): boolean {
23
+ return chunks > SUBSTANTIAL_MIN_CHUNKS && bytes > SUBSTANTIAL_MIN_BYTES;
24
+ }
25
+ /**
26
+ * Pipes an upstream readable stream to a client HTTP response.
27
+ *
28
+ * - Flushes HTTP headers immediately for low Time-to-First-Byte (TTFB).
29
+ * - Detects thinking/reasoning tokens in chunks for diagnostic logs.
30
+ * - Handles upstream errors (returns 502 if headers unsent) and ends response.
31
+ * - Cleans up resources and destroys upstream stream on client disconnect/abort.
32
+ */
33
+ export function pipeUpstreamStream(
34
+ nodeStream: Readable,
35
+ res: http.ServerResponse,
36
+ req: http.IncomingMessage,
37
+ reqId?: string,
38
+ relayUrl?: string,
39
+ ): void {
40
+ const rid = reqId || randomUUID().slice(0, 8);
41
+ let totalChunks = 0;
42
+ let totalBytes = 0;
43
+ let thinkingChunks = 0;
44
+ let thinkingBytes = 0;
45
+ let firstChunkAt: number | null = null;
46
+ const startAt = Date.now();
47
+ const isResponsesApi = req.url?.includes("/responses") ?? false;
48
+ let hasTerminalEvent = false;
49
+ // Abort-cause tracking: only genuine upstream-side truncation may penalize
50
+ // relay health. Client disconnects and clean upstream ends must not.
51
+ let upstreamEnded = false;
52
+ let clientAborted = false;
53
+ // Terminal-marker scan carry: holds the tail of the previously scanned
54
+ // view so a marker split across adjacent chunks ("[DO" | "NE]") is still
55
+ // recognized. Longest marker is "response.completed" (18 bytes); 32
56
+ // gives comfortable headroom.
57
+ let terminalScanCarry = Buffer.alloc(0);
58
+
59
+ const sniffThinking = (chunk: Buffer | string): boolean => {
60
+ const s =
61
+ typeof chunk === "string"
62
+ ? chunk
63
+ : chunk.toString("utf8", 0, Math.min(chunk.length, 4000));
64
+ return (
65
+ s.includes("reasoning") ||
66
+ s.includes("thinking") ||
67
+ s.includes("<think>") ||
68
+ s.includes("reasoning_content") ||
69
+ s.includes('"type":"thinking"') ||
70
+ s.includes("thinking_delta")
71
+ );
72
+ };
73
+
74
+ const checkTerminalEvent = (s: string): boolean => {
75
+ return (
76
+ s.includes("response.completed") ||
77
+ s.includes("response.done") ||
78
+ s.includes("response.failed") ||
79
+ s.includes("response.incomplete") ||
80
+ s.includes("[DONE]")
81
+ );
82
+ };
83
+ const ensureTerminalEvent = (
84
+ isError = false,
85
+ errorMsg?: string,
86
+ penalizeRelay = true,
87
+ ) => {
88
+ if (hasTerminalEvent || res.writableEnded) return;
89
+ // Only genuine upstream-side truncation penalizes relay health.
90
+ // Client disconnects and clean upstream ends leave it untouched.
91
+ if (penalizeRelay && relayUrl && relayUrl !== "direct") {
92
+ markRelayFailure(relayUrl, 0, errorMsg || "stream truncated prematurely");
93
+ }
94
+ if (isResponsesApi && totalChunks > 0) {
95
+ try {
96
+ if (isError) {
97
+ res.write(
98
+ `\nevent: response.failed\ndata: {"type":"response.failed","response":{"status":"failed","error":{"code":"stream_error","message":${JSON.stringify(errorMsg || "Upstream stream disconnected unexpectedly")}}}}\n\n`,
99
+ );
100
+ } else {
101
+ res.write(
102
+ `\nevent: response.incomplete\ndata: {"type":"response.incomplete","response":{"status":"incomplete","incomplete_details":{"reason":"cancelled"}}}\n\n`,
103
+ );
104
+ }
105
+ hasTerminalEvent = true;
106
+ log(
107
+ "warn",
108
+ `injected synthetic response.${isError ? "failed" : "incomplete"} for prematurely truncated stream`,
109
+ { totalChunks, totalBytes, isError, errorMsg },
110
+ rid,
111
+ );
112
+ } catch {}
113
+ } else if (!isResponsesApi && totalChunks > 0) {
114
+ try {
115
+ res.write("\ndata: [DONE]\n\n");
116
+ hasTerminalEvent = true;
117
+ } catch {}
118
+ }
119
+ };
120
+
121
+ try {
122
+ if (typeof res.flushHeaders === "function") {
123
+ res.flushHeaders();
124
+ }
125
+ } catch {}
126
+ nodeStream.on("data", (chunk: Buffer | string) => {
127
+ try {
128
+ if (firstChunkAt === null) {
129
+ firstChunkAt = Date.now();
130
+ const ttfb = firstChunkAt - startAt;
131
+ log("debug", `stream first chunk in ${ttfb}ms`, undefined, rid);
132
+ }
133
+ totalChunks++;
134
+ const chunkSize =
135
+ typeof chunk === "string" ? Buffer.byteLength(chunk) : chunk.length;
136
+ totalBytes += chunkSize;
137
+
138
+ if (sniffThinking(chunk)) {
139
+ thinkingChunks++;
140
+ thinkingBytes += chunkSize;
141
+ if (isDebugEnabled() && thinkingChunks <= 3) {
142
+ const preview =
143
+ typeof chunk === "string"
144
+ ? chunk.slice(0, 600)
145
+ : chunk.toString("utf8", 0, 600);
146
+ log(
147
+ "debug",
148
+ `thinking chunk #${thinkingChunks}`,
149
+ { preview: preview.slice(0, 400) },
150
+ rid,
151
+ );
152
+ }
153
+ }
154
+ // Always scan the FULL chunk plus the small carry from the previous
155
+ // view. SSE chunks are KBs at most, so includes() over everything
156
+ // is negligible against correctness — the removed head/tail windows
157
+ // are exactly what let markers buried mid-chunk or split across
158
+ // adjacent chunks escape and cause duplicate synthetic terminals.
159
+ const buf =
160
+ typeof chunk === "string" ? Buffer.from(chunk, "utf8") : chunk;
161
+ const scanBuf =
162
+ terminalScanCarry.length > 0
163
+ ? Buffer.concat([terminalScanCarry, buf])
164
+ : buf;
165
+ if (!hasTerminalEvent && checkTerminalEvent(scanBuf.toString("utf8"))) {
166
+ hasTerminalEvent = true;
167
+ }
168
+ terminalScanCarry = Buffer.from(scanBuf.subarray(Math.max(0, scanBuf.length - 32)));
169
+
170
+ const canContinue = res.write(chunk);
171
+ if (canContinue === false && !nodeStream.destroyed) {
172
+ // Backpressure: pause the upstream source until the client
173
+ // socket drains, instead of buffering an unbounded amount into
174
+ // the response. 'drain' might never fire if the client closes —
175
+ // the close handlers below destroy the stream regardless of
176
+ // its paused state.
177
+ nodeStream.pause();
178
+ const onDrain = () => {
179
+ res.off("drain", onDrain);
180
+ if (!nodeStream.destroyed) nodeStream.resume();
181
+ };
182
+ res.once("drain", onDrain);
183
+ }
184
+ const maybeFlush = res as unknown as { flush?: () => void };
185
+ if (typeof maybeFlush.flush === "function") {
186
+ maybeFlush.flush();
187
+ }
188
+ } catch {}
189
+ });
190
+
191
+ nodeStream.on("error", (e: unknown) => {
192
+ const errorMsg = (e as Error)?.message || String(e);
193
+ // An abort (client disconnect or a proxy-internal header timeout marked
194
+ // FF_INTERNAL_ABORT) is not a relay fault — never penalize relay health,
195
+ // and behave like a client abort for the terminal marker. Only genuine
196
+ // upstream-side failures (socket errors, truncation) penalize.
197
+ // Stream errors are always Error subclasses (node/undici).
198
+ const streamErr = e as Error & { code?: string };
199
+ const isInternalAbort =
200
+ streamErr?.name === "AbortError" || streamErr?.code === "FF_INTERNAL_ABORT";
201
+ log(
202
+ isInternalAbort ? "warn" : "error",
203
+ isInternalAbort
204
+ ? "upstream stream aborted (internal cancel/timeout)"
205
+ : "upstream stream error",
206
+ { error: errorMsg, totalChunks, thinkingChunks, hasTerminalEvent },
207
+ rid,
208
+ );
209
+ try {
210
+ if (!res.headersSent) {
211
+ res.writeHead(502, { "content-type": "application/json" });
212
+ } else if (isInternalAbort) {
213
+ clientAborted = true;
214
+ ensureTerminalEvent(false, errorMsg || "stream interrupted", false);
215
+ } else {
216
+ ensureTerminalEvent(!isSubstantial(totalChunks, totalBytes), errorMsg, true);
217
+ }
218
+ if (!res.writableEnded) {
219
+ res.end();
220
+ }
221
+ } catch {}
222
+ });
223
+
224
+ nodeStream.on("end", () => {
225
+ upstreamEnded = true;
226
+ const elapsed = ((Date.now() - startAt) / 1000).toFixed(1);
227
+ if (!hasTerminalEvent && totalChunks > 0) {
228
+ // Clean upstream end without a detectable marker: keep the host
229
+ // contract (synthetic terminal) but never blame the relay.
230
+ ensureTerminalEvent(
231
+ false,
232
+ "upstream ended without terminal marker",
233
+ false,
234
+ );
235
+ }
236
+ if (thinkingChunks > 0) {
237
+ log(
238
+ "info",
239
+ `stream ended in ${elapsed}s — ${totalChunks} chunks (${(totalBytes / 1024).toFixed(1)}KB), thinking: ${thinkingChunks} chunks (${(thinkingBytes / 1024).toFixed(1)}KB)`,
240
+ undefined,
241
+ rid,
242
+ );
243
+ } else if (isDebugEnabled()) {
244
+ log(
245
+ "debug",
246
+ `stream ended in ${elapsed}s — ${totalChunks} chunks (${(totalBytes / 1024).toFixed(1)}KB), no thinking detected`,
247
+ undefined,
248
+ rid,
249
+ );
250
+ }
251
+ try {
252
+ if (!res.writableEnded) res.end();
253
+ } catch {}
254
+ });
255
+
256
+ nodeStream.on("close", () => {
257
+ try {
258
+ if (!hasTerminalEvent && totalChunks > 0) {
259
+ if (clientAborted || upstreamEnded) {
260
+ // Client disconnect teardown or post-end cleanup — the relay
261
+ // is not at fault; still give the host a terminal event.
262
+ ensureTerminalEvent(false, "stream interrupted by client", false);
263
+ } else {
264
+ // Upstream socket died mid-stream with no error event.
265
+ // For muse-spark large payloads: raxtant 514KB failed but feoni 802KB
266
+ // succeeded with same 2.6MB in — so this is edge-specific, not pure
267
+ // provider token limit. Keep penalize=true to rotate failing relay,
268
+ // but inject incomplete (not failed) for substantial to avoid alarming
269
+ // stream_error. Small premature (<50 chunks) stays failed+penalize.
270
+ ensureTerminalEvent(!isSubstantial(totalChunks, totalBytes), "stream closed prematurely", true);
271
+ }
272
+ }
273
+ if (!res.writableEnded) res.end();
274
+ } catch {}
275
+ });
276
+
277
+ req.on("aborted", () => {
278
+ clientAborted = true;
279
+ log("warn", "client aborted — destroying upstream", { totalChunks }, rid);
280
+ if (!nodeStream.destroyed) nodeStream.destroy();
281
+ });
282
+
283
+ req.on("close", () => {
284
+ if (!upstreamEnded && !nodeStream.destroyed) {
285
+ clientAborted = true;
286
+ nodeStream.destroy();
287
+ }
288
+ });
289
+
290
+ res.on("close", () => {
291
+ if (
292
+ !upstreamEnded &&
293
+ !res.writableEnded &&
294
+ !nodeStream.destroyed
295
+ ) {
296
+ clientAborted = true;
297
+ nodeStream.destroy();
298
+ }
299
+ });
300
+
301
+ res.on("error", () => {
302
+ if (!upstreamEnded && !nodeStream.destroyed) {
303
+ clientAborted = true;
304
+ nodeStream.destroy();
305
+ }
306
+ });
307
+ }
package/src/types.ts CHANGED
@@ -38,6 +38,8 @@ export interface KnownRelay {
38
38
  url: string;
39
39
  label?: string;
40
40
  addedAt?: string;
41
+ /** Shared secret set by /freeflow deploy; proxy sends x-relay-auth only when present. */
42
+ auth?: string;
41
43
  }
42
44
  export type RelayMode = "auto" | "on" | "off";
43
45
 
@@ -92,9 +94,9 @@ export interface RateLimitStatus {
92
94
  export interface ExtensionUIContext {
93
95
  notify(message: string, type?: "info" | "warning" | "error"): void;
94
96
  setStatus(key: string, status: string | undefined): void;
95
- input(prompt: string, defaultValue?: string): Promise<string | undefined>;
97
+ input(prompt: string, placeholder?: string): Promise<string | undefined>;
96
98
  select(prompt: string, options: string[]): Promise<string | undefined>;
97
- confirm?(prompt: string): Promise<boolean>;
99
+ confirm?(title: string, message?: string): Promise<boolean>;
98
100
  /** Optional status-bar click handler (when host supports clickable widgets) */
99
101
  onStatusClick?(handler: () => void | Promise<void>): void | (() => void);
100
102
  /** Fallback/alternate names hosts may expose */