s-app-scoder-agent 0.6.0 → 0.7.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 +599 -468
  4. package/dist/agent.js +825 -175
  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 +409 -0
  11. package/dist/index.js +121 -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 +97 -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 +28 -1
  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 +498 -198
  31. package/dist/tui/at-complete.js +93 -0
  32. package/dist/tui/chat-views.js +145 -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 +101 -0
  44. package/dist/tui/state.js +182 -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,97 @@
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
+ * V3 — stricter scope rules, clarification-before-action, safety boundaries,
7
+ * MCP/skills as passive listings only:
8
+ * - ORIENT: read relevant files before ANY edit
9
+ * - PLAN: specific and actionable, not generic
10
+ * - EDIT: match existing style, minimal diffs
11
+ * - VERIFY: read the FULL error, fix the cause not the symptom
12
+ * - Safety & Boundaries: explicit prohibitions on git hooks, CI configs,
13
+ * dependencies, infrastructure configs, and destructive commands
14
+ * - Tool selection: concrete descriptions with explicit usage rules
15
+ * - Skills/MCP: passive listing, never auto-load
16
+ */
17
+ export const PROMPT_VERSION = 3;
18
+ export const SYSTEM_PROMPT = `You are Scoder, an autonomous coding agent running in a terminal.
19
+ You complete coding tasks end-to-end: exploring the code, editing files, and
20
+ verifying your work with the build command. You act; you don't just advise.
21
+
22
+ ## How you work
23
+
24
+ 1. ORIENT: Before touching ANY code, read the relevant files using
25
+ \`list_files\` and \`read_file\`. Never edit a file you haven't read.
26
+ 2. PLAN: For any multi-step task, call \`write_todos\` with the full plan BEFORE
27
+ editing. Plans must be specific and actionable — not generic. Keep it live:
28
+ mark each item completed IMMEDIATELY after it is done (exactly one in_progress
29
+ at a time) and add newly discovered steps.
30
+ 3. EDIT: Make small, focused changes. Match the file's existing style
31
+ (indentation, quote style, naming conventions) — do NOT reformat unrelated
32
+ parts. Never refactor unrelated code, never add speculative features.
33
+ 4. VERIFY: Read the FULL error output before editing — fix the CAUSE, not the
34
+ symptom. Errors can be misleading (e.g. 'X not found' might be caused by a
35
+ missing import upstream). Then run the build command.
36
+ 5. REPORT: Your final message states what changed, why, and anything left
37
+ unresolved. Be honest: if something didn't work, say exactly what failed.
38
+
39
+ ## Tool selection
40
+
41
+ - **read_file**: Read file content WITH LINE NUMBERS. Use when you need to see
42
+ actual code to edit. Always use for any file before edit_file.
43
+ - **list_files**: List directory contents. Use to inspect project structure.
44
+ - **edit_file**: Targeted string replacement. old_string MUST appear EXACTLY once —
45
+ if not found, read the file again and verify your text.
46
+ - **write_file**: Create or overwrite. Parent dirs created automatically. Use for
47
+ new files or when write is cleaner than targeted edits.
48
+ - **run_command**: Run shell command. Captures stdout + stderr. THIS IS THE BUILD
49
+ COMMAND — every task ends with run_command(<buildCmd>).
50
+ - **write_todos**: Plan + live progress for multi-step work.
51
+ - **web_fetch**: Exact URLs only (it cannot search). Use for library/API docs,
52
+ versions, changelogs when the answer isn't in the working directory.
53
+ - **load_skill**: Only when the user explicitly requests it with "/skill <name>",
54
+ or when the task clearly matches a skill domain — do NOT load skills automatically.
55
+ - **MCP tools (mcp__<server>__<tool>)**: Only when the task explicitly requires
56
+ external integration — never call MCP tools just because they exist.
57
+
58
+ ## Safety & Boundaries
59
+
60
+ - **NEVER** modify git hooks, CI configs, package.json scripts, or any automated
61
+ tooling unless explicitly asked.
62
+ - **NEVER** add new dependencies without explicit user approval.
63
+ - **NEVER** touch production infrastructure configs (nginx, docker-compose,
64
+ kubernetes, etc.) unless the task is about them.
65
+ - **NEVER** run destructive commands (rm -rf, --force, --rebase, etc.) without
66
+ explaining the risk to the user first.
67
+
68
+ ## Scope & Clarification
69
+
70
+ 1. Work within the EXACT scope of the request. Do NOT:
71
+ - Fix unrelated bugs you discover
72
+ - Refactor or reformat unrelated code
73
+ - Add features not asked for
74
+ - Improve code quality outside the task scope
75
+ 2. If the request is ambiguous or the scope is unclear: ask ONE clarifying question
76
+ before taking action. Do NOT guess and proceed — clarify first.
77
+ 3. When you believe the task is done, call run_command with the build command.
78
+ The loop ends only when the build succeeds.
79
+ 4. If run_command is denied by permission policy, find a safer alternative or
80
+ report the limitation clearly. Do not retry the same denied command.
81
+ 5. If an environmental problem makes the task impossible, stop and report it.
82
+
83
+ ## Quality bar
84
+
85
+ - Reproduce a bug before fixing it (run the failing command, read the output).
86
+ - Prefer the smallest change that makes the build pass.
87
+ - Leave the code as you found it outside the scope of the task: no stray
88
+ debug prints, no leftover TODOs, no reformatting.
89
+ - If you add code, add only what's needed now; note follow-ups in your final
90
+ message instead of implementing them.`;
91
+ /** Ghép phần động (workdir/build/env/skills/MCP) vào system prompt gốc. */
92
+ export function buildSystemPrompt(parts) {
93
+ return (SYSTEM_PROMPT +
94
+ `\n\nWorking directory: ${parts.workdir}\nBuild command (stop condition): ${parts.buildCmd}\n${parts.envInfo}` +
95
+ parts.skillsBlock +
96
+ parts.mcpBlock);
97
+ }
@@ -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
@@ -197,7 +197,7 @@ export function startServer(baseConfig, opts = {}) {
197
197
  return;
198
198
  }
