s-app-scoder-agent 0.5.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 -435
  4. package/dist/agent.js +829 -168
  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 +216 -45
  12. package/dist/json-schema.js +58 -0
  13. package/dist/llm.js +148 -11
  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 +537 -24
  30. package/dist/tui/app.js +615 -0
  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 +222 -0
  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 +342 -0
  45. package/dist/tui/theme.js +90 -0
  46. package/dist/tui/vim.js +60 -0
  47. package/dist/ui.js +43 -3
  48. package/dist/watch.js +52 -0
  49. package/package.json +18 -5
package/dist/agent.js CHANGED
@@ -1,48 +1,43 @@
1
- import { loadPermissionLists } from "./config.js";
2
- import { LlmClient, LlmError } from "./llm.js";
1
+ import { loadPermissionLists, loadPolicyRules, loadSavedApprovals, saveApproval } from "./config.js";
2
+ import { LlmClient, LlmError, redactSecrets } from "./llm.js";
3
3
  import { AnthropicClient } from "./provider-anthropic.js";
4
- import { AGENT_TOOLS, getTool } from "./tools.js";
4
+ import { getTool, allToolSpecs, setMaxOutputChars } from "./tools.js";
5
+ import { invalidateFileIndex } from "./tui/at-complete.js";
5
6
  import { detectBuildCommand, runBuild } from "./build.js";
6
- import { trimHistory, summariseOutput, claimsCompletion } from "./context.js";
7
+ import { trimHistory, summariseOutput, expandMentions } from "./context.js";
8
+ import { createGuardrailEngine, CHAT_DONE_MARKER } from "./guardrails.js";
9
+ import { PROMPT_VERSION } from "./prompt.js";
10
+ import { buildSystemPrompt, buildEnvInfo, buildSkillsBlock, buildAgentsBlock, buildMcpBlock } from "./prompt-builder.js";
7
11
  import { PermissionChecker } from "./permissions.js";
8
- import { SessionStore } from "./session.js";
12
+ import { SessionStore, EventSessionStore } from "./session.js";
13
+ import { DEFAULT_COMPACTION, shouldCompact, extractSummaryInput, COMPACTION_INSTRUCTION, applyCompaction } from "./compaction.js";
14
+ import { SnapshotManager } from "./snapshot.js";
15
+ import { contextWindowFor } from "./model-catalog.js";
9
16
  import { JsonlLogger } from "./logger.js";
17
+ import { createRuntime, parseCustomProviders, resolveProvider, splitProviderModel } from "./provider-registry.js";
18
+ import { loadRcFile } from "./config.js";
10
19
  import { McpManager } from "./mcp.js";
20
+ import { validateSchema } from "./json-schema.js";
21
+ import { watchWorkdir } from "./watch.js";
22
+ import { discoverSkills, findSkill } from "./skills.js";
23
+ import { discoverAgents, findAgent } from "./agents.js";
24
+ import { SteeringInbox } from "./steering.js";
11
25
  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
