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
@@ -0,0 +1,409 @@
1
+ import { claimsCompletion } from "./context.js";
2
+ /** Marker do system prompt quy ước: lượt chat thuần, không cần build. */
3
+ export const CHAT_DONE_MARKER = "<<CHAT_DONE>>";
4
+ export const DEFAULT_GUARDRAIL_OPTIONS = {
5
+ textOnlyStreakLimit: 2,
6
+ doomLoopRepeats: 2,
7
+ wrapUpStepsBeforeEnd: 2,
8
+ };
9
+ /**
10
+ * Wrap-up prefill: gần hết step budget → ép model dừng mở việc mới, chạy build
11
+ * và tổng kết. Nếu không, model thường đốt nốt các bước cuối để khám phá và
12
+ * run thất bại mà không có summary.
13
+ */
14
+ export function wrapUpRule(opts) {
15
+ return {
16
+ name: "wrap-up",
17
+ description: `inject reminder ${opts.wrapUpStepsBeforeEnd} steps before budget end`,
18
+ evaluate(ctx) {
19
+ const t = ctx;
20
+ if (t.wrapUpInjected)
21
+ return null;
22
+ if (ctx.maxSteps - ctx.step !== opts.wrapUpStepsBeforeEnd)
23
+ return null;
24
+ return {
25
+ action: {
26
+ kind: "inject",
27
+ reminder: "<system-reminder>Step budget nearly exhausted (2 steps left). Stop starting new work: finish the current change, run the build command, and reply with a final summary. If the build cannot pass in the remaining steps, report exactly what remains in your final message.</system-reminder>",
28
+ },
29
+ reason: `${opts.wrapUpStepsBeforeEnd} steps left in budget`,
30
+ };
31
+ },
32
+ };
33
+ }
34
+ /** Lượt chat thuần (marker CHAT_DONE) → kết thúc, không cần build. */
35
+ const chatDoneRule = {
36
+ name: "chat-done",
37
+ description: "assistant emitted the chat-done marker — end the conversational turn",
38
+ evaluate(ctx) {
39
+ if (!ctx.chatDone)
40
+ return null;
41
+ // Phòng vệ: làm sạch marker dù loop đã làm trước — summary không bao giờ
42
+ // lộ marker nội bộ ra UI.
43
+ const clean = ctx.text.split(CHAT_DONE_MARKER)[0].trim();
44
+ return {
45
+ action: {
46
+ kind: "finish",
47
+ success: true,
48
+ summary: clean || "Conversation turn complete.",
49
+ chatOnly: true,
50
+ },
51
+ reason: "chat-done marker present",
52
+ };
53
+ },
54
+ };
55
+ /**
56
+ * Completion claim: model tuyên bố xong việc → không tin lời nói, ép build xác
57
+ * minh. Trường hợp không sửa file nào (chỉ trả lời câu hỏi) thì bỏ qua build.
58
+ */
59
+ const completionClaimRule = {
60
+ name: "completion-claim",
61
+ description: "assistant claims completion — force a build verification",
62
+ evaluate(ctx) {
63
+ if (ctx.hasToolCalls)
64
+ return null;
65
+ if (!claimsCompletion(ctx.text))
66
+ return null;
67
+ if (ctx.step >= ctx.maxSteps)
68
+ return null;
69
+ if (!ctx.filesModified) {
70
+ return {
71
+ action: { kind: "finish", success: true, summary: ctx.text },
72
+ reason: "claims completion but no files were modified — skip build",
73
+ };
74
+ }
75
+ return {
76
+ action: {
77
+ kind: "verify-build",
78
+ successSummary: ctx.text,
79
+ // Loop sẽ ghép thêm "\nOutput:\n<build output>\nFix and continue."
80
+ failReminder: "Build still FAILED after your claimed completion.",
81
+ },
82
+ reason: "claims completion with modified files — verifying build",
83
+ };
84
+ },
85
+ };
86
+ /**
87
+ * Text-only streak breaker: model chỉ nói không làm (không gọi tool, không sửa
88
+ * file) đủ N bước liên tiếp → dừng run thay vì đốt step budget bằng vòng lặp
89
+ * "nudge" vô sản (failure mode "chào" nổi tiếng).
90
+ */
91
+ export function textOnlyStreakRule(opts) {
92
+ return {
93
+ name: "text-only-streak",
94
+ description: `end the run after ${opts.textOnlyStreakLimit} consecutive tool-less steps with no file changes`,
95
+ evaluate(ctx) {
96
+ const t = ctx;
97
+ if (ctx.hasToolCalls)
98
+ return null;
99
+ if (t.textOnlyStreak < opts.textOnlyStreakLimit)
100
+ return null;
101
+ if (ctx.filesModified)
102
+ return null;
103
+ return {
104
+ action: { kind: "finish", success: true, summary: ctx.text || "(agent replied without acting)" },
105
+ reason: `${t.textOnlyStreak} consecutive text-only steps with no file changes`,
106
+ };
107
+ },
108
+ };
109
+ }
110
+ /**
111
+ * Error-loop breaker (H-12): cùng tool + cùng error lặp lại 3 lần ⇒ inject
112
+ * "change approach" — doom-loop mở rộng bằng signature bao gồm error hash.
113
+ */
114
+ export function errorLoopRule(_opts) {
115
+ return {
116
+ name: "error-loop",
117
+ description: "same tool + same error 3x in a row — force a strategy change",
118
+ evaluate(ctx) {
119
+ const t = ctx;
120
+ if (t.toolErrorRepeat < 2)
121
+ return null; // 3rd occurrence = repeat 2
122
+ return {
123
+ action: {
124
+ kind: "inject",
125
+ reminder: "<system-reminder>The same tool call has failed with the same error 3 times. Stop retrying: the approach itself is wrong. Diagnose the root cause, try a fundamentally different method, or report the blocker in your final message.</system-reminder>",
126
+ },
127
+ reason: `same tool+error repeated ${t.toolErrorRepeat + 1}x`,
128
+ };
129
+ },
130
+ };
131
+ }
132
+ /**
133
+ * Token-no-progress (H-12): outputTokens < 20 trong 3 step liên tiếp ⇒ finish
134
+ * (model degenerate — tránh treo run không tiến triển).
135
+ */
136
+ export function tokenNoProgressRule(opts) {
137
+ return {
138
+ name: "token-no-progress",
139
+ description: "output tokens < 20 for 3 consecutive steps — end a degenerate run",
140
+ evaluate(ctx) {
141
+ const t = ctx;
142
+ void opts;
143
+ if (ctx.outputTokensThisStep === undefined)
144
+ return null;
145
+ if (ctx.outputTokensThisStep >= 20)
146
+ return null;
147
+ if (t.lowTokenStreak < 3)
148
+ return null;
149
+ return {
150
+ action: {
151
+ kind: "finish",
152
+ success: false,
153
+ summary: "Run stopped: 3 consecutive near-empty LLM responses (no progress).",
154
+ },
155
+ reason: `output tokens ${ctx.outputTokensThisStep} < 20 for ${t.lowTokenStreak} steps`,
156
+ };
157
+ },
158
+ };
159
+ }
160
+ /** Nudge: trả lời text thường (không claim, không hết streak) → nhắc tiếp tục làm việc. */
161
+ const continueNudgeRule = { name: "continue-nudge",
162
+ description: "tool-less step that neither claims completion nor ends the run — nudge the agent to act",
163
+ evaluate(ctx) {
164
+ if (ctx.hasToolCalls)
165
+ return null;
166
+ if (ctx.chatDone)
167
+ return null;
168
+ if (claimsCompletion(ctx.text))
169
+ return null; // completion-claim rule phụ trách
170
+ return {
171
+ action: {
172
+ kind: "inject",
173
+ reminder: "Continue working on the task. Use tools. Build must pass before you finish.",
174
+ },
175
+ reason: "plain text answer without tool calls",
176
+ };
177
+ },
178
+ };
179
+ /**
180
+ * Doom-loop breaker: chuỗi tool call giống hệt nhau lặp lại quá ngưỡng →
181
+ * can thiệp bằng lời nhắc đổi chiến lược (model hiếm khi tự thoát khỏi loop;
182
+ * run không người trông sẽ đốt sạch step budget).
183
+ */
184
+ export function doomLoopRule(opts) {
185
+ return {
186
+ name: "doom-loop",
187
+ description: `intervene after ${opts.doomLoopRepeats + 1} identical consecutive tool-call batches`,
188
+ evaluate(ctx) {
189
+ const t = ctx;
190
+ if (!ctx.hasToolCalls)
191
+ return null;
192
+ if (t.sigRepeat < opts.doomLoopRepeats)
193
+ return null;
194
+ return {
195
+ action: {
196
+ kind: "inject",
197
+ reminder: "<system-reminder>You have made the identical tool call 3 times in a row. This is a loop: read the last output carefully, change your approach, or report the blocker in your final message. Do NOT repeat the same call again.</system-reminder>",
198
+ },
199
+ reason: `identical tool-call batch repeated ${t.sigRepeat + 1}x`,
200
+ };
201
+ },
202
+ };
203
+ }
204
+ /** Context management: luôn cắt gọn lịch sử sau mỗi bước. */
205
+ const historyTrimRule = {
206
+ name: "history-trim",
207
+ description: "trim message history after every step (token budget)",
208
+ evaluate() {
209
+ return { action: { kind: "trim" }, reason: "routine context management" };
210
+ },
211
+ };
212
+ /**
213
+ * G6: token budget per run — tổng (input+output) vượt cap → kết thúc run
214
+ * (pause thay vì đốt tiền). Đọc usage từ ctx (loop truyền usageTotals).
215
+ */
216
+ export function tokenBudgetRule(capTokens) {
217
+ return {
218
+ name: "token-budget",
219
+ description: `end the run when total session tokens exceed ${capTokens}`,
220
+ evaluate(ctx) {
221
+ const total = (ctx.totalTokensIn ?? 0) + (ctx.totalTokensOut ?? 0);
222
+ if (total < capTokens)
223
+ return null;
224
+ return {
225
+ action: {
226
+ kind: "finish",
227
+ success: false,
228
+ summary: `Token budget exhausted: ${total} tokens used (cap ${capTokens}). ` +
229
+ "Report current progress in the summary.",
230
+ },
231
+ reason: `token budget ${total}/${capTokens}`,
232
+ };
233
+ },
234
+ };
235
+ }
236
+ /**
237
+ * Build stop condition: bước này có tool call chạy ĐÚNG build command →
238
+ * thành công là điều kiện kết thúc run (dùng lại kết quả tool, không build
239
+ * lần 2); thất bại thì tự xác minh lại rồi nudge fix.
240
+ */
241
+ const buildStopRule = {
242
+ name: "build-stop",
243
+ description: "a tool call ran the exact build command — success ends the run, failure re-verifies",
244
+ evaluate(ctx) {
245
+ if (!ctx.hasToolCalls || !ctx.buildCallPresent)
246
+ return null;
247
+ const summary = ctx.text || "Build passed.";
248
+ if (ctx.buildPassedThisStep) {
249
+ return {
250
+ action: { kind: "finish", success: true, summary },
251
+ reason: "build command succeeded this step",
252
+ };
253
+ }
254
+ return {
255
+ action: {
256
+ kind: "verify-build",
257
+ successSummary: summary,
258
+ // Loop sẽ ghép thêm output build thật vào sau.
259
+ failReminder: "Build FAILED (stop condition not met).",
260
+ },
261
+ reason: "build command ran and failed — re-verifying independently",
262
+ };
263
+ },
264
+ };
265
+ /** Bộ rule mặc định, theo thứ tự ưu tiên (finish đầu tiên thắng). */
266
+ export function defaultGuardrailRules(opts = DEFAULT_GUARDRAIL_OPTIONS) {
267
+ return [
268
+ wrapUpRule(opts),
269
+ chatDoneRule,
270
+ // G6: budget rule đứng sớm — vượt cap là finish ngay, trước mọi rule khác
271
+ // (trừ wrap-up, vốn vô hại khi budget đã cạn).
272
+ ...(opts.tokenBudget ? [tokenBudgetRule(opts.tokenBudget)] : []),
273
+ completionClaimRule,
274
+ textOnlyStreakRule(opts),
275
+ continueNudgeRule,
276
+ doomLoopRule(opts),
277
+ errorLoopRule(opts),
278
+ tokenNoProgressRule(opts),
279
+ buildStopRule,
280
+ historyTrimRule,
281
+ ];
282
+ }
283
+ // ── Engine ──────────────────────────────────────────────────────────────────
284
+ /**
285
+ * Engine chạy toàn bộ rule mỗi bước và thu thập các decision đã kích hoạt.
286
+ * runLoop áp dụng: finish đầu tiên thắng; inject/trim gom lại áp một lần.
287
+ * G1: toàn bộ tracker state (textOnlyStreak, sigRepeat, wrapUpInjected,
288
+ * toolError*, lowTokenStreak) sống ở đây — runLoop chỉ emit sự kiện thô.
289
+ */
290
+ export class GuardrailEngine {
291
+ onDecision;
292
+ rules;
293
+ /** G1: per-run tracker — reset() khi bắt đầu run mới. */
294
+ tracker = {
295
+ textOnlyStreak: 0,
296
+ lastCallSig: "",
297
+ sigRepeat: 0,
298
+ wrapUpInjected: false,
299
+ toolErrorSig: "",
300
+ toolErrorRepeat: 0,
301
+ lowTokenStreak: 0,
302
+ };
303
+ constructor(rules, onDecision) {
304
+ this.onDecision = onDecision;
305
+ this.rules = rules ?? defaultGuardrailRules();
306
+ }
307
+ /** Reset tracker — gọi ở đầu mỗi run (per-run state, không rò rỉ giữa các run). */
308
+ reset() {
309
+ this.tracker = {
310
+ textOnlyStreak: 0,
311
+ lastCallSig: "",
312
+ sigRepeat: 0,
313
+ wrapUpInjected: false,
314
+ toolErrorSig: "",
315
+ toolErrorRepeat: 0,
316
+ lowTokenStreak: 0,
317
+ };
318
+ }
319
+ /** Steer batch được deliver — reset text-only allowance một lần cho cả batch. */
320
+ notifySteered() {
321
+ this.tracker.textOnlyStreak = 0;
322
+ }
323
+ /** Wrap-up reminder vừa được apply — chỉ inject một lần mỗi run. */
324
+ markWrapUpInjected() {
325
+ this.tracker.wrapUpInjected = true;
326
+ }
327
+ /** Đăng ký thêm rule (chạy sau các rule mặc định nếu thêm sau khi tạo). */
328
+ register(rule) {
329
+ this.rules.push(rule);
330
+ }
331
+ listRules() {
332
+ return this.rules.map((r) => ({ name: r.name, description: r.description }));
333
+ }
334
+ /**
335
+ * Chạy tất cả rule; trả về các decision ĐÃ kích hoạt (theo thứ tự ưu tiên).
336
+ * Tracker được cập nhật từ ctx thô TRƯỚC khi rule chạy (rule thấy giá trị
337
+ * của bước này — đúng semantics cũ của runLoop).
338
+ */
339
+ evaluate(ctx) {
340
+ this.updateTrackers(ctx);
341
+ // Rules nhìn thấy tracker qua ctx mở rộng (internal — không thuộc API public).
342
+ const full = { ...ctx, ...this.tracker };
343
+ // G6: rule có thể async — chạy tuần tự (thứ tự ưu tiên giữ nguyên).
344
+ return (async () => {
345
+ const out = [];
346
+ for (const rule of this.rules) {
347
+ let r;
348
+ try {
349
+ r = await rule.evaluate(full);
350
+ }
351
+ catch (err) {
352
+ // Một rule lỗi không được phép làm gãy vòng lặp agent.
353
+ r = { action: { kind: "none" }, reason: `rule threw: ${err.message}` };
354
+ }
355
+ if (!r)
356
+ continue;
357
+ const d = { rule: rule.name, action: r.action, reason: r.reason };
358
+ out.push(d);
359
+ try {
360
+ this.onDecision?.(d);
361
+ }
362
+ catch {
363
+ /* logging không được phép giết loop */
364
+ }
365
+ }
366
+ return out;
367
+ })();
368
+ }
369
+ /**
370
+ * G1: cập nhật tracker từ sự kiện thô của bước này (thay cho state cục bộ
371
+ * của runLoop cũ). Semantics giữ nguyên 100% so với code inline trước đây.
372
+ */
373
+ updateTrackers(ctx) {
374
+ const t = this.tracker;
375
+ if (ctx.hasToolCalls) {
376
+ t.textOnlyStreak = 0;
377
+ // Doom-loop: chữ ký chuỗi tool call so với bước trước.
378
+ const callSig = (ctx.message?.tool_calls ?? [])
379
+ .map((tc) => `${tc.name}:${tc.arguments}`)
380
+ .sort()
381
+ .join("|");
382
+ t.sigRepeat = callSig === t.lastCallSig ? t.sigRepeat + 1 : 0;
383
+ t.lastCallSig = callSig;
384
+ }
385
+ else {
386
+ t.textOnlyStreak++;
387
+ }
388
+ // Error-loop (H-12): signature tool+error từ kết quả bước này.
389
+ const errSig = (ctx.message?.tool_calls ?? [])
390
+ .map((tc) => {
391
+ const r = ctx.toolResults?.get(tc.id);
392
+ return r && !r.ok ? `${tc.name}:${r.output.slice(0, 80)}` : null;
393
+ })
394
+ .filter(Boolean)
395
+ .sort()
396
+ .join("|");
397
+ t.toolErrorRepeat = errSig && errSig === t.toolErrorSig ? t.toolErrorRepeat + 1 : 0;
398
+ if (errSig)
399
+ t.toolErrorSig = errSig;
400
+ // Token-no-progress (H-12).
401
+ const outTok = ctx.outputTokensThisStep;
402
+ t.lowTokenStreak = outTok !== undefined && outTok < 20 ? t.lowTokenStreak + 1 : 0;
403
+ }
404
+ }
405
+ /** Engine với bộ rule mặc định + ngưỡng tùy chọn. */
406
+ export function createGuardrailEngine(opts = {}, onDecision) {
407
+ const merged = { ...DEFAULT_GUARDRAIL_OPTIONS, ...opts };
408
+ return new GuardrailEngine(defaultGuardrailRules(merged), onDecision);
409
+ }
package/dist/index.js CHANGED
@@ -8,56 +8,65 @@ import { listSessions, resolveSessionId, pruneSessions } from "./session.js";
8
8
  import { JsonlLogger } from "./logger.js";
