s-app-scoder-agent 0.4.0 → 0.6.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/README.md +127 -28
- package/dist/agent.js +75 -5
- package/dist/build.js +3 -2
- package/dist/index.js +171 -1
- package/dist/llm.js +25 -2
- package/dist/server.js +256 -0
- package/dist/tools.js +62 -11
- package/dist/tui/app.js +315 -0
- package/dist/tui/commands.js +24 -0
- package/dist/tui/state.js +166 -0
- package/dist/tui-direct.mjs +4 -0
- package/dist/ui.js +116 -10
- package/dist/webfetch.js +251 -0
- package/package.json +6 -1
package/dist/ui.js
CHANGED
|
@@ -1,57 +1,110 @@
|
|
|
1
1
|
import process from "node:process";
|
|
2
|
-
|
|
3
|
-
const
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
2
|
+
// Honor NO_COLOR and non-TTY output: drop ANSI codes so logs/CI stay clean.
|
|
3
|
+
const COLOR = process.env.NO_COLOR === undefined && process.stdout.isTTY === true;
|
|
4
|
+
// When a full-screen TUI is active (scoder tui), the Agent's console output
|
|
5
|
+
// would corrupt Ink's alt-screen rendering. The TUI renders from AgentEvents
|
|
6
|
+
// instead; setting QUIET suppresses every direct ui.* write.
|
|
7
|
+
let QUIET = false;
|
|
8
|
+
/** Suppress all direct console writes from ui.* (used by the Ink TUI frontend). */
|
|
9
|
+
export function setQuiet(quiet) {
|
|
10
|
+
QUIET = quiet;
|
|
11
|
+
}
|
|
12
|
+
const RESET = COLOR ? "\x1b[0m" : "";
|
|
13
|
+
const BOLD = COLOR ? "\x1b[1m" : "";
|
|
14
|
+
const DIM = COLOR ? "\x1b[2m" : "";
|
|
15
|
+
const GREEN = COLOR ? "\x1b[32m" : "";
|
|
16
|
+
const YELLOW = COLOR ? "\x1b[33m" : "";
|
|
17
|
+
const RED = COLOR ? "\x1b[31m" : "";
|
|
18
|
+
const CYAN = COLOR ? "\x1b[36m" : "";
|
|
19
|
+
const BLUE = COLOR ? "\x1b[34m" : "";
|
|
10
20
|
function ts() {
|
|
11
21
|
return new Date().toISOString().slice(11, 23);
|
|
12
22
|
}
|
|
13
23
|
export function info(msg) {
|
|
24
|
+
if (QUIET)
|
|
25
|
+
return;
|
|
14
26
|
console.log(`${DIM}[${ts()}]${RESET} ${msg}`);
|
|
15
27
|
}
|
|
16
28
|
export function step(stepNum, total, msg) {
|
|
17
|
-
|
|
29
|
+
if (QUIET)
|
|
30
|
+
return;
|
|
31
|
+
console.log(`${BLUE}[${String(stepNum).padStart(2)}/${total}]${RESET} ${BOLD}▸ ${msg}${RESET}`);
|
|
18
32
|
}
|
|
19
33
|
export function success(msg) {
|
|
34
|
+
if (QUIET)
|
|
35
|
+
return;
|
|
20
36
|
console.log(`${GREEN}✓${RESET} ${GREEN}${msg}${RESET}`);
|
|
21
37
|
}
|
|
22
38
|
export function warn(msg) {
|
|
39
|
+
if (QUIET)
|
|
40
|
+
return;
|
|
23
41
|
console.log(`${YELLOW}⚠${RESET} ${YELLOW}${msg}${RESET}`);
|
|
24
42
|
}
|
|
25
43
|
export function error(msg) {
|
|
44
|
+
if (QUIET)
|
|
45
|
+
return;
|
|
26
46
|
console.error(`${RED}✗${RESET} ${RED}${msg}${RESET}`);
|
|
27
47
|
}
|
|
28
48
|
export function llm(prefix, content) {
|
|
49
|
+
if (QUIET)
|
|
50
|
+
return;
|
|
29
51
|
console.log(`${CYAN}${prefix}:${RESET} ${content.slice(0, 300)}${content.length > 300 ? " ..." : ""}`);
|
|
30
52
|
}
|
|
31
53
|
export function toolCall(name, args) {
|
|
54
|
+
if (QUIET)
|
|
55
|
+
return;
|
|
32
56
|
const a = Object.entries(args)
|
|
33
57
|
.map(([k, v]) => `${k}=${JSON.stringify(v)?.slice(0, 80)}`)
|
|
34
58
|
.join(" ");
|
|
35
59
|
console.log(`${CYAN}→ ${name}(${a})${RESET}`);
|
|
36
60
|
}
|
|
37
61
|
export function toolResult(name, ok, output) {
|
|
62
|
+
if (QUIET)
|
|
63
|
+
return;
|
|
38
64
|
const preview = output.split("\n")[0]?.slice(0, 120) ?? "";
|
|
39
65
|
const icon = ok ? `${GREEN}✓${RESET}` : `${RED}✗${RESET}`;
|
|
40
|
-
console.log(`${icon} ${CYAN}${name}${RESET}
|
|
66
|
+
console.log(`${icon} ${CYAN}${name}${RESET} ${DIM}→${RESET} ${preview}`);
|
|
41
67
|
}
|
|
42
68
|
export function buildStatus(success, output) {
|
|
69
|
+
if (QUIET)
|
|
70
|
+
return;
|
|
43
71
|
const icon = success ? `${GREEN}✓ BUILD SUCCESS` : `${RED}✗ BUILD FAILED`;
|
|
44
72
|
console.log(`\n${icon}${RESET}\n${output.split("\n").slice(-10).join("\n")}\n`);
|
|
45
73
|
}
|
|
46
74
|
export function header(msg) {
|
|
75
|
+
if (QUIET)
|
|
76
|
+
return;
|
|
47
77
|
console.log(`\n${BOLD}${CYAN}── ${msg} ${"─".repeat(Math.max(2, 60 - msg.length))}${RESET}`);
|
|
48
78
|
}
|
|
79
|
+
/** Startup banner: a clean key/value box (plain-text fallback when piped). */
|
|
80
|
+
export function banner(title, rows) {
|
|
81
|
+
if (QUIET)
|
|
82
|
+
return;
|
|
83
|
+
const keyWidth = Math.max(8, ...rows.map(([k]) => k.length));
|
|
84
|
+
const inner = Math.max(title.length + 4, ...rows.map(([, v]) => keyWidth + Math.max(...String(v).split("\n").map((l) => l.length)) + 1));
|
|
85
|
+
console.log(`${BOLD}${CYAN}╭─ ${title} ${"─".repeat(Math.max(1, inner - title.length - 3))}╮${RESET}`);
|
|
86
|
+
for (const [key, value] of rows) {
|
|
87
|
+
const lines = String(value).split("\n");
|
|
88
|
+
for (let i = 0; i < lines.length; i++) {
|
|
89
|
+
const k = (i === 0 ? key : "").padEnd(keyWidth);
|
|
90
|
+
let v = lines[i];
|
|
91
|
+
const maxV = inner - keyWidth - 1;
|
|
92
|
+
if (v.length > maxV)
|
|
93
|
+
v = v.slice(0, maxV - 1) + "…";
|
|
94
|
+
const pad = Math.max(0, inner - 1 - keyWidth - v.length);
|
|
95
|
+
console.log(`${BOLD}${CYAN}│${RESET} ${DIM}${k}${RESET}${v}${" ".repeat(pad)}${BOLD}${CYAN}│${RESET}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
console.log(`${BOLD}${CYAN}╰${"─".repeat(inner)}╯${RESET}`);
|
|
99
|
+
}
|
|
49
100
|
// ── Streaming helpers ────────────────────────────────────────────────────────
|
|
50
101
|
let _streamLine = "";
|
|
51
102
|
let _streamColor = CYAN;
|
|
52
103
|
let _streamPrefix = "";
|
|
53
104
|
/** Start a streaming line. Call llmStreamChunk() for each delta, then llmStreamEnd(). */
|
|
54
105
|
export function llmStreamStart(prefix = "assistant", color = CYAN) {
|
|
106
|
+
if (QUIET)
|
|
107
|
+
return;
|
|
55
108
|
_streamLine = "";
|
|
56
109
|
_streamColor = color;
|
|
57
110
|
_streamPrefix = prefix;
|
|
@@ -59,19 +112,70 @@ export function llmStreamStart(prefix = "assistant", color = CYAN) {
|
|
|
59
112
|
}
|
|
60
113
|
/** Append a text delta to the current streaming line. */
|
|
61
114
|
export function llmStreamChunk(delta) {
|
|
115
|
+
if (QUIET)
|
|
116
|
+
return;
|
|
62
117
|
_streamLine += delta;
|
|
63
118
|
process.stdout.write(delta);
|
|
64
119
|
}
|
|
65
120
|
/** Finish the streaming line (append suffix + newline). */
|
|
66
121
|
export function llmStreamEnd(suffix = "") {
|
|
122
|
+
if (QUIET)
|
|
123
|
+
return "";
|
|
67
124
|
const line = _streamLine;
|
|
68
125
|
_streamLine = "";
|
|
69
126
|
process.stdout.write(`${suffix}\n`);
|
|
70
127
|
return line;
|
|
71
128
|
}
|
|
129
|
+
// ── Tool output streaming (live run_command output) ─────────────────────────
|
|
130
|
+
const TOOL_STREAM_MAX_CHARS = 20_000;
|
|
131
|
+
let _toolStreamOpen = false;
|
|
132
|
+
let _toolStreamLineOpen = false; // last chunk did not end with "\n"
|
|
133
|
+
let _toolStreamChars = 0;
|
|
134
|
+
/** Begin a live tool-output block. Output is printed dimmed, capped at 20k chars. */
|
|
135
|
+
export function toolStreamStart() {
|
|
136
|
+
if (QUIET || _toolStreamOpen)
|
|
137
|
+
return;
|
|
138
|
+
_toolStreamOpen = true;
|
|
139
|
+
_toolStreamLineOpen = false;
|
|
140
|
+
_toolStreamChars = 0;
|
|
141
|
+
process.stdout.write(`${DIM}┌ output${RESET}`);
|
|
142
|
+
}
|
|
143
|
+
/** Append a tool-output delta to the live block. */
|
|
144
|
+
export function toolStreamChunk(delta) {
|
|
145
|
+
if (QUIET || !_toolStreamOpen)
|
|
146
|
+
return;
|
|
147
|
+
if (_toolStreamChars >= TOOL_STREAM_MAX_CHARS)
|
|
148
|
+
return;
|
|
149
|
+
_toolStreamChars += delta.length;
|
|
150
|
+
if (_toolStreamChars >= TOOL_STREAM_MAX_CHARS) {
|
|
151
|
+
// Include the tail of this chunk up to the cap, then stop rendering live.
|
|
152
|
+
const keep = delta.slice(0, Math.max(0, delta.length - (_toolStreamChars - TOOL_STREAM_MAX_CHARS)));
|
|
153
|
+
if (keep.length > 0) {
|
|
154
|
+
process.stdout.write(`${DIM}${keep}${RESET}`);
|
|
155
|
+
_toolStreamLineOpen = !keep.endsWith("\n");
|
|
156
|
+
}
|
|
157
|
+
process.stdout.write(`\n${DIM}│ (live output truncated at ${TOOL_STREAM_MAX_CHARS} chars — full output in the result)${RESET}`);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
process.stdout.write(`\n${DIM}│ ${delta.replace(/\n/g, `\n${DIM}│ ${RESET}`)}${RESET}`);
|
|
161
|
+
_toolStreamLineOpen = !delta.endsWith("\n");
|
|
162
|
+
}
|
|
163
|
+
/** Finish the live tool-output block (closes the partial last line). */
|
|
164
|
+
export function toolStreamEnd() {
|
|
165
|
+
if (QUIET || !_toolStreamOpen)
|
|
166
|
+
return;
|
|
167
|
+
_toolStreamOpen = false;
|
|
168
|
+
process.stdout.write(_toolStreamLineOpen ? `\n${DIM}└${RESET}\n` : `${DIM}└${RESET}\n`);
|
|
169
|
+
}
|
|
170
|
+
/** True while a live tool-output block is open. */
|
|
171
|
+
export function isToolStreamOpen() {
|
|
172
|
+
return _toolStreamOpen;
|
|
173
|
+
}
|
|
72
174
|
// ── Diff rendering ───────────────────────────────────────────────────────────
|
|
73
175
|
/** Print a unified diff with colors (+ green, - red, @@ cyan). */
|
|
74
176
|
export function printDiff(diffText) {
|
|
177
|
+
if (QUIET)
|
|
178
|
+
return;
|
|
75
179
|
for (const line of diffText.split("\n")) {
|
|
76
180
|
if (line.startsWith("+++") || line.startsWith("---")) {
|
|
77
181
|
console.log(`${BOLD}${line}${RESET}`);
|
|
@@ -92,5 +196,7 @@ export function printDiff(diffText) {
|
|
|
92
196
|
}
|
|
93
197
|
/** Print a one-line diff summary: "✎ +12 -3 lines → src/foo.ts". */
|
|
94
198
|
export function diffSummary(path, added, removed) {
|
|
199
|
+
if (QUIET)
|
|
200
|
+
return;
|
|
95
201
|
console.log(`${CYAN}✎${RESET} ${BOLD}${path}${RESET} ${GREEN}+${added}${RESET} ${RED}-${removed}${RESET} lines`);
|
|
96
202
|
}
|
package/dist/webfetch.js
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { isIP } from "node:net";
|
|
2
|
+
import { lookup } from "node:dns/promises";
|
|
3
|
+
/**
|
|
4
|
+
* web_fetch — read-only HTTP(S) fetch with SSRF protection.
|
|
5
|
+
*
|
|
6
|
+
* - Only http/https; follows up to 5 redirects, validating every hop.
|
|
7
|
+
* - Private/loopback/link-local addresses are blocked by default
|
|
8
|
+
* (env SCODER_WEB_ALLOW_PRIVATE=1 lifts the block for local dev/testing).
|
|
9
|
+
* - HTML is converted to plain text; JSON is pretty-printed; binary is refused.
|
|
10
|
+
* - Output is capped so a huge page cannot flood the LLM context.
|
|
11
|
+
*/
|
|
12
|
+
const FETCH_TIMEOUT_MS = 30_000;
|
|
13
|
+
const MAX_BODY_BYTES = 512 * 1024;
|
|
14
|
+
const MAX_REDIRECTS = 5;
|
|
15
|
+
const DEFAULT_MAX_CHARS = 20_000;
|
|
16
|
+
function allowPrivate() {
|
|
17
|
+
return process.env.SCODER_WEB_ALLOW_PRIVATE === "1";
|
|
18
|
+
}
|
|
19
|
+
/** True if the address is private/loopback/link-local (IPv4 + IPv6). */
|
|
20
|
+
function isPrivateAddress(ip) {
|
|
21
|
+
const v = isIP(ip);
|
|
22
|
+
if (v === 4) {
|
|
23
|
+
const parts = ip.split(".").map(Number);
|
|
24
|
+
const a = parts[0];
|
|
25
|
+
const b = parts[1];
|
|
26
|
+
if (a === 10 || a === 127 || a === 0)
|
|
27
|
+
return true;
|
|
28
|
+
if (a === 172 && b >= 16 && b <= 31)
|
|
29
|
+
return true;
|
|
30
|
+
if (a === 192 && b === 168)
|
|
31
|
+
return true;
|
|
32
|
+
if (a === 169 && b === 254)
|
|
33
|
+
return true; // link-local
|
|
34
|
+
if (a === 100 && b >= 64 && b <= 127)
|
|
35
|
+
return true; // CGNAT
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
if (v === 6) {
|
|
39
|
+
const lower = ip.toLowerCase();
|
|
40
|
+
if (lower === "::1" || lower === "::")
|
|
41
|
+
return true;
|
|
42
|
+
if (lower.startsWith("fe80"))
|
|
43
|
+
return true; // link-local
|
|
44
|
+
if (/^f[cd][0-9a-f]{2}:/.test(lower))
|
|
45
|
+
return true; // unique local fc00::/7
|
|
46
|
+
// IPv4-mapped ::ffff:10.0.0.1
|
|
47
|
+
const mapped = lower.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
|
|
48
|
+
if (mapped)
|
|
49
|
+
return isPrivateAddress(mapped[1]);
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
return true; // not parseable -> treat as private (fail closed)
|
|
53
|
+
}
|
|
54
|
+
/** Resolve hostname and refuse private addresses (unless explicitly allowed). */
|
|
55
|
+
async function assertPublicHost(hostname) {
|
|
56
|
+
if (allowPrivate())
|
|
57
|
+
return;
|
|
58
|
+
let addresses;
|
|
59
|
+
try {
|
|
60
|
+
addresses = await lookup(hostname, { all: true, verbatim: true });
|
|
61
|
+
}
|
|
62
|
+
catch (err) {
|
|
63
|
+
throw new Error(`DNS lookup failed for "${hostname}": ${err.message}`, { cause: err });
|
|
64
|
+
}
|
|
65
|
+
if (addresses.length === 0) {
|
|
66
|
+
throw new Error(`DNS lookup returned no addresses for "${hostname}"`);
|
|
67
|
+
}
|
|
68
|
+
for (const addr of addresses) {
|
|
69
|
+
if (isPrivateAddress(addr.address)) {
|
|
70
|
+
throw new Error(`Blocked: "${hostname}" resolves to a private/loopback address (${addr.address}). ` +
|
|
71
|
+
`web_fetch only reaches public hosts (set SCODER_WEB_ALLOW_PRIVATE=1 to allow local testing).`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/** Decode the HTML entities that actually appear in practice. */
|
|
76
|
+
function decodeEntities(s) {
|
|
77
|
+
const named = {
|
|
78
|
+
amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " ", ndash: "–", mdash: "—",
|
|
79
|
+
hellip: "…", rsquo: "’", lsquo: "‘", rdquo: "”", ldquo: "“", copy: "©", reg: "®",
|
|
80
|
+
};
|
|
81
|
+
return s.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z]+);/g, (whole, body) => {
|
|
82
|
+
if (body.startsWith("#x") || body.startsWith("#X")) {
|
|
83
|
+
const code = parseInt(body.slice(2), 16);
|
|
84
|
+
return Number.isFinite(code) ? String.fromCodePoint(code) : whole;
|
|
85
|
+
}
|
|
86
|
+
if (body.startsWith("#")) {
|
|
87
|
+
const code = parseInt(body.slice(1), 10);
|
|
88
|
+
return Number.isFinite(code) ? String.fromCodePoint(code) : whole;
|
|
89
|
+
}
|
|
90
|
+
return named[body.toLowerCase()] ?? whole;
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
/** Convert HTML to readable plain text (scripts/styles stripped, block tags → newlines). */
|
|
94
|
+
export function htmlToText(html) {
|
|
95
|
+
const out = html
|
|
96
|
+
.replace(/<script[\s\S]*?<\/script\s*>/gi, " ")
|
|
97
|
+
.replace(/<style[\s\S]*?<\/style\s*>/gi, " ")
|
|
98
|
+
.replace(/<noscript[\s\S]*?<\/noscript\s*>/gi, " ")
|
|
99
|
+
.replace(/<!--[\s\S]*?-->/g, " ")
|
|
100
|
+
.replace(/<(br|\/p|\/div|\/section|\/article|\/header|\/footer|\/h[1-6]|\/li|\/tr|\/pre|\/blockquote)[^>]*>/gi, "\n")
|
|
101
|
+
.replace(/<[^>]+>/g, " ")
|
|
102
|
+
.replace(/[ \t]+/g, " ")
|
|
103
|
+
.replace(/ *\n */g, "\n")
|
|
104
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
105
|
+
.trim();
|
|
106
|
+
return decodeEntities(out);
|
|
107
|
+
}
|
|
108
|
+
/** Extract the <title> of an HTML document, if present. */
|
|
109
|
+
function extractTitle(html) {
|
|
110
|
+
const m = html.match(/<title[^>]*>([\s\S]*?)<\/title\s*>/i);
|
|
111
|
+
if (!m)
|
|
112
|
+
return null;
|
|
113
|
+
const t = decodeEntities(m[1]).replace(/\s+/g, " ").trim();
|
|
114
|
+
return t ? t.slice(0, 200) : null;
|
|
115
|
+
}
|
|
116
|
+
/** Read the response body as text, capped at MAX_BODY_BYTES, then cancel the stream. */
|
|
117
|
+
async function readBodyCapped(res) {
|
|
118
|
+
const reader = res.body?.getReader();
|
|
119
|
+
if (!reader)
|
|
120
|
+
return { text: "", truncated: false };
|
|
121
|
+
const decoder = new TextDecoder("utf-8");
|
|
122
|
+
let text = "";
|
|
123
|
+
let received = 0;
|
|
124
|
+
while (true) {
|
|
125
|
+
const { done, value } = await reader.read();
|
|
126
|
+
if (done)
|
|
127
|
+
break;
|
|
128
|
+
received += value.byteLength;
|
|
129
|
+
text += decoder.decode(value, { stream: true });
|
|
130
|
+
if (received >= MAX_BODY_BYTES) {
|
|
131
|
+
try {
|
|
132
|
+
await reader.cancel();
|
|
133
|
+
}
|
|
134
|
+
catch { /* already closed */ }
|
|
135
|
+
return { text: text + decoder.decode(), truncated: true };
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return { text: text + decoder.decode(), truncated: false };
|
|
139
|
+
}
|
|
140
|
+
/** Implementation of the web_fetch tool. Also used by tests via the registry. */
|
|
141
|
+
export async function toolWebFetch(ctx, args) {
|
|
142
|
+
const maxChars = Math.min(Math.max(args.max_chars ?? DEFAULT_MAX_CHARS, 500), 100_000);
|
|
143
|
+
let url;
|
|
144
|
+
try {
|
|
145
|
+
url = new URL(args.url);
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
return { ok: false, output: `ERROR: invalid URL: ${args.url}` };
|
|
149
|
+
}
|
|
150
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
151
|
+
return { ok: false, output: `ERROR: only http/https URLs are supported (got ${url.protocol})` };
|
|
152
|
+
}
|
|
153
|
+
// Combined timeout + external abort (SIGINT) — manual composition for Node 18 compat
|
|
154
|
+
const controller = new AbortController();
|
|
155
|
+
const timer = setTimeout(() => controller.abort(new Error(`timeout after ${FETCH_TIMEOUT_MS / 1000}s`)), FETCH_TIMEOUT_MS);
|
|
156
|
+
const onExternalAbort = () => controller.abort(new Error("interrupted by user"));
|
|
157
|
+
if (ctx.signal?.aborted)
|
|
158
|
+
onExternalAbort();
|
|
159
|
+
else
|
|
160
|
+
ctx.signal?.addEventListener("abort", onExternalAbort, { once: true });
|
|
161
|
+
const signal = controller.signal;
|
|
162
|
+
let current = url;
|
|
163
|
+
try {
|
|
164
|
+
// Redirect loop with per-hop SSRF validation
|
|
165
|
+
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
|
|
166
|
+
if (signal.aborted) {
|
|
167
|
+
return { ok: false, output: `ERROR: fetch aborted (${signal.reason?.message ?? "aborted"})` };
|
|
168
|
+
}
|
|
169
|
+
await assertPublicHost(current.hostname);
|
|
170
|
+
const res = await fetch(current, {
|
|
171
|
+
method: "GET",
|
|
172
|
+
redirect: "manual",
|
|
173
|
+
headers: {
|
|
174
|
+
"User-Agent": "scoder-agent (+https://github.com/nbhson/app-scoder-agent)",
|
|
175
|
+
Accept: "text/html, application/json, text/plain;q=0.9, */*;q=0.1",
|
|
176
|
+
},
|
|
177
|
+
signal,
|
|
178
|
+
});
|
|
179
|
+
// Follow redirects manually so every hop is validated against SSRF
|
|
180
|
+
if (res.status >= 300 && res.status < 400) {
|
|
181
|
+
const loc = res.headers.get("location");
|
|
182
|
+
try {
|
|
183
|
+
await res.body?.cancel();
|
|
184
|
+
}
|
|
185
|
+
catch { /* ignore */ }
|
|
186
|
+
if (!loc) {
|
|
187
|
+
return { ok: false, output: `ERROR: ${res.status} redirect without Location header at ${current.href}` };
|
|
188
|
+
}
|
|
189
|
+
if (hop === MAX_REDIRECTS) {
|
|
190
|
+
return { ok: false, output: `ERROR: too many redirects (>${MAX_REDIRECTS}) fetching ${args.url}` };
|
|
191
|
+
}
|
|
192
|
+
current = new URL(loc, current);
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
if (!res.ok) {
|
|
196
|
+
try {
|
|
197
|
+
await res.body?.cancel();
|
|
198
|
+
}
|
|
199
|
+
catch { /* ignore */ }
|
|
200
|
+
return { ok: false, output: `ERROR: HTTP ${res.status} ${res.statusText} for ${current.href}` };
|
|
201
|
+
}
|
|
202
|
+
const contentType = res.headers.get("content-type") ?? "";
|
|
203
|
+
if (/^(image|audio|video|font|application\/(octet-stream|zip|gzip|pdf|wasm))/i.test(contentType)) {
|
|
204
|
+
try {
|
|
205
|
+
await res.body?.cancel();
|
|
206
|
+
}
|
|
207
|
+
catch { /* ignore */ }
|
|
208
|
+
return { ok: false, output: `ERROR: unsupported content-type "${contentType}" — web_fetch returns text only (html/json/plain).` };
|
|
209
|
+
}
|
|
210
|
+
const { text: body, truncated } = await readBodyCapped(res);
|
|
211
|
+
let text;
|
|
212
|
+
if (/json/i.test(contentType)) {
|
|
213
|
+
text = prettyJson(body) ?? body;
|
|
214
|
+
}
|
|
215
|
+
else if (/html/i.test(contentType) || /^\s*</.test(body)) {
|
|
216
|
+
text = htmlToText(body);
|
|
217
|
+
}
|
|
218
|
+
else {
|
|
219
|
+
text = body;
|
|
220
|
+
}
|
|
221
|
+
const title = /text\/html/i.test(contentType) ? extractTitle(body) : null;
|
|
222
|
+
const meta = [
|
|
223
|
+
`HTTP ${res.status} ${res.statusText || ""}`.trimEnd(),
|
|
224
|
+
contentType || "unknown content-type",
|
|
225
|
+
truncated ? `body truncated at ${MAX_BODY_BYTES / 1024}KB` : null,
|
|
226
|
+
].filter(Boolean).join(", ");
|
|
227
|
+
const out = `${text.slice(0, maxChars)}${text.length > maxChars ? `\n... [truncated, ${text.length - maxChars} more chars]` : ""}`;
|
|
228
|
+
return {
|
|
229
|
+
ok: true,
|
|
230
|
+
output: `${title ? `Title: ${title}\n` : ""}${current.href}\n(${meta})\n\n${out}`,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
// unreachable
|
|
234
|
+
return { ok: false, output: "ERROR: redirect loop" };
|
|
235
|
+
}
|
|
236
|
+
catch (err) {
|
|
237
|
+
return { ok: false, output: `ERROR: fetch failed for ${current.href}: ${err.message}` };
|
|
238
|
+
}
|
|
239
|
+
finally {
|
|
240
|
+
clearTimeout(timer);
|
|
241
|
+
ctx.signal?.removeEventListener("abort", onExternalAbort);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
function prettyJson(body) {
|
|
245
|
+
try {
|
|
246
|
+
return JSON.stringify(JSON.parse(body), null, 2);
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
return undefined;
|
|
250
|
+
}
|
|
251
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "s-app-scoder-agent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Autonomous coding agent CLI - similar to opencode/cline/claude. Runs tasks until build succeeds.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -46,8 +46,13 @@
|
|
|
46
46
|
"devDependencies": {
|
|
47
47
|
"@eslint/js": "^10.0.1",
|
|
48
48
|
"@types/node": "^22.0.0",
|
|
49
|
+
"@types/react": "^18.3.31",
|
|
49
50
|
"eslint": "^10.13.0",
|
|
50
51
|
"typescript": "^5.6.0",
|
|
51
52
|
"typescript-eslint": "^8.72.1"
|
|
53
|
+
},
|
|
54
|
+
"dependencies": {
|
|
55
|
+
"ink": "^5.2.1",
|
|
56
|
+
"react": "^18.3.1"
|
|
52
57
|
}
|
|
53
58
|
}
|