ghostykit 0.0.0-stage → 0.2.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Héctor BlisS (FixterGeek)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,173 @@
1
- # Temporary Holding Version
1
+ # Piloto UI · `ghostykit`
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ **An in-app copilot for React.** Piloto UI is the agent chat from
4
+ [Ghosty Studio](https://www.ghosty.studio), packaged as a library. You drop it into an app you already
5
+ have. It sees what the user has on screen, streams what the agent says and does, and **stops to ask
6
+ before anything risky** (human in the loop).
7
+
8
+ > El copiloto de tu app: el chat de Ghosty, probado en producción, como biblioteca. Español por default, inglés incluido.
9
+
10
+ **[Open the playground →](https://blissito.github.io/ghostykit/)** (a fake backend, a real chat)
11
+
12
+ ![The permission pass: the agent asks before running a cron](https://raw.githubusercontent.com/blissito/ghostykit/main/docs/screenshots/ghosty-light-desktop-2-permission.png)
13
+
14
+ ## What you get
15
+
16
+ - **Live answers**: markdown streamed at a readable pace (Streamdown), with code blocks and tables. External links ask for confirmation before opening.
17
+ - **Agent steps**: every tool call as a line ("Read 2 files", "Searched the web"). Steps fold into a card when the turn ends, and you can show your own labels and icons.
18
+ - **The permission pass**: risky actions arrive as a card with a countdown, *Approve* and *Reject*. A stamp lands when you answer, and the decision stays recorded under the reply.
19
+ - **Context and references**: what the user is looking at travels with every turn. Rows from your app can be "@-mentioned" into the message.
20
+ - **Resilience**: reconnects after a dropped network or a deploy, re-attaches to a running turn, retries while the server drains, and offers *Resume* when a turn is cut short.
21
+ - **The composer**: attachments (you provide `upload`), voice notes (you provide `transcribe`), stop, model picker and reply-option buttons.
22
+ - **Solid core**: the whole chat state is a pure reducer, `reducePilotEvent(state, name, data)`, tested by replaying real SSE sessions.
23
+
24
+ ## 30-second quickstart
25
+
26
+ ```bash
27
+ npm i ghostykit motion streamdown
28
+ ```
29
+
30
+ ```tsx
31
+ import { Piloto, useSseTransport } from "ghostykit";
32
+ import "ghostykit/styles.css";
33
+ import "ghostykit/fonts.css"; // optional: the house typefaces
34
+
35
+ export function Copilot() {
36
+ const transport = useSseTransport("/api/copilot/conversations/123");
37
+ return <Piloto transport={transport} />;
38
+ }
39
+ ```
40
+
41
+ `<Piloto transport={…} />` works with nothing else. Give its container a height: Piloto fills it.
42
+
43
+ ## Transports
44
+
45
+ A transport is how Piloto talks to your agent. It is a small interface: `subscribe`, `send`, `cancel`,
46
+ plus optional `setModel`, `respondPermission` and `history`. Two are included.
47
+
48
+ ### 1. Split stream: `createSseTransport(base)` (Ghosty's contract)
49
+
50
+ The event stream stays open (`EventSource`) and messages go by POST.
51
+
52
+ | | |
53
+ |---|---|
54
+ | `GET {base}/events` | Named SSE events: `started`, `status`, `chunk`, `thought`, `tool`, `permission`, `permission-resolved`, `usage`, `models`, `title`, `done{reposo?,interrumpido?,ultimo?}`, `error`… |
55
+ | `POST {base}/messages` | `{content, images, primerTurno, retomar?, context?}` → `{inyectado?, estado?, turnId?}` |
56
+ | `POST {base}/cancel` · `/model` · `/permission` | stop, switch model, answer a card |
57
+
58
+ Messages sent during a running turn are injected into it (steer). A silent connection during a turn is
59
+ reopened, and the server says whether the turn continued, finished or was cut short.
60
+
61
+ ### 2. POST returns the stream: `createPostStreamTransport({ url })` (EasyBits' contract)
62
+
63
+ The POST that starts a turn *is* the SSE. A GET re-attaches to a running turn.
64
+
65
+ | | |
66
+ |---|---|
67
+ | `POST {url}` | `{text, context?, turnId, continueTurnId?}` → SSE `started`, `chunk`, `tool{title,status}`, `permission{…, expiresAt}`, `done{turnId,text}`, `error`, `capacity`, `interrupted{turnId,canContinue}`, `idle` |
68
+ | `GET {url}?attach=1` | `started`, one `permission` per open card, `snapshot{turnId,text,tools}`, then live |
69
+ | `GET {url}` | `{messages:[{role,text}]}`, loaded as the thread |
70
+
71
+ A 503 (deploy draining) is retried with the same `turnId`. A 409 `turn_busy` re-attaches to the turn
72
+ that is already running. Approvals usually go through your own route:
73
+
74
+ ```tsx
75
+ <Piloto
76
+ transport={useMemo(() => createPostStreamTransport({ url: "/api/v2/admin/copilot/chat" }), [])}
77
+ context={{ page: "crons", job: selectedJob }}
78
+ onPermission={async (id, optionId) => {
79
+ const res = await fetch(`/api/v2/admin/agent-actions/${id}`, {
80
+ method: "POST",
81
+ headers: { "content-type": "application/json" },
82
+ body: JSON.stringify({ decision: optionId === "approve" ? "approve" : "reject" }),
83
+ });
84
+ if (res.status === 409 || res.status === 404) return { resolved: false }; // expired or already answered
85
+ if (!res.ok) throw new Error("approval failed");
86
+ return { resolved: true, detail: (await res.json()).status };
87
+ }}
88
+ />
89
+ ```
90
+
91
+ ### 3. Your own
92
+
93
+ Implement `PilotTransport` and emit the same event names (see `ghostykit/core` types). If you only need
94
+ the state, use the reducer directly: `ghostykit/core` has no React dependency.
95
+
96
+ ## Make it yours
97
+
98
+ ```tsx
99
+ const ref = useRef<PilotoHandle>(null);
100
+
101
+ <Piloto
102
+ ref={ref}
103
+ transport={transport}
104
+ skin="easybits" // "ghosty" (default) | "easybits" | "neutral" | your own
105
+ theme="auto" // "light" | "dark" | "auto"
106
+ locale="en" // Spanish by default
107
+ agentName="Admin assistant"
108
+ welcome="How can I help with your crons?"
109
+ suggestions={["Why did the monitor fail?"]}
110
+ context={() => ({ screen: "crons", selected })} // sent with EVERY turn
111
+ toolVerbs={{ run_cron: { one: "Ran a cron", many: "Ran %n crons", running: "Running the cron…" } }}
112
+ upload={async (file) => ({ url: await myUpload(file) })}
113
+ transcribe={async (audio) => myWhisper(audio)}
114
+ onTurnDone={({ text }) => refreshMyData()}
115
+ slots={{ renderDeliveries: (turn) => <MyFiles turn={turn} />, composerExtras: <MyButton /> }}
116
+ />;
117
+
118
+ ref.current?.addReference({ id: "12", label: "#12 Rosa" }); // "@" a row into the message
119
+ ref.current?.send("Why did this fail?"); // ask on the user's behalf
120
+ ```
121
+
122
+ Other options: `strings`, `toolIcons`, `formatUserText`, `markdown`, `features`
123
+ (`attachments`, `retry`, `copy`, `usage`, `models`, `replyOptions`, `duration`), `references` /
124
+ `onReferencesChange`, `voiceNoteText`, `placeholder`, `avatar`, `initialTurns`, and the callbacks
125
+ `onTurnStart`, `onTitle`, `onNeedsLogin`, `onDisconnected`, `onEvent`, `onSend`. To read the state
126
+ outside the chat, call `usePilot(transport)` yourself and pass the result as `pilot`. Every piece is
127
+ exported (`AgentBubble`, `AgentSteps`, `Composer`, `ConfirmCard`, `Markdown`, …) if you want to build
128
+ your own layout.
129
+
130
+ ### Skins and theming
131
+
132
+ `ghostykit/styles.css` is self-contained and scoped under `.piloto`. It works the same with
133
+ **Tailwind 3, Tailwind 4 or no Tailwind** (verified in a Tailwind 3 app: its utilities compute
134
+ identically with and without Piloto's CSS). Every color, radius, border width and shadow is a
135
+ variable:
136
+
137
+ ```css
138
+ .my-app .piloto {
139
+ --piloto-accent: #0f766e;
140
+ --piloto-warning: #fde68a; /* the permission pass */
141
+ --piloto-radius-card: 6px;
142
+ --piloto-border-width: 3px;
143
+ --piloto-shadow-hero: 6px 6px 0 #0f766e;
144
+ --piloto-font: "Your Sans", sans-serif;
145
+ }
146
+ ```
147
+
148
+ | Ghosty | EasyBits | Neutral |
149
+ |---|---|---|
150
+ | ![Ghosty skin](https://raw.githubusercontent.com/blissito/ghostykit/main/docs/screenshots/ghosty-light-mobile-2-permission.png) | ![EasyBits skin, dark](https://raw.githubusercontent.com/blissito/ghostykit/main/docs/screenshots/easybits-dark-mobile-2-permission.png) | ![Neutral skin](https://raw.githubusercontent.com/blissito/ghostykit/main/docs/screenshots/neutral-light-mobile-2-permission.png) |
151
+
152
+ ## Accessibility
153
+
154
+ - Keyboard-first: Enter sends, Shift+Enter adds a new line, Backspace on an empty box removes the last reference. Menus close with Escape, and focus rings are visible (`--piloto-focus`).
155
+ - The permission pass is a labelled region; the stamp is announced as status, and errors as alerts.
156
+ - `prefers-reduced-motion` turns off every animation, including the text-at-pace effect, the stamp and the "thinking" lamps.
157
+ - Dark mode is real, not inverted: hard shadows switch to a color so they stay visible on dark backgrounds.
158
+
159
+ ## The repo
160
+
161
+ - `packages/ghostykit`: the package (`core`, `transport`, `react`, `pointer`, `styles.css`, `fonts.css`)
162
+ - `apps/demo`: the playground (Vite, simulated transport, skin switcher)
163
+
164
+ ```bash
165
+ npm install
166
+ npm test # reducer, both transports, parsers, SSR
167
+ npm run build # dist/ (JS + .d.ts + styles.css)
168
+ npm run demo # http://localhost:5173
169
+ ```
170
+
171
+ ## License
172
+
173
+ MIT © Héctor BlisS (FixterGeek)
@@ -0,0 +1,36 @@
1
+ // src/core/text.ts
2
+ var VOICE_NOTE = /\[Nota de voz transcrita[^\]]*\]\s*«([^»]*)»/;
3
+ function cleanUserText(raw) {
4
+ let t = raw;
5
+ const voice = t.match(VOICE_NOTE);
6
+ if (voice) t = t.replace(voice[0], voice[1]);
7
+ return t.replace(/\n{3,}/g, "\n\n").trim();
8
+ }
9
+ function systemNoteOf(text) {
10
+ if (!text.startsWith("\u23F0 ")) return null;
11
+ const cause = text.match(/^⏰ [^(\n]*\(([^)]+)\)/)?.[1] ?? text.split("\n")[0].replace(/^⏰ /, "").replace(/[.:].*$/, "").slice(0, 60);
12
+ return cause.toLowerCase();
13
+ }
14
+ function applyReferences(text, refs) {
15
+ if (!refs.length) return text;
16
+ const lines = refs.filter((r) => r.prompt).map((r) => r.prompt);
17
+ const labels = refs.filter((r) => !r.prompt).map((r) => r.label);
18
+ let out = text;
19
+ if (labels.length) out = `[${labels.join(", ")}] ${out}`.trim();
20
+ if (lines.length) out = `${lines.join("\n")}
21
+ ${out}`;
22
+ return out;
23
+ }
24
+ function formatDuration(s) {
25
+ if (s < 60) return `${s} s`;
26
+ const min = Math.floor(s / 60);
27
+ if (min < 60) return `${min} min`;
28
+ return `${Math.floor(min / 60)} h ${String(min % 60).padStart(2, "0")} min`;
29
+ }
30
+
31
+ export {
32
+ cleanUserText,
33
+ systemNoteOf,
34
+ applyReferences,
35
+ formatDuration
36
+ };
@@ -0,0 +1,365 @@
1
+ // src/transport/types.ts
2
+ var PilotHttpError = class extends Error {
3
+ constructor(message, status, needsLogin = false) {
4
+ super(message);
5
+ this.name = "PilotHttpError";
6
+ this.status = status;
7
+ this.needsLogin = needsLogin;
8
+ }
9
+ };
10
+
11
+ // src/transport/postStream.ts
12
+ async function readSse(body, onMessage, onBytes) {
13
+ const reader = body.getReader();
14
+ const decoder = new TextDecoder();
15
+ let buffer = "";
16
+ let event = "message";
17
+ let data = [];
18
+ const flush = () => {
19
+ if (data.length) onMessage({ event, data: data.join("\n") });
20
+ event = "message";
21
+ data = [];
22
+ };
23
+ for (; ; ) {
24
+ const { value, done } = await reader.read();
25
+ if (done) break;
26
+ onBytes?.();
27
+ buffer += decoder.decode(value, { stream: true });
28
+ let nl;
29
+ while ((nl = buffer.search(/\r\n|\r|\n/)) >= 0) {
30
+ const line = buffer.slice(0, nl);
31
+ buffer = buffer.slice(nl + (buffer[nl] === "\r" && buffer[nl + 1] === "\n" ? 2 : 1));
32
+ if (line === "") flush();
33
+ else if (line.startsWith(":")) continue;
34
+ else {
35
+ const i = line.indexOf(":");
36
+ const field = i < 0 ? line : line.slice(0, i);
37
+ const val = i < 0 ? "" : line.slice(i + 1).replace(/^ /, "");
38
+ if (field === "event") event = val;
39
+ else if (field === "data") data.push(val);
40
+ }
41
+ }
42
+ }
43
+ flush();
44
+ }
45
+ var TERMINAL = /* @__PURE__ */ new Set(["done", "capacity", "interrupted", "idle"]);
46
+ function parse(raw) {
47
+ if (!raw) return null;
48
+ try {
49
+ return JSON.parse(raw);
50
+ } catch {
51
+ return null;
52
+ }
53
+ }
54
+ function defaultHistory(json) {
55
+ const msgs = json?.messages ?? [];
56
+ return msgs.filter((m) => typeof m.text === "string").map((m) => ({ role: m.role === "user" ? "user" : "assistant", text: m.text }));
57
+ }
58
+ function uuid() {
59
+ const c = globalThis.crypto;
60
+ if (c?.randomUUID) return c.randomUUID();
61
+ return `t-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
62
+ }
63
+ function createPostStreamTransport(target, options = {}) {
64
+ const url = typeof target === "string" ? target : target.url;
65
+ const opts = typeof target === "string" ? options : { ...target, ...options };
66
+ const doFetch = (...a) => (opts.fetch ?? fetch)(...a);
67
+ const credentials = opts.credentials ?? "same-origin";
68
+ const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
69
+ const retries = opts.retries ?? 6;
70
+ const silenceMs = opts.silenceMs ?? 6e4;
71
+ const attachUrl = opts.attachUrl === void 0 ? `${url}${url.includes("?") ? "&" : "?"}attach=1` : opts.attachUrl;
72
+ const historyUrl = opts.historyUrl === void 0 ? url : opts.historyUrl;
73
+ const buildBody = opts.body ?? ((i) => ({
74
+ text: i.text,
75
+ ...i.context !== void 0 ? { context: i.context } : {},
76
+ turnId: i.turnId,
77
+ ...i.resume && i.resumeTurnId ? { continueTurnId: i.resumeTurnId } : {}
78
+ }));
79
+ let listener = null;
80
+ let isBusy = () => false;
81
+ let closed = false;
82
+ let current = null;
83
+ const emit = (name, data) => listener?.(name, data);
84
+ async function consume(res, ctrl) {
85
+ if (!res.body) return false;
86
+ let terminal = false;
87
+ let errored = false;
88
+ let lastByte = Date.now();
89
+ const watchdog = setInterval(() => {
90
+ if (Date.now() - lastByte > silenceMs) ctrl.abort();
91
+ }, Math.min(15e3, silenceMs));
92
+ try {
93
+ await readSse(
94
+ res.body,
95
+ ({ event, data }) => {
96
+ if (ctrl.signal.aborted) return;
97
+ const d = parse(data);
98
+ emit(event, d);
99
+ if (event === "error") errored = true;
100
+ if (TERMINAL.has(event)) terminal = true;
101
+ },
102
+ () => {
103
+ lastByte = Date.now();
104
+ }
105
+ );
106
+ } catch {
107
+ } finally {
108
+ clearInterval(watchdog);
109
+ }
110
+ if (errored && !terminal) {
111
+ emit("done", {});
112
+ return true;
113
+ }
114
+ return terminal;
115
+ }
116
+ async function attach() {
117
+ if (!attachUrl) return;
118
+ for (let attempt = 0; attempt <= retries && !closed; attempt++) {
119
+ const ctrl = new AbortController();
120
+ current?.abort();
121
+ current = ctrl;
122
+ try {
123
+ const res = await doFetch(attachUrl, { credentials, headers: { accept: "text/event-stream" }, signal: ctrl.signal });
124
+ if (res.ok && await consume(res, ctrl)) return;
125
+ if (ctrl.signal.aborted && current !== ctrl) return;
126
+ } catch {
127
+ if (current !== ctrl) return;
128
+ }
129
+ if (!isBusy()) return;
130
+ await sleep(Math.min(1e3 * 2 ** attempt, 1e4));
131
+ }
132
+ }
133
+ return {
134
+ // Sin steer: con un turno corriendo el servidor contesta 409. El composer no deja mandar.
135
+ steer: false,
136
+ subscribe(l, sub = {}) {
137
+ listener = l;
138
+ isBusy = sub.isBusy ?? (() => false);
139
+ closed = false;
140
+ void attach();
141
+ return () => {
142
+ closed = true;
143
+ listener = null;
144
+ current?.abort();
145
+ current = null;
146
+ };
147
+ },
148
+ async history() {
149
+ if (!historyUrl) return [];
150
+ const res = await doFetch(historyUrl, { credentials, headers: { accept: "application/json" } });
151
+ if (!res.ok) return [];
152
+ return (opts.parseHistory ?? defaultHistory)(await res.json().catch(() => null));
153
+ },
154
+ async send(input) {
155
+ const turnId = (opts.newTurnId ?? uuid)();
156
+ const body = JSON.stringify(buildBody({ ...input, turnId }));
157
+ for (let attempt = 0; ; attempt++) {
158
+ const ctrl = new AbortController();
159
+ current?.abort();
160
+ current = ctrl;
161
+ const res = await doFetch(url, {
162
+ method: "POST",
163
+ credentials,
164
+ headers: { "content-type": "application/json", accept: "text/event-stream" },
165
+ body,
166
+ signal: ctrl.signal
167
+ });
168
+ if (res.status === 503 && attempt < retries) {
169
+ await sleep(Number(res.headers.get("Retry-After")) * 1e3 || 3e3);
170
+ continue;
171
+ }
172
+ if (!res.ok) {
173
+ const json = await res.json().catch(() => null);
174
+ const err = new PilotHttpError(json?.message ?? json?.error ?? `Error ${res.status}`, res.status, res.headers.get("X-Needs-Login") != null);
175
+ if (json?.error) err.code = json.error;
176
+ if (res.status === 409 && json?.error === "turn_busy") void attach();
177
+ throw err;
178
+ }
179
+ void consume(res, ctrl).then((ok) => {
180
+ if (!ok && current === ctrl && !closed) void attach();
181
+ });
182
+ return { turnId, state: "running" };
183
+ }
184
+ },
185
+ async cancel() {
186
+ if (opts.cancelUrl) await doFetch(opts.cancelUrl, { method: "POST", credentials }).catch(() => {
187
+ });
188
+ current?.abort();
189
+ current = null;
190
+ }
191
+ };
192
+ }
193
+
194
+ // src/transport/index.ts
195
+ var SSE_EVENTS = [
196
+ "started",
197
+ "status",
198
+ "caps",
199
+ "models",
200
+ "chunk",
201
+ "thought",
202
+ "tool",
203
+ "artifact",
204
+ "usage",
205
+ "title",
206
+ "done",
207
+ "error",
208
+ "warning",
209
+ "reemplazo",
210
+ "permission",
211
+ "permission-resolved",
212
+ "closed",
213
+ "page-plan",
214
+ "section-building",
215
+ "section-update",
216
+ "section",
217
+ "page-ready",
218
+ "page-error",
219
+ "page-planning",
220
+ "page-drop",
221
+ "review",
222
+ "page-version",
223
+ "page-edit",
224
+ // Los de un servidor de turnos durables (EasyBits): re-enganche, sin turno, cortado, sin lugar.
225
+ "snapshot",
226
+ "idle",
227
+ "interrupted",
228
+ "capacity"
229
+ ];
230
+ var LIFE_EVENTS = /* @__PURE__ */ new Set([
231
+ "started",
232
+ "status",
233
+ "chunk",
234
+ "thought",
235
+ "tool",
236
+ "artifact",
237
+ "usage",
238
+ "done",
239
+ "error",
240
+ "warning",
241
+ "reemplazo",
242
+ "permission",
243
+ "page-plan",
244
+ "section-building",
245
+ "section-update",
246
+ "section",
247
+ "page-ready",
248
+ "page-error",
249
+ "page-planning",
250
+ "page-drop",
251
+ "review",
252
+ "page-version",
253
+ "page-edit",
254
+ "snapshot",
255
+ "idle",
256
+ "interrupted",
257
+ "capacity"
258
+ ]);
259
+ var SILENCE_MAX_MS = 11 * 6e4;
260
+ var MISSED_BEATS = 4;
261
+ var MIN_SILENCE_MS = 9e4;
262
+ function parse2(raw) {
263
+ if (typeof raw !== "string" || !raw) return null;
264
+ try {
265
+ return JSON.parse(raw);
266
+ } catch {
267
+ return null;
268
+ }
269
+ }
270
+ function createSseTransport(base, opts = {}) {
271
+ const doFetch = (...args) => (opts.fetch ?? fetch)(...args);
272
+ const now = opts.now ?? Date.now;
273
+ const root = base.replace(/\/+$/, "");
274
+ let lastEventAt = now();
275
+ const post = (path, body) => doFetch(`${root}/${path}`, {
276
+ method: "POST",
277
+ headers: body === void 0 ? void 0 : { "content-type": "application/json" },
278
+ body: body === void 0 ? void 0 : JSON.stringify(body),
279
+ credentials: opts.withCredentials ? "include" : void 0
280
+ });
281
+ return {
282
+ subscribe(listener, sub = {}) {
283
+ const ES = opts.EventSource ?? globalThis.EventSource;
284
+ if (!ES) throw new Error("ghostykit: no hay EventSource en este entorno (pasa `EventSource` en las opciones)");
285
+ let es = null;
286
+ let heartbeatMs = null;
287
+ let closed = false;
288
+ const open = () => {
289
+ const source = new ES(`${root}/events`, { withCredentials: Boolean(opts.withCredentials) });
290
+ es = source;
291
+ const names = /* @__PURE__ */ new Set([...SSE_EVENTS, ...opts.events ?? []]);
292
+ for (const name of names) {
293
+ source.addEventListener(name, (e) => {
294
+ if (es !== source) return;
295
+ if (LIFE_EVENTS.has(name)) lastEventAt = now();
296
+ const raw = e.data;
297
+ if (name === "error" && (raw === void 0 || raw === "")) return;
298
+ const data = parse2(raw);
299
+ if (name === "started") {
300
+ const d = data;
301
+ heartbeatMs = typeof d?.latidoMs === "number" && d.latidoMs > 0 ? d.latidoMs : null;
302
+ }
303
+ listener(name, data);
304
+ if (name === "closed") source.close();
305
+ });
306
+ }
307
+ };
308
+ open();
309
+ const timer = setInterval(() => {
310
+ if (closed || !sub.isBusy?.()) return;
311
+ const cap = heartbeatMs ? Math.max(heartbeatMs * MISSED_BEATS, MIN_SILENCE_MS) : SILENCE_MAX_MS;
312
+ if (now() - lastEventAt < cap) return;
313
+ lastEventAt = now();
314
+ es?.close();
315
+ open();
316
+ }, opts.checkEveryMs ?? 15e3);
317
+ return () => {
318
+ closed = true;
319
+ clearInterval(timer);
320
+ es?.close();
321
+ es = null;
322
+ };
323
+ },
324
+ async send(input) {
325
+ lastEventAt = now();
326
+ const res = await post("messages", {
327
+ content: input.text,
328
+ images: input.attachments,
329
+ // Lo que hace que el hilo se BAUTICE al cerrar este turno.
330
+ primerTurno: input.firstTurn,
331
+ ...input.resume ? { retomar: true } : {},
332
+ ...input.context !== void 0 ? { context: input.context } : {}
333
+ });
334
+ if (!res.ok) {
335
+ const text = await res.text().catch(() => "");
336
+ throw new PilotHttpError(text || `Error ${res.status}`, res.status, Boolean(res.headers.get("X-Needs-Login")));
337
+ }
338
+ const r = await res.json().catch(() => ({}));
339
+ return { injected: r.inyectado, state: r.estado, turnId: r.turnId };
340
+ },
341
+ async cancel() {
342
+ await post("cancel").catch(() => {
343
+ });
344
+ },
345
+ async setModel(value) {
346
+ await post("model", { value });
347
+ },
348
+ async respondPermission(id, optionId) {
349
+ const res = await post("permission", { id, optionId });
350
+ const r = await res.json().catch(() => ({}));
351
+ return { resolved: Boolean(r.resuelto ?? r.resolved) };
352
+ }
353
+ };
354
+ }
355
+
356
+ export {
357
+ PilotHttpError,
358
+ readSse,
359
+ createPostStreamTransport,
360
+ SSE_EVENTS,
361
+ SILENCE_MAX_MS,
362
+ MISSED_BEATS,
363
+ MIN_SILENCE_MS,
364
+ createSseTransport
365
+ };