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.
Files changed (49) hide show
  1. package/.scoderrc.example.json +97 -0
  2. package/LICENSE +21 -21
  3. package/README.md +600 -468
  4. package/dist/agent.js +841 -197
  5. package/dist/agents.js +70 -0
  6. package/dist/compaction.js +114 -0
  7. package/dist/config.js +178 -12
  8. package/dist/context.js +119 -14
  9. package/dist/cost.js +56 -6
  10. package/dist/guardrails.js +423 -0
  11. package/dist/index.js +129 -76
  12. package/dist/json-schema.js +58 -0
  13. package/dist/llm.js +123 -9
  14. package/dist/mcp.js +397 -70
  15. package/dist/model-catalog.js +175 -0
  16. package/dist/permissions.js +328 -35
  17. package/dist/prompt-builder.js +47 -0
  18. package/dist/prompt.js +103 -0
  19. package/dist/provider-anthropic.js +113 -35
  20. package/dist/provider-registry.js +116 -0
  21. package/dist/retry.js +3 -2
  22. package/dist/sandbox.js +96 -0
  23. package/dist/server.js +32 -2
  24. package/dist/session.js +282 -11
  25. package/dist/shell-scanner.js +112 -0
  26. package/dist/skills.js +122 -0
  27. package/dist/snapshot.js +170 -0
  28. package/dist/steering.js +35 -0
  29. package/dist/tools.js +513 -19
  30. package/dist/tui/app.js +505 -198
  31. package/dist/tui/at-complete.js +93 -0
  32. package/dist/tui/chat-views.js +146 -0
  33. package/dist/tui/chrome.js +96 -0
  34. package/dist/tui/command-registry.js +189 -0
  35. package/dist/tui/commands.js +200 -2
  36. package/dist/tui/image.js +149 -0
  37. package/dist/tui/input.js +214 -0
  38. package/dist/tui/markdown.js +229 -0
  39. package/dist/tui/mouse.js +20 -0
  40. package/dist/tui/permission-modal.js +34 -0
  41. package/dist/tui/review.js +64 -0
  42. package/dist/tui/session-picker.js +68 -0
  43. package/dist/tui/sidebar.js +99 -0
  44. package/dist/tui/state.js +192 -6
  45. package/dist/tui/theme.js +90 -0
  46. package/dist/tui/vim.js +60 -0
  47. package/dist/watch.js +52 -0
  48. package/package.json +16 -8
  49. 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
- async chat(messages, tools, opts = {}) {
104
- const url = `${this.opts.baseUrl.replace(/\/$/, "")}/messages`;
105
- const { system, messages: translated } = toAnthropicMessages(messages);
106
- const { signal, cleanup } = combinedSignal(opts.signal, LLM_REQUEST_TIMEOUT_MS);
107
- const body = JSON.stringify({
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: translated,
113
- tools: tools.length > 0
114
- ? tools.map((t) => ({
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, LLM_REQUEST_TIMEOUT_MS);
159
- const body = JSON.stringify({
160
- model: this.opts.model,
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 reader.read();
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
- reader.releaseLock();
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.replace(/sk-ant-[^\s,&\]]+/gi, "sk-ant-***REDACTED***");
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 ?? 3;
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 delay = Math.min(retryAfter ?? baseDelayMs * 2 ** attempt + jitter, maxDelayMs);
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
  }
@@ -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;