@pugi/cli 0.1.0-alpha.3 → 0.1.0-alpha.5
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/README.md +20 -0
- package/dist/commands/jobs.js +245 -0
- package/dist/core/agents/registry.js +69 -0
- package/dist/core/bash-classifier.js +1001 -0
- package/dist/core/context/builder.js +114 -0
- package/dist/core/context/compaction-events.js +99 -0
- package/dist/core/context/compaction.js +602 -0
- package/dist/core/context/invariants.js +250 -0
- package/dist/core/context/markdown-loader.js +270 -0
- package/dist/core/engine/compaction-hook.js +154 -0
- package/dist/core/engine/index.js +5 -0
- package/dist/core/engine/prompts.js +42 -0
- package/dist/core/engine/tool-bridge.js +159 -61
- package/dist/core/hooks.js +415 -0
- package/dist/core/jobs/registry.js +462 -0
- package/dist/core/mcp/client.js +316 -0
- package/dist/core/mcp/registry.js +171 -0
- package/dist/core/mcp/trust.js +91 -0
- package/dist/core/permission.js +221 -116
- package/dist/core/repl/cap-warning.js +91 -0
- package/dist/core/repl/session.js +399 -0
- package/dist/core/repl/slash-commands.js +116 -0
- package/dist/core/session.js +168 -0
- package/dist/core/subagents/dispatcher.js +258 -0
- package/dist/core/subagents/index.js +26 -0
- package/dist/core/subagents/spawn.js +86 -0
- package/dist/core/trust.js +109 -0
- package/dist/runtime/cli.js +157 -45
- package/dist/runtime/commands/budget.js +192 -0
- package/dist/runtime/commands/config.js +231 -0
- package/dist/runtime/commands/privacy.js +107 -0
- package/dist/runtime/commands/undo.js +329 -0
- package/dist/tools/bash.js +660 -0
- package/dist/tui/agent-tree.js +66 -0
- package/dist/tui/conversation-pane.js +45 -0
- package/dist/tui/input-box.js +91 -0
- package/dist/tui/login-picker.js +69 -0
- package/dist/tui/render.js +68 -0
- package/dist/tui/repl-render.js +218 -0
- package/dist/tui/repl.js +152 -0
- package/dist/tui/splash-data.js +61 -0
- package/dist/tui/splash.js +31 -0
- package/dist/tui/status-bar.js +58 -0
- 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
|