s-app-scoder-agent 0.0.0-stage → 0.4.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/dist/agent.js ADDED
@@ -0,0 +1,501 @@
1
+ import { loadPermissionLists } from "./config.js";
2
+ import { LlmClient, LlmError } from "./llm.js";
3
+ import { AnthropicClient } from "./provider-anthropic.js";
4
+ import { AGENT_TOOLS, getTool } from "./tools.js";
5
+ import { detectBuildCommand, runBuild } from "./build.js";
6
+ import { trimHistory, summariseOutput, claimsCompletion } from "./context.js";
7
+ import { PermissionChecker } from "./permissions.js";
8
+ import { SessionStore } from "./session.js";
9
+ import { JsonlLogger } from "./logger.js";
10
+ import { McpManager } from "./mcp.js";
11
+ import * as ui from "./ui.js";
12
+ const SYSTEM_PROMPT = `You are Scoder, an autonomous coding agent running in a terminal.
13
+ You complete coding tasks by using tools: reading/writing/editing files, listing
14
+ files, and running shell commands.
15
+
16
+ RULES:
17
+ 1. Work autonomously. Do NOT ask the user questions mid-task; make reasonable
18
+ decisions and proceed.
19
+ 2. When you believe the task is done, call run_command with the project's build
20
+ command (or the one the user specified). The loop ends only when the build
21
+ succeeds.
22
+ 3. If the build fails, read the error output carefully, fix the code, and build
23
+ again. Iterate until it passes.
24
+ 4. Prefer small, focused edits. Read a file before editing it.
25
+ 5. If an external/environmental problem (missing dependency, wrong environment)
26
+ makes the task impossible, stop editing and report it clearly in your final
27
+ message.
28
+ 6. Never invent requirements beyond the given task.
29
+ 7. IMPORTANT: if a run_command is denied by permission policy, you will receive
30
+ an error message. Use this as a signal to find a safer alternative or to
31
+ communicate the limitation to the user.`;
32
+ export class Agent {
33
+ config;
34
+ client;
35
+ messages = [];
36
+ tools = AGENT_TOOLS.map((t) => t.spec);
37
+ ctx;
38
+ permissions;
39
+ session;
40
+ logger;
41
+ aborted = false;
42
+ streamFallbackWarned = false;
43
+ sessionCreatedAt;
44
+ currentTask = "";
45
+ lastBuildResult = null;
46
+ /** Accumulated token usage across the current run (session state). */
47
+ usageTotals = { calls: 0, inputTokens: 0, outputTokens: 0, cachedTokens: 0 };
48
+ mcp = null;
49
+ /** Aborted when abort() is called — cancels in-flight LLM requests (SIGINT). */
50
+ runSignal = new AbortController();
51
+ constructor(config) {
52
+ this.config = config;
53
+ const commonOpts = {
54
+ baseUrl: config.baseUrl,
55
+ apiKey: config.apiKey,
56
+ model: config.model,
57
+ maxTokens: config.maxTokens,
58
+ temperature: config.temperature,
59
+ };
60
+ this.client =
61
+ config.provider === "anthropic"
62
+ ? new AnthropicClient(commonOpts)
63
+ : new LlmClient({ ...commonOpts, stream: config.stream });
64
+ const permLists = loadPermissionLists(config.workdir);
65
+ this.permissions = new PermissionChecker({
66
+ mode: config.permissionMode,
67
+ allowCommands: permLists.allowCommands,
68
+ denyCommands: permLists.denyCommands,
69
+ nonInteractive: config.yes,
70
+ });
71
+ this.ctx = {
72
+ workdir: config.workdir,
73
+ permissions: this.permissions,
74
+ permissionMode: config.permissionMode,
75
+ };
76
+ this.session = new SessionStore(config.workdir, config.noSave !== true);
77
+ this.logger = JsonlLogger.forSession(config.workdir, this.session.id, config.logFile);
78
+ }
79
+ /**
80
+ * Handle SIGINT: abort gracefully after current tool finishes.
81
+ * Also kills any in-flight run_command child process via the context signal.
82
+ */
83
+ abort() {
84
+ this.aborted = true;
85
+ this.runSignal.abort(new DOMException("interrupted by user", "AbortError"));
86
+ }
87
+ /** Reset the run-signal so a new run() can use a fresh controller. */
88
+ resetRunSignal() {
89
+ this.runSignal = new AbortController();
90
+ this.aborted = false;
91
+ // Keep the tool context in sync so run_command can observe SIGINT and
92
+ // terminate its child process immediately (long builds must be interruptible).
93
+ this.ctx.signal = this.runSignal.signal;
94
+ }
95
+ /** Push one tool-result message back into the conversation. */
96
+ pushToolResult(tc, content) {
97
+ this.messages.push({
98
+ role: "tool",
99
+ tool_call_id: tc.id,
100
+ name: tc.name,
101
+ content,
102
+ });
103
+ }
104
+ /**
105
+ * Handle a rejected tool call (unknown tool, policy denial, user denial):
106
+ * render the failure, log the event, and push an ERROR tool message so the
107
+ * LLM can react. Replaces the previously duplicated denied/deny/unknown blocks.
108
+ */
109
+ rejectToolCall(step, tc, output, logExtra) {
110
+ ui.toolResult(tc.name, false, output);
111
+ this.logger.log({ type: "tool", step, tool: tc.name, ok: false, ...logExtra });
112
+ this.pushToolResult(tc, output);
113
+ }
114
+ /**
115
+ * Record usage of one LLM call: render token counts, log an `llm_usage`
116
+ * event, and accumulate totals into session state.
117
+ */
118
+ recordLlmUsage(usage) {
119
+ if (!usage)
120
+ return;
121
+ const input = usage.prompt_tokens ?? 0;
122
+ const output = usage.completion_tokens ?? 0;
123
+ const cached = usage.cached_tokens ?? 0;
124
+ this.usageTotals.calls += 1;
125
+ this.usageTotals.inputTokens += input;
126
+ this.usageTotals.outputTokens += output;
127
+ this.usageTotals.cachedTokens += cached;
128
+ ui.info(`tokens: in=${usage.prompt_tokens ?? "?"} out=${usage.completion_tokens ?? "?"}`);
129
+ this.logger.log({
130
+ type: "llm_usage",
131
+ model: this.client.model,
132
+ provider: this.config.provider,
133
+ usage: {
134
+ input_tokens: input,
135
+ output_tokens: output,
136
+ cached_tokens: cached,
137
+ },
138
+ });
139
+ }
140
+ /** Reset per-run usage accumulation (called when a fresh run starts). */
141
+ resetUsageTotals() {
142
+ this.usageTotals = { calls: 0, inputTokens: 0, outputTokens: 0, cachedTokens: 0 };
143
+ }
144
+ /**
145
+ * One LLM turn: streaming if enabled (renders deltas live), with automatic
146
+ * fallback to non-streaming when the provider/stream fails.
147
+ */
148
+ async chatWithStream() {
149
+ const signal = this.runSignal.signal;
150
+ if (!this.config.stream) {
151
+ return this.client.chat(this.messages, this.tools, { signal });
152
+ }
153
+ try {
154
+ let streamedText = "";
155
+ let final;
156
+ let started = false;
157
+ for await (const chunk of this.client.streamChat(this.messages, this.tools, { signal })) {
158
+ if (chunk.contentDelta) {
159
+ if (!started) {
160
+ ui.llmStreamStart("assistant");
161
+ started = true;
162
+ }
163
+ ui.llmStreamChunk(chunk.contentDelta);
164
+ streamedText += chunk.contentDelta;
165
+ }
166
+ if (chunk.final)
167
+ final = chunk.final;
168
+ }
169
+ if (started)
170
+ ui.llmStreamEnd();
171
+ if (final) {
172
+ if ((final.message.content == null || final.message.content === "") && streamedText) {
173
+ final.message.content = streamedText;
174
+ }
175
+ return final;
176
+ }
177
+ // No finish_reason seen — fall through to non-streaming
178
+ return this.client.chat(this.messages, this.tools);
179
+ }
180
+ catch (err) {
181
+ if (err instanceof LlmError && !this.streamFallbackWarned) {
182
+ this.streamFallbackWarned = true;
183
+ ui.warn(`streaming unavailable (${err.message.slice(0, 120)}) — falling back to non-streaming`);
184
+ }
185
+ return this.client.chat(this.messages, this.tools);
186
+ }
187
+ }
188
+ get buildCmd() {
189
+ return this.config.buildCmd ?? detectBuildCommand(this.config.workdir) ?? "echo BUILD";
190
+ }
191
+ /** Connect configured MCP stdio servers (if any) and merge their tools. */
192
+ async connectMcp() {
193
+ const servers = this.config.mcpServers ?? {};
194
+ if (Object.keys(servers).length === 0)
195
+ return;
196
+ if (this.config.permissionMode === "readonly") {
197
+ ui.warn("readonly mode: MCP tools are disabled (they bypass the permission system)");
198
+ }
199
+ this.mcp = new McpManager(servers);
200
+ await this.mcp.connectAll();
201
+ const mcpSpecs = this.mcp.toToolSpecs();
202
+ if (mcpSpecs.length > 0) {
203
+ this.tools = [...AGENT_TOOLS.map((t) => t.spec), ...mcpSpecs];
204
+ }
205
+ }
206
+ /** Disconnect MCP servers at the end of a run. */
207
+ shutdownMcp() {
208
+ this.mcp?.killAll();
209
+ this.mcp = null;
210
+ }
211
+ /**
212
+ * Autonomous closed loop:
213
+ * task -> LLM plans -> tool calls -> build -> on failure: read error, patch, retry.
214
+ */
215
+ async run(task) {
216
+ if (!this.config.apiKey) {
217
+ throw new Error("No API key configured. Set SCODER_API_KEY or OPENAI_API_KEY (or put apiKey in .scoderrc.json).");
218
+ }
219
+ const buildCmd = this.buildCmd;
220
+ ui.header("TASK");
221
+ ui.info(task);
222
+ ui.info(`model=${this.client.model} workdir=${this.config.workdir} build="${buildCmd}" maxSteps=${this.config.maxSteps}`);
223
+ this.currentTask = task;
224
+ this.resetRunSignal();
225
+ this.resetUsageTotals();
226
+ this.messages = [
227
+ {
228
+ role: "system",
229
+ content: SYSTEM_PROMPT +
230
+ `\n\nWorking directory: ${this.config.workdir}\nBuild command (stop condition): ${buildCmd}`,
231
+ },
232
+ { role: "user", content: task },
233
+ ];
234
+ try {
235
+ await this.connectMcp();
236
+ return await this.runLoop(0, buildCmd, null);
237
+ }
238
+ finally {
239
+ this.shutdownMcp();
240
+ }
241
+ }
242
+ /**
243
+ * One conversational turn in an ongoing session (used by the REPL):
244
+ * appends the task to the existing message history instead of resetting it,
245
+ * then runs the autonomous loop. First call behaves like run().
246
+ */
247
+ async runTurn(task) {
248
+ if (this.messages.length === 0) {
249
+ return this.run(task);
250
+ }
251
+ const buildCmd = this.buildCmd;
252
+ this.currentTask = `${this.currentTask}\n${task}`.trim();
253
+ this.resetRunSignal();
254
+ this.resetUsageTotals();
255
+ this.messages.push({ role: "user", content: task });
256
+ try {
257
+ await this.connectMcp();
258
+ return await this.runLoop(0, buildCmd, null);
259
+ }
260
+ finally {
261
+ this.shutdownMcp();
262
+ }
263
+ }
264
+ /**
265
+ * Resume a previous session: restore messages/step count and continue the loop.
266
+ * An optional follow-up task is appended as a new user message.
267
+ */
268
+ async resume(session, followUp) {
269
+ const buildCmd = this.buildCmd;
270
+ this.sessionCreatedAt = session.createdAt;
271
+ this.currentTask = session.task;
272
+ this.resetRunSignal();
273
+ this.resetUsageTotals();
274
+ this.messages = session.messages ?? [];
275
+ ui.header(`RESUME ${session.id}`);
276
+ ui.info(`previous task: ${session.task} (${session.steps} step(s) done)`);
277
+ if (followUp) {
278
+ this.messages.push({ role: "user", content: followUp });
279
+ }
280
+ else {
281
+ this.messages.push({
282
+ role: "user",
283
+ content: "Continue working on the original task. Build must pass before you finish.",
284
+ });
285
+ }
286
+ const lastBuild = session.lastBuild
287
+ ? { success: session.lastBuild.success, command: session.lastBuild.command, output: session.lastBuild.output }
288
+ : null;
289
+ try {
290
+ await this.connectMcp();
291
+ return await this.runLoop(session.steps ?? 0, buildCmd, lastBuild);
292
+ }
293
+ finally {
294
+ this.shutdownMcp();
295
+ }
296
+ }
297
+ /**
298
+ * The shared autonomous loop:
299
+ * LLM plans -> tool calls -> build -> on failure: read error, patch, retry.
300
+ */
301
+ async runLoop(startSteps, buildCmd, initialBuild = null) {
302
+ let steps = startSteps;
303
+ let lastBuild = initialBuild;
304
+ while (steps < this.config.maxSteps && !this.aborted) {
305
+ steps++;
306
+ ui.step(steps, this.config.maxSteps, "thinking...");
307
+ const { message, usage } = await this.chatWithStream().catch((err) => {
308
+ // SIGINT during the LLM call — stop gracefully instead of crashing
309
+ if (this.aborted) {
310
+ return {
311
+ message: { role: "assistant", content: null, tool_calls: undefined },
312
+ usage: undefined,
313
+ };
314
+ }
315
+ throw err;
316
+ });
317
+ if (this.aborted)
318
+ break;
319
+ this.recordLlmUsage(usage);
320
+ this.logger.log({ type: "step", step: steps });
321
+ // No tool calls => assistant produced a text answer
322
+ if (!message.tool_calls || message.tool_calls.length === 0) {
323
+ const text = message.content ?? "";
324
+ this.messages.push({ role: "assistant", content: text });
325
+ // If the assistant says it's done without building, force a build check.
326
+ if (claimsCompletion(text) && steps < this.config.maxSteps) {
327
+ ui.info("agent claims completion -> verifying with build");
328
+ lastBuild = await this.checkBuild(buildCmd);
329
+ if (lastBuild.success) {
330
+ return this.finish(true, steps, buildCmd, text);
331
+ }
332
+ this.messages.push({
333
+ role: "user",
334
+ content: `Build still FAILED after your claimed completion. Output:\n${summariseOutput(lastBuild.output)}\nFix and continue.`,
335
+ });
336
+ continue;
337
+ }
338
+ ui.llm("agent", text);
339
+ // Treat a plain text answer with no build as interim commentary; nudge loop.
340
+ this.messages.push({
341
+ role: "user",
342
+ content: "Continue working on the task. Use tools. Build must pass before you finish.",
343
+ });
344
+ continue;
345
+ }
346
+ this.messages.push({
347
+ role: "assistant",
348
+ content: message.content ?? null,
349
+ tool_calls: message.tool_calls,
350
+ });
351
+ // Execute each tool call (validate args before running)
352
+ const stepToolResults = new Map();
353
+ for (const tc of message.tool_calls) {
354
+ if (this.aborted)
355
+ break;
356
+ const tool = getTool(tc.name);
357
+ let args;
358
+ try {
359
+ args = tc.arguments ? JSON.parse(tc.arguments) : {};
360
+ }
361
+ catch {
362
+ this.pushToolResult(tc, `ERROR: invalid JSON arguments: ${tc.arguments.slice(0, 200)}`);
363
+ continue;
364
+ }
365
+ ui.toolCall(tc.name, args);
366
+ if (!tool && !(this.mcp?.hasTool(tc.name))) {
367
+ this.rejectToolCall(steps, tc, `ERROR: unknown tool "${tc.name}". Available: ${AGENT_TOOLS.map((t) => t.spec.function.name).join(", ")}${this.mcp ? " + MCP tools (mcp__<server>__<tool>)" : ""}`, { error: "unknown tool" });
368
+ continue;
369
+ }
370
+ let result;
371
+ try {
372
+ // Permission gate for shell commands
373
+ if (tc.name === "run_command" && typeof args.command === "string") {
374
+ const decision = this.permissions.classify(args.command);
375
+ if (decision.decision === "deny") {
376
+ result = {
377
+ ok: false,
378
+ output: `ERROR: command denied by permission policy (${decision.reason}): ${String(args.command).slice(0, 200)}`,
379
+ };
380
+ this.rejectToolCall(steps, tc, result.output, { args, denied: decision.reason });
381
+ continue;
382
+ }
383
+ if (decision.decision === "ask") {
384
+ const allowed = await this.permissions.confirm(args.command);
385
+ if (!allowed) {
386
+ result = {
387
+ ok: false,
388
+ output: `ERROR: user denied permission to run: ${String(args.command).slice(0, 200)}`,
389
+ };
390
+ this.rejectToolCall(steps, tc, result.output, { args, denied: "user" });
391
+ continue;
392
+ }
393
+ }
394
+ }
395
+ const t0 = Date.now();
396
+ // Route MCP tools first (they carry the mcp__ prefix, not in AGENT_TOOLS)
397
+ if (tool) {
398
+ result = await tool.run(this.ctx, args);
399
+ }
400
+ else if (this.config.permissionMode === "readonly") {
401
+ // MCP tools are not covered by the permission system (no command
402
+ // classification, no diff preview) — deny them entirely in readonly.
403
+ result = {
404
+ ok: false,
405
+ output: `ERROR: readonly mode — MCP tool "${tc.name}" is disabled. MCP tools bypass the built-in permission policy, so they cannot run in readonly mode.`,
406
+ };
407
+ }
408
+ else {
409
+ const reg = this.mcp?.getTool(tc.name);
410
+ if (!reg) {
411
+ result = { ok: false, output: `ERROR: unknown tool "${tc.name}"` };
412
+ }
413
+ else {
414
+ const r = await reg.conn.callTool(reg.toolName, args);
415
+ result = { ok: r.ok, output: r.output.length > 20_000 ? r.output.slice(0, 20_000) + "\n... [truncated]" : r.output };
416
+ }
417
+ }
418
+ this.logger.log({ type: "tool", step: steps, tool: tc.name, args, ok: result.ok, elapsedMs: Date.now() - t0 });
419
+ }
420
+ catch (err) {
421
+ result = { ok: false, output: `ERROR: ${err.message}` };
422
+ this.logger.log({ type: "tool", step: steps, tool: tc.name, args, ok: false, error: err.message });
423
+ }
424
+ // User pressed [q]uit in an edit-confirmation prompt — stop the whole run.
425
+ if (this.permissions.abortRequested) {
426
+ this.aborted = true;
427
+ ui.warn("edit confirmation aborted by user ([q]uit) — stopping the run");
428
+ }
429
+ ui.toolResult(tc.name, result.ok, result.output);
430
+ stepToolResults.set(tc.id, { ok: result.ok, output: result.output });
431
+ this.pushToolResult(tc, summariseOutput(result.output));
432
+ }
433
+ // After a build-command run, treat success as the terminal condition.
434
+ // Match the FULL build command (not just its first word): matching "npm"
435
+ // would falsely trigger on "npm install" mid-task and end the agent.
436
+ const buildCall = message.tool_calls.find((tc) => tc.name === "run_command" && tc.arguments.includes(buildCmd));
437
+ if (buildCall) {
438
+ // Reuse the tool result when the agent ran the exact build command —
439
+ // avoids executing the build a second time just to verify.
440
+ const result = stepToolResults.get(buildCall.id);
441
+ if (result && result.ok) {
442
+ return this.finish(true, steps, buildCmd, message.content ?? "Build passed.");
443
+ }
444
+ lastBuild = await this.checkBuild(buildCmd);
445
+ if (lastBuild.success) {
446
+ return this.finish(true, steps, buildCmd, message.content ?? "Build passed.");
447
+ }
448
+ this.messages.push({
449
+ role: "user",
450
+ content: `Build FAILED (stop condition not met). Error output:\n${summariseOutput(lastBuild.output)}\nAnalyze the errors, fix the code, and rebuild.`,
451
+ });
452
+ }
453
+ this.messages = trimHistory(this.messages);
454
+ // Persist session state after every step (enables resume after crash/SIGINT)
455
+ this.saveSession(steps, buildCmd, lastBuild);
456
+ }
457
+ // Max steps reached or aborted
458
+ const reason = this.aborted ? "interrupted by user (SIGINT)" : `reached max steps (${this.config.maxSteps})`;
459
+ this.saveSession(steps, buildCmd, lastBuild);
460
+ return this.finish(false, steps, buildCmd, `Agent stopped: ${reason}`);
461
+ }
462
+ /** Persist current agent state to .scoder/sessions/<id>.json. */
463
+ saveSession(steps, buildCmd, lastBuild) {
464
+ this.lastBuildResult = lastBuild;
465
+ this.session.save({
466
+ task: this.currentTask,
467
+ model: this.client.model,
468
+ provider: this.config.provider,
469
+ buildCmd,
470
+ steps,
471
+ maxSteps: this.config.maxSteps,
472
+ messages: this.messages,
473
+ lastBuild: lastBuild
474
+ ? { success: lastBuild.success, command: lastBuild.command, output: lastBuild.output.slice(0, 10_000) }
475
+ : null,
476
+ usage: {
477
+ llmCalls: this.usageTotals.calls,
478
+ inputTokens: this.usageTotals.inputTokens,
479
+ outputTokens: this.usageTotals.outputTokens,
480
+ cachedTokens: this.usageTotals.cachedTokens,
481
+ },
482
+ }, this.sessionCreatedAt);
483
+ }
484
+ /** Explicitly verify the build (independent of what the LLM claims). */
485
+ async checkBuild(buildCmd) {
486
+ const res = await runBuild(this.config.workdir, buildCmd);
487
+ ui.buildStatus(res.success, res.output);
488
+ this.logger.log({ type: "build", success: res.success, command: buildCmd, output: res.output.slice(0, 10_000) });
489
+ return res;
490
+ }
491
+ finish(success, steps, buildCmd, summary) {
492
+ if (success) {
493
+ ui.success(`Task complete in ${steps} step(s). Build passed: ${buildCmd}`);
494
+ }
495
+ else {
496
+ ui.error(`Task NOT complete. ${summary}`);
497
+ }
498
+ this.logger.log({ type: "finish", success, steps });
499
+ return { success, steps, buildCmd, summary: summary.slice(0, 2000) };
500
+ }
501
+ }
package/dist/build.js ADDED
@@ -0,0 +1,41 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import { toolRunCommand } from "./tools.js";
4
+ /** Detect a sensible build command from project files in workdir. */
5
+ export function detectBuildCommand(workdir) {
6
+ const pkgPath = resolve(workdir, "package.json");
7
+ if (existsSync(pkgPath)) {
8
+ try {
9
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
10
+ if (pkg.scripts?.build)
11
+ return "npm run build";
12
+ }
13
+ catch (err) {
14
+ // malformed package.json -> fall through to the other detectors
15
+ console.error(`⚠ cannot parse package.json (${err.message}) — build command detection degraded`);
16
+ }
17
+ }
18
+ if (existsSync(resolve(workdir, "Makefile")))
19
+ return "make";
20
+ if (existsSync(resolve(workdir, "go.mod")))
21
+ return "go build ./...";
22
+ if (existsSync(resolve(workdir, "Cargo.toml")))
23
+ return "cargo build";
24
+ if (existsSync(resolve(workdir, "pyproject.toml")))
25
+ return "python -m compileall .";
26
+ if (existsSync(resolve(workdir, "tsconfig.json")))
27
+ return "npx tsc --noEmit";
28
+ return null;
29
+ }
30
+ /**
31
+ * Run the build command. This is the agent's stop condition:
32
+ * exit code 0 == task complete.
33
+ */
34
+ export async function runBuild(workdir, command) {
35
+ const res = await toolRunCommand(workdir, { command, timeout_secs: 600 });
36
+ return {
37
+ success: res.ok,
38
+ command,
39
+ output: res.output,
40
+ };
41
+ }
package/dist/config.js ADDED
@@ -0,0 +1,117 @@
1
+ import { readFileSync, existsSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ const DEFAULT_BASE_URL = "https://api.openai.com/v1";
4
+ const DEFAULT_MODEL = "gpt-4o-mini";
5
+ const ANTHROPIC_BASE_URL = "https://api.anthropic.com/v1";
6
+ const ANTHROPIC_DEFAULT_MODEL = "claude-sonnet-4-5";
7
+ function loadRcFile(workdir) {
8
+ for (const name of [".scoderrc.json", ".scoderrc"]) {
9
+ const p = resolve(workdir, name);
10
+ if (existsSync(p)) {
11
+ try {
12
+ return JSON.parse(readFileSync(p, "utf8"));
13
+ }
14
+ catch (err) {
15
+ // malformed rc file -> ignore it, but tell the user why their config is not applied
16
+ console.error(`⚠ ignoring malformed config file ${name}: ${err.message}`);
17
+ }
18
+ }
19
+ }
20
+ return {};
21
+ }
22
+ function parsePermissionMode(raw) {
23
+ if (raw === "auto" || raw === "ask" || raw === "readonly")
24
+ return raw;
25
+ return "auto";
26
+ }
27
+ function parseList(raw) {
28
+ if (!Array.isArray(raw))
29
+ return [];
30
+ return raw.filter((x) => typeof x === "string");
31
+ }
32
+ /** Validate the user-supplied pricing table; drop malformed entries with a warning. */
33
+ function parsePricing(raw) {
34
+ if (raw === undefined)
35
+ return undefined;
36
+ if (typeof raw !== "object" || raw === null) {
37
+ console.error("⚠ ignoring invalid modelPricing in config (expected an object)");
38
+ return undefined;
39
+ }
40
+ const out = {};
41
+ for (const key of Object.keys(raw)) {
42
+ const p = raw[key];
43
+ if (typeof p === "object" && p !== null &&
44
+ typeof p.input === "number" && Number.isFinite(p.input) &&
45
+ typeof p.output === "number" && Number.isFinite(p.output)) {
46
+ out[key] = { input: p.input, output: p.output };
47
+ }
48
+ else {
49
+ console.error(`⚠ ignoring invalid modelPricing entry "${key}" (expected { input: number, output: number })`);
50
+ }
51
+ }
52
+ return out;
53
+ }
54
+ export function loadConfig(overrides = {}) {
55
+ const workdir = overrides.workdir ?? process.cwd();
56
+ const rc = loadRcFile(workdir);
57
+ // Provider auto-detection: explicit flag > env > rc > baseUrl/model heuristics
58
+ const providerRaw = overrides.provider ??
59
+ process.env.SCODER_PROVIDER ??
60
+ rc.provider ??
61
+ undefined;
62
+ const isAnthropic = providerRaw === "anthropic" ||
63
+ (providerRaw === undefined &&
64
+ /api\.anthropic\.com/.test(process.env.SCODER_BASE_URL ?? process.env.OPENAI_BASE_URL ?? rc.baseUrl ?? "")) ||
65
+ (providerRaw === undefined &&
66
+ /^claude-/i.test(overrides.model ?? process.env.SCODER_MODEL ?? rc.model ?? ""));
67
+ const provider = providerRaw === "anthropic" || isAnthropic ? "anthropic" : "openai";
68
+ // Validate maxSteps: NaN/0/negative would silently break the agent loop
69
+ // (steps < NaN is always false → the loop never runs).
70
+ const rawMaxSteps = Number(process.env.SCODER_MAX_STEPS ?? rc.maxSteps ?? 40);
71
+ const validatedMaxSteps = Number.isFinite(rawMaxSteps) && rawMaxSteps >= 1 ? Math.floor(rawMaxSteps) : 40;
72
+ const cfg = {
73
+ provider,
74
+ baseUrl: overrides.baseUrl ?? process.env.SCODER_BASE_URL ?? process.env.OPENAI_BASE_URL ?? rc.baseUrl ?? (isAnthropic ? ANTHROPIC_BASE_URL : DEFAULT_BASE_URL),
75
+ apiKey: overrides.apiKey ??
76
+ process.env.SCODER_API_KEY ??
77
+ (isAnthropic ? process.env.ANTHROPIC_API_KEY : undefined) ??
78
+ process.env.OPENAI_API_KEY ??
79
+ rc.apiKey ??
80
+ "",
81
+ model: overrides.model ?? process.env.SCODER_MODEL ?? process.env.OPENAI_MODEL ?? rc.model ?? (isAnthropic ? ANTHROPIC_DEFAULT_MODEL : DEFAULT_MODEL),
82
+ workdir,
83
+ buildCmd: overrides.buildCmd ?? process.env.SCODER_BUILD_CMD ?? rc.buildCmd ?? null,
84
+ maxSteps: overrides.maxSteps ?? validatedMaxSteps,
85
+ maxTokens: 8192,
86
+ temperature: 0.2,
87
+ yes: overrides.yes ?? false,
88
+ noSave: overrides.noSave ?? process.env.SCODER_NO_SAVE === "1",
89
+ logFile: overrides.logFile ??
90
+ // "1" means auto-path (.scoder/logs/<session>.jsonl); any other non-empty
91
+ // value is treated as an explicit path. A bare string "1" would otherwise
92
+ // resolve to a file literally named "1".
93
+ (process.env.SCODER_LOG_FILE === "1"
94
+ ? true
95
+ : process.env.SCODER_LOG_FILE || undefined),
96
+ mcpServers: rc.mcpServers,
97
+ stream: overrides.stream ?? (process.env.SCODER_STREAM !== "0" ? true : false),
98
+ permissionMode: overrides.permissionMode ??
99
+ parsePermissionMode(process.env.SCODER_PERMISSION ?? rc.permission ?? rc.permissions?.mode ?? "auto"),
100
+ modelPricing: parsePricing(rc.modelPricing),
101
+ };
102
+ // Secrets hygiene: warn if apiKey is set in the rc file (risk of accidental commit)
103
+ if (rc.apiKey && !process.env.SCODER_NO_APIKEY_WARNING) {
104
+ console.error(`\n⚠ WARNING: API key found in .scoderrc.json — this file may be committed to git.\n` +
105
+ ` Prefer the environment variable SCODER_API_KEY (or OPENAI_API_KEY) instead,\n` +
106
+ ` and add .scoderrc.json to .gitignore. Suppress: SCODER_NO_APIKEY_WARNING=1\n`);
107
+ }
108
+ return cfg;
109
+ }
110
+ /** Extract permission lists from the rc file (loaded again here to avoid widening AgentConfig). */
111
+ export function loadPermissionLists(workdir) {
112
+ const rc = loadRcFile(workdir);
113
+ return {
114
+ allowCommands: parseList(rc.permissions?.allowCommands),
115
+ denyCommands: parseList(rc.permissions?.denyCommands),
116
+ };
117
+ }