@pugi/cli 0.1.0-alpha.3 → 0.1.0-alpha.6

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 (44) hide show
  1. package/README.md +20 -0
  2. package/dist/commands/jobs.js +245 -0
  3. package/dist/core/agents/registry.js +69 -0
  4. package/dist/core/bash-classifier.js +1001 -0
  5. package/dist/core/context/builder.js +114 -0
  6. package/dist/core/context/compaction-events.js +99 -0
  7. package/dist/core/context/compaction.js +602 -0
  8. package/dist/core/context/invariants.js +250 -0
  9. package/dist/core/context/markdown-loader.js +270 -0
  10. package/dist/core/engine/compaction-hook.js +154 -0
  11. package/dist/core/engine/index.js +5 -0
  12. package/dist/core/engine/prompts.js +42 -0
  13. package/dist/core/engine/tool-bridge.js +159 -61
  14. package/dist/core/hooks.js +415 -0
  15. package/dist/core/jobs/registry.js +462 -0
  16. package/dist/core/mcp/client.js +316 -0
  17. package/dist/core/mcp/registry.js +171 -0
  18. package/dist/core/mcp/trust.js +91 -0
  19. package/dist/core/permission.js +221 -116
  20. package/dist/core/repl/cap-warning.js +91 -0
  21. package/dist/core/repl/session.js +399 -0
  22. package/dist/core/repl/slash-commands.js +116 -0
  23. package/dist/core/session.js +168 -0
  24. package/dist/core/subagents/dispatcher.js +258 -0
  25. package/dist/core/subagents/index.js +26 -0
  26. package/dist/core/subagents/spawn.js +86 -0
  27. package/dist/core/trust.js +109 -0
  28. package/dist/runtime/cli.js +158 -46
  29. package/dist/runtime/commands/budget.js +192 -0
  30. package/dist/runtime/commands/config.js +231 -0
  31. package/dist/runtime/commands/privacy.js +107 -0
  32. package/dist/runtime/commands/undo.js +329 -0
  33. package/dist/tools/bash.js +660 -0
  34. package/dist/tui/agent-tree.js +66 -0
  35. package/dist/tui/conversation-pane.js +45 -0
  36. package/dist/tui/input-box.js +91 -0
  37. package/dist/tui/login-picker.js +69 -0
  38. package/dist/tui/render.js +68 -0
  39. package/dist/tui/repl-render.js +218 -0
  40. package/dist/tui/repl.js +152 -0
  41. package/dist/tui/splash-data.js +61 -0
  42. package/dist/tui/splash.js +31 -0
  43. package/dist/tui/status-bar.js +58 -0
  44. package/package.json +11 -5
