s-app-scoder-agent 0.9.10 → 0.9.15

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.
@@ -38,6 +38,8 @@
38
38
  "verifyCache": true,
39
39
  "localProfile": true,
40
40
 
41
+ "warmup": true,
42
+
41
43
  "mcpTools": {
42
44
  "include": [],
43
45
  "exclude": ["mcp__github-mcp__*notifications*", "mcp__github-mcp__*codespaces*"]
package/README.md CHANGED
@@ -395,7 +395,7 @@ Features:
395
395
  - **Slash commands** — `/help`, `/clear`, `/compact`, `/cost`, `/model`, `/skill`, `/mcp`, `/permission` (identical to the REPL; `/theme` is TUI-only)
396
396
  - **Multi-turn** — keep typing follow-ups after a task finishes; the conversation context carries over (same as the REPL)
397
397
  - **Scrollback** — `PgUp`/`PgDn` page in 0.8-viewport steps, `Home`/`End` jump to top/bottom, `Esc` returns to the live tail (press again to abort); rendering is capped at the last 200 items for smooth scrolling on long runs
398
- - **Keybinds** — `Enter` send · `PgUp`/`PgDn` scroll · `Esc` reset scroll / abort · `Ctrl+C` quit
398
+ - **Keybinds** — `Enter` send · `PgUp`/`PgDn` scroll · `Esc` reset scroll / abort · `Ctrl+O` toggle select mode (releases mouse tracking so you can drag-highlight chat text and copy it with `⌘C`/`Ctrl+C`) · `Ctrl+C` quit
399
399
  - Non-TTY environments (CI, pipes) automatically fall back to the classic REPL
400
400
  - `permission: "ask"` is not supported in the TUI (no readline prompts on the alt screen) — it falls back to `auto`
401
401
 
@@ -21,15 +21,22 @@ export class InteractionBroker {
21
21
  async awaitQuestion(runtime, request, id, handler) {
22
22
  try {
23
23
  const timeoutMs = (request.timeoutSecs ?? 120) * 1000;
24
- const answer = await Promise.race([
25
- handler({ ...request, id }),
26
- new Promise((resolve) => setTimeout(() => resolve(null), timeoutMs)),
27
- ]);
28
- if (answer == null || (typeof answer === "string" && !answer.trim())) {
29
- return { ok: false, output: "ERROR: ask_user timed out or was cancelled." };
24
+ let timer;
25
+ const timeoutPromise = new Promise((resolve) => {
26
+ timer = setTimeout(() => resolve(null), timeoutMs);
27
+ });
28
+ try {
29
+ const answer = await Promise.race([handler({ ...request, id }), timeoutPromise]);
30
+ if (answer == null || (typeof answer === "string" && !answer.trim())) {
31
+ return { ok: false, output: "ERROR: ask_user timed out or was cancelled." };
32
+ }
33
+ runtime.logger.log({ type: "ask_user", question: request.question, answer: String(answer).slice(0, 500) });
34
+ return { ok: true, output: `User answered: ${answer}` };
35
+ }
36
+ finally {
37
+ if (timer)
38
+ clearTimeout(timer);
30
39
  }
31
- runtime.logger.log({ type: "ask_user", question: request.question, answer: String(answer).slice(0, 500) });
32
- return { ok: true, output: `User answered: ${answer}` };
33
40
  }
34
41
  catch (err) {
35
42
  return { ok: false, output: `ERROR: ask_user failed: ${err.message.slice(0, 200)}` };
@@ -88,6 +88,11 @@ export class PromptContext {
88
88
  clearPendingTools() {
89
89
  this.pendingAllowedTools = null;
90
90
  }
91
+ /** Non-destructive read — warm-up dựng đúng messages như run() nhưng KHÔNG
92
+ * tiêu thụ pending context (nó thuộc về run thật, take là mất). */
93
+ peekPendingContext() {
94
+ return [...this.pendingContext];
95
+ }
91
96
  takePendingContext() {
92
97
  const pending = this.pendingContext;
93
98
  this.pendingContext = [];
package/dist/agent.js CHANGED
@@ -1,4 +1,4 @@
1
- import { loadPermissionLists, loadPolicyRules, loadSavedApprovals } from "./config/config.js";
1
+ import { loadPermissionLists, loadPolicyRules, loadSavedApprovals, adaptiveToolOutputChars } from "./config/config.js";
2
2
  import { LlmClient, LlmError, redactSecrets } from "./llm/llm.js";
3
3
  import { AnthropicClient } from "./llm/provider-anthropic.js";
4
4
  import { allToolSpecs } from "./tools/registry.js";
@@ -33,6 +33,12 @@ import { runAgentLoop } from "./agent/run-loop.js";
33
33
  // CHAT_DONE_MARKER import từ guardrails.js (marker chat thuần, rule 9).
34
34
  import { EVENT_SCHEMA_VERSION, applyToolFilter, resolvePrimaryConfig, } from "./agent/types.js";
35
35
  export { EVENT_SCHEMA_VERSION, resolvePrimaryConfig } from "./agent/types.js";
36
+ /**
37
+ * FILE-READ-PERF P3: nhịp heartbeat re-warm khi idle — 4' < TTL cache ~5'
38
+ * (Anthropic ephemeral / Ollama keep_alive mặc định), để prefix không bao giờ
39
+ * nguội trong lúc session còn mở.
40
+ */
41
+ export const WARMUP_IDLE_REFRESH_MS = 240_000;
36
42
  export class Agent {
37
43
  /** v2 event schema: stable id for the run — links agent_start/agent_end. */
38
44
  id = randomUUID();
@@ -89,6 +95,25 @@ export class Agent {
89
95
  /** v2: build no-progress tracking — same failure signature twice → escalate. */
90
96
  lastVerifySignature = "";
91
97
  verifyStrikes = 0;
98
+ /**
99
+ * WARMUP (plan/WARMUP-PLAN.md): request prefill nền — dựng đúng prefix
100
+ * system+tools mà lượt thật sẽ gửi, max_tokens=1, vứt response. Null khi
101
+ * chưa trigger; `settled` sau khi LLM request kết thúc (thành công/abort/lỗi).
102
+ */
103
+ warmupRun = null;
104
+ /** True after dispose() — warm-up không được để MCP server con mọc lại. */
105
+ disposed = false;
106
+ /**
107
+ * FILE-READ-PERF P3 (plan/FILE-READ-PERF-PLAN.md): heartbeat re-warm khi idle —
108
+ * engine local có keep_alive ~5', model unload thì lượt kế trả 35–56s cold
109
+ * prefill (đo thật). Timer chạy CHỈ lúc nhàn rỗi (settleWarmup tắt, finish()
110
+ * bật lại), unref để không giữ process. CHỈ cho baseUrl loopback.
111
+ */
112
+ warmupIdleTimer = null;
113
+ /** True nếu frontend này THỰC sự dùng warm-up (REPL/TUI) — serve/run-once không heartbeat. */
114
+ warmupUsed = false;
115
+ /** Test knob: thu ngắn nhịp heartbeat (default WARMUP_IDLE_REFRESH_MS). */
116
+ warmupIdleRefreshMs = WARMUP_IDLE_REFRESH_MS;
92
117
  /** v2: ask_user plumbing (frontends register handlers; serve → not-interactive). */
93
118
  /** Guardrail Engine — khởi tạo trong constructor (cần this.config). */
94
119
  guardrails;
@@ -411,7 +436,7 @@ export class Agent {
411
436
  const servers = rc.mcpServers ?? {};
412
437
  this.config.mcpServers = servers;
413
438
  if (this.mcp) {
414
- void this.mcp.applyConfig(servers);
439
+ void this.mcp.applyConfig(servers).catch(() => { });
415
440
  }
416
441
  }
417
442
  });
@@ -424,6 +449,8 @@ export class Agent {
424
449
  abort() {
425
450
  this.aborted = true;
426
451
  this.runSignal.abort(new DOMException("interrupted by user", "AbortError"));
452
+ // WARMUP: SIGINT giết luôn request prefill nếu còn chạy nền.
453
+ this.warmupRun?.abort.abort(new DOMException("interrupted by user", "AbortError"));
427
454
  }
428
455
  /**
429
456
  * Steer: user input delivered at the NEXT safe step boundary (pattern from
@@ -848,8 +875,10 @@ export class Agent {
848
875
  * server tự cắt history, và mỗi lần nó cắt là một lần mất KV-cache.
849
876
  */
850
877
  async applyContextProbe() {
851
- if (this.config.contextWindow)
878
+ if (this.config.contextWindow) {
879
+ this.applyAdaptiveReadBudget();
852
880
  return;
881
+ }
853
882
  let probe = null;
854
883
  try {
855
884
  // Server local vẫn có thể bật --api-key: /props trả 401 nếu không gửi
@@ -875,11 +904,190 @@ export class Agent {
875
904
  if (probe.tokens !== catalog) {
876
905
  this.reporter.info(`context window: ${probe.tokens} token (${probe.detail}) — thay fallback ${catalog} token, compaction/trim tính theo số này`);
877
906
  }
907
+ this.applyAdaptiveReadBudget();
908
+ }
909
+ /**
910
+ * FILE-READ-PERF (plan/FILE-READ-PERF-PLAN.md) P1: nới ngân sách một lần đọc
911
+ * theo window THẬT khi local + user không đặt tường minh. Chạy một lần; giá
912
+ * trị ghi cả vào config.limits (safety-net ở tool-executor) lẫn module budget
913
+ * mà read-file.ts cắt theo. Probe thất bại (không có window) → giữ floor cũ.
914
+ */
915
+ readBudgetAdapted = false;
916
+ applyAdaptiveReadBudget() {
917
+ if (this.readBudgetAdapted)
918
+ return;
919
+ const window = this.config.contextWindow;
920
+ if (!window || !isLocalBaseUrl(this.config.baseUrl) || this.config.localProfile === false)
921
+ return;
922
+ if (this.config.limits?.toolOutputChars !== undefined)
923
+ return; // user thắng, không đụng
924
+ const budget = adaptiveToolOutputChars(window);
925
+ this.config.limits = { ...this.config.limits, toolOutputChars: budget };
926
+ setMaxToolOutputChars(budget);
927
+ this.readBudgetAdapted = true;
928
+ this.logger.log({ type: "read_budget", chars: budget, window });
878
929
  }
879
930
  /** Rebuild built-in and connected MCP tool specs for the current skill scope. */
880
931
  rebuildToolList() {
881
932
  rebuildMcpToolList(this.mcpBridgeRuntime());
882
933
  }
934
+ /**
935
+ * WARMUP (plan/WARMUP-PLAN.md): prefill prefix system+tools NỀN ngay khi mở
936
+ * session, để lượt chat đầu không phải trả cold-load + 20–60k token prefill
937
+ * trên đường tới token đầu (đo: 30–120s trên máy cá nhân, xem P2-3). One-shot,
938
+ * idempotent, fire-and-forget — lỗi im lặng (best-effort).
939
+ * `rewarm` (FILE-READ-PERF P3): bản heartbeat — bỏ guard "chưa có messages",
940
+ * tiền đề là entry trước đã settle.
941
+ */
942
+ warmup(opts = {}) {
943
+ const w = this.warmupRun;
944
+ if (w && (!w.settled || !opts.rewarm))
945
+ return w.promise;
946
+ if (this.config.warmup === false)
947
+ return Promise.resolve();
948
+ // aborted chỉ chặn warm KHỞI ĐẦU; heartbeat sống lại sau lượt kế (resetRunSignal).
949
+ if (this.disposed || (this.aborted && !opts.rewarm))
950
+ return Promise.resolve();
951
+ if (!opts.rewarm && this.messages.length > 0)
952
+ return Promise.resolve();
953
+ if (!this.config.apiKey)
954
+ return Promise.resolve();
955
+ // I-12: harness eval thay client bằng scripted transcript — warm-up sẽ ăn
956
+ // mất một lượt kịch bản, làm hỏng mọi assertion đếm request.
957
+ if (this.config.clientFactory)
958
+ return Promise.resolve();
959
+ const abort = new AbortController();
960
+ const entry = { abort, settled: false, promise: Promise.resolve() };
961
+ this.warmupUsed = true;
962
+ entry.promise = this.runWarmup(abort.signal, () => {
963
+ entry.settled = true;
964
+ }, opts.rewarm === true);
965
+ this.warmupRun = entry;
966
+ return entry.promise;
967
+ }
968
+ async runWarmup(signal, settle, rewarm = false) {
969
+ const t0 = Date.now();
970
+ try {
971
+ // Báo UI biết warm-up đang chạy (giữ màn hình welcome + progress bar).
972
+ this.emit({ type: "warmup", active: true, stage: "init", percent: 5, message: "đang chuẩn bị warm-up…" });
973
+ // Dời hai việc network ra khỏi đường tới token đầu. run() vẫn gọi lại
974
+ // nguyên như cũ — cả hai đều return sớm khi đã hoàn tất ở đây.
975
+ await Promise.allSettled([this.applyContextProbe(), this.connectMcp()]);
976
+ if (signal.aborted || this.disposed)
977
+ return;
978
+ this.emit({ type: "warmup", active: true, stage: "context+mcp", percent: 60, message: "context window + MCP sẵn sàng" });
979
+ // FILE-READ-PERF P3: heartbeat sau khi đã chạy ≥1 lượt thật gửi lại
980
+ // HISTORY THẬT — đó mới là prefix của lượt kế tiếp (placeholder sẽ phân
981
+ // nhánh ngay sau system). Trước lượt đầu: dựng đúng messages như run().
982
+ const hasHistory = this.messages.length > 0;
983
+ // Tool list + messages dựng ĐÚNG như run() — cache-key (Anthropic
984
+ // breakpoint, prefix KV của llama.cpp) là từng byte JSON của schema.
985
+ const pendingAllowedTools = this.promptContext.pendingTools();
986
+ const allow = pendingAllowedTools?.length
987
+ ? new Set(pendingAllowedTools.map((t) => t.toLowerCase()))
988
+ : null;
989
+ let baseTools = applyToolFilter(allToolSpecs(), this.primaryDef, this.reporter);
990
+ if (allow)
991
+ baseTools = baseTools.filter((s) => allow.has(s.function.name.toLowerCase()));
992
+ const mcpSpecs = governedMcpSpecs(this.mcpBridgeRuntime());
993
+ const tools = allow
994
+ ? [...baseTools, ...mcpSpecs.filter((s) => allow.has(s.function.name.toLowerCase()))]
995
+ : [...baseTools, ...mcpSpecs];
996
+ const messages = hasHistory
997
+ ? this.messages
998
+ : buildRunMessages({
999
+ config: this.config,
1000
+ primaryDef: this.primaryDef,
1001
+ skills: this.promptContext.getSkills(),
1002
+ buildCmd: this.buildCmd,
1003
+ // Placeholder KHÔNG mention file nào: system prompt trùng byte với
1004
+ // request thật ở mọi task không có focus-file guide (xem plan §7).
1005
+ task: "[warmup]",
1006
+ pendingContext: this.promptContext.peekPendingContext(),
1007
+ });
1008
+ this.emit({ type: "warmup", active: true, stage: "prompt", percent: 80, message: "đang prefill system prompt…" });
1009
+ const res = await this.client.chat(messages, tools, { signal, maxTokens: 1 });
1010
+ this.emit({ type: "warmup", active: true, stage: "done", percent: 100, message: "warm-up xong" });
1011
+ this.logger.log({
1012
+ type: "warmup",
1013
+ ok: true,
1014
+ rewarm,
1015
+ ms: Date.now() - t0,
1016
+ model: this.client.model,
1017
+ promptTokens: res.usage?.prompt_tokens,
1018
+ cachedTokens: res.usage?.cached_tokens,
1019
+ });
1020
+ }
1021
+ catch (err) {
1022
+ // Warm-up không bao giờ được làm hỏng run thật — kể cả SIGINT abort.
1023
+ this.logger.log({
1024
+ type: "warmup",
1025
+ ok: false,
1026
+ ms: Date.now() - t0,
1027
+ error: (err?.message ?? String(err)).slice(0, 200),
1028
+ });
1029
+ }
1030
+ finally {
1031
+ this.emit({ type: "warmup", active: false, stage: "done", percent: 100, message: "warm-up kết thúc" });
1032
+ settle();
1033
+ // dispose() chạy giữa chừng: kill lại MCP servers mà connectMcp của
1034
+ // warm-up vừa spawn (killAll của dispose đã chạy TRƯỚC khi chúng mọc).
1035
+ if (this.disposed)
1036
+ this.shutdownMcp();
1037
+ // FILE-READ-PERF P3: cycle warm kết thúc TỰ NHIÊN (không bị supersede/
1038
+ // abort) → arm nhịp heartbeat tiếp theo. Bị abort nghĩa là run thật đang
1039
+ // chạy — finish() sẽ arm lại khi xong.
1040
+ else if (!signal.aborted)
1041
+ this.scheduleWarmupRefresh();
1042
+ }
1043
+ }
1044
+ /**
1045
+ * Đầu mọi run thật (run/runTurn/resume): lượt thật thắng warm-up — abort
1046
+ * request prefill nếu còn treo, rồi await cho settle để connectMcp/probe
1047
+ * không double-spawn server con song song với chính nó.
1048
+ */
1049
+ async settleWarmup() {
1050
+ // Run thật bắt đầu → heartbeat phải im lặng (timer re-arm ở finish()).
1051
+ this.clearWarmupTimer();
1052
+ const w = this.warmupRun;
1053
+ if (!w)
1054
+ return;
1055
+ if (!w.settled)
1056
+ w.abort.abort(new DOMException("real run supersedes warmup", "AbortError"));
1057
+ await w.promise.catch(() => { });
1058
+ }
1059
+ /** Tắt heartbeat idle (run thật vào / dispose). */
1060
+ clearWarmupTimer() {
1061
+ if (this.warmupIdleTimer) {
1062
+ clearTimeout(this.warmupIdleTimer);
1063
+ this.warmupIdleTimer = null;
1064
+ }
1065
+ }
1066
+ /**
1067
+ * FILE-READ-PERF P3: arm lại nhịp re-warm — chỉ khi engine local và bộ máy
1068
+ * warmup còn bật (đúng các guard của warmup()). unref(): timer không được
1069
+ * giữ process sống (run-once/test thoát sạch).
1070
+ */
1071
+ scheduleWarmupRefresh() {
1072
+ if (this.disposed || !this.warmupUsed)
1073
+ return;
1074
+ if (this.config.warmup === false || !this.config.apiKey || this.config.clientFactory) {
1075
+ this.clearWarmupTimer();
1076
+ return;
1077
+ }
1078
+ // KHÔNG phải local (kể cả /model đổi từ local → cloud giữa session): tắt
1079
+ // heartbeat đang arm — không đốt tiền API để giữ cache.
1080
+ if (!isLocalBaseUrl(this.config.baseUrl)) {
1081
+ this.clearWarmupTimer();
1082
+ return;
1083
+ }
1084
+ this.clearWarmupTimer();
1085
+ this.warmupIdleTimer = setTimeout(() => {
1086
+ this.warmupIdleTimer = null;
1087
+ void this.warmup({ rewarm: true }).catch(() => { });
1088
+ }, this.warmupIdleRefreshMs);
1089
+ this.warmupIdleTimer.unref();
1090
+ }
883
1091
  /**
884
1092
  * Disconnect MCP servers when the agent is disposed — connections are kept
885
1093
  * alive for the whole agent/session lifetime (one "connected" line, no
@@ -891,8 +1099,13 @@ export class Agent {
891
1099
  }
892
1100
  /** M4: đóng hot-reload watcher (khi agent bị hủy vĩnh viễn). */
893
1101
  dispose() {
1102
+ this.disposed = true;
1103
+ this.clearWarmupTimer();
894
1104
  this.watcher?.close();
895
1105
  this.watcher = null;
1106
+ // WARMUP: hủy request prefill đang chạy (chặn MCP servers connect thêm
1107
+ // sau khi kill — runWarmup tự shutdownMcp lần cuối trong finally).
1108
+ this.warmupRun?.abort.abort(new DOMException("agent disposed", "AbortError"));
896
1109
  this.shutdownMcp();
897
1110
  }
898
1111
  /**
@@ -934,6 +1147,8 @@ export class Agent {
934
1147
  if (!this.config.apiKey) {
935
1148
  throw new Error("No API key configured. Set SCODER_API_KEY or OPENAI_API_KEY (or put apiKey in .scoderrc.json).");
936
1149
  }
1150
+ // WARMUP: lượt thật thắng request prefill đang chạy nền (xem warmup()).
1151
+ await this.settleWarmup();
937
1152
  const buildCmd = this.buildCmd;
938
1153
  const rawTask = task;
939
1154
  // @file mention expansion — task trước khi build messages.
@@ -974,6 +1189,8 @@ export class Agent {
974
1189
  else {
975
1190
  this.tools = [...baseTools, ...mcpSpecs];
976
1191
  }
1192
+ // Tool list (re)built for this run — previous calibration no longer valid.
1193
+ this.measuredFixedTokens = null;
977
1194
  this.messages = buildRunMessages({
978
1195
  config: this.config,
979
1196
  primaryDef: this.primaryDef,
@@ -1025,6 +1242,9 @@ export class Agent {
1025
1242
  if (this.messages.length === 0) {
1026
1243
  return this.run(task);
1027
1244
  }
1245
+ // WARMUP: thường đã settle ở run() đầu; await lại cho chắc (race /clear+
1246
+ // warm-up + runTurn ngay lập tức).
1247
+ await this.settleWarmup();
1028
1248
  const buildCmd = this.buildCmd;
1029
1249
  const rawTask = task;
1030
1250
  task = expandMentions(task, this.config.workdir);
@@ -1033,6 +1253,10 @@ export class Agent {
1033
1253
  this.resetUsageTotals();
1034
1254
  this.filesModified = false;
1035
1255
  this.plan = [];
1256
+ this.runDiffs = [];
1257
+ this.selfReviewDone = false;
1258
+ this.verifyStrikes = 0;
1259
+ this.lastVerifySignature = "";
1036
1260
  for (const content of this.promptContext.takePendingContext())
1037
1261
  this.messages.push({ role: "user", content });
1038
1262
  this.messages.push({ role: "user", content: task });
@@ -1049,6 +1273,9 @@ export class Agent {
1049
1273
  * An optional follow-up task is appended as a new user message.
1050
1274
  */
1051
1275
  async resume(session, followUp) {
1276
+ // WARMUP: session restore mang system prompt RIÊNG (từ session cũ) — hủy
1277
+ // request prefill của prompt mới để không nhân đôi prefill nền.
1278
+ await this.settleWarmup();
1052
1279
  const buildCmd = this.buildCmd;
1053
1280
  this.sessionCreatedAt = session.createdAt;
1054
1281
  this.currentTask = session.task;
@@ -1325,6 +1552,8 @@ export class Agent {
1325
1552
  : { ok: null, detail: "no verification command ran" };
1326
1553
  this.emit({ type: "finish", success, steps, summary: summary.slice(0, 2000), usage: this.getUsage(), verification });
1327
1554
  this.logger.log({ type: "finish", success, steps });
1555
+ // FILE-READ-PERF P3: run kết thúc → session quay về nhàn rỗi, arm heartbeat.
1556
+ this.scheduleWarmupRefresh();
1328
1557
  return { success, steps, buildCmd, summary: summary.slice(0, 2000) };
1329
1558
  }
1330
1559
  }
package/dist/cli/repl.js CHANGED
@@ -12,6 +12,9 @@ export async function repl(cfg) {
12
12
  // every input is a follow-up turn, context carries over, saved continuously.
13
13
  // /clear swaps in a fresh Agent (the /clear branch reassigns this).
14
14
  let agent = new Agent(cfg);
15
+ // WARMUP (plan/WARMUP-PLAN.md): dựng MCP+probe+prefill system+tools nền
16
+ // trong lúc user còn đọc prompt — lệnh đầu không trả 30–120s cold prefill.
17
+ void agent.warmup();
15
18
  process.on("SIGINT", () => {
16
19
  ui.warn("\nSIGINT — aborting current task...");
17
20
  agent.abort();
@@ -37,6 +40,9 @@ export async function repl(cfg) {
37
40
  agent.dispose();
38
41
  agent = next ?? new Agent(cfg);
39
42
  first = true;
43
+ // Agent mới = prefix mới (session id, repo state) — warm-up lại từ đầu.
44
+ // Với `next` đã có messages (resume) warmup() tự no-op.
45
+ void agent.warmup();
40
46
  },
41
47
  };
42
48
  const { handled, outcome } = await dispatchCommand(input, host, (text, level) => {
@@ -68,11 +68,29 @@ export const LOCAL_PROFILE_DEFAULTS = {
68
68
  maxSteps: 24,
69
69
  /** P2-3: prefill 20–60k token trên máy cá nhân có thể vượt 120s → timeout + retry. */
70
70
  requestTimeoutMs: 600_000,
71
- /** P0-2: history của MỘT tool result nhỏ hơn mặc định cloud (8k). */
72
- toolOutputChars: 6_000,
71
+ /**
72
+ * FILE-READ-PERF P1: profile KHÔNG pin toolOutputChars nữa (trước là 6000) —
73
+ * ngân sách đọc giờ suy từ context window THẬT qua adaptiveToolOutputChars,
74
+ * apply ở cuối applyContextProbe (agent.ts). Window nhỏ vẫn ra 6000 như cũ.
75
+ */
73
76
  /** P0-3: trần token cho MCP schema; 46 tool GitHub MCP ≈ 12k token mỗi lượt. */
74
77
  mcpSchemaTokens: 6_000,
75
78
  };
79
+ /**
80
+ * FILE-READ-PERF (plan/FILE-READ-PERF-PLAN.md) N1: một lần đọc file bị cắt
81
+ * thành 5–6 MẢNH vì budget 6000 ký tự, mà mỗi mảnh = một lượt LLM tròn
82
+ * (đo: 8–11s/mảnh với README 28KB). Nới budget theo window đã probe:
83
+ * 0.17 ký tự mỗi token window ≈ 4% history cho một tool result
84
+ * (4 chars ≈ 1 token) — window 16k giữ nguyên 6000, 96k lên trần 16000.
85
+ */
86
+ export const TOOL_OUTPUT_WINDOW_SHARE = 0.17;
87
+ export const TOOL_OUTPUT_CHARS_FLOOR = 6_000;
88
+ export const TOOL_OUTPUT_CHARS_CEIL = 16_000;
89
+ export function adaptiveToolOutputChars(windowTokens) {
90
+ if (!Number.isFinite(windowTokens) || windowTokens <= 0)
91
+ return TOOL_OUTPUT_CHARS_FLOOR;
92
+ return Math.min(TOOL_OUTPUT_CHARS_CEIL, Math.max(TOOL_OUTPUT_CHARS_FLOOR, Math.round(windowTokens * TOOL_OUTPUT_WINDOW_SHARE)));
93
+ }
76
94
  /**
77
95
  * Áp mặc định phù hợp cho local engine. Trả về danh sách thay đổi đã áp (để
78
96
  * log/kiểm thử); rỗng khi không áp dụng (cloud, hoặc localProfile:false).
@@ -98,10 +116,8 @@ export function applyLocalProfile(cfg, explicit = {}) {
98
116
  limits.requestTimeoutMs = LOCAL_PROFILE_DEFAULTS.requestTimeoutMs;
99
117
  applied.push(`requestTimeoutMs=${LOCAL_PROFILE_DEFAULTS.requestTimeoutMs}`);
100
118
  }
101
- if (limits.toolOutputChars === undefined) {
102
- limits.toolOutputChars = LOCAL_PROFILE_DEFAULTS.toolOutputChars;
103
- applied.push(`toolOutputChars=${LOCAL_PROFILE_DEFAULTS.toolOutputChars}`);
104
- }
119
+ // FILE-READ-PERF P1: toolOutputChars KHÔNG pin ở đây nữa — để undefined để
120
+ // applyAdaptiveReadBudget nhận diện "user không đặt" và suy từ window.
105
121
  if (limits.mcpSchemaTokens === undefined) {
106
122
  limits.mcpSchemaTokens = LOCAL_PROFILE_DEFAULTS.mcpSchemaTokens;
107
123
  applied.push(`mcpSchemaTokens=${LOCAL_PROFILE_DEFAULTS.mcpSchemaTokens}`);
@@ -304,6 +320,7 @@ export function loadConfig(overrides = {}) {
304
320
  agent: overrides.agent ?? process.env.SCODER_AGENT ?? rc.agent,
305
321
  routing: rc.routing,
306
322
  repoMap: rc.repoMap,
323
+ warmup: rc.warmup,
307
324
  promptVersion: rc.promptVersion,
308
325
  verifyCmds: rc.verifyCmds,
309
326
  selfReview: rc.selfReview,
@@ -203,8 +203,9 @@ verifying your work with the build command. You act; you don't just advise.`,
203
203
  Your answers render in a terminal (Ink). Markdown displays best when you:
204
204
  - Tables: at most 6 columns with short cell text; no line breaks inside cells;
205
205
  escape literal pipes as \\| and keep pipes inside \`code spans\` intact.
206
- - Diagrams: keep \`\`\`mermaid sources simple (flowchart/sequence with plain
207
- node ids). The terminal shows a structured summary, not the graph image.
206
+ - Diagrams: \`\`\`mermaid flowchart/graph renders as a real box-drawing chart
207
+ in the terminal — keep it ≤12 nodes, plain node ids, short labels (line
208
+ breaks via <br/>). sequence/state/other types show a structured summary.
208
209
  - Numeric comparisons/trends: prefer a \`\`\`chart fence (JSON array of
209
210
  {"label","value"}) — it renders as a native terminal bar/pie chart.
210
211
  - Code fences: always tag the language; keep lines reasonably short (they wrap
@@ -259,6 +260,10 @@ export const PROMPT_SECTIONS = {
259
260
  tools: `## Tool policy
260
261
  - Orient with list_files / grep / glob / repo_map — prefer them over shell
261
262
  greps. read_file before every edit_file.
263
+ - read_file: when a result ends with "N more line(s)", read the REST in ONE
264
+ step — issue several read_file calls with successive offset/limit covering
265
+ every remaining chunk at once (they run in parallel). Never re-read lines
266
+ you already have.
262
267
  - edit_file: old_string MUST appear exactly once — otherwise re-read the file
263
268
  and add more context.
264
269
  - run_command IS the build command: run it only when you changed files; a
@@ -294,8 +299,9 @@ export const PROMPT_SECTIONS = {
294
299
  - Reply in the user's language; keep final reports concise. Never print
295
300
  secrets (API keys, tokens, .env contents).
296
301
  - Terminal markdown: tag code fences with the language; tables ≤6 columns,
297
- short cells; keep mermaid sources simple (flowchart/sequence, plain ids);
298
- numeric trends as a \`\`\`chart fence (JSON array of {"label","value"}).`,
302
+ short cells; \`\`\`mermaid flowcharts render as real terminal graphs (keep
303
+ ≤12 nodes, plain ids, short labels), sequence as a summary; numeric trends
304
+ as a \`\`\`chart fence (JSON array of {"label","value"}).`,
299
305
  };
300
306
  /** V6 — assembled from the sections above (order matters). */
301
307
  function assembleV6() {
package/dist/llm/llm.js CHANGED
@@ -169,7 +169,9 @@ export class LlmClient {
169
169
  messages: toWireMessages(messages),
170
170
  tools: tools.length > 0 ? tools : undefined,
171
171
  temperature: this.opts.temperature,
172
- max_tokens: this.opts.maxTokens,
172
+ // WARMUP: override max_tokens cho request prefill (1 token là đủ để
173
+ // engine tính xong KV-cache của prefix) — lượt thật không truyền key này.
174
+ max_tokens: opts.maxTokens ?? this.opts.maxTokens,
173
175
  stream: false,
174
176
  });
175
177
  let res;
@@ -153,12 +153,12 @@ export class AnthropicClient {
153
153
  this.opts = { ...this.opts, model };
154
154
  }
155
155
  /** I-05: request body builder — shared by chat/streamChat. */
156
- buildBody(system, messages, tools, stream) {
156
+ buildBody(system, messages, tools, stream, maxTokensOverride) {
157
157
  // Extended thinking: temperature must be 1 và thinking block format.
158
158
  const thinkingOn = (this.opts.thinkingBudgetTokens ?? 0) > 0;
159
159
  return JSON.stringify({
160
160
  model: this.opts.model,
161
- max_tokens: this.opts.maxTokens,
161
+ max_tokens: maxTokensOverride ?? this.opts.maxTokens,
162
162
  temperature: thinkingOn ? 1 : this.opts.temperature,
163
163
  ...(thinkingOn ? { thinking: { type: "enabled", budget_tokens: this.opts.thinkingBudgetTokens } } : {}),
164
164
  system,
@@ -172,7 +172,9 @@ export class AnthropicClient {
172
172
  const { system, messages: translated } = toAnthropicMessages(messages);
173
173
  const { signal, cleanup } = combinedSignal(opts.signal, this.timeoutMs);
174
174
  const cached = this.applyCacheControl(system, translated, tools);
175
- const body = this.buildBody(cached.system, cached.messages, cached.tools, false);
175
+ // WARMUP: override max_tokens cho request prefill — các breakpoint
176
+ // cache_control (tools + system) đã áp dụng y hệt lượt thật.
177
+ const body = this.buildBody(cached.system, cached.messages, cached.tools, false, opts.maxTokens);
176
178
  let res;
177
179
  try {
178
180
  res = await withRetry(async () => {
package/dist/llm/retry.js CHANGED
@@ -32,6 +32,10 @@ export async function withRetry(fn, opts = {}, onRetry) {
32
32
  catch (err) {
33
33
  lastErr = err;
34
34
  const status = err.status;
35
+ // WARMUP: abort của CHÍNH caller (warm-up bị lượt thật supersede, SIGINT)
36
+ // không bao giờ được retry — message có thể là bất kỳ chuỗi nào.
37
+ if (err?.name === "AbortError")
38
+ throw err;
35
39
  const retryable = (typeof status === "number" && isRetryableStatus(status)) ||
36
40
  (!(err instanceof NonRetryableError) && isRetryableNetworkError(err));
37
41
  // Client errors (except 429) — fail immediately
@@ -89,8 +89,8 @@ export class McpManager {
89
89
  .listTools()
90
90
  .then((defs) => this.reregisterTools(name, conn, defs))
91
91
  .catch(() => { });
92
- void this.refreshResources(name, conn);
93
- void this.refreshPrompts(name, conn);
92
+ void this.refreshResources(name, conn).catch(() => { });
93
+ void this.refreshPrompts(name, conn).catch(() => { });
94
94
  for (const uri of this.subscriptions.get(name) ?? []) {
95
95
  void conn.subscribeResource(uri).catch(() => { });
96
96
  }
@@ -272,22 +272,29 @@ export class PermissionChecker {
272
272
  // T3: timeout enforced HERE (checker-level) — a hung frontend handler
273
273
  // (TUI modal never answered, server client disconnected) must not leak
274
274
  // the pending permission promise forever.
275
- const answer = await Promise.race([
276
- this.promptHandler(req),
277
- new Promise((_, rej) => setTimeout(() => {
275
+ let timer;
276
+ const timeoutPromise = new Promise((_, rej) => {
277
+ timer = setTimeout(() => {
278
278
  const e = new Error("permission handler timed out");
279
279
  e.name = "AbortError";
280
280
  rej(e);
281
- }, req.timeoutSecs * 1000)),
282
- ]);
283
- if (answer === "quit")
284
- this.abortRequested = true;
285
- if (answer === "always" && req.kind === "edit")
286
- this.allowAllEdits = true;
287
- if (answer === "always" && req.kind === "shell" && req.alwaysAllowPrefix) {
288
- this.sessionAllow.addPrefix(req.alwaysAllowPrefix);
281
+ }, req.timeoutSecs * 1000);
282
+ });
283
+ try {
284
+ const answer = await Promise.race([this.promptHandler(req), timeoutPromise]);
285
+ if (answer === "quit")
286
+ this.abortRequested = true;
287
+ if (answer === "always" && req.kind === "edit")
288
+ this.allowAllEdits = true;
289
+ if (answer === "always" && req.kind === "shell" && req.alwaysAllowPrefix) {
290
+ this.sessionAllow.addPrefix(req.alwaysAllowPrefix);
291
+ }
292
+ return answer === "yes" || answer === "always";
293
+ }
294
+ finally {
295
+ if (timer)
296
+ clearTimeout(timer);
289
297
  }
290
- return answer === "yes" || answer === "always";
291
298
  }
292
299
  catch {
293
300
  return false; // handler error/timeout — deny
@@ -461,6 +468,19 @@ export class PermissionChecker {
461
468
  };
462
469
  }
463
470
  }
471
+ // Command substitution $(...) / backticks can hide arbitrary commands inside
472
+ // an otherwise-allowed prefix (e.g. `echo $(rm -rf /)`). Treat as ask
473
+ // unless an explicit rule allows the whole command line.
474
+ if (scan?.ok && scan.hasCommandSubstitution) {
475
+ const e = matchRule(this.activeRules(), "shell", command);
476
+ if (e !== "allow") {
477
+ return {
478
+ decision: "ask",
479
+ reason: "command substitution ($() / backticks) — approval required",
480
+ alwaysAllowPrefix: extractFirstWord(command) + " ",
481
+ };
482
+ }
483
+ }
464
484
  return this.classify(command);
465
485
  }
466
486
  /**
@@ -74,8 +74,10 @@ export function toolReadFile(workdir, args) {
74
74
  const header = offset > 1 || end < total ? `[lines ${offset}-${end} of ${total} — ${args.path}]\n` : "";
75
75
  const footer = dropped > 0
76
76
  ? `\n... [${dropped} more line(s): ${end + 1}-${offset + slice.length - 1} of ${total}. ` +
77
- `Read the next chunk with read_file({"path":"${args.path}","offset":${end + 1}}) — ` +
78
- `do NOT re-read from the start.]`
77
+ `Read the REST in ONE step — parallel read_file calls at ` +
78
+ `{"path":"${args.path}","offset":${end + 1}}, ` +
79
+ `{"path":"${args.path}","offset":${end + 1 + kept.length}}, ... ` +
80
+ `until the file is done; do NOT re-read from the start.]`
79
81
  : "";
80
82
  // clamp cho dòng kếch xù (một dòng 40k ký tự vẫn phải nằm trong ngân sách).
81
83
  const numbered = clamp(kept.join("\n"), budget);