s-app-scoder-agent 0.6.0 → 0.8.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/.scoderrc.example.json +97 -0
- package/LICENSE +21 -21
- package/README.md +600 -468
- package/dist/agent.js +841 -197
- package/dist/agents.js +70 -0
- package/dist/compaction.js +114 -0
- package/dist/config.js +178 -12
- package/dist/context.js +119 -14
- package/dist/cost.js +56 -6
- package/dist/guardrails.js +423 -0
- package/dist/index.js +129 -76
- package/dist/json-schema.js +58 -0
- package/dist/llm.js +123 -9
- package/dist/mcp.js +397 -70
- package/dist/model-catalog.js +175 -0
- package/dist/permissions.js +328 -35
- package/dist/prompt-builder.js +47 -0
- package/dist/prompt.js +103 -0
- package/dist/provider-anthropic.js +113 -35
- package/dist/provider-registry.js +116 -0
- package/dist/retry.js +3 -2
- package/dist/sandbox.js +96 -0
- package/dist/server.js +32 -2
- package/dist/session.js +282 -11
- package/dist/shell-scanner.js +112 -0
- package/dist/skills.js +122 -0
- package/dist/snapshot.js +170 -0
- package/dist/steering.js +35 -0
- package/dist/tools.js +513 -19
- package/dist/tui/app.js +505 -198
- package/dist/tui/at-complete.js +93 -0
- package/dist/tui/chat-views.js +146 -0
- package/dist/tui/chrome.js +96 -0
- package/dist/tui/command-registry.js +189 -0
- package/dist/tui/commands.js +200 -2
- package/dist/tui/image.js +149 -0
- package/dist/tui/input.js +214 -0
- package/dist/tui/markdown.js +229 -0
- package/dist/tui/mouse.js +20 -0
- package/dist/tui/permission-modal.js +34 -0
- package/dist/tui/review.js +64 -0
- package/dist/tui/session-picker.js +68 -0
- package/dist/tui/sidebar.js +99 -0
- package/dist/tui/state.js +192 -6
- package/dist/tui/theme.js +90 -0
- package/dist/tui/vim.js +60 -0
- package/dist/watch.js +52 -0
- package/package.json +16 -8
- package/dist/tui-direct.mjs +0 -4
package/dist/prompt.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* System prompt của Scoder — tách thành module riêng để:
|
|
3
|
+
* - versioning rõ ràng (PROMPT_VERSION được ghi vào session JSONL khi chạy),
|
|
4
|
+
* - test được độc lập (không cần instantiate Agent).
|
|
5
|
+
*
|
|
6
|
+
* V4 — conversational exits + no wasted builds:
|
|
7
|
+
* - restore the <<CHAT_DONE>> marker rule (V3 dropped it, so the chat-done
|
|
8
|
+
* guardrail could never fire and every Q&A turn was nudged into a build)
|
|
9
|
+
* - build only after file edits; never run the build when nothing changed
|
|
10
|
+
* - V3 baseline: stricter scope rules, clarification-before-action, safety
|
|
11
|
+
* boundaries, MCP/skills as passive listings only
|
|
12
|
+
*/
|
|
13
|
+
export const PROMPT_VERSION = 4;
|
|
14
|
+
export const SYSTEM_PROMPT = `You are Scoder, an autonomous coding agent running in a terminal.
|
|
15
|
+
You complete coding tasks end-to-end: exploring the code, editing files, and
|
|
16
|
+
verifying your work with the build command. You act; you don't just advise.
|
|
17
|
+
|
|
18
|
+
## How you work
|
|
19
|
+
|
|
20
|
+
1. ORIENT: Before touching ANY code, read the relevant files using
|
|
21
|
+
\`list_files\` and \`read_file\`. Never edit a file you haven't read.
|
|
22
|
+
2. PLAN: For any multi-step task, call \`write_todos\` with the full plan BEFORE
|
|
23
|
+
editing. Plans must be specific and actionable — not generic. Keep it live:
|
|
24
|
+
mark each item completed IMMEDIATELY after it is done (exactly one in_progress
|
|
25
|
+
at a time) and add newly discovered steps.
|
|
26
|
+
3. EDIT: Make small, focused changes. Match the file's existing style
|
|
27
|
+
(indentation, quote style, naming conventions) — do NOT reformat unrelated
|
|
28
|
+
parts. Never refactor unrelated code, never add speculative features.
|
|
29
|
+
4. VERIFY: If you changed files, run the build command. Read the FULL error
|
|
30
|
+
output before editing — fix the CAUSE, not the symptom. Errors can be
|
|
31
|
+
misleading (e.g. 'X not found' might be caused by a missing import upstream).
|
|
32
|
+
5. REPORT: Your final message states what changed, why, and anything left
|
|
33
|
+
unresolved. Be honest: if something didn't work, say exactly what failed.
|
|
34
|
+
|
|
35
|
+
## Tool selection
|
|
36
|
+
|
|
37
|
+
- **read_file**: Read file content WITH LINE NUMBERS. Use when you need to see
|
|
38
|
+
actual code to edit. Always use for any file before edit_file.
|
|
39
|
+
- **list_files**: List directory contents. Use to inspect project structure.
|
|
40
|
+
- **edit_file**: Targeted string replacement. old_string MUST appear EXACTLY once —
|
|
41
|
+
if not found, read the file again and verify your text.
|
|
42
|
+
- **write_file**: Create or overwrite. Parent dirs created automatically. Use for
|
|
43
|
+
new files or when write is cleaner than targeted edits.
|
|
44
|
+
- **run_command**: Run shell command. Captures stdout + stderr. THIS IS THE BUILD
|
|
45
|
+
COMMAND — run it only when you changed files; a coding task ends with
|
|
46
|
+
run_command(<buildCmd>).
|
|
47
|
+
- **write_todos**: Plan + live progress for multi-step work.
|
|
48
|
+
- **web_fetch**: Exact URLs only (it cannot search). Use for library/API docs,
|
|
49
|
+
versions, changelogs when the answer isn't in the working directory.
|
|
50
|
+
- **load_skill**: Only when the user explicitly requests it with "/skill <name>",
|
|
51
|
+
or when the task clearly matches a skill domain — do NOT load skills automatically.
|
|
52
|
+
- **MCP tools (mcp__<server>__<tool>)**: Only when the task explicitly requires
|
|
53
|
+
external integration — never call MCP tools just because they exist.
|
|
54
|
+
|
|
55
|
+
## Safety & Boundaries
|
|
56
|
+
|
|
57
|
+
- **NEVER** modify git hooks, CI configs, package.json scripts, or any automated
|
|
58
|
+
tooling unless explicitly asked.
|
|
59
|
+
- **NEVER** add new dependencies without explicit user approval.
|
|
60
|
+
- **NEVER** touch production infrastructure configs (nginx, docker-compose,
|
|
61
|
+
kubernetes, etc.) unless the task is about them.
|
|
62
|
+
- **NEVER** run destructive commands (rm -rf, --force, --rebase, etc.) without
|
|
63
|
+
explaining the risk to the user first.
|
|
64
|
+
|
|
65
|
+
## Scope & Clarification
|
|
66
|
+
|
|
67
|
+
1. Work within the EXACT scope of the request. Do NOT:
|
|
68
|
+
- Fix unrelated bugs you discover
|
|
69
|
+
- Refactor or reformat unrelated code
|
|
70
|
+
- Add features not asked for
|
|
71
|
+
- Improve code quality outside the task scope
|
|
72
|
+
2. If the request is ambiguous or the scope is unclear: ask ONE clarifying question
|
|
73
|
+
before taking action. Do NOT guess and proceed — clarify first.
|
|
74
|
+
3. When you believe the task is done:
|
|
75
|
+
- If you changed files, call run_command with the build command. The coding
|
|
76
|
+
loop ends only when the build succeeds.
|
|
77
|
+
- If you changed nothing (you answered a question or explained code), do NOT
|
|
78
|
+
run the build command — a build with no changes proves nothing and wastes
|
|
79
|
+
time.
|
|
80
|
+
4. CONVERSATIONAL INPUT: if the user's message is purely conversational — a
|
|
81
|
+
greeting, thanks, or a question needing no code changes and no shell
|
|
82
|
+
commands — do NOT use tools, do NOT edit files, and do NOT run a build.
|
|
83
|
+
Reply briefly in the user's language and end your reply with the exact marker
|
|
84
|
+
<<CHAT_DONE>> on its own last line.
|
|
85
|
+
5. If run_command is denied by permission policy, find a safer alternative or
|
|
86
|
+
report the limitation clearly. Do not retry the same denied command.
|
|
87
|
+
6. If an environmental problem makes the task impossible, stop and report it.
|
|
88
|
+
|
|
89
|
+
## Quality bar
|
|
90
|
+
|
|
91
|
+
- Reproduce a bug before fixing it (run the failing command, read the output).
|
|
92
|
+
- Prefer the smallest change that makes the build pass.
|
|
93
|
+
- Leave the code as you found it outside the scope of the task: no stray
|
|
94
|
+
debug prints, no leftover TODOs, no reformatting.
|
|
95
|
+
- If you add code, add only what's needed now; note follow-ups in your final
|
|
96
|
+
message instead of implementing them.`;
|
|
97
|
+
/** Ghép phần động (workdir/build/env/skills/MCP) vào system prompt gốc. */
|
|
98
|
+
export function buildSystemPrompt(parts) {
|
|
99
|
+
return (SYSTEM_PROMPT +
|
|
100
|
+
`\n\nWorking directory: ${parts.workdir}\nBuild command (stop condition): ${parts.buildCmd}\n${parts.envInfo}` +
|
|
101
|
+
parts.skillsBlock +
|
|
102
|
+
parts.mcpBlock);
|
|
103
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { LlmError, LLM_REQUEST_TIMEOUT_MS, combinedSignal } from "./llm.js";
|
|
1
|
+
import { LlmError, LLM_REQUEST_TIMEOUT_MS, combinedSignal, redactSecrets } from "./llm.js";
|
|
2
2
|
import { withRetry } from "./retry.js";
|
|
3
3
|
// ---------------------------------------------------------------------------
|
|
4
4
|
// Translation: OpenAI-style -> Anthropic
|
|
@@ -59,11 +59,15 @@ export function toAnthropicMessages(messages) {
|
|
|
59
59
|
// ---------------------------------------------------------------------------
|
|
60
60
|
export function fromAnthropicResponse(data) {
|
|
61
61
|
let text = "";
|
|
62
|
+
let thinking = "";
|
|
62
63
|
const toolCalls = [];
|
|
63
64
|
for (const block of data.content ?? []) {
|
|
64
65
|
if (block.type === "text") {
|
|
65
66
|
text = text ? `${text}\n${block.text ?? ""}` : (block.text ?? "");
|
|
66
67
|
}
|
|
68
|
+
else if (block.type === "thinking") {
|
|
69
|
+
thinking += block.thinking ?? "";
|
|
70
|
+
}
|
|
67
71
|
else if (block.type === "tool_use") {
|
|
68
72
|
toolCalls.push({
|
|
69
73
|
id: block.id ?? "",
|
|
@@ -83,6 +87,7 @@ export function fromAnthropicResponse(data) {
|
|
|
83
87
|
message: {
|
|
84
88
|
role: "assistant",
|
|
85
89
|
content: text.length > 0 ? text : null,
|
|
90
|
+
reasoning: thinking || undefined,
|
|
86
91
|
tool_calls: toolCalls.length > 0 ? toolCalls : undefined,
|
|
87
92
|
},
|
|
88
93
|
usage,
|
|
@@ -94,30 +99,80 @@ export function fromAnthropicResponse(data) {
|
|
|
94
99
|
const ANTHROPIC_VERSION = "2023-06-01";
|
|
95
100
|
export class AnthropicClient {
|
|
96
101
|
opts;
|
|
102
|
+
/** Last interrupted stream's accumulated tool calls (H-09 continuation). */
|
|
103
|
+
lastPartialToolCalls = [];
|
|
97
104
|
constructor(opts) {
|
|
98
105
|
this.opts = opts;
|
|
99
106
|
}
|
|
107
|
+
get timeoutMs() {
|
|
108
|
+
return this.opts.requestTimeoutMs ?? LLM_REQUEST_TIMEOUT_MS;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* I-04 prompt caching — Anthropic 4-block formula (≤4 cache breakpoints):
|
|
112
|
+
* tools (last block), system, and the last user message get
|
|
113
|
+
* `cache_control: {type: "ephemeral"}`. Applied only when enableCache.
|
|
114
|
+
*/
|
|
115
|
+
applyCacheControl(system, translated, tools) {
|
|
116
|
+
if (!this.opts.enableCache)
|
|
117
|
+
return { system, messages: translated, tools };
|
|
118
|
+
const CC = { cache_control: { type: "ephemeral" } };
|
|
119
|
+
// System: string → single block array with marker.
|
|
120
|
+
const sysOut = system
|
|
121
|
+
? [{ type: "text", text: system, ...CC }]
|
|
122
|
+
: undefined;
|
|
123
|
+
// Tools: last definition carries the marker.
|
|
124
|
+
const toolsOut = tools && tools.length > 0
|
|
125
|
+
? tools.map((t, i) => ({
|
|
126
|
+
name: t.function.name,
|
|
127
|
+
description: t.function.description,
|
|
128
|
+
input_schema: t.function.parameters,
|
|
129
|
+
...(i === tools.length - 1 ? CC : {}),
|
|
130
|
+
}))
|
|
131
|
+
: undefined;
|
|
132
|
+
// Last user message: annotate final content block (string → block array).
|
|
133
|
+
const messages = [...translated];
|
|
134
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
135
|
+
if (messages[i].role === "user") {
|
|
136
|
+
const c = messages[i].content;
|
|
137
|
+
if (typeof c === "string") {
|
|
138
|
+
messages[i] = { ...messages[i], content: [{ type: "text", text: c, ...CC }] };
|
|
139
|
+
}
|
|
140
|
+
else if (c.length > 0) {
|
|
141
|
+
messages[i] = { ...messages[i], content: [...c.slice(0, -1), { ...c[c.length - 1], ...CC }] };
|
|
142
|
+
}
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return { system: sysOut, messages, tools: toolsOut };
|
|
147
|
+
}
|
|
100
148
|
get model() {
|
|
101
149
|
return this.opts.model;
|
|
102
150
|
}
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
151
|
+
/** T-04: runtime hot-swap — next request uses the new model id. */
|
|
152
|
+
setModel(model) {
|
|
153
|
+
this.opts = { ...this.opts, model };
|
|
154
|
+
}
|
|
155
|
+
/** I-05: request body builder — shared by chat/streamChat. */
|
|
156
|
+
buildBody(system, messages, tools, stream) {
|
|
157
|
+
// Extended thinking: temperature must be 1 và thinking block format.
|
|
158
|
+
const thinkingOn = (this.opts.thinkingBudgetTokens ?? 0) > 0;
|
|
159
|
+
return JSON.stringify({
|
|
108
160
|
model: this.opts.model,
|
|
109
161
|
max_tokens: this.opts.maxTokens,
|
|
110
|
-
temperature: this.opts.temperature,
|
|
162
|
+
temperature: thinkingOn ? 1 : this.opts.temperature,
|
|
163
|
+
...(thinkingOn ? { thinking: { type: "enabled", budget_tokens: this.opts.thinkingBudgetTokens } } : {}),
|
|
111
164
|
system,
|
|
112
|
-
messages
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
name: t.function.name,
|
|
116
|
-
description: t.function.description,
|
|
117
|
-
input_schema: t.function.parameters,
|
|
118
|
-
}))
|
|
119
|
-
: undefined,
|
|
165
|
+
messages,
|
|
166
|
+
stream,
|
|
167
|
+
tools,
|
|
120
168
|
});
|
|
169
|
+
}
|
|
170
|
+
async chat(messages, tools, opts = {}) {
|
|
171
|
+
const url = `${this.opts.baseUrl.replace(/\/$/, "")}/messages`;
|
|
172
|
+
const { system, messages: translated } = toAnthropicMessages(messages);
|
|
173
|
+
const { signal, cleanup } = combinedSignal(opts.signal, this.timeoutMs);
|
|
174
|
+
const cached = this.applyCacheControl(system, translated, tools);
|
|
175
|
+
const body = this.buildBody(cached.system, cached.messages, cached.tools, false);
|
|
121
176
|
let res;
|
|
122
177
|
try {
|
|
123
178
|
res = await withRetry(async () => {
|
|
@@ -155,22 +210,9 @@ export class AnthropicClient {
|
|
|
155
210
|
async *streamChat(messages, tools, opts = {}) {
|
|
156
211
|
const url = `${this.opts.baseUrl.replace(/\/$/, "")}/messages`;
|
|
157
212
|
const { system, messages: translated } = toAnthropicMessages(messages);
|
|
158
|
-
const { signal, cleanup } = combinedSignal(opts.signal,
|
|
159
|
-
const
|
|
160
|
-
|
|
161
|
-
max_tokens: this.opts.maxTokens,
|
|
162
|
-
temperature: this.opts.temperature,
|
|
163
|
-
system,
|
|
164
|
-
messages: translated,
|
|
165
|
-
stream: true,
|
|
166
|
-
tools: tools.length > 0
|
|
167
|
-
? tools.map((t) => ({
|
|
168
|
-
name: t.function.name,
|
|
169
|
-
description: t.function.description,
|
|
170
|
-
input_schema: t.function.parameters,
|
|
171
|
-
}))
|
|
172
|
-
: undefined,
|
|
173
|
-
});
|
|
213
|
+
const { signal, cleanup, stopTimer } = combinedSignal(opts.signal, this.timeoutMs);
|
|
214
|
+
const cached = this.applyCacheControl(system, translated, tools);
|
|
215
|
+
const body = this.buildBody(cached.system, cached.messages, cached.tools, true);
|
|
174
216
|
let res;
|
|
175
217
|
try {
|
|
176
218
|
res = await withRetry(async () => {
|
|
@@ -198,6 +240,7 @@ export class AnthropicClient {
|
|
|
198
240
|
}
|
|
199
241
|
// Accumulators — text content + tool_use blocks (by block index)
|
|
200
242
|
let text = "";
|
|
243
|
+
let thinkingAccum = "";
|
|
201
244
|
const toolBlocks = new Map();
|
|
202
245
|
let stopReason;
|
|
203
246
|
let usage;
|
|
@@ -205,6 +248,23 @@ export class AnthropicClient {
|
|
|
205
248
|
const reader = res.body.getReader();
|
|
206
249
|
const decoder = new TextDecoder();
|
|
207
250
|
let buffer = "";
|
|
251
|
+
// Headers received — switch whole-request timeout to idle timeout per read
|
|
252
|
+
// (same rationale as LlmClient.streamChat: long streams are normal).
|
|
253
|
+
stopTimer();
|
|
254
|
+
const idleTimeoutMs = opts.idleTimeoutMs ?? this.timeoutMs;
|
|
255
|
+
const readWithIdleTimeout = async () => {
|
|
256
|
+
const ac = new AbortController();
|
|
257
|
+
const t = setTimeout(() => ac.abort(new DOMException(`stream idle for ${idleTimeoutMs / 1000}s`, "TimeoutError")), idleTimeoutMs);
|
|
258
|
+
try {
|
|
259
|
+
return await Promise.race([
|
|
260
|
+
reader.read(),
|
|
261
|
+
new Promise((_, rej) => ac.signal.addEventListener("abort", () => rej(ac.signal.reason), { once: true })),
|
|
262
|
+
]);
|
|
263
|
+
}
|
|
264
|
+
finally {
|
|
265
|
+
clearTimeout(t);
|
|
266
|
+
}
|
|
267
|
+
};
|
|
208
268
|
const handleEvent = (raw) => {
|
|
209
269
|
let ev;
|
|
210
270
|
try {
|
|
@@ -239,6 +299,12 @@ export class AnthropicClient {
|
|
|
239
299
|
text += ev.delta.text;
|
|
240
300
|
return { contentDelta: ev.delta.text, done: false };
|
|
241
301
|
}
|
|
302
|
+
// I-05: thinking deltas stream as their own content block type.
|
|
303
|
+
if (ev.delta?.type === "thinking_delta" && ev.delta.thinking) {
|
|
304
|
+
const t = ev.delta.thinking ?? "";
|
|
305
|
+
thinkingAccum += t;
|
|
306
|
+
return { reasoningDelta: t, done: false };
|
|
307
|
+
}
|
|
242
308
|
if (ev.delta?.type === "input_json_delta" && ev.delta.partial_json) {
|
|
243
309
|
const block = toolBlocks.get(ev.index ?? 0);
|
|
244
310
|
if (block)
|
|
@@ -272,9 +338,11 @@ export class AnthropicClient {
|
|
|
272
338
|
return null;
|
|
273
339
|
};
|
|
274
340
|
let finalResult;
|
|
341
|
+
// Reset the partial-call record for this stream (H-09 continuation).
|
|
342
|
+
this.lastPartialToolCalls = [];
|
|
275
343
|
try {
|
|
276
344
|
while (true) {
|
|
277
|
-
const { done, value } = await
|
|
345
|
+
const { done, value } = await readWithIdleTimeout();
|
|
278
346
|
if (done)
|
|
279
347
|
break;
|
|
280
348
|
buffer += decoder.decode(value, { stream: true });
|
|
@@ -300,6 +368,7 @@ export class AnthropicClient {
|
|
|
300
368
|
message: {
|
|
301
369
|
role: "assistant",
|
|
302
370
|
content: text.length > 0 ? text : null,
|
|
371
|
+
reasoning: thinkingAccum || undefined,
|
|
303
372
|
stop_reason: stopReason,
|
|
304
373
|
tool_calls: toolCalls.length > 0 ? toolCalls : undefined,
|
|
305
374
|
},
|
|
@@ -308,11 +377,20 @@ export class AnthropicClient {
|
|
|
308
377
|
}
|
|
309
378
|
}
|
|
310
379
|
finally {
|
|
380
|
+
// H-09: record complete tool calls accumulated so far, for continuation.
|
|
381
|
+
this.lastPartialToolCalls = [...toolBlocks.values()]
|
|
382
|
+
.filter((b) => b.name)
|
|
383
|
+
.map((b) => ({ id: b.id, name: b.name, arguments: b.json || "{}" }));
|
|
311
384
|
try {
|
|
312
|
-
reader.cancel();
|
|
385
|
+
const c = reader.cancel();
|
|
386
|
+
if (c && typeof c.catch === "function")
|
|
387
|
+
c.catch(() => { });
|
|
313
388
|
}
|
|
314
389
|
catch { /* already released */ }
|
|
315
|
-
|
|
390
|
+
try {
|
|
391
|
+
reader.releaseLock();
|
|
392
|
+
}
|
|
393
|
+
catch { /* lock already released */ }
|
|
316
394
|
cleanup();
|
|
317
395
|
}
|
|
318
396
|
yield { done: true, final: finalResult };
|
|
@@ -320,7 +398,7 @@ export class AnthropicClient {
|
|
|
320
398
|
}
|
|
321
399
|
async function toAnthropicError(res, url) {
|
|
322
400
|
let body = await res.text().catch(() => "");
|
|
323
|
-
body = body
|
|
401
|
+
body = redactSecrets(body);
|
|
324
402
|
const err = new LlmError(`Anthropic API error ${res.status} at ${url}: ${body.slice(0, 500)}`, res.status);
|
|
325
403
|
const retryAfterHeader = res.headers.get("retry-after");
|
|
326
404
|
if (retryAfterHeader != null && retryAfterHeader !== "") {
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider Registry v2 (I-01) — unified runtime selection.
|
|
3
|
+
*
|
|
4
|
+
* Source: anomalyco/opencode (packages/core/src/provider.ts) — concept
|
|
5
|
+
* adapted: registry resolving a provider id → client runtime, from
|
|
6
|
+
* config `providers.<id>` entries + built-ins. No npm plugins in v1.0.
|
|
7
|
+
*
|
|
8
|
+
* Built-ins:
|
|
9
|
+
* openai — OpenAI chat-completions (default)
|
|
10
|
+
* anthropic — native Messages API (prompt caching, thinking)
|
|
11
|
+
* openai-compatible — any OpenAI-compatible endpoint (DeepSeek, Groq, Together…)
|
|
12
|
+
* ollama — local Ollama (http://localhost:11434/v1)
|
|
13
|
+
* gemini — Gemini via its OpenAI-compat endpoint
|
|
14
|
+
*/
|
|
15
|
+
import { LlmClient } from "./llm.js";
|
|
16
|
+
import { AnthropicClient } from "./provider-anthropic.js";
|
|
17
|
+
export const DEFAULT_OLLAMA_BASE_URL = "http://localhost:11434/v1";
|
|
18
|
+
export const DEFAULT_GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta/openai";
|
|
19
|
+
export const DEFAULT_LMSTUDIO_BASE_URL = "http://localhost:1234/v1";
|
|
20
|
+
/** Built-in provider definitions (lowest priority — rc/env override). */
|
|
21
|
+
export const BUILTIN_PROVIDERS = {
|
|
22
|
+
openai: { id: "openai", baseUrl: "https://api.openai.com/v1", apiKeyEnv: "OPENAI_API_KEY", kind: "openai" },
|
|
23
|
+
anthropic: {
|
|
24
|
+
id: "anthropic",
|
|
25
|
+
baseUrl: "https://api.anthropic.com/v1",
|
|
26
|
+
apiKeyEnv: "ANTHROPIC_API_KEY",
|
|
27
|
+
kind: "anthropic",
|
|
28
|
+
supportCache: true,
|
|
29
|
+
supportReasoning: true,
|
|
30
|
+
},
|
|
31
|
+
"openai-compatible": { id: "openai-compatible", baseUrl: "", apiKeyEnv: "SCODER_API_KEY", kind: "openai" },
|
|
32
|
+
ollama: { id: "ollama", baseUrl: DEFAULT_OLLAMA_BASE_URL, apiKeyEnv: "OLLAMA_API_KEY", kind: "openai", supportReasoning: true },
|
|
33
|
+
gemini: { id: "gemini", baseUrl: DEFAULT_GEMINI_BASE_URL, apiKeyEnv: "GEMINI_API_KEY", kind: "openai" },
|
|
34
|
+
};
|
|
35
|
+
/** Create the concrete runtime for a provider def. */
|
|
36
|
+
export function createRuntime(def, opts) {
|
|
37
|
+
const common = {
|
|
38
|
+
baseUrl: opts.baseUrl || def.baseUrl,
|
|
39
|
+
apiKey: opts.apiKey,
|
|
40
|
+
model: opts.model,
|
|
41
|
+
maxTokens: opts.maxTokens,
|
|
42
|
+
temperature: opts.temperature,
|
|
43
|
+
enableCache: opts.enableCache ?? def.supportCache,
|
|
44
|
+
};
|
|
45
|
+
if (def.kind === "anthropic")
|
|
46
|
+
return new AnthropicClient(common);
|
|
47
|
+
return new LlmClient({ ...common, stream: opts.stream });
|
|
48
|
+
}
|
|
49
|
+
/** A configured custom provider from rc: providers.<id> = {baseUrl, apiKeyEnv, kind, ...}. */
|
|
50
|
+
export function parseCustomProviders(raw) {
|
|
51
|
+
if (typeof raw !== "object" || raw === null)
|
|
52
|
+
return {};
|
|
53
|
+
const out = {};
|
|
54
|
+
for (const [id, v] of Object.entries(raw)) {
|
|
55
|
+
if (typeof v !== "object" || v === null)
|
|
56
|
+
continue;
|
|
57
|
+
const d = v;
|
|
58
|
+
if (typeof d.baseUrl !== "string" || !d.baseUrl)
|
|
59
|
+
continue;
|
|
60
|
+
out[id] = {
|
|
61
|
+
id,
|
|
62
|
+
baseUrl: d.baseUrl,
|
|
63
|
+
apiKeyEnv: typeof d.apiKeyEnv === "string" ? d.apiKeyEnv : "SCODER_API_KEY",
|
|
64
|
+
kind: d.kind === "anthropic" ? "anthropic" : "openai",
|
|
65
|
+
supportCache: d.supportCache === true,
|
|
66
|
+
supportReasoning: d.supportReasoning === true,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Resolve a provider id → def. Model may be "provider/model" (splits).
|
|
73
|
+
* Returns null with a clean reason when the key env var is missing.
|
|
74
|
+
*/
|
|
75
|
+
export function resolveProvider(id, custom = {}) {
|
|
76
|
+
const def = custom[id] ?? BUILTIN_PROVIDERS[id];
|
|
77
|
+
if (!def) {
|
|
78
|
+
return { error: `unknown provider "${id}" — available: ${Object.keys({ ...BUILTIN_PROVIDERS, ...custom }).join(", ")}` };
|
|
79
|
+
}
|
|
80
|
+
return { def };
|
|
81
|
+
}
|
|
82
|
+
/** Split "provider/model" syntax; returns [provider, model] (unchanged when no slash). */
|
|
83
|
+
export function splitProviderModel(model) {
|
|
84
|
+
const idx = model.indexOf("/");
|
|
85
|
+
if (idx <= 0)
|
|
86
|
+
return [null, model];
|
|
87
|
+
// anthropic/claude-x, openai/gpt-4o, groq/llama-3…
|
|
88
|
+
return [model.slice(0, idx), model.slice(idx + 1)];
|
|
89
|
+
}
|
|
90
|
+
const LOCAL_PROBES = [
|
|
91
|
+
{ id: "ollama", baseUrl: DEFAULT_OLLAMA_BASE_URL },
|
|
92
|
+
{ id: "lmstudio", baseUrl: DEFAULT_LMSTUDIO_BASE_URL },
|
|
93
|
+
{ id: "vllm", baseUrl: "http://localhost:8000/v1" },
|
|
94
|
+
];
|
|
95
|
+
/**
|
|
96
|
+
* Probe local engines (300ms timeout each, concurrent). Returns engines that
|
|
97
|
+
* answered /v1/models with a valid list. Never throws; offline-safe.
|
|
98
|
+
*/
|
|
99
|
+
export async function discoverLocalEngines(timeoutMs = 300) {
|
|
100
|
+
const probes = await Promise.allSettled(LOCAL_PROBES.map(async (p) => {
|
|
101
|
+
const res = await fetch(`${p.baseUrl}/models`, { signal: AbortSignal.timeout(timeoutMs) });
|
|
102
|
+
if (!res.ok)
|
|
103
|
+
return null;
|
|
104
|
+
const data = (await res.json());
|
|
105
|
+
const models = (data.data ?? []).map((m) => m.id ?? "").filter(Boolean);
|
|
106
|
+
if (models.length === 0)
|
|
107
|
+
return null;
|
|
108
|
+
const builtin = BUILTIN_PROVIDERS[p.id];
|
|
109
|
+
const def = builtin ?? { id: p.id, baseUrl: p.baseUrl, apiKeyEnv: "SCODER_API_KEY", kind: "openai" };
|
|
110
|
+
return { provider: def, models };
|
|
111
|
+
}));
|
|
112
|
+
return probes
|
|
113
|
+
.filter((r) => r.status === "fulfilled")
|
|
114
|
+
.map((r) => r.value)
|
|
115
|
+
.filter((v) => v !== null);
|
|
116
|
+
}
|
package/dist/retry.js
CHANGED
|
@@ -21,7 +21,7 @@ function sleep(ms) {
|
|
|
21
21
|
* fail immediately via NonRetryableError semantics.
|
|
22
22
|
*/
|
|
23
23
|
export async function withRetry(fn, opts = {}, onRetry) {
|
|
24
|
-
const maxRetries = opts.maxRetries ??
|
|
24
|
+
const maxRetries = opts.maxRetries ?? 4;
|
|
25
25
|
const baseDelayMs = opts.baseDelayMs ?? 1000;
|
|
26
26
|
const maxDelayMs = opts.maxDelayMs ?? 30_000;
|
|
27
27
|
let lastErr;
|
|
@@ -44,7 +44,8 @@ export async function withRetry(fn, opts = {}, onRetry) {
|
|
|
44
44
|
// Respect Retry-After header if present (seconds)
|
|
45
45
|
const retryAfter = err.retryAfterMs;
|
|
46
46
|
const jitter = Math.floor(Math.random() * 500);
|
|
47
|
-
const
|
|
47
|
+
const rawDelay = retryAfter ?? baseDelayMs * 2 ** attempt + jitter;
|
|
48
|
+
const delay = Math.min(rawDelay, maxDelayMs);
|
|
48
49
|
onRetry?.(attempt + 1, delay, status ? `HTTP ${status}` : String(err.message).slice(0, 80));
|
|
49
50
|
await sleep(delay);
|
|
50
51
|
}
|
package/dist/sandbox.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* G4: Sandbox layer cho run_command (pattern từ opencode environment drivers —
|
|
3
|
+
* detection-first, không hard-depend).
|
|
4
|
+
*
|
|
5
|
+
* Profiles:
|
|
6
|
+
* - "readonly" — đọc được mọi nơi, ghi chỉ trong workspace
|
|
7
|
+
* - "workspace-write" — ghi trong workspace (+TMPDIR), mạng vẫn mở
|
|
8
|
+
*
|
|
9
|
+
* Có wrapper binary (sandbox-exec / bwrap / docker) → wrap lệnh. Không có →
|
|
10
|
+
* trả về null (caller chạy trực tiếp + warn). ponytail: docker wrap chỉ build
|
|
11
|
+
* args cơ bản (volume workspace, không network flag) — nâng cấp image/network
|
|
12
|
+
* khi có nhu cầu thực.
|
|
13
|
+
*/
|
|
14
|
+
/** Workspace + temp dirs a command may always touch. */
|
|
15
|
+
export function sandboxWritePaths(workdir) {
|
|
16
|
+
return [workdir, "/tmp", "/private/tmp"];
|
|
17
|
+
}
|
|
18
|
+
/** Build the wrapper argv for a profile. Pure — unit-test được. */
|
|
19
|
+
export function buildSandboxArgv(wrapper, profile, workdir, command) {
|
|
20
|
+
const writes = sandboxWritePaths(workdir);
|
|
21
|
+
if (wrapper === "sandbox-exec") {
|
|
22
|
+
// macOS Seatbelt: một profile string param, readonly chặn file-write-*.
|
|
23
|
+
// Seatbelt đánh giá theo resolved path — /var/folders là symlink của
|
|
24
|
+
// /private/var/folders, nên subpath phải phủ cả hai dạng.
|
|
25
|
+
let realWs = workdir;
|
|
26
|
+
try {
|
|
27
|
+
realWs = realpathSync(workdir);
|
|
28
|
+
}
|
|
29
|
+
catch { /* nonexistent — raw path đủ */ }
|
|
30
|
+
const wsSubpaths = [...new Set([workdir, realWs])]
|
|
31
|
+
.map((p) => `(subpath "${p}/")`)
|
|
32
|
+
.join(" ");
|
|
33
|
+
const allowWrites = profile === "workspace-write"
|
|
34
|
+
? `(allow file-write* ${wsSubpaths} (subpath "/tmp") (subpath "/private/tmp"))`
|
|
35
|
+
: `(deny file-write*)`;
|
|
36
|
+
const profileSbp = `(version 1)(deny default)(allow process*)(allow system-socket)(allow file-read*)(allow network*)(allow mach-lookup)(allow sysctl-read)${allowWrites}`;
|
|
37
|
+
return [
|
|
38
|
+
"sandbox-exec",
|
|
39
|
+
"-p", profileSbp,
|
|
40
|
+
"/bin/sh", "-c", command,
|
|
41
|
+
];
|
|
42
|
+
}
|
|
43
|
+
if (wrapper === "bwrap") {
|
|
44
|
+
// bubblewrap: bind ro toàn hệ thống, rw workspace; unshare all để isolate.
|
|
45
|
+
const args = [
|
|
46
|
+
"bwrap",
|
|
47
|
+
"--dev-bind", "/", "/", // giữ root — sandbox theo write-permission, không chroot
|
|
48
|
+
"--ro-bind", "/", "/", // override: mọi mount ro
|
|
49
|
+
];
|
|
50
|
+
for (const w of writes)
|
|
51
|
+
args.push("--bind", w, w);
|
|
52
|
+
if (profile === "readonly")
|
|
53
|
+
args.pop(); // readOnly: bỏ rw workspace
|
|
54
|
+
args.push("--", "/bin/sh", "-c", command);
|
|
55
|
+
return args;
|
|
56
|
+
}
|
|
57
|
+
// docker: volume workspace rw/ro, mount proc, no network isolation flag
|
|
58
|
+
// (network giữ mở — nâng cấp khi cần).
|
|
59
|
+
const roFlag = profile === "readonly" ? ":ro" : "";
|
|
60
|
+
return [
|
|
61
|
+
"docker", "run", "--rm",
|
|
62
|
+
"-v", `${workdir}:/workspace${roFlag}`,
|
|
63
|
+
"-w", "/workspace",
|
|
64
|
+
"--entrypoint", "/bin/sh",
|
|
65
|
+
"node:22-alpine",
|
|
66
|
+
"-c", command,
|
|
67
|
+
];
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Detect available wrapper và trả plan, hoặc null khi không sandbox được
|
|
71
|
+
* (caller: chạy trực tiếp + warn — never hard-fail).
|
|
72
|
+
*/
|
|
73
|
+
export function planSandbox(cfg, workdir, command) {
|
|
74
|
+
const mode = cfg.mode ?? "off";
|
|
75
|
+
if (mode === "off")
|
|
76
|
+
return null;
|
|
77
|
+
const wrapper = cfg.wrapper ?? (process.platform === "darwin" ? "sandbox-exec" : "bwrap");
|
|
78
|
+
return { wrapper, profile: mode, argv: buildSandboxArgv(wrapper, mode, workdir, command) };
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Spawn argv qua sandbox khi có plan; nếu wrapper binary thiếu trên máy →
|
|
82
|
+
* null (fallback chạy trực tiếp). Async để dùng which-like check nhẹ.
|
|
83
|
+
*/
|
|
84
|
+
export async function resolveSandbox(cfg, workdir, command) {
|
|
85
|
+
const plan = planSandbox(cfg, workdir, command);
|
|
86
|
+
if (!plan)
|
|
87
|
+
return { argv: null, wrapper: null, reason: "sandbox off" };
|
|
88
|
+
// `which` không có trên Windows — dùng `where`.
|
|
89
|
+
const check = spawnSync(process.platform === "win32" ? "where" : "which", [plan.wrapper], { encoding: "utf8" });
|
|
90
|
+
if (check.status !== 0) {
|
|
91
|
+
return { argv: null, wrapper: null, reason: `${plan.wrapper} not found — running unsandboxed` };
|
|
92
|
+
}
|
|
93
|
+
return { argv: plan.argv.map(String), wrapper: plan.wrapper };
|
|
94
|
+
}
|
|
95
|
+
import { spawnSync } from "node:child_process";
|
|
96
|
+
import { realpathSync } from "node:fs";
|
package/dist/server.js
CHANGED
|
@@ -83,7 +83,10 @@ export function startServer(baseConfig, opts = {}) {
|
|
|
83
83
|
record.finishedAt = new Date().toISOString();
|
|
84
84
|
for (const sub of record.subscribers)
|
|
85
85
|
sub();
|
|
86
|
-
})
|
|
86
|
+
})
|
|
87
|
+
// MCP connections live for the agent lifetime — each server run owns an
|
|
88
|
+
// ephemeral agent, so dispose it here (never leak child processes).
|
|
89
|
+
.finally(() => agent.dispose());
|
|
87
90
|
};
|
|
88
91
|
const handleEventsSSE = (req, res, record) => {
|
|
89
92
|
res.writeHead(200, {
|
|
@@ -197,7 +200,7 @@ export function startServer(baseConfig, opts = {}) {
|
|
|
197
200
|
return;
|
|
198
201
|
}
|
|
199
202
|
// ── /api/runs/:id[...] ──
|
|
200
|
-
const runMatch = path.match(/^\/api\/runs\/([0-9a-f-]{36})(\/events|\/abort)?$/i);
|
|
203
|
+
const runMatch = path.match(/^\/api\/runs\/([0-9a-f-]{36})(\/events|\/abort|\/steer)?$/i);
|
|
201
204
|
if (runMatch) {
|
|
202
205
|
const record = runs.get(runMatch[1]);
|
|
203
206
|
if (!record) {
|
|
@@ -218,6 +221,33 @@ export function startServer(baseConfig, opts = {}) {
|
|
|
218
221
|
sendJson(res, 200, { ok: true, status: "running (aborting...)" });
|
|
219
222
|
return;
|
|
220
223
|
}
|
|
224
|
+
if (runMatch[2] === "/steer" && req.method === "POST") {
|
|
225
|
+
if (record.status !== "running" || !record.agent) {
|
|
226
|
+
sendJson(res, 409, { error: `run is not running (status: ${record.status})` });
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
let body = "";
|
|
230
|
+
req.on("data", (c) => {
|
|
231
|
+
body += c.toString();
|
|
232
|
+
if (body.length > 1_000_000)
|
|
233
|
+
req.destroy(); // 1MB cap
|
|
234
|
+
});
|
|
235
|
+
req.on("end", () => {
|
|
236
|
+
try {
|
|
237
|
+
const text = JSON.parse(body).text;
|
|
238
|
+
if (typeof text !== "string" || !text.trim()) {
|
|
239
|
+
sendJson(res, 400, { error: "body must be {text: string}" });
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
record.agent?.steer(text);
|
|
243
|
+
sendJson(res, 200, { ok: true, delivered: "next step boundary" });
|
|
244
|
+
}
|
|
245
|
+
catch {
|
|
246
|
+
sendJson(res, 400, { error: "invalid JSON body" });
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
221
251
|
if (!runMatch[2] && req.method === "GET") {
|
|
222
252
|
sendJson(res, 200, publicRun(record));
|
|
223
253
|
return;
|