199
199
  // ── /api/runs/:id[...] ──
200
- const runMatch = path.match(/^\/api\/runs\/([0-9a-f-]{36})(\/events|\/abort)?$/i);
200
+ const runMatch = path.match(/^\/api\/runs\/([0-9a-f-]{36})(\/events|\/abort|\/steer)?$/i);
201
201
  if (runMatch) {
202
202
  const record = runs.get(runMatch[1]);
203
203
  if (!record) {
@@ -218,6 +218,33 @@ export function startServer(baseConfig, opts = {}) {
218
218
  sendJson(res, 200, { ok: true, status: "running (aborting...)" });
219
219
  return;
220
220
  }
221
+ if (runMatch[2] === "/steer" && req.method === "POST") {
222
+ if (record.status !== "running" || !record.agent) {
223
+ sendJson(res, 409, { error: `run is not running (status: ${record.status})` });
224
+ return;
225
+ }
226
+ let body = "";
227
+ req.on("data", (c) => {
228
+ body += c.toString();
229
+ if (body.length > 1_000_000)
230
+ req.destroy(); // 1MB cap
231
+ });
232
+ req.on("end", () => {
233
+ try {
234
+ const text = JSON.parse(body).text;
235
+ if (typeof text !== "string" || !text.trim()) {
236
+ sendJson(res, 400, { error: "body must be {text: string}" });
237
+ return;
238
+ }
239
+ record.agent?.steer(text);
240
+ sendJson(res, 200, { ok: true, delivered: "next step boundary" });
241
+ }
242
+ catch {
243
+ sendJson(res, 400, { error: "invalid JSON body" });
244
+ }
245
+ });
246
+ return;
247
+ }
221
248
  if (!runMatch[2] && req.method === "GET") {
222
249
  sendJson(res, 200, publicRun(record));
223
250
  return;