9
9
  import { collectUsage, formatCostReport } from "./cost.js";
10
10
  import { getVersion as getPackageVersion } from "./version.js";
11
- import { formatSlashHelp, parseSlashCommand } from "./tui/commands.js";
11
+ import { dispatchCommand } from "./tui/command-registry.js";
12
12
  import * as ui from "./ui.js";
13
13
  // Single source of truth: read version from package.json (see src/version.ts).
14
14
  const VERSION = getPackageVersion();
15
- const HELP = `scoder — autonomous coding agent CLI
16
-
17
- USAGE
18
- scoder [options] "<task>" Run a task autonomously until the build passes
19
- scoder Start interactive session (chat + tools)
20
- scoder tui [task] Full-screen terminal UI (chat pane, live tool feed)
21
- scoder init First-run wizard: writes .scoderrc.json + tests the connection
22
- scoder list List saved sessions for this directory
23
- scoder resume <id|last> [task] Resume a saved session (optionally with a follow-up task)
24
- scoder logs [id|last] [filter] Print a session's JSONL log (requires --log-file runs)
25
- scoder cost [--session <id>] [--last <n>]
26
- Token usage & estimated cost from JSONL logs
27
- scoder serve [--port <n>] [--host <addr>]
28
- Run as an HTTP server: POST tasks, stream
29
- progress via Server-Sent Events (see below)
30
- scoder --version | --help
31
-
32
- OPTIONS
33
- -m, --model <name> LLM model (env SCODER_MODEL / OPENAI_MODEL, default gpt-4o-mini)
34
- --base-url <url> OpenAI-compatible API base URL (env SCODER_BASE_URL / OPENAI_BASE_URL)
35
- -k, --api-key <key> API key (env SCODER_API_KEY / OPENAI_API_KEY)
36
- --provider <name> openai | anthropic (env SCODER_PROVIDER; auto-detected from model/baseUrl)
37
- -B, --build <cmd> Build command = stop condition (auto-detected if omitted)
38
- -d, --dir <path> Working directory (default: current directory)
39
- -s, --max-steps <n> Max autonomous loop iterations (default 40)
40
- -y, --yes Non-interactive mode (auto-approves commands)
41
- --permission <mode> auto | ask | readonly (env SCODER_PERMISSION, default auto)
42
- --no-save Disable session auto-save to .scoder/sessions/
43
- --log-file Write a JSONL event log to .scoder/logs/<session>.jsonl
44
- (or set SCODER_LOG_FILE=1 / =/path/to/log.jsonl)
45
- --session <id> (cost) Aggregate usage of one specific session
46
- --last <n> (cost) Aggregate usage of the n most recent sessions
47
- -h, --help Show this help
48
- -v, --version Show version
49
-
50
- CONFIG FILE
51
- .scoderrc.json in the working directory (see .scoderrc.example.json):
52
- { "model": "...", "baseUrl": "...", "buildCmd": "...", "maxSteps": 40,
53
- "permissions": { "mode": "auto", "allowCommands": ["npm "], "denyCommands": ["curl "] } }
54
-
55
- EXAMPLES
56
- scoder "add a divide function to src/math.ts with tests"
57
- scoder --build "make test" "fix the failing test in utils"
58
- scoder --permission ask "npm install express and wire up a route"
59
- scoder -m claude-sonnet-4-5 "refactor the parser module" # auto-detects Anthropic
60
- scoder resume last "now add unit tests"
15
+ // Safety net: a stray promise rejection (e.g. a provider socket aborting after
16
+ // its consumer already moved on) must log — never hard-crash the agent.
17
+ process.on("unhandledRejection", (reason) => {
18
+ const msg = reason instanceof Error ? `${reason.name}: ${reason.message}` : String(reason);
19
+ ui.warn(`background error (ignored): ${msg.slice(0, 200)}`);
20
+ });
21
+ const HELP = `scoder — autonomous coding agent CLI
22
+
23
+ USAGE
24
+ scoder [options] "<task>" Run a task autonomously until the build passes
25
+ scoder Start interactive session (chat + tools)
26
+ scoder tui [task] Full-screen terminal UI (chat pane, live tool feed)
27
+ scoder init First-run wizard: writes .scoderrc.json + tests the connection
28
+ scoder list List saved sessions for this directory
29
+ scoder models [provider] [--refresh]
30
+ Model catalog (context window, pricing) —
31
+ synced from models.dev, cached 24h
32
+ scoder resume <id|last> [task] Resume a saved session (optionally with a follow-up task)
33
+ scoder logs [id|last] [filter] Print a session's JSONL log (requires --log-file runs)
34
+ scoder cost [--session <id>] [--last <n>]
35
+ Token usage & estimated cost from JSONL logs
36
+ scoder serve [--port <n>] [--host <addr>]
37
+ Run as an HTTP server: POST tasks, stream
38
+ progress via Server-Sent Events (see below)
39
+ scoder --version | --help
40
+
41
+ OPTIONS
42
+ -m, --model <name> LLM model (env SCODER_MODEL / OPENAI_MODEL, default gpt-4o-mini)
43
+ --base-url <url> OpenAI-compatible API base URL (env SCODER_BASE_URL / OPENAI_BASE_URL)
44
+ -k, --api-key <key> API key (env SCODER_API_KEY / OPENAI_API_KEY)
45
+ --provider <name> openai | anthropic (env SCODER_PROVIDER; auto-detected from model/baseUrl)
46
+ -B, --build <cmd> Build command = stop condition (auto-detected if omitted)
47
+ -d, --dir <path> Working directory (default: current directory)
48
+ -s, --max-steps <n> Max autonomous loop iterations (default 40)
49
+ -y, --yes Non-interactive mode (auto-approves commands)
50
+ --permission <mode> auto | ask | readonly (env SCODER_PERMISSION, default auto)
51
+ --no-save Disable session auto-save to .scoder/sessions/
52
+ --log-file Write a JSONL event log to .scoder/logs/<session>.jsonl
53
+ (or set SCODER_LOG_FILE=1 / =/path/to/log.jsonl)
54
+ --session <id> (cost) Aggregate usage of one specific session
55
+ --last <n> (cost) Aggregate usage of the n most recent sessions
56
+ -h, --help Show this help
57
+ -v, --version Show version
58
+
59
+ CONFIG FILE
60
+ .scoderrc.json in the working directory (see .scoderrc.example.json):
61
+ { "model": "...", "baseUrl": "...", "buildCmd": "...", "maxSteps": 40,
62
+ "permissions": { "mode": "auto", "allowCommands": ["npm "], "denyCommands": ["curl "] } }
63
+
64
+ EXAMPLES
65
+ scoder "add a divide function to src/math.ts with tests"
66
+ scoder --build "make test" "fix the failing test in utils"
67
+ scoder --permission ask "npm install express and wire up a route"
68
+ scoder -m claude-sonnet-4-5 "refactor the parser module" # auto-detects Anthropic
69
+ scoder resume last "now add unit tests"
61
70
  `;
62
71
  function parseArgs(argv) {
63
72
  const args = { task: null, yes: false, help: false, version: false };
@@ -181,6 +190,13 @@ function parseArgs(argv) {
181
190
  args.subcommand = "tui";
182
191
  args.task = rest.length > 1 ? rest.slice(1).join(" ") : null;
183
192
  }
193
+ else if (rest[0] === "models") {
194
+ // scoder models [provider] [--refresh] — models.dev catalog (I-02)
195
+ args.subcommand = "models";
196
+ args.modelsProvider = rest[1] && !rest[1].startsWith("--") ? rest[1] : undefined;
197
+ args.modelsRefresh = rest.includes("--refresh");
198
+ args.task = null;
199
+ }
184
200
  else if (rest[0] === "init") {
185
201
  args.subcommand = "init";
186
202
  args.task = null;
@@ -227,9 +243,19 @@ export async function runInit() {
227
243
  }
228
244
  const rl = createInterface({ input: process.stdin, output: process.stdout });
229
245
  const ask = (q, def) => new Promise((res) => rl.question(`${q}${def ? ` [${def}]` : ""}: `, (a) => res(a.trim() || def)));
230
- const baseUrl = await ask("API base URL (OpenAI-compatible)", "https://api.openai.com/v1");
246
+ // I-03: probe local engines first — suggest when found (no auto-switch when
247
+ // a cloud key exists; user always decides).
248
+ ui.info("probing local engines (ollama :11434, lmstudio :1234, vllm :8000)…");
249
+ const { discoverLocalEngines } = await import("./provider-registry.js");
250
+ const locals = await discoverLocalEngines();
251
+ for (const e of locals) {
252
+ ui.info(` found ${e.provider.id} at ${e.provider.baseUrl} — ${e.models.length} model(s), e.g. ${e.models[0]}`);
253
+ }
254
+ const suggestBase = locals[0]?.provider.baseUrl ?? "https://api.openai.com/v1";
255
+ const suggestModel = locals[0]?.models[0] ?? "gpt-4o-mini";
256
+ const baseUrl = await ask("API base URL (OpenAI-compatible)", suggestBase);
231
257
  const apiKey = await ask("API key (SCODER_API_KEY/OPENAI_API_KEY also work at runtime)", "");
232
- const model = await ask("Model", "gpt-4o-mini");
258
+ const model = await ask("Model", suggestModel);
233
259
  const buildCmd = await ask("Build command (empty = auto-detect)", "");
234
260
  const permission = (await ask("Permission mode (auto | ask | readonly)", "auto")).toLowerCase();
235
261
  rl.close();
@@ -290,35 +316,35 @@ async function repl(cfg) {
290
316
  continue;
291
317
  if (input === "exit" || input === "quit")
292
318
  break;
293
- // Slash commands — handled by the UI, never sent to the LLM.
294
- const slash = parseSlashCommand(input);
295
- if (slash) {
296
- if (slash.cmd === "/exit" || slash.cmd === "/quit")
297
- break;
298
- if (slash.cmd === "/help") {
299
- console.log(formatSlashHelp("scoder"));
300
- continue;
301
- }
302
- if (slash.cmd === "/clear") {
319
+ // Slash commands — registry dùng chung với TUI (T1). Command riêng của
320
+ // TUI (/review /undo /theme /diff turn-scope) do TUI tự xử lý trước.
321
+ const host = {
322
+ agent: () => agent,
323
+ config: cfg,
324
+ replaceAgent: (next) => {
303
325
  agent.abort();
304
- agent = new Agent(cfg);
326
+ agent = next ?? new Agent(cfg);
305
327
  first = true;
306
- ui.info("conversation cleared — context reset");
307
- continue;
308
- }
309
- if (slash.cmd === "/cost") {
310
- const u = agent.getUsage();
311
- console.log(u.calls > 0
312
- ? `session usage: ${u.calls} LLM calls · tokens in ${u.inputTokens} / out ${u.outputTokens}` +
313
- (u.cachedTokens > 0 ? ` (+${u.cachedTokens} cached)` : "")
314
- : "no LLM calls yet in this session");
315
- continue;
316
- }
317
- if (slash.cmd === "/model") {
318
- console.log(`model: ${cfg.model} · provider: ${cfg.provider} · build: ${cfg.buildCmd ?? "auto-detect"}`);
319
- continue;
320
- }
321
- ui.error(`unknown command ${slash.cmd} — try /help`);
328
+ },
329
+ resumeAndRun: async (s) => {
330
+ agent.abort();
331
+ agent = new Agent(cfg);
332
+ first = false;
333
+ await agent.resume(s);
334
+ return { success: true, steps: 0, buildCmd: cfg.buildCmd, summary: "resumed" };
335
+ },
336
+ };
337
+ const { handled, outcome } = await dispatchCommand(input, host, (text, level) => {
338
+ if (level === "error")
339
+ ui.error(text);
340
+ else
341
+ console.log(text);
342
+ });
343
+ if (handled) {
344
+ if (outcome?.quit)
345
+ break;
346
+ if (outcome?.notice)
347
+ ui.info(outcome.notice);
322
348
  continue;
323
349
  }
324
350
  try {
@@ -433,6 +459,25 @@ async function main() {
433
459
  console.log(formatCostReport(sessionCosts));
434
460
  return;
435
461
  }
462
+ if (args.subcommand === "models") {
463
+ const { loadCatalog, cacheAgeHours } = await import("./model-catalog.js");
464
+ const filter = args.modelsProvider ?? null;
465
+ const { models, source } = await loadCatalog(args.modelsRefresh === true);
466
+ const age = cacheAgeHours();
467
+ const ageStr = source === "fallback" ? "offline fallback snapshot" : age < 0 ? "?" : `${age.toFixed(1)}h ago`;
468
+ console.log(`models catalog — source: ${source} (updated ${ageStr}), ${Object.keys(models).length} entries\n`);
469
+ const rows = Object.values(models)
470
+ .filter((m) => !filter || m.provider === filter || m.id.includes(filter))
471
+ .sort((a, b) => a.provider.localeCompare(b.provider) || a.id.localeCompare(b.id))
472
+ .slice(0, 60);
473
+ for (const m of rows) {
474
+ const flags = [m.supportCache ? "cache" : null, m.supportReasoning ? "reasoning" : null].filter(Boolean).join(",");
475
+ console.log(` ${m.provider}/${m.id} ctx=${(m.contextWindow / 1000).toFixed(0)}k${m.pricing ? ` $${m.pricing.input}/$${m.pricing.output} per 1M` : ""}${flags ? ` [${flags}]` : ""}`);
476
+ }
477
+ if (rows.length === 0)
478
+ console.log(` (no models match "${filter}")`);
479
+ return;
480
+ }
436
481
  if (args.subcommand === "tui") {
437
482
  if (!process.stdin.isTTY || !process.stdout.isTTY) {
438
483
  ui.warn("TUI requires an interactive terminal (TTY) — falling back to the classic REPL.");