s-app-scoder-agent 0.9.9 → 0.9.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.scoderrc.example.json +2 -0
- package/README.md +1 -1
- package/dist/agent/guardrails.js +17 -0
- package/dist/agent/interaction.js +15 -8
- package/dist/agent/prompt-context.js +20 -2
- package/dist/agent/run-loop.js +3 -0
- package/dist/agent.js +226 -3
- package/dist/cli/repl.js +6 -0
- package/dist/config/config.js +23 -6
- package/dist/context/prompt.js +4 -0
- package/dist/context/repo-map.js +8 -6
- package/dist/llm/llm.js +3 -1
- package/dist/llm/provider-anthropic.js +5 -3
- package/dist/llm/retry.js +4 -0
- package/dist/mcp/manager.js +2 -2
- package/dist/security/permissions.js +32 -12
- package/dist/tools/read-file.js +4 -2
- package/dist/tools/run-command.js +42 -24
- package/dist/tools/search.js +7 -0
- package/dist/tui/app.js +40 -13
- package/dist/tui/chrome.js +9 -6
- package/dist/tui/hooks/use-keybinds.js +24 -1
- package/dist/tui/hooks/use-scroll.js +16 -4
- package/dist/tui/selectors.js +61 -6
- package/dist/tui/state.js +1 -1
- package/dist/workspace/file-index.js +6 -5
- package/package.json +1 -1
package/.scoderrc.example.json
CHANGED
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
|
|
package/dist/agent/guardrails.js
CHANGED
|
@@ -167,6 +167,23 @@ const continueNudgeRule = { name: "continue-nudge",
|
|
|
167
167
|
return null;
|
|
168
168
|
if (claimsCompletion(ctx.text))
|
|
169
169
|
return null; // completion-claim rule phụ trách
|
|
170
|
+
// P0-8: text-only + không sửa file + không có plan mở ⇒ đây là LƯỢT CHAT.
|
|
171
|
+
// Finish ngay với chính text làm summary — trước đây rule này inject nudge
|
|
172
|
+
// ⇒ thêm 1 LLM call ~14,5k token mỗi câu chào (đo: call 2 in=14.569, chỉ
|
|
173
|
+
// thêm đúng dòng nudge 81 token). Bài học aec95ef giữ nguyên: nhánh CÓ
|
|
174
|
+
// sửa file vẫn nudge/ép build bên dưới; plan còn mở (model nghỉ giữa
|
|
175
|
+
// task, quên gọi tool) cũng vẫn nudge.
|
|
176
|
+
if (!ctx.filesModified && ctx.planOpen !== true) {
|
|
177
|
+
return {
|
|
178
|
+
action: {
|
|
179
|
+
kind: "finish",
|
|
180
|
+
success: true,
|
|
181
|
+
summary: ctx.text || "(agent replied without acting)",
|
|
182
|
+
chatOnly: true,
|
|
183
|
+
},
|
|
184
|
+
reason: "chat turn: tool-less step, no files modified, no open plan — one LLM call per message",
|
|
185
|
+
};
|
|
186
|
+
}
|
|
170
187
|
// No file changed yet → never demand a build for what may be a pure Q&A or
|
|
171
188
|
// an explanation-only answer. Give the model the explicit conversational
|
|
172
189
|
// exit instead; text-only-streak still ends the run if it keeps stalling.
|
|
@@ -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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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)}` };
|
|
@@ -19,7 +19,12 @@ export function buildRunMessages(input) {
|
|
|
19
19
|
const { config, primaryDef, skills, buildCmd, task, pendingContext } = input;
|
|
20
20
|
const skillsBlock = buildSkillsBlock(skills);
|
|
21
21
|
const agentsGuideBlock = buildAgentGuideBlock(config.workdir, task);
|
|
22
|
-
|
|
22
|
+
// P0-10: REPO MAP rời khỏi system prompt. Map mô tả TRẠNG THÁI repo — mỗi
|
|
23
|
+
// byte đổi (file mới, tên symbol đổi) làm lệch prefix system+tools ở token
|
|
24
|
+
// ~954 ⇒ mọi cửa sổ mới mất 96% KV-cache (đo: cached 14.484 → 0, TTFT
|
|
25
|
+
// 0,2 s → 30–47 s). Đặt làm message sát task: prefix system+tools+junk byte
|
|
26
|
+
// stable giữa các cửa sổ, map (cold) chỉ còn là đuôi ~2k token.
|
|
27
|
+
const repoMapBlock = config.repoMap === false ? "" : buildRepoMap(config.workdir);
|
|
23
28
|
const baseOverride = primaryDef?.prompt?.trim() ? primaryDef.prompt : undefined;
|
|
24
29
|
return [
|
|
25
30
|
{
|
|
@@ -31,12 +36,20 @@ export function buildRunMessages(input) {
|
|
|
31
36
|
skillsBlock,
|
|
32
37
|
mcpBlock: buildMcpBlock(config),
|
|
33
38
|
agentsGuideBlock,
|
|
34
|
-
repoMapBlock,
|
|
39
|
+
repoMapBlock: "",
|
|
35
40
|
base: baseOverride,
|
|
36
41
|
promptVersion: config.promptVersion,
|
|
37
42
|
}) + buildAgentsBlock(discoverAgents(config.workdir), config.allowSubagents !== false),
|
|
38
43
|
},
|
|
39
44
|
...pendingContext.map((content) => ({ role: "user", content })),
|
|
45
|
+
...(repoMapBlock
|
|
46
|
+
? [{
|
|
47
|
+
role: "user",
|
|
48
|
+
content: "[Context — repository map as of session start; paths + exported symbols only. " +
|
|
49
|
+
"If a file looks missing or stale, confirm with list_files/glob/repo_map rather than assume.]\n\n" +
|
|
50
|
+
repoMapBlock,
|
|
51
|
+
}]
|
|
52
|
+
: []),
|
|
40
53
|
{ role: "user", content: task },
|
|
41
54
|
];
|
|
42
55
|
}
|
|
@@ -75,6 +88,11 @@ export class PromptContext {
|
|
|
75
88
|
clearPendingTools() {
|
|
76
89
|
this.pendingAllowedTools = null;
|
|
77
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
|
+
}
|
|
78
96
|
takePendingContext() {
|
|
79
97
|
const pending = this.pendingContext;
|
|
80
98
|
this.pendingContext = [];
|
package/dist/agent/run-loop.js
CHANGED
|
@@ -246,6 +246,9 @@ export async function runAgentLoop(runtime, startSteps, buildCmd, initialBuild =
|
|
|
246
246
|
buildCallPresent: !!buildCall,
|
|
247
247
|
filesModified: runtime.filesModified,
|
|
248
248
|
chatDone,
|
|
249
|
+
// P0-8: plan chưa xong ⇒ bước text-only chỉ là nghỉ giữa task (nudge);
|
|
250
|
+
// plan trống/đủ ⇒ rule coi đó là lượt chat và finish (1 call).
|
|
251
|
+
planOpen: runtime.plan.some((t) => t.status !== "completed"),
|
|
249
252
|
outputTokensThisStep: usage?.completion_tokens,
|
|
250
253
|
totalTokensIn: runtime.usageTotals.inputTokens, // G6 budget rule
|
|
251
254
|
totalTokensOut: runtime.usageTotals.outputTokens,
|
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,184 @@ 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
|
+
// Dời hai việc network ra khỏi đường tới token đầu. run() vẫn gọi lại
|
|
972
|
+
// nguyên như cũ — cả hai đều return sớm khi đã hoàn tất ở đây.
|
|
973
|
+
await Promise.allSettled([this.applyContextProbe(), this.connectMcp()]);
|
|
974
|
+
if (signal.aborted || this.disposed)
|
|
975
|
+
return;
|
|
976
|
+
// FILE-READ-PERF P3: heartbeat sau khi đã chạy ≥1 lượt thật gửi lại
|
|
977
|
+
// HISTORY THẬT — đó mới là prefix của lượt kế tiếp (placeholder sẽ phân
|
|
978
|
+
// nhánh ngay sau system). Trước lượt đầu: dựng đúng messages như run().
|
|
979
|
+
const hasHistory = this.messages.length > 0;
|
|
980
|
+
// Tool list + messages dựng ĐÚNG như run() — cache-key (Anthropic
|
|
981
|
+
// breakpoint, prefix KV của llama.cpp) là từng byte JSON của schema.
|
|
982
|
+
const pendingAllowedTools = this.promptContext.pendingTools();
|
|
983
|
+
const allow = pendingAllowedTools?.length
|
|
984
|
+
? new Set(pendingAllowedTools.map((t) => t.toLowerCase()))
|
|
985
|
+
: null;
|
|
986
|
+
let baseTools = applyToolFilter(allToolSpecs(), this.primaryDef, this.reporter);
|
|
987
|
+
if (allow)
|
|
988
|
+
baseTools = baseTools.filter((s) => allow.has(s.function.name.toLowerCase()));
|
|
989
|
+
const mcpSpecs = governedMcpSpecs(this.mcpBridgeRuntime());
|
|
990
|
+
const tools = allow
|
|
991
|
+
? [...baseTools, ...mcpSpecs.filter((s) => allow.has(s.function.name.toLowerCase()))]
|
|
992
|
+
: [...baseTools, ...mcpSpecs];
|
|
993
|
+
const messages = hasHistory
|
|
994
|
+
? this.messages
|
|
995
|
+
: buildRunMessages({
|
|
996
|
+
config: this.config,
|
|
997
|
+
primaryDef: this.primaryDef,
|
|
998
|
+
skills: this.promptContext.getSkills(),
|
|
999
|
+
buildCmd: this.buildCmd,
|
|
1000
|
+
// Placeholder KHÔNG mention file nào: system prompt trùng byte với
|
|
1001
|
+
// request thật ở mọi task không có focus-file guide (xem plan §7).
|
|
1002
|
+
task: "[warmup]",
|
|
1003
|
+
pendingContext: this.promptContext.peekPendingContext(),
|
|
1004
|
+
});
|
|
1005
|
+
const res = await this.client.chat(messages, tools, { signal, maxTokens: 1 });
|
|
1006
|
+
this.logger.log({
|
|
1007
|
+
type: "warmup",
|
|
1008
|
+
ok: true,
|
|
1009
|
+
rewarm,
|
|
1010
|
+
ms: Date.now() - t0,
|
|
1011
|
+
model: this.client.model,
|
|
1012
|
+
promptTokens: res.usage?.prompt_tokens,
|
|
1013
|
+
cachedTokens: res.usage?.cached_tokens,
|
|
1014
|
+
});
|
|
1015
|
+
}
|
|
1016
|
+
catch (err) {
|
|
1017
|
+
// Warm-up không bao giờ được làm hỏng run thật — kể cả SIGINT abort.
|
|
1018
|
+
this.logger.log({
|
|
1019
|
+
type: "warmup",
|
|
1020
|
+
ok: false,
|
|
1021
|
+
ms: Date.now() - t0,
|
|
1022
|
+
error: (err?.message ?? String(err)).slice(0, 200),
|
|
1023
|
+
});
|
|
1024
|
+
}
|
|
1025
|
+
finally {
|
|
1026
|
+
settle();
|
|
1027
|
+
// dispose() chạy giữa chừng: kill lại MCP servers mà connectMcp của
|
|
1028
|
+
// warm-up vừa spawn (killAll của dispose đã chạy TRƯỚC khi chúng mọc).
|
|
1029
|
+
if (this.disposed)
|
|
1030
|
+
this.shutdownMcp();
|
|
1031
|
+
// FILE-READ-PERF P3: cycle warm kết thúc TỰ NHIÊN (không bị supersede/
|
|
1032
|
+
// abort) → arm nhịp heartbeat tiếp theo. Bị abort nghĩa là run thật đang
|
|
1033
|
+
// chạy — finish() sẽ arm lại khi xong.
|
|
1034
|
+
else if (!signal.aborted)
|
|
1035
|
+
this.scheduleWarmupRefresh();
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
/**
|
|
1039
|
+
* Đầu mọi run thật (run/runTurn/resume): lượt thật thắng warm-up — abort
|
|
1040
|
+
* request prefill nếu còn treo, rồi await cho settle để connectMcp/probe
|
|
1041
|
+
* không double-spawn server con song song với chính nó.
|
|
1042
|
+
*/
|
|
1043
|
+
async settleWarmup() {
|
|
1044
|
+
// Run thật bắt đầu → heartbeat phải im lặng (timer re-arm ở finish()).
|
|
1045
|
+
this.clearWarmupTimer();
|
|
1046
|
+
const w = this.warmupRun;
|
|
1047
|
+
if (!w)
|
|
1048
|
+
return;
|
|
1049
|
+
if (!w.settled)
|
|
1050
|
+
w.abort.abort(new DOMException("real run supersedes warmup", "AbortError"));
|
|
1051
|
+
await w.promise.catch(() => { });
|
|
1052
|
+
}
|
|
1053
|
+
/** Tắt heartbeat idle (run thật vào / dispose). */
|
|
1054
|
+
clearWarmupTimer() {
|
|
1055
|
+
if (this.warmupIdleTimer) {
|
|
1056
|
+
clearTimeout(this.warmupIdleTimer);
|
|
1057
|
+
this.warmupIdleTimer = null;
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
/**
|
|
1061
|
+
* FILE-READ-PERF P3: arm lại nhịp re-warm — chỉ khi engine local và bộ máy
|
|
1062
|
+
* warmup còn bật (đúng các guard của warmup()). unref(): timer không được
|
|
1063
|
+
* giữ process sống (run-once/test thoát sạch).
|
|
1064
|
+
*/
|
|
1065
|
+
scheduleWarmupRefresh() {
|
|
1066
|
+
if (this.disposed || !this.warmupUsed)
|
|
1067
|
+
return;
|
|
1068
|
+
if (this.config.warmup === false || !this.config.apiKey || this.config.clientFactory) {
|
|
1069
|
+
this.clearWarmupTimer();
|
|
1070
|
+
return;
|
|
1071
|
+
}
|
|
1072
|
+
// KHÔNG phải local (kể cả /model đổi từ local → cloud giữa session): tắt
|
|
1073
|
+
// heartbeat đang arm — không đốt tiền API để giữ cache.
|
|
1074
|
+
if (!isLocalBaseUrl(this.config.baseUrl)) {
|
|
1075
|
+
this.clearWarmupTimer();
|
|
1076
|
+
return;
|
|
1077
|
+
}
|
|
1078
|
+
this.clearWarmupTimer();
|
|
1079
|
+
this.warmupIdleTimer = setTimeout(() => {
|
|
1080
|
+
this.warmupIdleTimer = null;
|
|
1081
|
+
void this.warmup({ rewarm: true }).catch(() => { });
|
|
1082
|
+
}, this.warmupIdleRefreshMs);
|
|
1083
|
+
this.warmupIdleTimer.unref();
|
|
1084
|
+
}
|
|
883
1085
|
/**
|
|
884
1086
|
* Disconnect MCP servers when the agent is disposed — connections are kept
|
|
885
1087
|
* alive for the whole agent/session lifetime (one "connected" line, no
|
|
@@ -891,8 +1093,13 @@ export class Agent {
|
|
|
891
1093
|
}
|
|
892
1094
|
/** M4: đóng hot-reload watcher (khi agent bị hủy vĩnh viễn). */
|
|
893
1095
|
dispose() {
|
|
1096
|
+
this.disposed = true;
|
|
1097
|
+
this.clearWarmupTimer();
|
|
894
1098
|
this.watcher?.close();
|
|
895
1099
|
this.watcher = null;
|
|
1100
|
+
// WARMUP: hủy request prefill đang chạy (chặn MCP servers connect thêm
|
|
1101
|
+
// sau khi kill — runWarmup tự shutdownMcp lần cuối trong finally).
|
|
1102
|
+
this.warmupRun?.abort.abort(new DOMException("agent disposed", "AbortError"));
|
|
896
1103
|
this.shutdownMcp();
|
|
897
1104
|
}
|
|
898
1105
|
/**
|
|
@@ -934,6 +1141,8 @@ export class Agent {
|
|
|
934
1141
|
if (!this.config.apiKey) {
|
|
935
1142
|
throw new Error("No API key configured. Set SCODER_API_KEY or OPENAI_API_KEY (or put apiKey in .scoderrc.json).");
|
|
936
1143
|
}
|
|
1144
|
+
// WARMUP: lượt thật thắng request prefill đang chạy nền (xem warmup()).
|
|
1145
|
+
await this.settleWarmup();
|
|
937
1146
|
const buildCmd = this.buildCmd;
|
|
938
1147
|
const rawTask = task;
|
|
939
1148
|
// @file mention expansion — task trước khi build messages.
|
|
@@ -974,6 +1183,8 @@ export class Agent {
|
|
|
974
1183
|
else {
|
|
975
1184
|
this.tools = [...baseTools, ...mcpSpecs];
|
|
976
1185
|
}
|
|
1186
|
+
// Tool list (re)built for this run — previous calibration no longer valid.
|
|
1187
|
+
this.measuredFixedTokens = null;
|
|
977
1188
|
this.messages = buildRunMessages({
|
|
978
1189
|
config: this.config,
|
|
979
1190
|
primaryDef: this.primaryDef,
|
|
@@ -1025,6 +1236,9 @@ export class Agent {
|
|
|
1025
1236
|
if (this.messages.length === 0) {
|
|
1026
1237
|
return this.run(task);
|
|
1027
1238
|
}
|
|
1239
|
+
// WARMUP: thường đã settle ở run() đầu; await lại cho chắc (race /clear+
|
|
1240
|
+
// warm-up + runTurn ngay lập tức).
|
|
1241
|
+
await this.settleWarmup();
|
|
1028
1242
|
const buildCmd = this.buildCmd;
|
|
1029
1243
|
const rawTask = task;
|
|
1030
1244
|
task = expandMentions(task, this.config.workdir);
|
|
@@ -1033,6 +1247,10 @@ export class Agent {
|
|
|
1033
1247
|
this.resetUsageTotals();
|
|
1034
1248
|
this.filesModified = false;
|
|
1035
1249
|
this.plan = [];
|
|
1250
|
+
this.runDiffs = [];
|
|
1251
|
+
this.selfReviewDone = false;
|
|
1252
|
+
this.verifyStrikes = 0;
|
|
1253
|
+
this.lastVerifySignature = "";
|
|
1036
1254
|
for (const content of this.promptContext.takePendingContext())
|
|
1037
1255
|
this.messages.push({ role: "user", content });
|
|
1038
1256
|
this.messages.push({ role: "user", content: task });
|
|
@@ -1049,6 +1267,9 @@ export class Agent {
|
|
|
1049
1267
|
* An optional follow-up task is appended as a new user message.
|
|
1050
1268
|
*/
|
|
1051
1269
|
async resume(session, followUp) {
|
|
1270
|
+
// WARMUP: session restore mang system prompt RIÊNG (từ session cũ) — hủy
|
|
1271
|
+
// request prefill của prompt mới để không nhân đôi prefill nền.
|
|
1272
|
+
await this.settleWarmup();
|
|
1052
1273
|
const buildCmd = this.buildCmd;
|
|
1053
1274
|
this.sessionCreatedAt = session.createdAt;
|
|
1054
1275
|
this.currentTask = session.task;
|
|
@@ -1325,6 +1546,8 @@ export class Agent {
|
|
|
1325
1546
|
: { ok: null, detail: "no verification command ran" };
|
|
1326
1547
|
this.emit({ type: "finish", success, steps, summary: summary.slice(0, 2000), usage: this.getUsage(), verification });
|
|
1327
1548
|
this.logger.log({ type: "finish", success, steps });
|
|
1549
|
+
// FILE-READ-PERF P3: run kết thúc → session quay về nhàn rỗi, arm heartbeat.
|
|
1550
|
+
this.scheduleWarmupRefresh();
|
|
1328
1551
|
return { success, steps, buildCmd, summary: summary.slice(0, 2000) };
|
|
1329
1552
|
}
|
|
1330
1553
|
}
|
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) => {
|
package/dist/config/config.js
CHANGED
|
@@ -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
|
-
/**
|
|
72
|
-
|
|
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
|
-
|
|
102
|
-
|
|
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,
|
package/dist/context/prompt.js
CHANGED
|
@@ -259,6 +259,10 @@ export const PROMPT_SECTIONS = {
|
|
|
259
259
|
tools: `## Tool policy
|
|
260
260
|
- Orient with list_files / grep / glob / repo_map — prefer them over shell
|
|
261
261
|
greps. read_file before every edit_file.
|
|
262
|
+
- read_file: when a result ends with "N more line(s)", read the REST in ONE
|
|
263
|
+
step — issue several read_file calls with successive offset/limit covering
|
|
264
|
+
every remaining chunk at once (they run in parallel). Never re-read lines
|
|
265
|
+
you already have.
|
|
262
266
|
- edit_file: old_string MUST appear exactly once — otherwise re-read the file
|
|
263
267
|
and add more context.
|
|
264
268
|
- run_command IS the build command: run it only when you changed files; a
|
package/dist/context/repo-map.js
CHANGED
|
@@ -104,19 +104,21 @@ export function extractSymbols(path, content) {
|
|
|
104
104
|
return [...out].slice(0, MAX_SYMBOLS_PER_FILE);
|
|
105
105
|
}
|
|
106
106
|
// ── Rendering ────────────────────────────────────────────────────────────────
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
107
|
+
/** Render the map without touching the cache (used for sub-tree maps).
|
|
108
|
+
* P0-10: deliberately NO per-file sizes and NO file-count header — those bytes
|
|
109
|
+
* change on every edit/add and used to invalidate the KV-cache prefix of every
|
|
110
|
+
* fresh window (measured: 1 char in REPO MAP ⇒ cached=0, TTFT 30–47 s). Symbol
|
|
111
|
+
* names still change only when a file's exported API changes, so the map is
|
|
112
|
+
* byte-stable across normal coding. */
|
|
111
113
|
export function renderRepoMap(workdir, budgetChars = DEFAULT_BUDGET_CHARS, sub = ".") {
|
|
112
114
|
const files = walkRepo(workdir, sub);
|
|
113
115
|
const lines = [
|
|
114
|
-
|
|
116
|
+
"REPO MAP — auto-generated; orient from this, don't read everything",
|
|
115
117
|
];
|
|
116
118
|
let budget = budgetChars;
|
|
117
119
|
let truncated = 0;
|
|
118
120
|
for (const f of files) {
|
|
119
|
-
let line = ` ${f.rel}
|
|
121
|
+
let line = ` ${f.rel}`;
|
|
120
122
|
if (SYMBOL_FILE.test(f.rel) && f.size <= MAX_FILE_BYTES) {
|
|
121
123
|
try {
|
|
122
124
|
const syms = extractSymbols(f.rel, readFileSync(f.abs, "utf8"));
|
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
|
-
|
|
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
|
-
|
|
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
|
package/dist/mcp/manager.js
CHANGED
|
@@ -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
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
-
|
|
284
|
-
this.
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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
|
/**
|
package/dist/tools/read-file.js
CHANGED
|
@@ -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
|
|
78
|
-
`
|
|
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);
|
|
@@ -116,23 +116,34 @@ export async function toolRunCommand(workdir, args, signal, onOutput, sandbox, r
|
|
|
116
116
|
cwd: workdir,
|
|
117
117
|
stdio: ["ignore", "pipe", "pipe"],
|
|
118
118
|
env: buildChildEnv(workdir), // G3: allowlist — không kế thừa full process.env
|
|
119
|
+
// Own process group so killTree(-pid) can reap grandchildren (see killTree).
|
|
120
|
+
detached: process.platform !== "win32",
|
|
119
121
|
});
|
|
122
|
+
let exited = false;
|
|
120
123
|
let stdout = "";
|
|
121
124
|
let stderr = "";
|
|
122
125
|
let killedReason = null;
|
|
123
|
-
|
|
124
|
-
|
|
126
|
+
// Linux/macOS: sh fork command con (sleep, make, npm…) rồi chết trước —
|
|
127
|
+
// kill mỗi `child` để chúng mồ côi, còn giữ PIPE => 'close' không bao giờ
|
|
128
|
+
// tới, tool treo, run không finish (fail CI serve-abort reproducibly).
|
|
129
|
+
// detached:true cho child một process group riêng -> kill cả nhóm qua -pid.
|
|
130
|
+
const killTree = (sig) => {
|
|
125
131
|
try {
|
|
126
|
-
|
|
132
|
+
if (process.platform !== "win32" && child.pid)
|
|
133
|
+
process.kill(-child.pid, sig);
|
|
134
|
+
else
|
|
135
|
+
child.kill(sig);
|
|
127
136
|
}
|
|
128
|
-
catch { /* already dead */ }
|
|
129
|
-
|
|
137
|
+
catch { /* already dead / group gone */ }
|
|
138
|
+
};
|
|
139
|
+
const killChild = (reason) => {
|
|
140
|
+
killedReason = reason;
|
|
141
|
+
killTree("SIGTERM");
|
|
142
|
+
// SIGKILL escalation — sh chết nhưng GRANDCHILD ignore TERM thì group
|
|
143
|
+
// vẫn sống; exitCode của bản thân sh không phản ánh điều đó, dùng `exited`.
|
|
130
144
|
setTimeout(() => {
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
child.kill("SIGKILL");
|
|
134
|
-
}
|
|
135
|
-
catch { /* already dead */ }
|
|
145
|
+
if (!exited)
|
|
146
|
+
killTree("SIGKILL");
|
|
136
147
|
}, 5_000).unref();
|
|
137
148
|
};
|
|
138
149
|
const timer = setTimeout(() => killChild("timeout"), timeoutMs);
|
|
@@ -156,34 +167,41 @@ export async function toolRunCommand(workdir, args, signal, onOutput, sandbox, r
|
|
|
156
167
|
child.on("error", (err) => {
|
|
157
168
|
finish({ ok: false, output: clamp(`${echoLine}\n(spawn error) ${err.message}`) });
|
|
158
169
|
});
|
|
159
|
-
|
|
170
|
+
const buildResult = (code) => {
|
|
160
171
|
if (killedReason === "timeout") {
|
|
161
|
-
|
|
172
|
+
return {
|
|
162
173
|
ok: false,
|
|
163
174
|
output: clamp(`${echoLine}\n(command timed out after ${Math.round(timeoutMs / 1000)}s and was killed)\n--- stdout ---\n${redactSecrets(stdout)}\n--- stderr ---\n${redactSecrets(stderr)}`),
|
|
164
|
-
}
|
|
165
|
-
return;
|
|
175
|
+
};
|
|
166
176
|
}
|
|
167
177
|
if (killedReason === "aborted") {
|
|
168
|
-
|
|
178
|
+
return {
|
|
169
179
|
ok: false,
|
|
170
180
|
output: clamp(`${echoLine}\n(interrupted by user)\n--- stdout ---\n${redactSecrets(stdout)}\n--- stderr ---\n${redactSecrets(stderr)}`),
|
|
171
|
-
}
|
|
172
|
-
return;
|
|
181
|
+
};
|
|
173
182
|
}
|
|
174
183
|
const status = code ?? -1;
|
|
175
184
|
// G3: redact trước khi output vào conversation/log/TUI.
|
|
176
185
|
const redacted = redactSecrets(stdout);
|
|
177
186
|
const redactedErr = redactSecrets(stderr);
|
|
178
187
|
if (status === 0) {
|
|
179
|
-
|
|
180
|
-
}
|
|
181
|
-
else {
|
|
182
|
-
finish({
|
|
183
|
-
ok: false,
|
|
184
|
-
output: clamp(`${echoLine}\n(exit ${status})\n--- stdout ---\n${redacted}\n--- stderr ---\n${redactedErr}`),
|
|
185
|
-
});
|
|
188
|
+
return { ok: true, output: clamp(`${echoLine}\n(exit 0)\n${redacted}`) };
|
|
186
189
|
}
|
|
190
|
+
return {
|
|
191
|
+
ok: false,
|
|
192
|
+
output: clamp(`${echoLine}\n(exit ${status})\n--- stdout ---\n${redacted}\n--- stderr ---\n${redactedErr}`),
|
|
193
|
+
};
|
|
194
|
+
};
|
|
195
|
+
// 'close' chờ EOF của pipe; grandchild lì (khác process group — hiếm, hoặc
|
|
196
|
+
// spawn của chính command) có thể giữ nó mở vĩnh viễn. Đã kill mà child
|
|
197
|
+
// exit rồi thì resolve ngay trên 'exit', đừng đợi close.
|
|
198
|
+
child.on("exit", (code) => {
|
|
199
|
+
exited = true;
|
|
200
|
+
if (killedReason)
|
|
201
|
+
finish(buildResult(code));
|
|
202
|
+
});
|
|
203
|
+
child.on("close", (code) => {
|
|
204
|
+
finish(buildResult(code));
|
|
187
205
|
});
|
|
188
206
|
});
|
|
189
207
|
}
|
package/dist/tools/search.js
CHANGED
|
@@ -91,7 +91,14 @@ export function toolGrep(workdir, args) {
|
|
|
91
91
|
// status >= 2 → rg error; fall through to grep fallback.
|
|
92
92
|
}
|
|
93
93
|
// Fallback: grep -rn (portable) — output already path:line:text.
|
|
94
|
+
// CI runners (ubuntu-latest) không có rg → đường này là mainstream, phải
|
|
95
|
+
// hỗ trợ include + skip noise dirs như rg; grep BSD không hiểu các cờ này
|
|
96
|
+
// sẽ exit ≥2 → rơi tiếp xuống jsGrepFallback (xử lý đúng, không mất sạch).
|
|
94
97
|
const gArgs = ["-rn", "--binary-files=without-match", "-I", "-m", "5", "-e", pattern];
|
|
98
|
+
for (const d of SEARCH_SKIP)
|
|
99
|
+
gArgs.push(`--exclude-dir=${d}`);
|
|
100
|
+
if (args.include)
|
|
101
|
+
gArgs.push("--include", args.include);
|
|
95
102
|
if (ctxBefore > 0)
|
|
96
103
|
gArgs.push("-B", String(ctxBefore));
|
|
97
104
|
if (ctxAfter > 0)
|
package/dist/tui/app.js
CHANGED
|
@@ -19,6 +19,7 @@ import { setQuiet } from "../io/terminal.js";
|
|
|
19
19
|
import { discoverSkills } from "../context/skills.js";
|
|
20
20
|
import { discoverAgents } from "../context/agents.js";
|
|
21
21
|
import { initialState } from "./state.js";
|
|
22
|
+
import { transcriptWindow, estimateTotalRows } from "./selectors.js";
|
|
22
23
|
import { SLASH_COMMANDS, formatSlashHelp } from "../commands/definitions.js";
|
|
23
24
|
import { ChatItemView } from "./chat-views.js"; // T2
|
|
24
25
|
import { BuildRail, StatusBar, SIDEBAR_MIN_COLS, SIDEBAR_WIDTH } from "./chrome.js"; // T2
|
|
@@ -72,6 +73,8 @@ export function TuiApp({ config, initialTask }) {
|
|
|
72
73
|
const [input, setInput] = useState(initialTask ?? "");
|
|
73
74
|
/** Rows scrolled up from the bottom; 0 = follow the newest content. */
|
|
74
75
|
const [scrollOffset, setScrollOffset] = useState(0);
|
|
76
|
+
/** Ctrl+O: native terminal text selection (mouse tracking released). */
|
|
77
|
+
const [selectMode, setSelectMode] = useState(false);
|
|
75
78
|
const [permMode, setPermMode] = useState(cfg.permissionMode);
|
|
76
79
|
// T-03: active permission prompt (modal) — the run blocks until answered.
|
|
77
80
|
const [permPrompt, setPermPrompt] = useState(null);
|
|
@@ -95,13 +98,25 @@ export function TuiApp({ config, initialTask }) {
|
|
|
95
98
|
agentRef.current?.dispose();
|
|
96
99
|
agentRef.current = null;
|
|
97
100
|
}, []);
|
|
98
|
-
// ── Scrollable chat viewport (Ink 8 alt-screen) ──
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
//
|
|
104
|
-
const
|
|
101
|
+
// ── Scrollable chat viewport (Ink 8 alt-screen) — P1-8 virtualized ──
|
|
102
|
+
// Chi phí layout/wrap mỗi frame tỷ lệ với số item MOUNT, không phải số
|
|
103
|
+
// item transcript ⇒ chỉ mount cửa sổ quanh viewport (transcriptWindow).
|
|
104
|
+
// Scroll offset khi đọc lại dùng rows ước lượng; khi BÁM ĐÁY dùng chiều
|
|
105
|
+
// cao THẬT của cửa sổ đã mount (đo rẻ vì cửa sổ ~viewport+overscan) —
|
|
106
|
+
// estimate sai lệch bao nhiêu thì dòng cuối vẫn luôn thấy.
|
|
107
|
+
const { viewportRef, viewport, contentRef, content, viewportRowsRef } = useScrollMetrics(setScrollOffset, !selectMode);
|
|
108
|
+
const viewportRows = viewport.clientHeight || 24;
|
|
109
|
+
const win = useMemo(() => {
|
|
110
|
+
const total = estimateTotalRows(state.items);
|
|
111
|
+
const max = Math.max(0, total - viewportRows);
|
|
112
|
+
const top = Math.max(0, max - scrollOffset);
|
|
113
|
+
return { ...transcriptWindow(state.items, top, viewportRows), maxScroll: max };
|
|
114
|
+
}, [state.items, scrollOffset, viewportRows]);
|
|
115
|
+
const maxScroll = win.maxScroll;
|
|
116
|
+
// Follow-bottom (scrollOffset = 0): offset chính xác theo số đo thật.
|
|
117
|
+
const contentOffset = scrollOffset === 0 && content.hasMeasured
|
|
118
|
+
? Math.max(0, content.height - viewport.clientHeight)
|
|
119
|
+
: win.offset;
|
|
105
120
|
const skills = useMemo(() => discoverSkills(cfg.workdir), [cfg.workdir]);
|
|
106
121
|
const skillNames = useMemo(() => skills.map((s) => s.name), [skills]);
|
|
107
122
|
// T-02: completion sources (agents + files) computed once per mount.
|
|
@@ -141,8 +156,19 @@ export function TuiApp({ config, initialTask }) {
|
|
|
141
156
|
useTuiKeybinds({
|
|
142
157
|
scrollOffset, permPromptRef, setPermPrompt, agentRef, runningRef, sessionPickerOpen,
|
|
143
158
|
setSessionPickerOpen, setPaletteOpen, inputRef, setHelpOpen, update, viewportRowsRef,
|
|
144
|
-
setScrollOffset, exit,
|
|
159
|
+
setScrollOffset, selectMode, setSelectMode, exit,
|
|
145
160
|
});
|
|
161
|
+
// WARMUP (plan/WARMUP-PLAN.md): tạo agent + prefill prefix system+tools nền
|
|
162
|
+
// NGAY khi mở TUI — TTFT của câu chat đầu không còn phải trả cold-load model
|
|
163
|
+
// và 20–60k token prefill. Bỏ qua khi có initialTask (submit trả prefill ngay
|
|
164
|
+
// sau mount, warm-up chỉ nhân đôi việc — settleWarmup sẽ abort nó).
|
|
165
|
+
const warmupRef = useRef(false);
|
|
166
|
+
useEffect(() => {
|
|
167
|
+
if (initialTask || warmupRef.current)
|
|
168
|
+
return;
|
|
169
|
+
warmupRef.current = true;
|
|
170
|
+
void createAgent().warmup();
|
|
171
|
+
}, [initialTask]);
|
|
146
172
|
// Submit an initial task passed on the command line once, after mount.
|
|
147
173
|
const bootRef = useRef(false);
|
|
148
174
|
useEffect(() => {
|
|
@@ -164,19 +190,20 @@ export function TuiApp({ config, initialTask }) {
|
|
|
164
190
|
setScrollOffset(0);
|
|
165
191
|
prevStatusRef.current = state.status;
|
|
166
192
|
}, [state.status]);
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
//
|
|
193
|
+
// P1-8: viewport mount CỬA SỔ item quanh scrollTop (transcriptWindow), không
|
|
194
|
+
// mount cả transcript — chi phí layout/wrap mỗi frame hết tỷ lệ với tổng text.
|
|
195
|
+
// Ink clip bằng overflow="hidden"; contentOffsetY = số dòng phải ẩn ở đầu
|
|
196
|
+
// cửa sổ (scrollTop - startRow[item_start]).
|
|
170
197
|
const { rows, columns } = useWindowSize();
|
|
171
198
|
const showSidebar = (columns ?? 100) >= SIDEBAR_MIN_COLS;
|
|
172
199
|
return (
|
|
173
200
|
// flexShrink={0}: pinned chrome must never be squeezed when the chat
|
|
174
201
|
// content overflows — Ink defaults every box to flexShrink:1, which
|
|
175
202
|
// shrank the input bar to zero rows once the log outgrew the terminal.
|
|
176
|
-
_jsxs(Box, { flexDirection: "column", height: rows || 24, flexShrink: 0, children: [_jsx(BuildRail, { state: state, config: cfg }), _jsxs(Box, { flexDirection: "row", flexGrow: 1, flexShrink: 1, minHeight: 0, children: [_jsx(Box, { ref: viewportRef, flexDirection: "column", flexGrow: 1, flexShrink: 1, minHeight: 0, overflow: "hidden", contentOffsetY:
|
|
203
|
+
_jsxs(Box, { flexDirection: "column", height: rows || 24, flexShrink: 0, children: [_jsx(BuildRail, { state: state, config: cfg }), _jsxs(Box, { flexDirection: "row", flexGrow: 1, flexShrink: 1, minHeight: 0, children: [_jsx(Box, { ref: viewportRef, flexDirection: "column", flexGrow: 1, flexShrink: 1, minHeight: 0, overflow: "hidden", contentOffsetY: contentOffset, children: _jsxs(Box, { ref: contentRef, flexDirection: "column", paddingX: 1, flexShrink: 0, children: [state.items.length === 0 ? (
|
|
177
204
|
// Welcome: căn giữa cả 2 chiều — height = chiều cao đo được của
|
|
178
205
|
// viewport (flexGrow không giãn được vì content box flexShrink=0).
|
|
179
|
-
_jsxs(Box, { flexDirection: "column", alignItems: "center", justifyContent: "center", height: viewport.clientHeight || undefined, children: [(columns ?? 100) - (showSidebar ? SIDEBAR_WIDTH + 2 : 4) >= 54 ? (_jsx(Box, { flexDirection: "column", alignItems: "center", children: (isAsciiMode() ? BANNER_ASCII : BANNER).map((line, i) => (_jsx(Text, { bold: true, color: theme.brand, children: line }, i))) })) : (_jsx(Text, { bold: true, color: theme.brand, children: "\u2691 SCoder" })), _jsx(Text, { dimColor: true, children: `autonomous coding agent · v${getVersion()}` }), _jsx(Text, {}), _jsx(Text, { dimColor: true, children: "type a task to start \u2014 the agent plans, edits, and builds" }), _jsx(Text, {}), _jsxs(Text, { children: [_jsx(Text, { bold: true, color: theme.accent, children: "\u23CE" }), _jsx(Text, { dimColor: true, children: " send " }), _jsx(Text, { bold: true, color: theme.accent, children: "ctrl+s" }), _jsx(Text, { dimColor: true, children: " sessions " }), _jsx(Text, { bold: true, color: theme.accent, children: "tab" }), _jsx(Text, { dimColor: true, children: " complete " }), _jsx(Text, { bold: true, color: theme.accent, children: "/help" }), _jsx(Text, { dimColor: true, children: " commands" })] })] })) : (state.items.map((item, i) => _jsx(ChatItemView, { item: item, width: (columns ?? 100) - (showSidebar ? SIDEBAR_WIDTH + 2 : 4) }, i))), lastSummary && (_jsx(Box, { marginTop: 1, children: state.status === "success" ? (_jsxs(Text, { color: theme.success, bold: true, children: ["\u2714 ", lastSummary] })) : state.status === "aborted" ? (_jsxs(Text, { color: theme.warn, bold: true, children: ["\u23F9 ", lastSummary] })) : (_jsxs(Text, { color: theme.error, bold: true, children: ["\u2718 ", lastSummary] })) }))] }) }), showSidebar && _jsx(Sidebar, { state: state, config: cfg, rows: viewport.clientHeight || undefined })] }), sessionPickerOpen && (_jsx(SessionPicker, { workdir: cfg.workdir, onSelect: handleSessionSelect, onClose: () => setSessionPickerOpen(false) })), paletteOpen && (_jsxs(Box, { flexDirection: "column", borderStyle: boxBorderStyle(), borderColor: theme.accent, paddingX: 1, paddingY: 1, children: [_jsx(Text, { color: theme.accent, bold: true, children: "Command palette (Ctrl+P to close)" }), SLASH_COMMANDS.slice(0, 40).map((c) => (_jsxs(Text, { children: [c.cmd.padEnd(12), " ", c.desc] }, c.cmd)))] })), helpOpen && (_jsxs(Box, { flexDirection: "column", borderStyle: boxBorderStyle(), borderColor: theme.dim, paddingX: 1, paddingY: 1, children: [_jsx(Text, { color: theme.brand, bold: true, children: "Help (?)" }), _jsx(Text, { children: formatSlashHelp("scoder tui") })] })), permPrompt && _jsx(PermissionModal, { prompt: permPrompt }), _jsx(Box, { paddingX: 1, paddingTop: 0, paddingBottom: 0, flexShrink: 0, alignSelf: "stretch", children: _jsx(Input, { valueRef: inputRef, value: input, onChange: writeInput, onSubmit: (v) => void submit(v), historyRef: historyRef, skillNames: skillNames, agentRef: agentRef, config: cfg, atSources: atSources, modalRef: permPromptRef }) }), _jsx(StatusBar, { state: state, scroll: scrollOffset > 0 ? `↑ ${Math.min(scrollOffset, maxScroll)}/${maxScroll}` : null })] }));
|
|
206
|
+
_jsxs(Box, { flexDirection: "column", alignItems: "center", justifyContent: "center", height: viewport.clientHeight || undefined, children: [(columns ?? 100) - (showSidebar ? SIDEBAR_WIDTH + 2 : 4) >= 54 ? (_jsx(Box, { flexDirection: "column", alignItems: "center", children: (isAsciiMode() ? BANNER_ASCII : BANNER).map((line, i) => (_jsx(Text, { bold: true, color: theme.brand, children: line }, i))) })) : (_jsx(Text, { bold: true, color: theme.brand, children: "\u2691 SCoder" })), _jsx(Text, { dimColor: true, children: `autonomous coding agent · v${getVersion()}` }), _jsx(Text, {}), _jsx(Text, { dimColor: true, children: "type a task to start \u2014 the agent plans, edits, and builds" }), _jsx(Text, {}), _jsxs(Text, { children: [_jsx(Text, { bold: true, color: theme.accent, children: "\u23CE" }), _jsx(Text, { dimColor: true, children: " send " }), _jsx(Text, { bold: true, color: theme.accent, children: "ctrl+s" }), _jsx(Text, { dimColor: true, children: " sessions " }), _jsx(Text, { bold: true, color: theme.accent, children: "tab" }), _jsx(Text, { dimColor: true, children: " complete " }), _jsx(Text, { bold: true, color: theme.accent, children: "/help" }), _jsx(Text, { dimColor: true, children: " commands" })] }), _jsxs(Text, { children: [_jsx(Text, { bold: true, color: theme.accent, children: "ctrl+o" }), _jsx(Text, { dimColor: true, children: " select/copy (t\u1EAFt mouse tracking \u0111\u1EC3 t\u00F4 \u0111en k\u00E9o chu\u1ED9t)" })] })] })) : (state.items.slice(win.start, win.end).map((item, i) => (_jsx(ChatItemView, { item: item, width: (columns ?? 100) - (showSidebar ? SIDEBAR_WIDTH + 2 : 4) }, win.start + i)))), lastSummary && (_jsx(Box, { marginTop: 1, children: state.status === "success" ? (_jsxs(Text, { color: theme.success, bold: true, children: ["\u2714 ", lastSummary] })) : state.status === "aborted" ? (_jsxs(Text, { color: theme.warn, bold: true, children: ["\u23F9 ", lastSummary] })) : (_jsxs(Text, { color: theme.error, bold: true, children: ["\u2718 ", lastSummary] })) }))] }) }), showSidebar && _jsx(Sidebar, { state: state, config: cfg, rows: viewport.clientHeight || undefined })] }), sessionPickerOpen && (_jsx(SessionPicker, { workdir: cfg.workdir, onSelect: handleSessionSelect, onClose: () => setSessionPickerOpen(false) })), paletteOpen && (_jsxs(Box, { flexDirection: "column", borderStyle: boxBorderStyle(), borderColor: theme.accent, paddingX: 1, paddingY: 1, children: [_jsx(Text, { color: theme.accent, bold: true, children: "Command palette (Ctrl+P to close)" }), SLASH_COMMANDS.slice(0, 40).map((c) => (_jsxs(Text, { children: [c.cmd.padEnd(12), " ", c.desc] }, c.cmd)))] })), helpOpen && (_jsxs(Box, { flexDirection: "column", borderStyle: boxBorderStyle(), borderColor: theme.dim, paddingX: 1, paddingY: 1, children: [_jsx(Text, { color: theme.brand, bold: true, children: "Help (?)" }), _jsx(Text, { children: formatSlashHelp("scoder tui") })] })), permPrompt && _jsx(PermissionModal, { prompt: permPrompt }), _jsx(Box, { paddingX: 1, paddingTop: 0, paddingBottom: 0, flexShrink: 0, alignSelf: "stretch", children: _jsx(Input, { valueRef: inputRef, value: input, onChange: writeInput, onSubmit: (v) => void submit(v), historyRef: historyRef, skillNames: skillNames, agentRef: agentRef, config: cfg, atSources: atSources, modalRef: permPromptRef }) }), _jsx(StatusBar, { state: state, scroll: scrollOffset > 0 ? `↑ ${Math.min(scrollOffset, maxScroll)}/${maxScroll}` : null, selectMode: selectMode })] }));
|
|
180
207
|
}
|
|
181
208
|
/** Mount the TUI (called from the `scoder tui` subcommand). */
|
|
182
209
|
export async function runTui(config, initialTask) {
|
package/dist/tui/chrome.js
CHANGED
|
@@ -67,9 +67,10 @@ function PhaseBreadcrumb({ phases }) {
|
|
|
67
67
|
/** Metrics cho narrow-terminal — không còn dùng (sidebar đã thay thế), giữ để tham khảo. */
|
|
68
68
|
/**
|
|
69
69
|
* Status bar — bottom row. Left: spinner · status · elapsed · model.
|
|
70
|
-
* Right: scroll position OR keyboard hints.
|
|
70
|
+
* Right: scroll position OR keyboard hints. In select mode (Ctrl+O) the hints
|
|
71
|
+
* swap to the copy reminder — the mouse belongs to the terminal there.
|
|
71
72
|
*/
|
|
72
|
-
export function StatusBar({ state, scroll }) {
|
|
73
|
+
export function StatusBar({ state, scroll, selectMode = false }) {
|
|
73
74
|
const { columns } = useWindowSize();
|
|
74
75
|
const width = Math.max(40, (columns ?? 100) - 6);
|
|
75
76
|
const statusColor = state.status === "running" ? theme.accent :
|
|
@@ -86,10 +87,12 @@ export function StatusBar({ state, scroll }) {
|
|
|
86
87
|
state.status === "aborted" ? "⚠" :
|
|
87
88
|
state.status === "idle" ? "○" : null;
|
|
88
89
|
const model = state.model || "—";
|
|
89
|
-
// Right side: scroll position OR
|
|
90
|
-
const rightText =
|
|
91
|
-
?
|
|
92
|
-
:
|
|
90
|
+
// Right side: select-mode reminder OR scroll position OR keybind hints
|
|
91
|
+
const rightText = selectMode
|
|
92
|
+
? "⊝ select · drag to highlight · ⌘C/ctrl+c copy · ctrl+o back"
|
|
93
|
+
: scroll
|
|
94
|
+
? `scroll ${scroll} · esc→tail`
|
|
95
|
+
: `⏎ send · ctrl+s sessions · ctrl+t ${state.thinkingVisible ? "◉" : "○"} · esc abort · ctrl+c quit`;
|
|
93
96
|
const leftBudget = Math.max(24, width - rightText.length - 3);
|
|
94
97
|
let left = statusLabel;
|
|
95
98
|
if (model && model !== "—") {
|
|
@@ -3,13 +3,18 @@ import { useInput } from "ink";
|
|
|
3
3
|
import { useEffect, useRef } from "react";
|
|
4
4
|
import { markAborted } from "../state.js";
|
|
5
5
|
export function useTuiKeybinds(deps) {
|
|
6
|
-
const { scrollOffset, permPromptRef, setPermPrompt, agentRef, runningRef, sessionPickerOpen, setSessionPickerOpen, setPaletteOpen, inputRef, setHelpOpen, update, viewportRowsRef, setScrollOffset, exit } = deps;
|
|
6
|
+
const { scrollOffset, permPromptRef, setPermPrompt, agentRef, runningRef, sessionPickerOpen, setSessionPickerOpen, setPaletteOpen, inputRef, setHelpOpen, update, viewportRowsRef, setScrollOffset, selectMode, setSelectMode, exit } = deps;
|
|
7
7
|
// Esc aborts the running task (like SIGINT in the CLI); when scrolled up,
|
|
8
8
|
// the first Esc returns to the bottom instead.
|
|
9
9
|
const scrollOffsetRef = useRef(0);
|
|
10
10
|
useEffect(() => {
|
|
11
11
|
scrollOffsetRef.current = scrollOffset;
|
|
12
12
|
}, [scrollOffset]);
|
|
13
|
+
// useInput closures go stale between renders (same reason as scrollOffsetRef).
|
|
14
|
+
const selectModeRef = useRef(false);
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
selectModeRef.current = selectMode;
|
|
17
|
+
}, [selectMode]);
|
|
13
18
|
// T-03: permission modal keybinds — intercepts y/a/n/q while open.
|
|
14
19
|
useInput((input, key) => {
|
|
15
20
|
const p = permPromptRef.current;
|
|
@@ -47,6 +52,24 @@ export function useTuiKeybinds(deps) {
|
|
|
47
52
|
update((s) => ({ ...s, thinkingVisible: !s.thinkingVisible }));
|
|
48
53
|
return;
|
|
49
54
|
}
|
|
55
|
+
// Ctrl+O: toggle select mode — mouse tracking off (native drag-select +
|
|
56
|
+
// Cmd/Ctrl+C copy) / on (wheel scroll). Scroll with PgUp/PgDn while off.
|
|
57
|
+
if (key.ctrl && _input === "o") {
|
|
58
|
+
const next = !selectModeRef.current;
|
|
59
|
+
selectModeRef.current = next;
|
|
60
|
+
setSelectMode(next);
|
|
61
|
+
update((s) => ({
|
|
62
|
+
...s,
|
|
63
|
+
items: [...s.items, {
|
|
64
|
+
kind: "notice",
|
|
65
|
+
text: next
|
|
66
|
+
? "select mode ON — drag to highlight, copy with ⌘C/Ctrl+C · scroll via PgUp/PgDn · Ctrl+O back to mouse wheel"
|
|
67
|
+
: "select mode OFF — mouse wheel scrolling restored",
|
|
68
|
+
level: "info",
|
|
69
|
+
}],
|
|
70
|
+
}));
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
50
73
|
if (key.pageUp || key.pageDown || key.home || key.end) {
|
|
51
74
|
const page = Math.max(1, Math.floor((viewportRowsRef.current ?? 20) * 0.8));
|
|
52
75
|
if (key.pageUp)
|
|
@@ -1,8 +1,13 @@
|
|
|
1
|
-
/** Scroll viewport metrics + mouse wheel (SGR 1006) wiring for the TUI.
|
|
1
|
+
/** Scroll viewport metrics + mouse wheel (SGR 1006) wiring for the TUI.
|
|
2
|
+
* P1-8: contentRef giờ đo CỬA SỔ item đã mount (không phải cả transcript) —
|
|
3
|
+
* chi phí đo O(window) nên vẫn rẻ; số đo thật dùng cho follow-bottom chính
|
|
4
|
+
* xác, ước lượng (transcriptWindow) chỉ dùng khi scroll lên trên.
|
|
5
|
+
* mouseEnabled=false (select mode, Ctrl+O): nhả mouse tracking để terminal
|
|
6
|
+
* cho tô đen kéo chuột + copy thường — wheel scroll tắt, dùng PgUp/PgDn. */
|
|
2
7
|
import { useCallback, useEffect, useRef } from "react";
|
|
3
8
|
import { useBoxMetrics, useStdin } from "ink";
|
|
4
9
|
import { parseWheel, MOUSE_ENABLE, MOUSE_DISABLE } from "../mouse.js";
|
|
5
|
-
export function useScrollMetrics(setScrollOffset) {
|
|
10
|
+
export function useScrollMetrics(setScrollOffset, mouseEnabled = true) {
|
|
6
11
|
const viewportRef = useRef(null);
|
|
7
12
|
const contentRef = useRef(null);
|
|
8
13
|
const viewport = useBoxMetrics(viewportRef);
|
|
@@ -14,6 +19,8 @@ export function useScrollMetrics(setScrollOffset) {
|
|
|
14
19
|
// T5: mouse wheel scroll (SGR 1006) — raw stdin listener; Ink's useInput
|
|
15
20
|
// drops unrepresentable CSI sequences, so wheel events must be read here.
|
|
16
21
|
// Only in alt-screen TTY (mouse tracking sequences corrupt plain pipes).
|
|
22
|
+
// Select mode (mouseEnabled=false): MOUSE_DISABLE → native terminal drag
|
|
23
|
+
// selection/copy returns; setRawMode calls stay balanced across toggles.
|
|
17
24
|
const { stdin, setRawMode, isRawModeSupported } = useStdin();
|
|
18
25
|
const wheelScroll = useCallback((dir) => {
|
|
19
26
|
setScrollOffset((o) => Math.max(0, o + (dir === "up" ? 3 : -3)));
|
|
@@ -21,6 +28,10 @@ export function useScrollMetrics(setScrollOffset) {
|
|
|
21
28
|
useEffect(() => {
|
|
22
29
|
if (process.stdout.isTTY !== true || !isRawModeSupported)
|
|
23
30
|
return;
|
|
31
|
+
if (!mouseEnabled) {
|
|
32
|
+
process.stdout.write(MOUSE_DISABLE);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
24
35
|
process.stdout.write(MOUSE_ENABLE);
|
|
25
36
|
setRawMode(true);
|
|
26
37
|
const onData = (chunk) => {
|
|
@@ -32,7 +43,8 @@ export function useScrollMetrics(setScrollOffset) {
|
|
|
32
43
|
return () => {
|
|
33
44
|
stdin.off("data", onData);
|
|
34
45
|
process.stdout.write(MOUSE_DISABLE);
|
|
46
|
+
setRawMode(false);
|
|
35
47
|
};
|
|
36
|
-
}, [stdin, setRawMode, isRawModeSupported, wheelScroll]);
|
|
37
|
-
return { viewportRef,
|
|
48
|
+
}, [stdin, setRawMode, isRawModeSupported, wheelScroll, mouseEnabled]);
|
|
49
|
+
return { viewportRef, viewport, contentRef, content, viewportRowsRef };
|
|
38
50
|
}
|
package/dist/tui/selectors.js
CHANGED
|
@@ -70,11 +70,13 @@ export function estimateMarkdownRows(text, width = EST_WIDTH) {
|
|
|
70
70
|
export function estimateItemRows(item) {
|
|
71
71
|
switch (item.kind) {
|
|
72
72
|
case "user":
|
|
73
|
-
|
|
73
|
+
// Render: marginTop 1 + border-left box paddingX 1, wrap theo chat width
|
|
74
|
+
// (~60–96 cột). 60 là giả định hẹp an toàn (over-estimate nhẹ).
|
|
75
|
+
return 2 + wrapEstimate(item.text ?? "", 60);
|
|
74
76
|
case "assistant":
|
|
75
77
|
// +1 marginTop; streaming raw text dùng wrap đơn giản (chưa qua markdown).
|
|
76
78
|
return item.streaming
|
|
77
|
-
? wrapEstimate(item.text ?? "",
|
|
79
|
+
? (item.text ? 1 : 0) + wrapEstimate(item.text ?? "", 76)
|
|
78
80
|
: 1 + estimateMarkdownRows(item.text ?? "", EST_WIDTH);
|
|
79
81
|
case "thinking":
|
|
80
82
|
// Render: collapse to last 4 lines.
|
|
@@ -82,7 +84,7 @@ export function estimateItemRows(item) {
|
|
|
82
84
|
case "tool":
|
|
83
85
|
return 1 + 2; // title + preview 2 lines
|
|
84
86
|
case "compaction":
|
|
85
|
-
return
|
|
87
|
+
return 2; // marginTop + divider
|
|
86
88
|
case "diff":
|
|
87
89
|
return 3 + Math.min(24, (item.text?.split("\n").length ?? 0)); // header + ≤24 lines + border
|
|
88
90
|
case "todo":
|
|
@@ -90,15 +92,19 @@ export function estimateItemRows(item) {
|
|
|
90
92
|
// chiếm 0 dòng thực tế trong transcript.
|
|
91
93
|
return 0;
|
|
92
94
|
case "build":
|
|
93
|
-
return
|
|
95
|
+
return 3; // marginTop + label + command
|
|
94
96
|
case "notice":
|
|
95
|
-
|
|
97
|
+
// Render raw text (cap 2000 chars) với wrap thật theo width — /help là
|
|
98
|
+
// notice 40 dòng; estimate cũ (1 + len/2000) under-estimate khiến
|
|
99
|
+
// follow-bottom clip dòng kế tiếp ( regression caught by tui.test E2E).
|
|
100
|
+
return 1 + wrapEstimate(item.text.length > 2000 ? item.text.slice(0, 2000) + "…" : item.text, 76);
|
|
96
101
|
}
|
|
97
102
|
}
|
|
98
103
|
/**
|
|
99
104
|
* T4: chọn window item để render: lấy từ cuối lên tới khi tổng rows ước
|
|
100
105
|
* lượng ≥ viewportRows + margin (render dư phía trên để scroll mượt), nhưng
|
|
101
|
-
* giữ toàn bộ khi tổng nhỏ. Trả về slice của items (không
|
|
106
|
+
* giữ toàn bộ khi tổng nhỏ. Trả về slice của items (không rẻ — dùng
|
|
107
|
+
* transcriptWindow cho scroll đúng offset, hàm này chỉ còn cho callers cũ).
|
|
102
108
|
*/
|
|
103
109
|
export function visibleWindow(items, viewportRows, margin = 50) {
|
|
104
110
|
const budget = viewportRows + margin;
|
|
@@ -110,6 +116,55 @@ export function visibleWindow(items, viewportRows, margin = 50) {
|
|
|
110
116
|
}
|
|
111
117
|
return start === 0 ? items : items.slice(start);
|
|
112
118
|
}
|
|
119
|
+
// P1-8: cache số dòng ước lượng theo IDENTITY của item. state.ts patch item
|
|
120
|
+
// kiểu immutable (object mới khi nội dung đổi) ⇒ item finalized không bao giờ
|
|
121
|
+
// tính lại; chỉ item đang stream (mỗi flush đổi reference) mới estimate lại.
|
|
122
|
+
const itemRowsCache = new WeakMap();
|
|
123
|
+
function itemRowsCached(item) {
|
|
124
|
+
const hit = itemRowsCache.get(item);
|
|
125
|
+
if (hit !== undefined)
|
|
126
|
+
return hit;
|
|
127
|
+
const v = estimateItemRows(item);
|
|
128
|
+
itemRowsCache.set(item, v);
|
|
129
|
+
return v;
|
|
130
|
+
}
|
|
131
|
+
/** Tổng chiều cao ước lượng của cả transcript — O(n) phép cộng số nhỏ. */
|
|
132
|
+
export function estimateTotalRows(items) {
|
|
133
|
+
let acc = 0;
|
|
134
|
+
for (const it of items)
|
|
135
|
+
acc += itemRowsCached(it);
|
|
136
|
+
return acc;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* P1-8 — virtualization cho transcript viewport. Mount duy các item phủ
|
|
140
|
+
* [scrollTop - overscan, scrollTop + viewportRows + overscan] thay vì cả
|
|
141
|
+
* transcript: chi phí React diff + Yoga layout + wrap mỗi frame hết tỷ lệ
|
|
142
|
+
* với tổng số text (đo: 42–53 ms/frame ở 300–400 item → ~7 ms khi window).
|
|
143
|
+
* Scroll dùng ước lượng rows (estimateItemRows, cache theo identity item),
|
|
144
|
+
* nên khi estimate lệch thực tế thì offset chỉ sai vài dòng — cùng trade-off
|
|
145
|
+
* mọi TUI virtualized dùng.
|
|
146
|
+
*/
|
|
147
|
+
export function transcriptWindow(items, scrollTop, viewportRows, overscan = 24) {
|
|
148
|
+
const n = items.length;
|
|
149
|
+
// Cumulative start rows một lượt O(n) nhưng chỉ là phép cộng số nhỏ.
|
|
150
|
+
const startRow = new Array(n + 1);
|
|
151
|
+
startRow[0] = 0;
|
|
152
|
+
for (let i = 0; i < n; i++)
|
|
153
|
+
startRow[i + 1] = startRow[i] + itemRowsCached(items[i]);
|
|
154
|
+
const totalRows = startRow[n];
|
|
155
|
+
const top = Math.max(0, Math.min(scrollTop, totalRows));
|
|
156
|
+
const target = Math.max(0, top - overscan);
|
|
157
|
+
let start = 0;
|
|
158
|
+
while (start + 1 < n && startRow[start + 1] <= target)
|
|
159
|
+
start++;
|
|
160
|
+
const bottomTarget = top + viewportRows + overscan;
|
|
161
|
+
let end = start;
|
|
162
|
+
while (end < n && startRow[end] < bottomTarget)
|
|
163
|
+
end++;
|
|
164
|
+
if (end === start && start < n)
|
|
165
|
+
end = start + 1; // item khổng lồ vẫn phải hiện
|
|
166
|
+
return { start, end, offset: top - startRow[start], totalRows };
|
|
167
|
+
}
|
|
113
168
|
/**
|
|
114
169
|
* Plan mới nhất cho sidebar — todo items được append mỗi lần update nhưng
|
|
115
170
|
* sidebar chỉ cần snapshot hiện tại (last-wins), null khi chưa có plan.
|
package/dist/tui/state.js
CHANGED
|
@@ -79,7 +79,7 @@ function applyGuardrail(state, ev) {
|
|
|
79
79
|
}
|
|
80
80
|
// Pure selectors live in ./selectors.js (Phase 5.4) — re-exported so existing
|
|
81
81
|
// import sites (app.tsx, hooks, tests) keep working unchanged.
|
|
82
|
-
export { estimateItemRows, visibleWindow, latestPlan, toolPhase } from "./selectors.js";
|
|
82
|
+
export { estimateItemRows, visibleWindow, transcriptWindow, estimateTotalRows, latestPlan, toolPhase } from "./selectors.js";
|
|
83
83
|
/**
|
|
84
84
|
* Fold one AgentEvent into the state (mutates `state`, returns it for
|
|
85
85
|
* reducer-style chaining). Unknown events are ignored.
|
|
@@ -8,7 +8,7 @@ import { readdirSync } from "node:fs";
|
|
|
8
8
|
import { join, relative } from "node:path";
|
|
9
9
|
import fuzzysort from "fuzzysort";
|
|
10
10
|
const SKIP = new Set(["node_modules", ".git", "dist", "build", ".next", "__pycache__", ".venv"]);
|
|
11
|
-
|
|
11
|
+
const cache = new Map();
|
|
12
12
|
/**
|
|
13
13
|
* File index for `workdir` (cached, TTL 5s; invalidateFileIndex on tool writes).
|
|
14
14
|
* `force` (P1-2) bỏ qua cache — dùng cho fingerprint, nơi một file mới được tạo
|
|
@@ -16,8 +16,9 @@ let cache = null;
|
|
|
16
16
|
* sẽ được tái sử dụng sai.
|
|
17
17
|
*/
|
|
18
18
|
export function fileIndex(workdir, force = false) {
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
const hit = cache.get(workdir);
|
|
20
|
+
if (!force && hit && Date.now() - hit.built < 5_000)
|
|
21
|
+
return hit.files;
|
|
21
22
|
const files = [];
|
|
22
23
|
// Fast path: ripgrep --files (gitignore-aware). Bare-name globs match at any
|
|
23
24
|
// depth; `!**/x/**` does NOT match absolute paths (I-07 lesson).
|
|
@@ -36,12 +37,12 @@ export function fileIndex(workdir, force = false) {
|
|
|
36
37
|
if (files.length === 0)
|
|
37
38
|
walkFiles(workdir, "", files, 0);
|
|
38
39
|
files.sort();
|
|
39
|
-
cache
|
|
40
|
+
cache.set(workdir, { files, built: Date.now() });
|
|
40
41
|
return files;
|
|
41
42
|
}
|
|
42
43
|
/** Cache invalidate — call after write/edit tool_result (scope: whole index). */
|
|
43
44
|
export function invalidateFileIndex() {
|
|
44
|
-
cache
|
|
45
|
+
cache.clear();
|
|
45
46
|
}
|
|
46
47
|
function walkFiles(root, prefix, out, depth) {
|
|
47
48
|
if (depth > 6)
|
package/package.json
CHANGED