@@ -0,0 +1,66 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ export function AgentTree(props) {
4
+ if (props.agents.length === 0) {
5
+ return (_jsx(Box, { children: _jsx(Text, { dimColor: true, children: "No agents on watch. Type a brief to dispatch one." }) }));
6
+ }
7
+ const now = props.nowEpochMs ?? Date.now();
8
+ return (_jsx(Box, { flexDirection: "column", children: props.agents.map((agent, index) => (_jsx(AgentRow, { agent: agent, last: index === props.agents.length - 1, nowEpochMs: now }, agent.taskId))) }));
9
+ }
10
+ function AgentRow({ agent, last, nowEpochMs, }) {
11
+ const branch = last ? '└' : '├';
12
+ const glyph = statusGlyph(agent.status);
13
+ const glyphColor = statusColor(agent.status);
14
+ const elapsed = formatElapsed(agent.startedAtEpochMs, nowEpochMs);
15
+ const tokens = formatTokens(agent.tokensIn + agent.tokensOut);
16
+ const name = agent.personaName.padEnd(8, ' ');
17
+ const role = agent.role.padEnd(10, ' ');
18
+ const detail = agent.detail.length > 60 ? `${agent.detail.slice(0, 57)}…` : agent.detail;
19
+ return (_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: ` ${branch} ` }), _jsx(Text, { bold: true, children: `${name}` }), _jsx(Text, { dimColor: true, children: ` ${role} ` }), _jsx(Text, { color: glyphColor, children: glyph }), _jsx(Text, { children: ` ${detail}` }), _jsx(Text, { dimColor: true, children: ` (${elapsed}${tokens ? ` · ↓ ${tokens}` : ''})` })] }));
20
+ }
21
+ function statusGlyph(status) {
22
+ switch (status) {
23
+ case 'queued':
24
+ return '□';
25
+ case 'thinking':
26
+ return '⏳';
27
+ case 'shipped':
28
+ return '✓';
29
+ case 'blocked':
30
+ return '✗';
31
+ case 'failed':
32
+ return '✗';
33
+ }
34
+ }
35
+ function statusColor(status) {
36
+ switch (status) {
37
+ case 'queued':
38
+ return undefined;
39
+ case 'thinking':
40
+ return 'cyan';
41
+ case 'shipped':
42
+ return 'green';
43
+ case 'blocked':
44
+ return 'yellow';
45
+ case 'failed':
46
+ return 'red';
47
+ }
48
+ }
49
+ function formatElapsed(startedAtEpochMs, nowEpochMs) {
50
+ const ms = Math.max(0, nowEpochMs - startedAtEpochMs);
51
+ if (ms < 60_000)
52
+ return `${Math.floor(ms / 1000)}s`;
53
+ const minutes = Math.floor(ms / 60_000);
54
+ const seconds = Math.floor((ms % 60_000) / 1000);
55
+ return `${minutes}m ${seconds.toString().padStart(2, '0')}s`;
56
+ }
57
+ function formatTokens(total) {
58
+ if (total <= 0)
59
+ return '';
60
+ if (total < 1_000)
61
+ return total.toString();
62
+ if (total < 1_000_000)
63
+ return `${(total / 1_000).toFixed(1)}k`;
64
+ return `${(total / 1_000_000).toFixed(1)}m`;
65
+ }
66
+ //# sourceMappingURL=agent-tree.js.map
@@ -0,0 +1,45 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ const HUE_COLOR_BY_SLUG = {
4
+ // Mira (Pug) - coordinator
5
+ main: 'cyan',
6
+ // Olivia (Honeybee) - release
7
+ pm: 'yellow',
8
+ // Marcus (Owl)
9
+ architect: 'magenta',
10
+ // Hiroshi (Wolf) - lead dev
11
+ dev: 'blueBright',
12
+ // Mia (Hummingbird) - frontend
13
+ frontend: 'magentaBright',
14
+ // Vera (Fox) - QA
15
+ qa: 'red',
16
+ // Diego (Octopus) - devops
17
+ devops: 'cyan',
18
+ // Sofia (Stag) - designer
19
+ designer: 'green',
20
+ // Anika (Raven) - researcher
21
+ researcher: 'gray',
22
+ // Liam (Spider) - analyst
23
+ analyst: 'gray',
24
+ };
25
+ export function ConversationPane(props) {
26
+ if (props.rows.length === 0) {
27
+ return (_jsx(Box, { children: _jsx(Text, { dimColor: true, children: "Brief the workforce to begin. Try a short sentence or /help." }) }));
28
+ }
29
+ return (_jsx(Box, { flexDirection: "column", children: props.rows.map((row) => (_jsx(ConversationRow, { row: row, personaNames: props.personaNames }, row.id))) }));
30
+ }
31
+ function ConversationRow({ row, personaNames, }) {
32
+ switch (row.source) {
33
+ case 'operator':
34
+ return (_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "cyan", children: '› ' }), _jsx(Text, { children: row.text })] }));
35
+ case 'system':
36
+ return (_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: '· ' }), _jsx(Text, { dimColor: true, children: row.text })] }));
37
+ case 'persona': {
38
+ const slug = row.personaSlug ?? '';
39
+ const color = HUE_COLOR_BY_SLUG[slug] ?? 'white';
40
+ const displayName = personaNames?.get(slug) ?? slug;
41
+ return (_jsxs(Box, { children: [_jsx(Text, { color: color, bold: true, children: `▸ ${displayName} ` }), _jsx(Text, { children: row.text })] }));
42
+ }
43
+ }
44
+ }
45
+ //# sourceMappingURL=conversation-pane.js.map
@@ -0,0 +1,91 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * REPL input box - Sprint α5.7 (ADR-0056 acceptance #5, #9).
4
+ *
5
+ * Single-line input with:
6
+ * - history navigation (↑/↓)
7
+ * - slash command palette suggestion when the line starts with `/`
8
+ * - Enter submits the line
9
+ * - Esc clears the current line
10
+ * - Ctrl+C once cancels current line; twice within 1s exits the REPL
11
+ *
12
+ * The component owns the cursor + history state. Submission is passed
13
+ * to the REPL root via the `onSubmit` callback; the root forwards to
14
+ * `ReplSession.handleInput`. Ctrl+C double-tap is also surfaced via a
15
+ * dedicated `onExit` callback so the REPL root can stop Ink and let
16
+ * the runtime exit gracefully.
17
+ */
18
+ import { useState } from 'react';
19
+ import { Box, Text, useInput } from 'ink';
20
+ import { matchSlashPrefix } from '../core/repl/slash-commands.js';
21
+ const CTRL_C_DOUBLE_TAP_MS = 1_000;
22
+ export function InputBox(props) {
23
+ const [line, setLine] = useState(props.initial ?? '');
24
+ const [history, setHistory] = useState([]);
25
+ const [historyIndex, setHistoryIndex] = useState(-1);
26
+ const [lastCtrlCAt, setLastCtrlCAt] = useState(undefined);
27
+ const now = props.now ?? Date.now;
28
+ useInput((input, key) => {
29
+ if (key.ctrl && input === 'c') {
30
+ const t = now();
31
+ if (typeof lastCtrlCAt === 'number' && t - lastCtrlCAt <= CTRL_C_DOUBLE_TAP_MS) {
32
+ props.onExit();
33
+ return;
34
+ }
35
+ setLastCtrlCAt(t);
36
+ setLine('');
37
+ return;
38
+ }
39
+ if (key.return) {
40
+ const trimmed = line.trim();
41
+ if (trimmed.length > 0) {
42
+ setHistory((prev) => [...prev, trimmed]);
43
+ setHistoryIndex(-1);
44
+ props.onSubmit(trimmed);
45
+ }
46
+ setLine('');
47
+ return;
48
+ }
49
+ if (key.escape) {
50
+ setLine('');
51
+ setHistoryIndex(-1);
52
+ return;
53
+ }
54
+ if (key.upArrow) {
55
+ if (history.length === 0)
56
+ return;
57
+ const nextIndex = historyIndex === -1 ? history.length - 1 : Math.max(0, historyIndex - 1);
58
+ setHistoryIndex(nextIndex);
59
+ setLine(history[nextIndex] ?? '');
60
+ return;
61
+ }
62
+ if (key.downArrow) {
63
+ if (history.length === 0)
64
+ return;
65
+ if (historyIndex === -1)
66
+ return;
67
+ const nextIndex = historyIndex + 1;
68
+ if (nextIndex >= history.length) {
69
+ setHistoryIndex(-1);
70
+ setLine('');
71
+ return;
72
+ }
73
+ setHistoryIndex(nextIndex);
74
+ setLine(history[nextIndex] ?? '');
75
+ return;
76
+ }
77
+ if (key.backspace || key.delete) {
78
+ setLine((prev) => prev.slice(0, -1));
79
+ return;
80
+ }
81
+ if (input && !key.meta && !key.ctrl) {
82
+ // Ink's typing surface delivers one or more characters per
83
+ // event; concatenate without filtering so non-Latin and emoji
84
+ // sequences (the operator's own brief copy) survive paste.
85
+ setLine((prev) => prev + input);
86
+ }
87
+ });
88
+ const palette = line.startsWith('/') ? matchSlashPrefix(line) : [];
89
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: '› ' }), _jsx(Text, { children: line }), _jsx(Text, { color: "cyan", children: '_' })] }), palette.length > 0 ? (_jsx(Box, { flexDirection: "column", marginTop: 0, children: palette.map((row) => (_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: ` /${row.name}${row.args ? ` ${row.args}` : ''}`.padEnd(22, ' ') }), _jsx(Text, { dimColor: true, children: row.gloss })] }, row.name))) })) : null, _jsx(Box, { children: _jsx(Text, { dimColor: true, children: '↑/↓ history · / commands · Enter brief · Esc cancel · Ctrl+C ×2 exit' }) })] }));
90
+ }
91
+ //# sourceMappingURL=input-box.js.map
@@ -0,0 +1,69 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState } from 'react';
3
+ import { Box, Text, useInput } from 'ink';
4
+ const ITEMS = [
5
+ {
6
+ provider: 'device',
7
+ title: 'Browser OAuth',
8
+ hint: 'Opens app.pugi.io in your browser to approve this device',
9
+ },
10
+ {
11
+ provider: 'token',
12
+ title: 'API key',
13
+ hint: 'Paste a personal access token (pugi.io/settings/api-keys)',
14
+ },
15
+ {
16
+ provider: 'env',
17
+ title: 'Environment variable',
18
+ hint: 'Use PUGI_API_KEY from the current shell',
19
+ },
20
+ ];
21
+ /**
22
+ * Arrow-key login picker. Driven entirely by Ink's `useInput`. The
23
+ * component does NOT perform the OAuth flow itself, it only resolves
24
+ * the selected provider back to the caller (CLI dispatcher), which
25
+ * then unmounts Ink and hands control to the existing handlers in
26
+ * runtime/cli.ts.
27
+ */
28
+ export function LoginPicker(props) {
29
+ const [index, setIndex] = useState(Math.min(Math.max(props.initialIndex ?? 0, 0), ITEMS.length - 1));
30
+ useInput((input, key) => {
31
+ if (key.upArrow || input === 'k') {
32
+ setIndex((current) => (current === 0 ? ITEMS.length - 1 : current - 1));
33
+ return;
34
+ }
35
+ if (key.downArrow || input === 'j') {
36
+ setIndex((current) => (current === ITEMS.length - 1 ? 0 : current + 1));
37
+ return;
38
+ }
39
+ if (key.return) {
40
+ const selected = ITEMS[index];
41
+ if (selected)
42
+ props.onSelect(selected.provider);
43
+ return;
44
+ }
45
+ if (key.escape || input === 'q') {
46
+ props.onCancel();
47
+ return;
48
+ }
49
+ // Number shortcuts mirror the legacy text picker for muscle memory.
50
+ if (input === '1')
51
+ props.onSelect('device');
52
+ if (input === '2')
53
+ props.onSelect('token');
54
+ if (input === '3')
55
+ props.onSelect('env');
56
+ });
57
+ return (_jsxs(Box, { flexDirection: "column", paddingX: 2, paddingY: 1, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, children: "Sign in to Pugi" }), _jsx(Text, { dimColor: true, children: ` (endpoint: ${props.apiUrl})` })] }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: ITEMS.map((item, itemIndex) => {
58
+ const isSelected = itemIndex === index;
59
+ return (_jsx(PickerRow, { isSelected: isSelected, title: item.title, hint: item.hint }, item.provider));
60
+ }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: '↑/↓ select Enter confirm Esc cancel' }) })] }));
61
+ }
62
+ function PickerRow({ isSelected, title, hint, }) {
63
+ // Arrow glyph + padded title so highlighted and dim rows share
64
+ // column alignment.
65
+ const indicator = isSelected ? '▸ ' : ' ';
66
+ const padded = title.padEnd(22, ' ');
67
+ return (_jsxs(Text, { children: [_jsxs(Text, { color: isSelected ? 'cyan' : undefined, bold: isSelected, children: [indicator, padded] }), _jsx(Text, { dimColor: true, children: hint })] }));
68
+ }
69
+ //# sourceMappingURL=login-picker.js.map
@@ -0,0 +1,68 @@
1
+ import React from 'react';
2
+ import { render } from 'ink';
3
+ import { LoginPicker } from './login-picker.js';
4
+ import { Splash } from './splash.js';
5
+ import { collectSplashData } from './splash-data.js';
6
+ /**
7
+ * Mount `<Splash />` on a TTY, await unmount, return. The CLI
8
+ * dispatcher only reaches this entry point when `isInteractive()`
9
+ * already cleared.
10
+ */
11
+ export async function renderSplash(cliVersion) {
12
+ const data = collectSplashData({ cliVersion });
13
+ const instance = render(React.createElement(Splash, { data }));
14
+ // The splash is static: nothing reads input, nothing animates. Defer
15
+ // the unmount one macrotask so Ink's log-update flush, Yoga layout
16
+ // settle, and the initial process.stdout.write all complete before
17
+ // we tear the runtime down. Without setImmediate, some terminals
18
+ // batch writes and lose the splash frame entirely on fast exit.
19
+ await new Promise((resolve) => setImmediate(resolve));
20
+ instance.unmount();
21
+ await instance.waitUntilExit();
22
+ }
23
+ /**
24
+ * Sentinel thrown when the user dismisses the login picker via Esc
25
+ * or `q`. The CLI dispatcher catches it, prints a one-line abort
26
+ * message, and exits 130 (the standard exit code for SIGINT-style
27
+ * user cancellations — matches gh CLI, codex, claude-code).
28
+ */
29
+ export class LoginCancelledError extends Error {
30
+ constructor() {
31
+ super('Login cancelled');
32
+ this.name = 'LoginCancelledError';
33
+ }
34
+ }
35
+ /**
36
+ * Mount `<LoginPicker />`, resolve to the chosen provider. Rejects
37
+ * with `LoginCancelledError` when the user cancels.
38
+ *
39
+ * After selection we call `unmount()` and resolve on the next
40
+ * macrotask. We deliberately do NOT chain `waitUntilExit()` — under
41
+ * some shimmed-stdin scenarios (CI test harnesses) Ink's
42
+ * `waitUntilExit` never settles because its raw-mode-release path
43
+ * waits on a stdin event that never fires. The next handler in the
44
+ * CLI dispatcher restores stdin state on its own (the device flow
45
+ * forks a browser, the token path attaches a fresh data listener).
46
+ */
47
+ export function renderLoginPicker(apiUrl) {
48
+ return new Promise((resolveProvider, rejectProvider) => {
49
+ let settled = false;
50
+ const finish = (cb) => {
51
+ if (settled)
52
+ return;
53
+ settled = true;
54
+ instance.unmount();
55
+ setImmediate(cb);
56
+ };
57
+ const instance = render(React.createElement(LoginPicker, {
58
+ apiUrl,
59
+ onSelect: (provider) => {
60
+ finish(() => resolveProvider(provider));
61
+ },
62
+ onCancel: () => {
63
+ finish(() => rejectProvider(new LoginCancelledError()));
64
+ },
65
+ }));
66
+ });
67
+ }
68
+ //# sourceMappingURL=render.js.map
@@ -0,0 +1,218 @@
1
+ /**
2
+ * Production REPL mount + transport - Sprint α5.7.
3
+ *
4
+ * Owns the Ink mount lifecycle for `<Repl />` and wires the real
5
+ * fetch + SSE transport. The CLI dispatcher in `runtime/cli.ts` calls
6
+ * `renderRepl` on the bare-`pugi` path when stdin / stdout are TTYs.
7
+ *
8
+ * The transport speaks to admin-api:
9
+ * POST /api/pugi/sessions → { sessionId }
10
+ * POST /api/pugi/sessions/:id/brief → { dispatchId }
11
+ * POST /api/pugi/sessions/:id/stop → { stopped }
12
+ * GET /api/pugi/sessions/:id/stream → text/event-stream
13
+ *
14
+ * SSE is parsed client-side from a streaming fetch response - Node 22
15
+ * native fetch returns a WHATWG `ReadableStream` which we feed through
16
+ * a tiny `event:`/`data:`/`id:` parser. This keeps the dependency
17
+ * graph at zero new packages.
18
+ */
19
+ import React from 'react';
20
+ import { render } from 'ink';
21
+ import { Repl } from './repl.js';
22
+ import { ReplSession } from '../core/repl/session.js';
23
+ /**
24
+ * Mount the REPL and resolve when the user exits via Ctrl+C × 2 or
25
+ * `/quit`. The session is closed (server-side stays alive; resume via
26
+ * `pugi resume <sessionId>` once that command exists).
27
+ */
28
+ export async function renderRepl(options) {
29
+ const transport = createProductionTransport();
30
+ const session = new ReplSession({
31
+ apiUrl: options.apiUrl,
32
+ apiKey: options.apiKey,
33
+ workspaceLabel: options.workspaceLabel,
34
+ cliVersion: options.cliVersion,
35
+ transport,
36
+ });
37
+ // Kick off the connect; the Repl renders the connecting state until
38
+ // the session pushes `connection: 'on_watch'` from the SSE onOpen.
39
+ void session.start();
40
+ const instance = render(React.createElement(Repl, { session }));
41
+ try {
42
+ await instance.waitUntilExit();
43
+ }
44
+ finally {
45
+ session.close();
46
+ }
47
+ }
48
+ /* ------------------------------------------------------------------ */
49
+ /* Production transport */
50
+ /* ------------------------------------------------------------------ */
51
+ function createProductionTransport() {
52
+ return {
53
+ async createSession({ apiUrl, apiKey }) {
54
+ const response = await fetch(joinUrl(apiUrl, '/api/pugi/sessions'), {
55
+ method: 'POST',
56
+ headers: jsonHeaders(apiKey),
57
+ body: JSON.stringify({}),
58
+ });
59
+ const json = await readJson(response);
60
+ const sessionId = json.sessionId;
61
+ if (typeof sessionId !== 'string' || sessionId.length === 0) {
62
+ throw new Error('admin-api did not return a sessionId');
63
+ }
64
+ return { sessionId };
65
+ },
66
+ async postBrief({ apiUrl, apiKey, sessionId, brief }) {
67
+ const response = await fetch(joinUrl(apiUrl, `/api/pugi/sessions/${encodeURIComponent(sessionId)}/brief`), {
68
+ method: 'POST',
69
+ headers: jsonHeaders(apiKey),
70
+ body: JSON.stringify({ brief }),
71
+ });
72
+ const json = await readJson(response);
73
+ const dispatchId = json.dispatchId;
74
+ if (typeof dispatchId !== 'string' || dispatchId.length === 0) {
75
+ throw new Error('admin-api did not return a dispatchId');
76
+ }
77
+ return { dispatchId };
78
+ },
79
+ async postStop({ apiUrl, apiKey, sessionId, persona }) {
80
+ const response = await fetch(joinUrl(apiUrl, `/api/pugi/sessions/${encodeURIComponent(sessionId)}/stop`), {
81
+ method: 'POST',
82
+ headers: jsonHeaders(apiKey),
83
+ body: JSON.stringify({ persona }),
84
+ });
85
+ const json = await readJson(response);
86
+ const stopped = Boolean(json.stopped);
87
+ return { stopped };
88
+ },
89
+ subscribe({ apiUrl, apiKey, sessionId, lastEventId, onEvent, onError, onOpen }) {
90
+ const controller = new AbortController();
91
+ const url = joinUrl(apiUrl, `/api/pugi/sessions/${encodeURIComponent(sessionId)}/stream`);
92
+ const headers = {
93
+ Accept: 'text/event-stream',
94
+ Authorization: `Bearer ${apiKey}`,
95
+ };
96
+ if (lastEventId) {
97
+ headers['Last-Event-ID'] = lastEventId;
98
+ }
99
+ void (async () => {
100
+ try {
101
+ const response = await fetch(url, {
102
+ method: 'GET',
103
+ headers,
104
+ signal: controller.signal,
105
+ });
106
+ if (!response.ok) {
107
+ throw new Error(`HTTP ${response.status} on SSE stream`);
108
+ }
109
+ if (!response.body) {
110
+ throw new Error('SSE response has no body');
111
+ }
112
+ onOpen();
113
+ await consumeSseStream(response.body, onEvent);
114
+ // Server closed the stream cleanly. Treat as an error so
115
+ // the session reconnects (the spec says "transient
116
+ // disconnect" - a clean close from the server side is also
117
+ // transient because the operator may have toggled wifi).
118
+ onError(new Error('SSE stream ended'));
119
+ }
120
+ catch (error) {
121
+ if (controller.signal.aborted)
122
+ return;
123
+ onError(error instanceof Error ? error : new Error(String(error)));
124
+ }
125
+ })();
126
+ return {
127
+ close: () => controller.abort(),
128
+ };
129
+ },
130
+ };
131
+ }
132
+ /* ------------------------------------------------------------------ */
133
+ /* SSE parser */
134
+ /* ------------------------------------------------------------------ */
135
+ /**
136
+ * Minimal SSE parser. Reads a UTF-8 stream of `id:` / `event:` / `data:`
137
+ * lines separated by blank lines. We only need the `data` payload (a
138
+ * JSON object) and the `id` field (so we can replay on reconnect via
139
+ * Last-Event-ID).
140
+ */
141
+ async function consumeSseStream(body, onEvent) {
142
+ const reader = body.getReader();
143
+ const decoder = new TextDecoder('utf-8');
144
+ let buffer = '';
145
+ let currentId = '';
146
+ let currentData = '';
147
+ while (true) {
148
+ const { value, done } = await reader.read();
149
+ if (done)
150
+ break;
151
+ buffer += decoder.decode(value, { stream: true });
152
+ let newlineIndex;
153
+ while ((newlineIndex = buffer.indexOf('\n')) !== -1) {
154
+ const rawLine = buffer.slice(0, newlineIndex).replace(/\r$/, '');
155
+ buffer = buffer.slice(newlineIndex + 1);
156
+ if (rawLine.length === 0) {
157
+ // Dispatch - only if we have a data payload.
158
+ if (currentData.length > 0) {
159
+ try {
160
+ const parsed = JSON.parse(currentData);
161
+ onEvent(parsed, currentId);
162
+ }
163
+ catch {
164
+ // Drop malformed frames silently - protocol-level
165
+ // robustness is the controller's job, not the client's.
166
+ }
167
+ }
168
+ currentData = '';
169
+ currentId = '';
170
+ continue;
171
+ }
172
+ if (rawLine.startsWith(':'))
173
+ continue; // Comment / keepalive.
174
+ const colonIndex = rawLine.indexOf(':');
175
+ const field = colonIndex === -1 ? rawLine : rawLine.slice(0, colonIndex);
176
+ const value = colonIndex === -1 ? '' : rawLine.slice(colonIndex + 1).replace(/^ /, '');
177
+ switch (field) {
178
+ case 'id':
179
+ currentId = value;
180
+ break;
181
+ case 'data':
182
+ currentData = currentData.length === 0 ? value : `${currentData}\n${value}`;
183
+ break;
184
+ case 'event':
185
+ case 'retry':
186
+ default:
187
+ // We do not surface the `event:` name to the consumer - the
188
+ // payload itself carries `type`. Future events that need
189
+ // dispatcher-side routing without parsing JSON can wire
190
+ // through this branch.
191
+ break;
192
+ }
193
+ }
194
+ }
195
+ }
196
+ /* ------------------------------------------------------------------ */
197
+ /* Small helpers */
198
+ /* ------------------------------------------------------------------ */
199
+ function jsonHeaders(apiKey) {
200
+ return {
201
+ 'Content-Type': 'application/json',
202
+ Accept: 'application/json',
203
+ Authorization: `Bearer ${apiKey}`,
204
+ };
205
+ }
206
+ async function readJson(response) {
207
+ if (!response.ok) {
208
+ const detail = await response.text().catch(() => '');
209
+ throw new Error(`HTTP ${response.status}${detail ? `: ${detail.slice(0, 200)}` : ''}`);
210
+ }
211
+ return response.json();
212
+ }
213
+ function joinUrl(base, path) {
214
+ const trimmedBase = base.endsWith('/') ? base.slice(0, -1) : base;
215
+ const trimmedPath = path.startsWith('/') ? path : `/${path}`;
216
+ return `${trimmedBase}${trimmedPath}`;
217
+ }
218
+ //# sourceMappingURL=repl-render.js.map