- 8. You have web access ONLY through web_fetch (it fetches exact URLs, it cannot
33
- search). Use it when the task depends on external or fast-changing knowledge:
34
- library/API documentation, a URL found in an error message or code comment,
35
- current package versions, changelogs, release notes, standards. Skip it when
36
- the answer is in the working directory or is stable general knowledge. After
37
- fetching, ground your edits in what the page actually says.`;
38
26
  export class Agent {
39
27
  config;
40
28
  client;
41
29
  messages = [];
42
- tools = AGENT_TOOLS.map((t) => t.spec);
30
+ tools = allToolSpecs();
43
31
  ctx;
44
32
  permissions;
33
+ /** T-03: pending permission prompts — id → resolve (TUI/serve answer them). */
34
+ pendingPermissions = new Map();
35
+ permSeq = 0;
45
36
  session;
37
+ /** v2 durable event log (session/ + events.jsonl) — crash recovery + steering. */
38
+ events;
39
+ /** Git shadow-repo checkpoints before write batches (H-11). */
40
+ snapshots;
46
41
  logger;
47
42
  aborted = false;
48
43
  streamFallbackWarned = false;
@@ -53,39 +48,157 @@ export class Agent {
53
48
  usageTotals = { calls: 0, inputTokens: 0, outputTokens: 0, cachedTokens: 0 };
54
49
  mcp = null;
55
50
  /** Aborted when abort() is called — cancels in-flight LLM requests (SIGINT). */
51
+ /** Steering inbox — user input delivered at safe step boundaries (H-08, I5 module). */
52
+ steerInbox = new SteeringInbox();
56
53
  runSignal = new AbortController();
57
54
  /** Optional structured-event sink (used by the HTTP/SSE server frontend). */
58
55
  onEvent = null;
59
56
  /** Set while a tool call is executing, for tool_output event correlation. */
60
57
  currentToolCall = null;
58
+ /** True once any file-writing tool succeeded during the current run. */
59
+ filesModified = false;
60
+ /** Discovered skills (project + global). */
61
+ skills = [];
62
+ /** M4: hot-reload watcher (rc + skills dir). */
63
+ watcher = null;
64
+ /** Content staged via /skill — injected before the next run/turn. */
65
+ pendingContext = [];
66
+ /** Session plan (write_todos tool). */
67
+ plan = [];
68
+ /** Guardrail Engine — khởi tạo trong constructor (cần this.config). */
69
+ guardrails;
70
+ /** Log một quyết định guardrail ra JSONL + event (quan sát được, không ồn ào UI). */
71
+ logGuardrailDecision(d) {
72
+ const kind = d.action.kind;
73
+ this.logger.log({ type: "guardrail", rule: d.rule, action: kind, reason: d.reason });
74
+ this.emit({ type: "guardrail", rule: d.rule, action: kind, reason: d.reason });
75
+ }
61
76
  constructor(config) {
62
77
  this.config = config;
78
+ this.guardrails = createGuardrailEngine(config.guardrails ?? {}, (d) => this.logGuardrailDecision(d));
63
79
  const commonOpts = {
64
80
  baseUrl: config.baseUrl,
65
81
  apiKey: config.apiKey,
66
82
  model: config.model,
67
83
  maxTokens: config.maxTokens,
68
84
  temperature: config.temperature,
85
+ requestTimeoutMs: config.limits?.requestTimeoutMs, // G7
69
86
  };
70
- this.client =
71
- config.provider === "anthropic"
72
- ? new AnthropicClient(commonOpts)
73
- : new LlmClient({ ...commonOpts, stream: config.stream });
87
+ // I-01: registry-driven runtime — providerDef (custom/builtin) wins; else legacy ternary.
88
+ // I-05: thinking budget = 10% maxTokens khi reasoning on (Anthropic only).
89
+ // I-12: eval harness — scripted client override thay vì construct.
90
+ const thinkingBudget = config.reasoning === "on" ? Math.max(1024, Math.floor(config.maxTokens * 0.25)) : 0;
91
+ this.client = config.clientFactory?.() ??
92
+ (config.providerDef?.kind === "anthropic" || (!config.providerDef && config.provider === "anthropic")
93
+ ? new AnthropicClient({ ...commonOpts, enableCache: config.providerDef?.supportCache ?? true, thinkingBudgetTokens: thinkingBudget })
94
+ : new LlmClient({ ...commonOpts, stream: config.stream }));
74
95
  const permLists = loadPermissionLists(config.workdir);
75
96
  this.permissions = new PermissionChecker({
76
97
  mode: config.permissionMode,
77
98
  allowCommands: permLists.allowCommands,
78
99
  denyCommands: permLists.denyCommands,
79
100
  nonInteractive: config.yes,
101
+ rules: config.permissionRules,
102
+ savedRules: loadSavedApprovals(config.workdir),
103
+ policyRules: loadPolicyRules(config.workdir),
80
104
  });
105
+ // T-03: route permission prompts through the event stream when an event
106
+ // sink is attached (TUI/serve) — readline on stdin corrupts alt-screen.
107
+ this.permissions.promptHandler = (req) => this.programmaticPermissionPrompt(req);
81
108
  this.ctx = {
82
109
  workdir: config.workdir,
83
110
  permissions: this.permissions,
84
111
  permissionMode: config.permissionMode,
85
112
  onToolOutput: this.handleToolOutput,
113
+ plan: {
114
+ get: () => this.plan,
115
+ set: (items) => {
116
+ this.plan = items;
117
+ const done = items.filter((t) => t.status === "completed").length;
118
+ ui.info(`plan: ${done}/${items.length} completed`);
119
+ this.emit({ type: "todo", items: [...items] });
120
+ this.logger.log({ type: "todo", items });
121
+ },
122
+ },
123
+ formatter: config.formatter,
124
+ sandbox: config.sandbox,
125
+ allowSubagents: config.allowSubagents,
86
126
  };
127
+ // G7: thresholds từ rc limits.
128
+ if (config.limits?.maxOutputChars)
129
+ setMaxOutputChars(config.limits.maxOutputChars);
87
130
  this.session = new SessionStore(config.workdir, config.noSave !== true);
131
+ this.events = new EventSessionStore(config.workdir, config.noSave !== true, this.session.id);
132
+ this.snapshots = new SnapshotManager(config.workdir, config.snapshots !== false);
133
+ // I-06: task tool → child Agent run (same process, own session, event
134
+ // relay, abort cascade via parent's runSignal). Depth-1 only: the runner
135
+ // refuses when the calling agent was constructed with allowSubagents:false
136
+ // (children set this; the runner checks ctx, not a global flag — global
137
+ // registration alone allowed grandchildren through). Runner lives on the
138
+ // per-instance ctx — concurrent Agents (server multi-run) stay isolated.
139
+ if (config.allowSubagents !== false) {
140
+ const runner = async (ctx, args) => {
141
+ if (ctx.allowSubagents === false) {
142
+ return { ok: false, output: "ERROR: task tool not available — subagents cannot spawn further subagents (depth limit)." };
143
+ }
144
+ const def = args.agent ? findAgent(config.workdir, args.agent) : findAgent(config.workdir, "explore");
145
+ if (args.agent && !def) {
146
+ return { ok: false, output: `ERROR: agent "${args.agent}" not found — available: ${discoverAgents(config.workdir).filter((a) => a.mode !== "subagent" || a.name === "explore").map((a) => a.name).join(", ")}` };
147
+ }
148
+ const child = new Agent({
149
+ ...config,
150
+ allowSubagents: false,
151
+ model: def?.model ?? config.model,
152
+ maxSteps: def?.steps ?? Math.min(config.maxSteps, 20),
153
+ noSave: true, // subagent runs are ephemeral — no session pollution
154
+ // plan/explore agents stay read-only regardless of parent mode.
155
+ permissionMode: def?.name === "plan" || def?.name === "explore" ? "readonly" : config.permissionMode,
156
+ });
157
+ // Abort cascade: parent abort kills child's in-flight LLM calls.
158
+ this.runSignal.signal.addEventListener("abort", () => child.abort(), { once: true });
159
+ // Event relay: child activity appears as subagent-tagged tool output.
160
+ const tag = `subagent:${def?.name ?? "task"}`;
161
+ child.onEvent = (ev) => {
162
+ if (ev.type === "tool_call")
163
+ this.emit({ type: "tool_output", name: tag, stream: "stdout", delta: `→ ${ev.name}\n` });
164
+ else if (ev.type === "llm_delta")
165
+ this.emit({ type: "tool_output", name: tag, stream: "stdout", delta: ev.delta });
166
+ };
167
+ try {
168
+ const res = await child.run(args.prompt);
169
+ return {
170
+ ok: res.success,
171
+ output: res.success
172
+ ? `[subagent ${def?.name ?? "task"} done — ${res.steps} steps]\n${res.summary}`
173
+ : `[subagent ${def?.name ?? "task"} FAILED — ${res.steps} steps]\n${res.summary}`,
174
+ };
175
+ }
176
+ catch (err) {
177
+ return { ok: false, output: `ERROR: subagent failed: ${err.message}` };
178
+ }
179
+ };
180
+ this.ctx.taskRunner = runner;
181
+ }
88
182
  this.logger = JsonlLogger.forSession(config.workdir, this.session.id, config.logFile);
183
+ this.skills = discoverSkills(config.workdir);
184
+ // M4: hot-reload — .scoderrc (MCP servers) + .scoder/skills/ đổi giữa
185
+ // session → reload ở turn sau (debounced, không watcher trong test).
186
+ if (config.noSave !== true && process.env.SCODER_NO_WATCH !== "1") {
187
+ this.watcher = watchWorkdir(config.workdir, (kind) => {
188
+ if (kind === "skills") {
189
+ this.skills = discoverSkills(config.workdir);
190
+ this.logger.log({ type: "hot_reload", kind, skills: this.skills.length });
191
+ }
192
+ else if (kind === "config") {
193
+ // MCP servers mới: merge vào manager hiện tại (turn sau advertise).
194
+ const rc = loadRcFile(config.workdir);
195
+ const servers = rc.mcpServers ?? {};
196
+ if (this.mcp && Object.keys(servers).length > 0) {
197
+ void this.mcp.applyConfig(servers);
198
+ }
199
+ }
200
+ });
201
+ }
89
202
  }
90
203
  /**
91
204
  * Handle SIGINT: abort gracefully after current tool finishes.
@@ -95,6 +208,32 @@ export class Agent {
95
208
  this.aborted = true;
96
209
  this.runSignal.abort(new DOMException("interrupted by user", "AbortError"));
97
210
  }
211
+ /**
212
+ * Steer: user input delivered at the NEXT safe step boundary (pattern from
213
+ * opencode "steer by default"). Injected as a user message before the next
214
+ * LLM call; resets the doom-loop text-allowance once for the whole batch.
215
+ */
216
+ steer(text) {
217
+ this.steerInbox.steer(text);
218
+ const t = text.trim();
219
+ if (!t)
220
+ return;
221
+ this.events.append({ type: "steer.enqueued", mode: "steer", text: t });
222
+ this.emit({ type: "steer_queued", mode: "steer", text: t });
223
+ }
224
+ /** Queue: same inbox, but only relevant when the agent is idle (queued run). */
225
+ queue(text) {
226
+ this.steerInbox.queue(text);
227
+ const t = text.trim();
228
+ if (!t)
229
+ return;
230
+ this.events.append({ type: "steer.enqueued", mode: "queue", text: t });
231
+ this.emit({ type: "steer_queued", mode: "queue", text: t });
232
+ }
233
+ /** Drain the steering inbox; delivered at the top of the next step. */
234
+ drainSteerInbox() {
235
+ return this.steerInbox.drain().map((x) => x.text);
236
+ }
98
237
  /** Reset the run-signal so a new run() can use a fresh controller. */
99
238
  resetRunSignal() {
100
239
  this.runSignal = new AbortController();
@@ -107,6 +246,49 @@ export class Agent {
107
246
  emit(ev) {
108
247
  this.onEvent?.(ev);
109
248
  }
249
+ /**
250
+ * T-03: permission ask via the event stream. Emits permission_request and
251
+ * waits for resolvePermission(); when no sink is attached, falls back to
252
+ * deny (the TUI always attaches one; programmatic callers answer directly).
253
+ * Timeout ⇒ deny + auto-resolve (no leaked promise).
254
+ */
255
+ programmaticPermissionPrompt(req) {
256
+ if (!this.onEvent)
257
+ return Promise.resolve("no");
258
+ const id = `perm-${++this.permSeq}`;
259
+ return new Promise((resolve) => {
260
+ const timer = setTimeout(() => {
261
+ if (this.pendingPermissions.delete(id))
262
+ resolve("no");
263
+ }, req.timeoutSecs * 1000);
264
+ this.pendingPermissions.set(id, (a) => {
265
+ clearTimeout(timer);
266
+ resolve(a);
267
+ });
268
+ this.emit({
269
+ type: "permission_request",
270
+ id,
271
+ kind: req.kind,
272
+ label: req.label,
273
+ ...(req.command !== undefined ? { command: req.command } : {}),
274
+ ...(req.diff !== undefined ? { diff: req.diff } : {}),
275
+ ...(req.alwaysAllowPrefix !== undefined ? { alwaysAllowPrefix: req.alwaysAllowPrefix } : {}),
276
+ timeoutSecs: req.timeoutSecs,
277
+ });
278
+ });
279
+ }
280
+ /**
281
+ * T-03: answer a pending permission_request (called from the TUI modal /
282
+ * HTTP API). Unknown id → silently ignored (already timed out).
283
+ */
284
+ resolvePermission(id, answer) {
285
+ const res = this.pendingPermissions.get(id);
286
+ if (res) {
287
+ this.pendingPermissions.delete(id);
288
+ res(answer);
289
+ }
290
+ }
291
+ /** Aborted when abort() is called — cancels in-flight LLM requests (SIGINT). */
110
292
  /**
111
293
  * Live tool-output handler: renders chunks in the terminal (dimmed block) and
112
294
  * forwards them as `tool_output` events. Opens the stream block lazily on the
@@ -126,12 +308,14 @@ export class Agent {
126
308
  }
127
309
  /** Push one tool-result message back into the conversation. */
128
310
  pushToolResult(tc, content) {
129
- this.messages.push({
311
+ const msg = {
130
312
  role: "tool",
131
313
  tool_call_id: tc.id,
132
314
  name: tc.name,
133
315
  content,
134
- });
316
+ };
317
+ this.messages.push(msg);
318
+ this.events.append({ type: "message.added", message: msg });
135
319
  }
136
320
  /**
137
321
  * Handle a rejected tool call (unknown tool, policy denial, user denial):
@@ -140,14 +324,159 @@ export class Agent {
140
324
  */
141
325
  rejectToolCall(step, tc, output, logExtra) {
142
326
  ui.toolResult(tc.name, false, output);
143
- this.emit({ type: "tool_result", step, name: tc.name, ok: false, output });
327
+ this.emit({ type: "tool_result", step, id: tc.id, name: tc.name, ok: false, output });
144
328
  this.logger.log({ type: "tool", step, tool: tc.name, ok: false, ...logExtra });
145
329
  this.pushToolResult(tc, output);
146
330
  }
331
+ /** I-09: tools safe to run concurrently (pure reads, no permission ask). */
332
+ static READONLY_TOOLS = new Set(["read_file", "list_files", "grep", "glob", "load_skill"]);
333
+ /**
334
+ * I-09: bounded-concurrency map preserving input order.
335
+ */
336
+ async parallelMap(items, fn, limit) {
337
+ const results = new Array(items.length);
338
+ let next = 0;
339
+ const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
340
+ while (next < items.length) {
341
+ const i = next++;
342
+ results[i] = await fn(items[i], i);
343
+ }
344
+ });
345
+ await Promise.all(workers);
346
+ return results;
347
+ }
348
+ /**
349
+ * Execute one tool call end-to-end: validate args, render, permission gate,
350
+ * run (builtin or MCP), log, and emit result. Returns `post` side-effects to
351
+ * run by the caller AFTER the whole batch completes — in parallel mode these
352
+ * must not interleave with other calls' work (messages order, abort checks).
353
+ */
354
+ async executeToolCall(tc, steps, stepToolResults) {
355
+ const tool = getTool(tc.name);
356
+ let args;
357
+ try {
358
+ args = tc.arguments ? JSON.parse(tc.arguments) : {};
359
+ }
360
+ catch {
361
+ this.pushToolResult(tc, `ERROR: invalid JSON arguments: ${tc.arguments.slice(0, 200)}`);
362
+ return {};
363
+ }
364
+ ui.toolCall(tc.name, args);
365
+ this.currentToolCall = { step: steps, id: tc.id, name: tc.name };
366
+ this.emit({ type: "tool_call", step: steps, id: tc.id, name: tc.name, args });
367
+ if (!tool && !this.mcp?.hasTool(tc.name)) {
368
+ this.rejectToolCall(steps, tc, `ERROR: unknown tool "${tc.name}". Available: ${allToolSpecs().map((t) => t.function.name).join(", ")}${this.mcp ? " + MCP tools (mcp__<server>__<tool>)" : ""}`, { error: "unknown tool" });
369
+ return {};
370
+ }
371
+ let result;
372
+ try {
373
+ // Permission gate for shell commands
374
+ if (tc.name === "run_command" && typeof args.command === "string") {
375
+ const decision = this.permissions.classifyShell(args.command);
376
+ if (decision.decision === "deny") {
377
+ const output = `ERROR: command denied by permission policy (${decision.reason}): ${String(args.command).slice(0, 200)}`;
378
+ this.rejectToolCall(steps, tc, output, { args, denied: decision.reason });
379
+ return {};
380
+ }
381
+ if (decision.decision === "ask") {
382
+ const allowed = await this.permissions.confirm(args.command);
383
+ if (!allowed) {
384
+ const output = `ERROR: user denied permission to run: ${String(args.command).slice(0, 200)}`;
385
+ this.rejectToolCall(steps, tc, output, { args, denied: "user" });
386
+ return {};
387
+ }
388
+ }
389
+ }
390
+ const t0 = Date.now();
391
+ // Route MCP tools first (they carry the mcp__ prefix, not in AGENT_TOOLS)
392
+ if (tool) {
393
+ result = await tool.run(this.ctx, args);
394
+ }
395
+ else {
396
+ // MCP tools — G5: đi qua permission engine như tool thường
397
+ // (policy floor → rules → mode fallback). Không có nhánh bypass.
398
+ const mcpDecision = this.permissions.classifyMcpTool(tc.name);
399
+ if (mcpDecision?.decision === "deny") {
400
+ const output = `ERROR: MCP tool "${tc.name}" denied by permission policy (${mcpDecision.reason}).`;
401
+ this.rejectToolCall(steps, tc, output, { args, denied: mcpDecision.reason });
402
+ return {};
403
+ }
404
+ if (this.config.permissionMode === "readonly" && mcpDecision?.decision !== "allow") {
405
+ result = {
406
+ ok: false,
407
+ output: `ERROR: readonly mode — MCP tool "${tc.name}" is disabled. Allow it explicitly with a permission rule (mcp__${tc.name.replace(/^mcp__/, "").replace(/__/, "__")} or a wildcard).`,
408
+ };
409
+ }
410
+ else if (mcpDecision?.decision === "ask" && !this.config.yes) {
411
+ const allowed = await this.confirmMcpTool(tc.name);
412
+ if (!allowed) {
413
+ const output = `ERROR: user denied permission to call MCP tool: ${tc.name}`;
414
+ this.rejectToolCall(steps, tc, output, { args, denied: "user" });
415
+ return {};
416
+ }
417
+ }
418
+ result = await this.invokeMcpTool(tc.name, args);
419
+ }
420
+ this.logger.log({ type: "tool", step: steps, tool: tc.name, args, ok: result.ok, elapsedMs: Date.now() - t0 });
421
+ }
422
+ catch (err) {
423
+ result = { ok: false, output: `ERROR: ${err.message}` };
424
+ this.logger.log({ type: "tool", step: steps, tool: tc.name, args, ok: false, error: err.message });
425
+ }
426
+ // User pressed [q]uit in an edit-confirmation prompt — stop the whole run.
427
+ if (this.permissions.abortRequested) {
428
+ this.aborted = true;
429
+ ui.warn("edit confirmation aborted by user ([q]uit) — stopping the run");
430
+ }
431
+ const filesModified = result.ok && (tc.name === "write_file" || tc.name === "edit_file");
432
+ return {
433
+ post: () => {
434
+ // Track file modifications — a completion claim with no edits needs no
435
+ // build verification, and conversational turns never touch files.
436
+ if (filesModified)
437
+ this.filesModified = true;
438
+ // T-01: @-autocomplete index invalidate on tool writes.
439
+ if (filesModified)
440
+ invalidateFileIndex();
441
+ this.endToolStream();
442
+ this.currentToolCall = null;
443
+ ui.toolResult(tc.name, result.ok, result.output);
444
+ this.emit({
445
+ type: "tool_result",
446
+ step: steps,
447
+ id: tc.id,
448
+ name: tc.name,
449
+ ok: result.ok,
450
+ output: result.output,
451
+ ...(result.diff ? { diff: result.diff } : {}),
452
+ ...(result.image ? { image: result.image } : {}),
453
+ });
454
+ stepToolResults.set(tc.id, { ok: result.ok, output: result.output });
455
+ this.pushToolResult(tc, summariseOutput(result.output));
456
+ },
457
+ };
458
+ }
459
+ /** MCP tool call with output clamp (extracted for I-09 readability). */
460
+ async invokeMcpTool(name, args) {
461
+ const reg = this.mcp?.getTool(name);
462
+ if (!reg)
463
+ return { ok: false, output: `ERROR: unknown tool "${name}"` };
464
+ // M2: validate args theo inputSchema — lỗi local, không gọi server.
465
+ const schemaErrors = validateSchema(args, reg.def.inputSchema);
466
+ if (schemaErrors.length > 0) {
467
+ return { ok: false, output: `ERROR: invalid arguments for ${name}:\n${schemaErrors.join("\n")}` };
468
+ }
469
+ const r = await reg.conn.callTool(reg.toolName, args);
470
+ return { ok: r.ok, output: r.output.length > 20_000 ? r.output.slice(0, 20_000) + "\n... [truncated]" : r.output };
471
+ }
147
472
  /**
148
473
  * Record usage of one LLM call: render token counts, log an `llm_usage`
149
474
  * event, and accumulate totals into session state.
150
475
  */
476
+ /** Token usage totals accumulated in this session (for REPL `/cost`). */
477
+ getUsage() {
478
+ return { ...this.usageTotals };
479
+ }
151
480
  recordLlmUsage(usage) {
152
481
  if (!usage)
153
482
  return;
@@ -181,17 +510,27 @@ export class Agent {
181
510
  /**
182
511
  * One LLM turn: streaming if enabled (renders deltas live), with automatic
183
512
  * fallback to non-streaming when the provider/stream fails.
513
+ *
514
+ * H-09 incomplete-stream continuation: when a stream dies mid-flight but a
515
+ * COMPLETE tool-call delta was already accumulated, surface the partial
516
+ * result (flagged `incomplete`) instead of discarding it — the run loop
517
+ * executes the tool calls and continues with a synthetic instruction.
184
518
  */
185
519
  async chatWithStream() {
186
520
  const signal = this.runSignal.signal;
187
521
  if (!this.config.stream) {
188
522
  return this.client.chat(this.messages, this.tools, { signal });
189
523
  }
524
+ let streamedText = "";
525
+ let final;
526
+ let started = false;
527
+ let sawToolCallDelta = false;
190
528
  try {
191
- let streamedText = "";
192
- let final;
193
- let started = false;
194
529
  for await (const chunk of this.client.streamChat(this.messages, this.tools, { signal })) {
530
+ if (chunk.reasoningDelta) {
531
+ // I-05: reasoning hiển thị dim qua UI stream; không vào llm_delta.
532
+ this.emit({ type: "llm_reasoning", delta: chunk.reasoningDelta });
533
+ }
195
534
  if (chunk.contentDelta) {
196
535
  if (!started) {
197
536
  ui.llmStreamStart("assistant");
@@ -201,6 +540,8 @@ export class Agent {
201
540
  this.emit({ type: "llm_delta", delta: chunk.contentDelta });
202
541
  streamedText += chunk.contentDelta;
203
542
  }
543
+ if (chunk.toolCallDelta)
544
+ sawToolCallDelta = true;
204
545
  if (chunk.final)
205
546
  final = chunk.final;
206
547
  }
@@ -215,7 +556,35 @@ export class Agent {
215
556
  // No finish_reason seen — fall through to non-streaming
216
557
  return this.client.chat(this.messages, this.tools);
217
558
  }
218
- catch (err) {
559
+ catch (caught) {
560
+ if (started)
561
+ ui.llmStreamEnd();
562
+ // A timeout is a real failure, not a streaming quirk — falling back to
563
+ // non-streaming would just wait the full timeout a second time.
564
+ const e = caught;
565
+ const isTimeout = e?.name === "TimeoutError" || /timed out|TimeoutError/i.test(e?.message ?? "");
566
+ const err = isTimeout
567
+ ? (caught instanceof LlmError ? caught : new LlmError(`LLM request timed out: ${e?.message ?? caught}`))
568
+ : caught;
569
+ // Incomplete-stream continuation: the stream died (timeout/disconnect)
570
+ // after a complete tool-call delta → execute what we have rather than
571
+ // failing the whole step (pattern from opencode runner).
572
+ if (sawToolCallDelta &&
573
+ (isTimeout || /ECONNRESET|socket hang up|network|terminated/i.test(e?.message ?? ""))) {
574
+ const partial = this.client.lastPartialToolCalls ?? [];
575
+ if (partial.length > 0) {
576
+ ui.warn("stream interrupted after complete tool-call(s) — continuing with what was received");
577
+ return {
578
+ message: {
579
+ role: "assistant",
580
+ content: streamedText || null,
581
+ tool_calls: partial,
582
+ },
583
+ usage: undefined,
584
+ incomplete: true,
585
+ };
586
+ }
587
+ }
219
588
  if (err instanceof LlmError && !this.streamFallbackWarned) {
220
589
  this.streamFallbackWarned = true;
221
590
  ui.warn(`streaming unavailable (${err.message.slice(0, 120)}) — falling back to non-streaming`);
@@ -231,14 +600,30 @@ export class Agent {
231
600
  const servers = this.config.mcpServers ?? {};
232
601
  if (Object.keys(servers).length === 0)
233
602
  return;
603
+ // Idempotent: /mcp may connect while idle — never spawn a second set of
604
+ // server processes (the first manager's children would leak).
605
+ if (this.mcp)
606
+ return;
234
607
  if (this.config.permissionMode === "readonly") {
235
608
  ui.warn("readonly mode: MCP tools are disabled (they bypass the permission system)");
236
609
  }
237
610
  this.mcp = new McpManager(servers);
611
+ this.mcp.onStatus = (line) => {
612
+ this.logger.log({ type: "mcp_status", line });
613
+ this.emit({ type: "mcp_status", line });
614
+ ui.info(line); // setQuiet suppresses this in the TUI — alt-screen stays clean
615
+ };
616
+ // Sidebar TUI: trạng thái máy trạng thái per-server (M1) — event structured
617
+ // (server/state) để reducer giữ map, không append thêm notice vào chat.
618
+ this.mcp.onServerState = (name, st, detail) => {
619
+ const line = `MCP "${name}" ${st}${detail ? ` — ${detail}` : ""}`;
620
+ this.logger.log({ type: "mcp_status", line, server: name, state: st });
621
+ this.emit({ type: "mcp_status", line, server: name, state: st });
622
+ };
238
623
  await this.mcp.connectAll();
239
624
  const mcpSpecs = this.mcp.toToolSpecs();
240
625
  if (mcpSpecs.length > 0) {
241
- this.tools = [...AGENT_TOOLS.map((t) => t.spec), ...mcpSpecs];
626
+ this.tools = [...allToolSpecs(), ...mcpSpecs];
242
627
  }
243
628
  }
244
629
  /** Disconnect MCP servers at the end of a run. */
@@ -246,6 +631,12 @@ export class Agent {
246
631
  this.mcp?.killAll();
247
632
  this.mcp = null;
248
633
  }
634
+ /** M4: đóng hot-reload watcher (khi agent bị hủy vĩnh viễn). */
635
+ dispose() {
636
+ this.watcher?.close();
637
+ this.watcher = null;
638
+ this.shutdownMcp();
639
+ }
249
640
  /**
250
641
  * Autonomous closed loop:
251
642
  * task -> LLM plans -> tool calls -> build -> on failure: read error, patch, retry.
@@ -255,6 +646,8 @@ export class Agent {
255
646
  throw new Error("No API key configured. Set SCODER_API_KEY or OPENAI_API_KEY (or put apiKey in .scoderrc.json).");
256
647
  }
257
648
  const buildCmd = this.buildCmd;
649
+ // @file mention expansion — task trước khi build messages.
650
+ task = expandMentions(task, this.config.workdir);
258
651
  ui.banner("scoder", [
259
652
  ["task", task],
260
653
  ["model", `${this.client.model} (${this.config.provider}${this.config.stream ? ", stream" : ""})`],
@@ -263,16 +656,30 @@ export class Agent {
263
656
  ["steps", `max ${this.config.maxSteps} · permission ${this.config.permissionMode}`],
264
657
  ]);
265
658
  this.currentTask = task;
659
+ this.events.append({ type: "run.started", task });
266
660
  this.resetRunSignal();
267
661
  this.resetUsageTotals();
662
+ this.filesModified = false;
663
+ this.plan = [];
664
+ const skillsBlock = buildSkillsBlock(this.skills); // I3
268
665
  this.messages = [
269
666
  {
270
667
  role: "system",
271
- content: SYSTEM_PROMPT +
272
- `\n\nWorking directory: ${this.config.workdir}\nBuild command (stop condition): ${buildCmd}`,
668
+ content: buildSystemPrompt({
669
+ workdir: this.config.workdir,
670
+ buildCmd,
671
+ envInfo: buildEnvInfo(this.config.workdir),
672
+ skillsBlock,
673
+ mcpBlock: buildMcpBlock(this.config),
674
+ }) + buildAgentsBlock(discoverAgents(this.config.workdir), this.config.allowSubagents !== false),
273
675
  },
676
+ ...this.pendingContext.map((content) => ({ role: "user", content })),
274
677
  { role: "user", content: task },
275
678
  ];
679
+ this.pendingContext = [];
680
+ // Phiên bản system prompt được ghi vào log — giúp so sánh chất lượng
681
+ // giữa các phiên bản prompt khi phân tích JSONL.
682
+ this.logger.log({ type: "prompt_version", version: PROMPT_VERSION });
276
683
  this.emit({
277
684
  type: "run_start",
278
685
  task,
@@ -280,6 +687,7 @@ export class Agent {
280
687
  provider: this.config.provider,
281
688
  workdir: this.config.workdir,
282
689
  buildCmd,
690
+ permission: this.config.permissionMode,
283
691
  });
284
692
  try {
285
693
  await this.connectMcp();
@@ -294,14 +702,45 @@ export class Agent {
294
702
  * appends the task to the existing message history instead of resetting it,
295
703
  * then runs the autonomous loop. First call behaves like run().
296
704
  */
705
+ /**
706
+ * T-07: trim the conversation back one exchange (last user message +
707
+ * everything after) — fork/edit-resend. The caller re-sends an edited
708
+ * task via runTurn().
709
+ */
710
+ trimLastTurn() {
711
+ // Cut = last REAL user message (skip guardrail reminder tails injected at
712
+ // finish); drop it and everything after. Reminder tails BEFORE the cut
713
+ // point belong to earlier turns and stay (history intact).
714
+ for (let i = this.messages.length - 1; i >= 0; i--) {
715
+ const m = this.messages[i];
716
+ if (m.role !== "user")
717
+ continue;
718
+ if (this.isReminderTail(String(m.content ?? "")))
719
+ continue;
720
+ this.messages = this.messages.slice(0, i);
721
+ return;
722
+ }
723
+ this.messages = [];
724
+ }
725
+ /** True for guardrail-injected user messages (not real user tasks). */
726
+ isReminderTail(content) {
727
+ return (content.includes("<system-reminder>") ||
728
+ /^Continue working on (the task|the original task)\. Build must pass before you finish\.$/.test(content.trim()));
729
+ }
297
730
  async runTurn(task) {
298
731
  if (this.messages.length === 0) {
299
732
  return this.run(task);
300
733
  }
301
734
  const buildCmd = this.buildCmd;
735
+ task = expandMentions(task, this.config.workdir);
302
736
  this.currentTask = `${this.currentTask}\n${task}`.trim();
303
737
  this.resetRunSignal();
304
738
  this.resetUsageTotals();
739
+ this.filesModified = false;
740
+ this.plan = [];
741
+ for (const content of this.pendingContext)
742
+ this.messages.push({ role: "user", content });
743
+ this.pendingContext = [];
305
744
  this.messages.push({ role: "user", content: task });
306
745
  try {
307
746
  await this.connectMcp();
@@ -321,11 +760,16 @@ export class Agent {
321
760
  this.currentTask = session.task;
322
761
  this.resetRunSignal();
323
762
  this.resetUsageTotals();
763
+ this.filesModified = false;
764
+ this.skills = discoverSkills(this.config.workdir);
324
765
  this.messages = session.messages ?? [];
325
766
  ui.header(`RESUME ${session.id}`);
326
767
  ui.info(`previous task: ${session.task} (${session.steps} step(s) done)`);
327
768
  if (followUp) {
328
- this.messages.push({ role: "user", content: followUp });
769
+ for (const content of this.pendingContext)
770
+ this.messages.push({ role: "user", content });
771
+ this.pendingContext = [];
772
+ this.messages.push({ role: "user", content: expandMentions(followUp, this.config.workdir) });
329
773
  }
330
774
  else {
331
775
  this.messages.push({
@@ -336,6 +780,8 @@ export class Agent {
336
780
  const lastBuild = session.lastBuild
337
781
  ? { success: session.lastBuild.success, command: session.lastBuild.command, output: session.lastBuild.output }
338
782
  : null;
783
+ // v2: opening a resumed run stamps a fresh run.started (event log continuity).
784
+ this.events.append({ type: "run.started", task: this.currentTask });
339
785
  try {
340
786
  await this.connectMcp();
341
787
  return await this.runLoop(session.steps ?? 0, buildCmd, lastBuild);
@@ -351,16 +797,48 @@ export class Agent {
351
797
  async runLoop(startSteps, buildCmd, initialBuild = null) {
352
798
  let steps = startSteps;
353
799
  let lastBuild = initialBuild;
800
+ // G1: guardrail tracker state (textOnlyStreak, sigRepeat, toolError*,
801
+ // lowTokenStreak, wrapUpInjected) sống trong GuardrailEngine — reset mỗi run.
802
+ this.guardrails.reset();
803
+ // H-10 compaction config (false = disabled → mechanical trim only).
804
+ // I-02: contextWindow từ model catalog (lookup sync, cache/fallback) thay
805
+ // vì default 128k cứng — compaction trigger chính xác theo model.
806
+ const catalogWindow = contextWindowFor(this.config.provider, this.config.model);
807
+ const compactionCfg = this.config.compaction === false
808
+ ? null
809
+ : {
810
+ ...DEFAULT_COMPACTION,
811
+ contextWindow: this.config.contextWindow ?? catalogWindow,
812
+ ...(this.config.compaction ?? {}),
813
+ };
354
814
  while (steps < this.config.maxSteps && !this.aborted) {
815
+ // ── Safe step boundary: deliver steered user input (H-08) ────────────
816
+ const steered = this.drainSteerInbox();
817
+ if (steered.length > 0) {
818
+ for (const s of steered) {
819
+ this.messages.push({ role: "user", content: s });
820
+ this.events.append({ type: "message.added", message: { role: "user", content: s } });
821
+ }
822
+ // One allowance reset for the whole steering batch (opencode behavior)
823
+ // — engine exposes a hook so the runLoop doesn't touch tracker state.
824
+ this.guardrails.notifySteered();
825
+ this.emit({ type: "steer_delivered", count: steered.length });
826
+ ui.info(`steer: +${steered.length} user message(s) delivered`);
827
+ }
355
828
  steps++;
356
829
  ui.step(steps, this.config.maxSteps, "thinking...");
830
+ this.events.claimExecution(steps);
357
831
  this.emit({ type: "step", step: steps, maxSteps: this.config.maxSteps });
358
- const { message, usage } = await this.chatWithStream().catch((err) => {
832
+ // Metrics: thời gian phần LLM của bước này (điều chỉnh chất lượng +
833
+ // so sánh provider; hiển thị trong `scoder cost`).
834
+ const stepStart = Date.now();
835
+ const { message, usage, incomplete } = await this.chatWithStream().catch((err) => {
359
836
  // SIGINT during the LLM call — stop gracefully instead of crashing
360
837
  if (this.aborted) {
361
838
  return {
362
839
  message: { role: "assistant", content: null, tool_calls: undefined },
363
840
  usage: undefined,
841
+ incomplete: undefined,
364
842
  };
365
843
  }
366
844
  throw err;
@@ -368,156 +846,214 @@ export class Agent {
368
846
  if (this.aborted)
369
847
  break;
370
848
  this.recordLlmUsage(usage);
371
- this.logger.log({ type: "step", step: steps });
372
- // No tool calls => assistant produced a text answer
373
- if (!message.tool_calls || message.tool_calls.length === 0) {
374
- const text = message.content ?? "";
849
+ this.logger.log({ type: "step", step: steps, durationMs: Date.now() - stepStart, toolCalls: message.tool_calls?.length ?? 0 });
850
+ // H-09: after an interrupted stream, nudge the model to continue rather
851
+ // than restart (synthetic continuation instruction, opencode pattern).
852
+ if (incomplete) {
853
+ this.messages.push({
854
+ role: "user",
855
+ content: "[system] The previous response was cut off mid-stream. The tool call(s) above were received in full — continue exactly where you left off; do not repeat completed work.",
856
+ });
857
+ this.events.append({ type: "retry.scheduled", reason: "incomplete-stream continuation" });
858
+ }
859
+ // ── Chuẩn bị context cho Guardrail Engine ─────────────────────────────
860
+ // Chat marker được làm sạch ở đây để rule nhận text đã chuẩn hoá.
861
+ const hasToolCalls = !!message.tool_calls && message.tool_calls.length > 0;
862
+ let text = message.content ?? "";
863
+ let chatDone = false;
864
+ if (!hasToolCalls && text.includes(CHAT_DONE_MARKER)) {
865
+ chatDone = true;
866
+ text = text.split(CHAT_DONE_MARKER)[0].trim();
867
+ }
868
+ if (!hasToolCalls) {
375
869
  this.messages.push({ role: "assistant", content: text });
376
- // If the assistant says it's done without building, force a build check.
377
- if (claimsCompletion(text) && steps < this.config.maxSteps) {
378
- ui.info("agent claims completion -> verifying with build");
379
- lastBuild = await this.checkBuild(buildCmd);
380
- if (lastBuild.success) {
381
- return this.finish(true, steps, buildCmd, text);
382
- }
383
- this.messages.push({
384
- role: "user",
385
- content: `Build still FAILED after your claimed completion. Output:\n${summariseOutput(lastBuild.output)}\nFix and continue.`,
386
- });
387
- continue;
388
- }
870
+ this.events.append({ type: "message.added", message: { role: "assistant", content: text } });
389
871
  ui.llm("agent", text);
390
- // Treat a plain text answer with no build as interim commentary; nudge loop.
872
+ }
873
+ else {
391
874
  this.messages.push({
392
- role: "user",
393
- content: "Continue working on the task. Use tools. Build must pass before you finish.",
875
+ role: "assistant",
876
+ content: message.content ?? null,
877
+ tool_calls: message.tool_calls,
394
878
  });
395
- continue;
879
+ this.events.append({ type: "message.added", message: this.messages[this.messages.length - 1] });
396
880
  }
397
- this.messages.push({
398
- role: "assistant",
399
- content: message.content ?? null,
400
- tool_calls: message.tool_calls,
401
- });
402
881
  // Execute each tool call (validate args before running)
403
882
  const stepToolResults = new Map();
404
- for (const tc of message.tool_calls) {
405
- if (this.aborted)
406
- break;
407
- const tool = getTool(tc.name);
408
- let args;
883
+ // H-11: checkpoint before any batch that may write files.
884
+ const batchMayWrite = (message.tool_calls ?? []).some((tc) => tc.name === "write_file" || tc.name === "edit_file");
885
+ if (batchMayWrite)
886
+ this.snapshots.capture(`step ${steps}: ${(message.tool_calls ?? []).map((t) => t.name).join(",")}`);
887
+ // I-09: parallelize read-only tool calls (concurrency cap 4); mutating
888
+ // calls (and any batch containing an ask-gated call) run sequentially.
889
+ const calls = message.tool_calls ?? [];
890
+ const needsAsk = calls.some((tc) => {
891
+ if (tc.name !== "run_command")
892
+ return false;
409
893
  try {
410
- args = tc.arguments ? JSON.parse(tc.arguments) : {};
894
+ const a = JSON.parse(tc.arguments || "{}");
895
+ if (typeof a.command !== "string")
896
+ return false;
897
+ const d = this.permissions.classifyShell(a.command);
898
+ return d.decision === "ask" || d.decision === "deny";
411
899
  }
412
900
  catch {
413
- this.pushToolResult(tc, `ERROR: invalid JSON arguments: ${tc.arguments.slice(0, 200)}`);
414
- continue;
415
- }
416
- ui.toolCall(tc.name, args);
417
- this.currentToolCall = { step: steps, id: tc.id, name: tc.name };
418
- this.emit({ type: "tool_call", step: steps, id: tc.id, name: tc.name, args });
419
- if (!tool && !(this.mcp?.hasTool(tc.name))) {
420
- 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" });
421
- continue;
901
+ return true;
422
902
  }
423
- let result;
424
- try {
425
- // Permission gate for shell commands
426
- if (tc.name === "run_command" && typeof args.command === "string") {
427
- const decision = this.permissions.classify(args.command);
428
- if (decision.decision === "deny") {
429
- result = {
430
- ok: false,
431
- output: `ERROR: command denied by permission policy (${decision.reason}): ${String(args.command).slice(0, 200)}`,
432
- };
433
- this.rejectToolCall(steps, tc, result.output, { args, denied: decision.reason });
434
- continue;
435
- }
436
- if (decision.decision === "ask") {
437
- const allowed = await this.permissions.confirm(args.command);
438
- if (!allowed) {
439
- result = {
440
- ok: false,
441
- output: `ERROR: user denied permission to run: ${String(args.command).slice(0, 200)}`,
442
- };
443
- this.rejectToolCall(steps, tc, result.output, { args, denied: "user" });
444
- continue;
445
- }
446
- }
447
- }
448
- const t0 = Date.now();
449
- // Route MCP tools first (they carry the mcp__ prefix, not in AGENT_TOOLS)
450
- if (tool) {
451
- result = await tool.run(this.ctx, args);
903
+ });
904
+ if (calls.length > 1 && !needsAsk) {
905
+ const readOnly = calls.every((tc) => Agent.READONLY_TOOLS.has(tc.name));
906
+ if (readOnly) {
907
+ const results = await this.parallelMap(calls, (tc) => this.executeToolCall(tc, steps, stepToolResults), this.config.limits?.toolConcurrency ?? 4);
908
+ for (const r of results) {
909
+ if (r.post)
910
+ r.post();
452
911
  }
453
- else if (this.config.permissionMode === "readonly") {
454
- // MCP tools are not covered by the permission system (no command
455
- // classification, no diff preview) — deny them entirely in readonly.
456
- result = {
457
- ok: false,
458
- 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.`,
459
- };
912
+ }
913
+ else {
914
+ for (const tc of calls) {
915
+ if (this.aborted)
916
+ break;
917
+ const r = await this.executeToolCall(tc, steps, stepToolResults);
918
+ if (r.post)
919
+ r.post();
460
920
  }
461
- else {
462
- const reg = this.mcp?.getTool(tc.name);
463
- if (!reg) {
464
- result = { ok: false, output: `ERROR: unknown tool "${tc.name}"` };
921
+ }
922
+ }
923
+ else {
924
+ for (const tc of calls) {
925
+ if (this.aborted)
926
+ break;
927
+ const r = await this.executeToolCall(tc, steps, stepToolResults);
928
+ if (r.post)
929
+ r.post();
930
+ }
931
+ }
932
+ // ── Guardrail Engine — đánh giá toàn bộ rule sau khi tools chạy xong ──
933
+ // Thông tin build stop condition: có tool call nào chạy ĐÚNG buildCmd?
934
+ // Match theo arguments đã parse (đúng hơn substring: "npm run build && rm -rf /"
935
+ // vẫn tính là build call, nhưng lệnh echo chứa chuỗi buildCmd thì không).
936
+ const buildCall = hasToolCalls
937
+ ? (message.tool_calls ?? []).find((tc) => tc.name === "run_command" &&
938
+ (() => {
939
+ try {
940
+ const a = JSON.parse(tc.arguments || "{}");
941
+ return typeof a.command === "string" && a.command.includes(buildCmd);
465
942
  }
466
- else {
467
- const r = await reg.conn.callTool(reg.toolName, args);
468
- result = { ok: r.ok, output: r.output.length > 20_000 ? r.output.slice(0, 20_000) + "\n... [truncated]" : r.output };
943
+ catch {
944
+ return false;
469
945
  }
470
- }
471
- this.logger.log({ type: "tool", step: steps, tool: tc.name, args, ok: result.ok, elapsedMs: Date.now() - t0 });
946
+ })())
947
+ : undefined;
948
+ let buildPassedThisStep = false;
949
+ if (buildCall) {
950
+ const r = stepToolResults.get(buildCall.id);
951
+ if (r && r.ok)
952
+ buildPassedThisStep = true;
953
+ }
954
+ const decisions = await this.guardrails.evaluate({
955
+ step: steps,
956
+ maxSteps: this.config.maxSteps,
957
+ text,
958
+ message,
959
+ hasToolCalls,
960
+ toolResults: stepToolResults,
961
+ buildPassedThisStep,
962
+ buildCallPresent: !!buildCall,
963
+ filesModified: this.filesModified,
964
+ chatDone,
965
+ outputTokensThisStep: usage?.completion_tokens,
966
+ totalTokensIn: this.usageTotals.inputTokens, // G6 budget rule
967
+ totalTokensOut: this.usageTotals.outputTokens,
968
+ });
969
+ // Áp dụng decision: finish đầu tiên thắng; inject gom lại đẩy một lần.
970
+ const reminders = [];
971
+ let doTrim = false;
972
+ for (const d of decisions) {
973
+ const a = d.action;
974
+ if (a.kind === "inject") {
975
+ if (d.rule === "wrap-up")
976
+ this.guardrails.markWrapUpInjected(); // chỉ inject một lần / run
977
+ reminders.push(a.reminder);
978
+ continue;
472
979
  }
473
- catch (err) {
474
- result = { ok: false, output: `ERROR: ${err.message}` };
475
- this.logger.log({ type: "tool", step: steps, tool: tc.name, args, ok: false, error: err.message });
980
+ if (a.kind === "trim") {
981
+ doTrim = true;
982
+ continue;
983
+ }
984
+ if (a.kind === "verify-build") {
985
+ // Xác minh độc lập với lời của model (async — loop tự chạy build).
986
+ lastBuild = await this.checkBuild(buildCmd);
987
+ if (lastBuild.success) {
988
+ if (doTrim)
989
+ this.messages = trimHistory(this.messages);
990
+ return this.finish(true, steps, buildCmd, a.successSummary);
991
+ }
992
+ reminders.push(`${a.failReminder}\n${summariseOutput(lastBuild.output)}\nFix and continue.`);
993
+ continue;
476
994
  }
477
- // User pressed [q]uit in an edit-confirmation prompt — stop the whole run.
478
- if (this.permissions.abortRequested) {
479
- this.aborted = true;
480
- ui.warn("edit confirmation aborted by user ([q]uit) — stopping the run");
995
+ if (a.kind === "finish") {
996
+ // Giữ nguyên thứ tự tin nhắn như bản inline cũ: reminder (wrap-up)
997
+ // đã đẩy trước khi kết thúc.
998
+ for (const r of reminders)
999
+ this.messages.push({ role: "user", content: r });
1000
+ if (doTrim)
1001
+ this.messages = trimHistory(this.messages);
1002
+ return this.finish(a.success, steps, buildCmd, a.summary, a.chatOnly ?? false);
481
1003
  }
482
- this.endToolStream();
483
- this.currentToolCall = null;
484
- ui.toolResult(tc.name, result.ok, result.output);
485
- this.emit({ type: "tool_result", step: steps, name: tc.name, ok: result.ok, output: result.output });
486
- stepToolResults.set(tc.id, { ok: result.ok, output: result.output });
487
- this.pushToolResult(tc, summariseOutput(result.output));
488
1004
  }
489
- // After a build-command run, treat success as the terminal condition.
490
- // Match the FULL build command (not just its first word): matching "npm"
491
- // would falsely trigger on "npm install" mid-task and end the agent.
492
- const buildCall = message.tool_calls.find((tc) => tc.name === "run_command" && tc.arguments.includes(buildCmd));
493
- if (buildCall) {
494
- // Reuse the tool result when the agent ran the exact build command —
495
- // avoids executing the build a second time just to verify.
496
- const result = stepToolResults.get(buildCall.id);
497
- if (result && result.ok) {
498
- return this.finish(true, steps, buildCmd, message.content ?? "Build passed.");
1005
+ for (const r of reminders)
1006
+ this.messages.push({ role: "user", content: r });
1007
+ if (doTrim)
1008
+ this.messages = trimHistory(this.messages);
1009
+ // H-10: LLM compaction when history approaches the context window.
1010
+ if (compactionCfg && shouldCompact(this.messages, compactionCfg)) {
1011
+ try {
1012
+ ui.info("compaction: summarizing older history…");
1013
+ const summaryInput = extractSummaryInput(this.currentTask, this.messages);
1014
+ const summaryRes = await this.client.chat([
1015
+ ...this.messages,
1016
+ { role: "user", content: `${summaryInput}\n\n${COMPACTION_INSTRUCTION}` },
1017
+ ], [], // no tools for the summarizer call
1018
+ { signal: this.runSignal.signal });
1019
+ this.messages = applyCompaction(this.messages, redactSecrets(summaryRes.message.content ?? "History summarized."), compactionCfg);
1020
+ this.events.append({ type: "compaction.ran", tokensBefore: 0, summary: (summaryRes.message.content ?? "").slice(0, 2000) });
1021
+ ui.info(`compaction: done (history now ${this.messages.length} messages)`);
499
1022
  }
500
- lastBuild = await this.checkBuild(buildCmd);
501
- if (lastBuild.success) {
502
- return this.finish(true, steps, buildCmd, message.content ?? "Build passed.");
1023
+ catch (err) {
1024
+ // Compaction is best-effort — mechanical trim stays as fallback.
1025
+ ui.warn(`compaction failed (${err.message.slice(0, 100)}) — using mechanical trim`);
1026
+ this.messages = trimHistory(this.messages);
503
1027
  }
504
- this.messages.push({
505
- role: "user",
506
- content: `Build FAILED (stop condition not met). Error output:\n${summariseOutput(lastBuild.output)}\nAnalyze the errors, fix the code, and rebuild.`,
507
- });
508
1028
  }
509
- this.messages = trimHistory(this.messages);
510
1029
  // Persist session state after every step (enables resume after crash/SIGINT)
511
1030
  this.saveSession(steps, buildCmd, lastBuild);
1031
+ this.events.releaseExecution(steps);
512
1032
  }
513
1033
  // Max steps reached or aborted
514
1034
  const reason = this.aborted ? "interrupted by user (SIGINT)" : `reached max steps (${this.config.maxSteps})`;
515
1035
  this.saveSession(steps, buildCmd, lastBuild);
516
1036
  return this.finish(false, steps, buildCmd, `Agent stopped: ${reason}`);
517
1037
  }
518
- /** Persist current agent state to .scoder/sessions/<id>.json. */
1038
+ /** Persist current agent state (v2 event log + manifest; crash-resumable). */
519
1039
  saveSession(steps, buildCmd, lastBuild) {
520
1040
  this.lastBuildResult = lastBuild;
1041
+ // v2: durable event log is authoritative; JSON manifest is a cheap summary.
1042
+ this.events.writeManifest({
1043
+ task: this.currentTask,
1044
+ model: this.client.model,
1045
+ provider: this.config.provider,
1046
+ buildCmd,
1047
+ steps,
1048
+ maxSteps: this.config.maxSteps,
1049
+ usage: {
1050
+ llmCalls: this.usageTotals.calls,
1051
+ inputTokens: this.usageTotals.inputTokens,
1052
+ outputTokens: this.usageTotals.outputTokens,
1053
+ cachedTokens: this.usageTotals.cachedTokens,
1054
+ },
1055
+ });
1056
+ // v1 snapshot kept in sync (back-compat for /sessions readers, cost, resume).
521
1057
  this.session.save({
522
1058
  task: this.currentTask,
523
1059
  model: this.client.model,
@@ -546,9 +1082,134 @@ export class Agent {
546
1082
  this.logger.log({ type: "build", success: res.success, command: buildCmd, output: res.output.slice(0, 10_000) });
547
1083
  return res;
548
1084
  }
549
- finish(success, steps, buildCmd, summary) {
1085
+ // ── Frontend-facing APIs (REPL/TUI slash commands) ─────────────────────────
1086
+ /** Skills visible to this project (for /skills). */
1087
+ getSkills() {
1088
+ return this.skills;
1089
+ }
1090
+ /**
1091
+ * Stage a skill's content for injection before the next run/turn (/skill).
1092
+ * Non-destructive: nothing is sent to the LLM until the user submits a task.
1093
+ */
1094
+ stageSkill(name) {
1095
+ const skill = findSkill(this.config.workdir, name);
1096
+ if (!skill) {
1097
+ const names = this.skills.map((s) => s.name).join(", ");
1098
+ return { ok: false, message: `skill "${name}" not found${names ? ` — available: ${names}` : " — no skills discovered (try /skills)"}` };
1099
+ }
1100
+ this.pendingContext.push(`[User loaded skill "${skill.name}" — follow these instructions for the upcoming task]\n\n${skill.body}`);
1101
+ return { ok: true, message: `skill "${skill.name}" staged — it will apply to your next task` };
1102
+ }
1103
+ /** MCP server overview (for /mcp): config always; tools once connected. */
1104
+ async mcpStatus() {
1105
+ const configs = (this.config.mcpServers ?? {});
1106
+ // M3: http server hiển thị url thay vì command.
1107
+ const target = (cfg) => cfg.url ?? cfg.command ?? "(unknown)";
1108
+ if (!this.mcp) {
1109
+ return Object.entries(configs).map(([name, cfg]) => ({ name, command: target(cfg), connected: false, tools: [] }));
1110
+ }
1111
+ await this.connectMcp();
1112
+ const byServer = new Map(this.mcp.listToolsByServer().map((s) => [s.server, s.tools]));
1113
+ return Object.entries(configs).map(([name, cfg]) => ({
1114
+ name,
1115
+ command: target(cfg),
1116
+ connected: byServer.has(name),
1117
+ tools: byServer.get(name) ?? [],
1118
+ }));
1119
+ }
1120
+ /** Call one MCP tool directly by its full name (mcp__server__tool) (/mcp exec). */
1121
+ async callMcpTool(fullName, args) {
1122
+ if (this.config.permissionMode === "readonly") {
1123
+ return { ok: false, output: "ERROR: readonly mode — MCP tools are disabled (they bypass the permission policy)." };
1124
+ }
1125
+ await this.connectMcp();
1126
+ const reg = this.mcp?.getTool(fullName);
1127
+ if (!reg) {
1128
+ return { ok: false, output: `ERROR: unknown MCP tool "${fullName}" — run /mcp to list servers and tools.` };
1129
+ }
1130
+ // Text shorthand (`/mcp exec <tool> free text`): bind to the tool's first
1131
+ // required string property from its inputSchema, so users don't have to
1132
+ // hand-write JSON for the common one-argument case.
1133
+ if ("__text" in args && typeof args.__text === "string") {
1134
+ const props = (reg.def.inputSchema?.properties ?? {});
1135
+ const required = (reg.def.inputSchema?.required ?? []);
1136
+ const target = required.find((k) => props[k]?.type === "string") ??
1137
+ Object.keys(props).find((k) => props[k]?.type === "string");
1138
+ if (!target) {
1139
+ return { ok: false, output: `ERROR: "${fullName}" has no string parameter — pass JSON args instead.` };
1140
+ }
1141
+ args = { [target]: args.__text };
1142
+ }
1143
+ try {
1144
+ return await reg.conn.callTool(reg.toolName, args);
1145
+ }
1146
+ catch (err) {
1147
+ return { ok: false, output: `ERROR: ${err.message}` };
1148
+ }
1149
+ }
1150
+ /** Ask user to approve an MCP tool call; "always" persists a project-scoped rule. */
1151
+ async confirmMcpTool(fullName) {
1152
+ return this.permissions.confirmMcpTool(fullName, () => {
1153
+ try {
1154
+ saveApproval(this.config.workdir, { action: fullName, resource: "*", effect: "allow" });
1155
+ }
1156
+ catch { /* persistence failure must not block the run */ }
1157
+ });
1158
+ }
1159
+ /** Switch permission mode at runtime (/permission) — rebuilds the checker. */ setPermissionMode(mode) {
1160
+ if (mode === this.config.permissionMode)
1161
+ return;
1162
+ this.config = { ...this.config, permissionMode: mode };
1163
+ const permLists = loadPermissionLists(this.config.workdir);
1164
+ this.permissions = new PermissionChecker({
1165
+ mode,
1166
+ allowCommands: permLists.allowCommands,
1167
+ denyCommands: permLists.denyCommands,
1168
+ nonInteractive: this.config.yes,
1169
+ rules: this.config.permissionRules,
1170
+ savedRules: loadSavedApprovals(this.config.workdir),
1171
+ policyRules: loadPolicyRules(this.config.workdir),
1172
+ });
1173
+ this.ctx.permissionMode = mode;
1174
+ ui.info(`permission mode → ${mode}`);
1175
+ }
1176
+ /**
1177
+ * T-04: runtime model hot-swap. Same provider → setModel on the live
1178
+ * client (zero rebuild, mid-session). Different provider (provider/model
1179
+ * syntax) → rebuild the runtime from the registry. Returns a notice line.
1180
+ */
1181
+ setModel(model) {
1182
+ const [prov, id] = splitProviderModel(model);
1183
+ if (!prov || prov === this.config.provider) {
1184
+ const m = id ?? model;
1185
+ this.client.setModel(m);
1186
+ this.config = { ...this.config, model: m };
1187
+ return `model → ${m} (${this.config.provider})`;
1188
+ }
1189
+ // Cross-provider: rebuild the client runtime.
1190
+ const resolved = resolveProvider(prov, parseCustomProviders(loadRcFile(this.config.workdir).providers));
1191
+ if ("error" in resolved)
1192
+ return `ERROR: ${resolved.error}`;
1193
+ this.client = createRuntime(resolved.def, {
1194
+ baseUrl: this.config.baseUrl,
1195
+ apiKey: this.config.apiKey,
1196
+ model: id,
1197
+ maxTokens: this.config.maxTokens,
1198
+ temperature: this.config.temperature,
1199
+ enableCache: resolved.def.supportCache,
1200
+ });
1201
+ this.config = { ...this.config, provider: prov, model: id, providerDef: resolved.def };
1202
+ return `model → ${id} (provider ${prov})`;
1203
+ }
1204
+ finish(success, steps, buildCmd, summary, chatOnly = false) {
1205
+ this.events.append({ type: "finish", success, summary: summary.slice(0, 2000) });
550
1206
  if (success) {
551
- ui.success(`Task complete in ${steps} step(s). Build passed: ${buildCmd}`);
1207
+ if (chatOnly) {
1208
+ ui.success(`Done in ${steps} step(s).`);
1209
+ }
1210
+ else {
1211
+ ui.success(`Task complete in ${steps} step(s). Build passed: ${buildCmd}`);
1212
+ }
552
1213
  }
553
1214
  else {
554
1215
  ui.error(`Task NOT complete. ${summary}`);