thincoder 0.12.10 → 0.12.12
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/README.md +19 -2
- package/bin/thincoder.mjs +11 -1
- package/package.json +1 -1
- package/src/acp/bridge.mjs +229 -0
- package/src/acp/session.mjs +46 -0
- package/src/acp/transport.mjs +155 -0
- package/src/acp.mjs +335 -0
- package/src/advisor/citations.mjs +77 -0
- package/src/advisor/history.mjs +25 -6
- package/src/advisor/messages.mjs +76 -22
- package/src/advisor/run.mjs +185 -91
- package/src/advisor.mjs +209 -87
- package/src/agent/completion.mjs +9 -2
- package/src/agent/dispatch.mjs +14 -0
- package/src/agent-tools/advisor.mjs +12 -11
- package/src/agent-tools/subagent.mjs +73 -9
- package/src/agent.mjs +5 -5
- package/src/config.mjs +3 -0
- package/src/prompts/advisor-round1.md +8 -2
- package/src/prompts/advisor-round2.md +6 -4
- package/src/prompts/advisor-round3.md +6 -4
- package/src/prompts/discipline.md +1 -1
- package/src/session.mjs +19 -0
- package/src/tools/file.mjs +30 -0
- package/src/tools/insert_after.md +1 -0
- package/src/tools/patch.mjs +4 -0
- package/src/tools/shared.mjs +68 -57
- package/src/tools/system.mjs +13 -5
- package/src/tui/agent-turn.mjs +73 -121
- package/src/tui/cmd-shell.mjs +104 -0
- package/src/tui/cmd-submodel.mjs +151 -0
- package/src/tui/index.mjs +4 -3
- package/src/tui/markdown.mjs +26 -8
- package/src/tui/pickers.mjs +32 -1
- package/src/tui/render-conversation.mjs +103 -37
- package/src/tui/render.mjs +24 -5
- package/src/tui/slash-commands.mjs +10 -0
- package/src/tui/tool-summaries.mjs +113 -0
|
@@ -14,6 +14,48 @@ import { validateDesignToken } from "./advisor.mjs"
|
|
|
14
14
|
* - parallel subagent calls via the parallel channel (parallel: true)
|
|
15
15
|
* - non-recursive: child agents do not get the subagent tool (depth > 0 is not injected)
|
|
16
16
|
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Effective subagent model override for a role (CLI parity shared with VS Code):
|
|
20
|
+
* priority — subagent tool `model` arg > config.agent.subagentModels[role] > config.agent.subagentModel > null (inherit parent).
|
|
21
|
+
*/
|
|
22
|
+
export function effectiveSubagentModel(parent, role, modelArg) {
|
|
23
|
+
if (modelArg) return modelArg
|
|
24
|
+
const cfg = parent.config?.agent ?? {}
|
|
25
|
+
return cfg.subagentModels?.[role] ?? cfg.subagentModel ?? null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Resolve the sub-agent's provider from a model override string (shared with the
|
|
30
|
+
* VS Code port). Forms accepted:
|
|
31
|
+
* "provider:model" → the named provider with the named model
|
|
32
|
+
* "provider" → the named provider's configured model
|
|
33
|
+
* "model" → same provider as the parent, different model
|
|
34
|
+
* null → parent's provider unchanged.
|
|
35
|
+
* API keys follow the config fallback order (provider.apiKey → THINCODER_API_KEY → provider-specific env).
|
|
36
|
+
*/
|
|
37
|
+
export function resolveChildProvider(parent, modelArg) {
|
|
38
|
+
if (!modelArg) return { ...parent.provider }
|
|
39
|
+
const providers = parent.config?.providersList ?? []
|
|
40
|
+
const withKey = (p) => {
|
|
41
|
+
if (p.apiKey?.trim()) return { ...p, apiKey: p.apiKey.trim() }
|
|
42
|
+
if (process.env.THINCODER_API_KEY) return { ...p, apiKey: process.env.THINCODER_API_KEY }
|
|
43
|
+
const envMap = { deepseek: "DEEPSEEK_API_KEY", openai: "OPENAI_API_KEY" }
|
|
44
|
+
const keyVar = envMap[p.name]
|
|
45
|
+
if (keyVar && process.env[keyVar]) return { ...p, apiKey: process.env[keyVar] }
|
|
46
|
+
return { ...p }
|
|
47
|
+
}
|
|
48
|
+
if (modelArg.includes(":")) {
|
|
49
|
+
const [pname, mname] = modelArg.split(":")
|
|
50
|
+
const p = providers.find((x) => x.name === pname)
|
|
51
|
+
if (!p) throw new Error(`subagent model: unknown provider "${pname}" (available: ${providers.map((x) => x.name).join(", ") || "none"})`)
|
|
52
|
+
return { ...withKey(p), model: mname || p.model }
|
|
53
|
+
}
|
|
54
|
+
const byName = providers.find((x) => x.name === modelArg)
|
|
55
|
+
if (byName) return withKey(byName)
|
|
56
|
+
return { ...parent.provider, model: modelArg }
|
|
57
|
+
}
|
|
58
|
+
|
|
17
59
|
export const subagentTool = {
|
|
18
60
|
name: "subagent",
|
|
19
61
|
description:
|
|
@@ -30,6 +72,7 @@ export const subagentTool = {
|
|
|
30
72
|
task: { type: "string", description: "Self-contained task description for the sub-agent" },
|
|
31
73
|
context: { type: "string", description: "Optional background the sub-agent needs (it cannot see this conversation)" },
|
|
32
74
|
role: { type: "string", enum: ["explore", "plan", "coder", "eng-coder"], description: "Sub-agent role: 'explore' (read-only search/analysis), 'plan' (read-only implementation planning), 'coder' (full implementation), 'eng-coder' (engineering-mode coder — strict methodology, design-driven). ENUM IS OVERRIDDEN IN setup.mjs PER ENGINEERING MODE." },
|
|
75
|
+
model: { type: "string", description: "Provider/model override for this sub-agent: 'provider:model', a provider name from config, or a model name on the parent's provider. Defaults to the agent.subagentModel config, then the parent's provider. Useful for offloading heavy work to a cheaper model." },
|
|
33
76
|
designToken: { type: "string", description: "Required when role='eng-coder': the token returned by advisor(type='design') after the design review passed. Without a valid token, eng-coder cannot modify files." },
|
|
34
77
|
},
|
|
35
78
|
required: ["task"],
|
|
@@ -49,6 +92,9 @@ export const subagentTool = {
|
|
|
49
92
|
throw new Error("Engineering mode is not active — use role='coder' for implementation tasks.")
|
|
50
93
|
}
|
|
51
94
|
|
|
95
|
+
// Provider/model override: tool `model` arg > subagentModels[role] > subagentModel > parent provider
|
|
96
|
+
const childProvider = resolveChildProvider(parent, effectiveSubagentModel(parent, role, args.model))
|
|
97
|
+
|
|
52
98
|
// eng-coder token gate: the design review must have passed and the caller must
|
|
53
99
|
// present the exact token advisor issued — otherwise the child is not authorized to code.
|
|
54
100
|
if (role === "eng-coder") {
|
|
@@ -97,7 +143,7 @@ export const subagentTool = {
|
|
|
97
143
|
: parent.config
|
|
98
144
|
|
|
99
145
|
const child = createAgent({
|
|
100
|
-
provider:
|
|
146
|
+
provider: childProvider,
|
|
101
147
|
tools,
|
|
102
148
|
config: childConfig,
|
|
103
149
|
cwd: parent.cwd,
|
|
@@ -134,7 +180,7 @@ export const subagentTool = {
|
|
|
134
180
|
? (name, args) => ctx.callbacks.onToolCall(`${relayPrefix}${name}`, args)
|
|
135
181
|
: null,
|
|
136
182
|
}
|
|
137
|
-
const childRunOpts =
|
|
183
|
+
const childRunOpts = buildChildRunOpts(ctx)
|
|
138
184
|
let report = await runAgent(child, input, childOpts, childRunOpts)
|
|
139
185
|
|
|
140
186
|
// Report too short = incomplete handoff: send back for expansion once (inspired by kimi-code's summaryPolicy: min 200 chars, retry 1 time).
|
|
@@ -157,6 +203,20 @@ export const subagentTool = {
|
|
|
157
203
|
},
|
|
158
204
|
}
|
|
159
205
|
|
|
206
|
+
/**
|
|
207
|
+
* Child agent run options — the parent's abort signal MUST propagate to the
|
|
208
|
+
* child: without it, Ctrl+C aborts the parent's controller but the child keeps
|
|
209
|
+
* running its full turn budget (up to subagentTurns) while the parent awaits —
|
|
210
|
+
* the interrupt appears to do nothing.
|
|
211
|
+
*/
|
|
212
|
+
export function buildChildRunOpts(ctx) {
|
|
213
|
+
return {
|
|
214
|
+
depth: (ctx.depth ?? 0) + 1,
|
|
215
|
+
maxTurns: ctx.agent?.config?.agent?.subagentTurns ?? DEFAULT_SUBAGENT_TURNS,
|
|
216
|
+
signal: ctx.signal ?? null,
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
160
220
|
/**
|
|
161
221
|
* Merge an eng-coder child's mutations into the parent agent's bookkeeping.
|
|
162
222
|
* The parent must stay aware of delegated file changes: `_touchedFiles` enables
|
|
@@ -164,13 +224,20 @@ export const subagentTool = {
|
|
|
164
224
|
* pushback for review. Prior verify/advisor state is invalidated because it
|
|
165
225
|
* judged an older state.
|
|
166
226
|
*
|
|
167
|
-
* `_advisorRound` is reset
|
|
168
|
-
*
|
|
227
|
+
* `_advisorRound` is NOT reset: merged code enters the CURRENT convergence
|
|
228
|
+
* cycle. Resetting here would break the review→fix→re-review loop (the parent
|
|
229
|
+
* reviews, spawns an eng-coder to fix, merges, reviews again — every merge
|
|
230
|
+
* would restart at round 1 and the 5-round cap could never be reached).
|
|
231
|
+
* `_calledAdvisorThisRun` IS cleared so the merged code triggers a fresh
|
|
232
|
+
* advisor call (the guard demands review of new mutations).
|
|
169
233
|
*
|
|
170
234
|
* Returns true when mutations were merged (kept for future caller checks).
|
|
171
235
|
*/
|
|
172
236
|
export function mergeChildMutations(parent, child) {
|
|
173
|
-
|
|
237
|
+
// A child claiming mutations without any touched file is a misbehaving
|
|
238
|
+
// child (or a bookkeeping bug) — do not propagate an empty mutation claim
|
|
239
|
+
// to the parent's guard state.
|
|
240
|
+
if (!child._mutatedThisRun || !(child._touchedFiles?.length)) return false
|
|
174
241
|
parent._mutatedThisRun = true
|
|
175
242
|
for (const abs of child._touchedFiles ?? []) {
|
|
176
243
|
if (!parent._touchedFiles.includes(abs)) parent._touchedFiles.push(abs)
|
|
@@ -180,10 +247,7 @@ export function mergeChildMutations(parent, child) {
|
|
|
180
247
|
parent._verifiedThisRun = false
|
|
181
248
|
parent._verifyPassed = undefined
|
|
182
249
|
}
|
|
183
|
-
//
|
|
184
|
-
// _advisorRound reset ensures new code gets a full round-1 review;
|
|
185
|
-
// _advisorSession prevents cross-contamination between reviews.
|
|
186
|
-
parent._advisorRound = 0
|
|
250
|
+
// Stale session cleanup only — the round counter survives (see above).
|
|
187
251
|
parent._advisorSession = null
|
|
188
252
|
return true
|
|
189
253
|
}
|
package/src/agent.mjs
CHANGED
|
@@ -404,18 +404,18 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
|
|
|
404
404
|
if (toolCall.name === "verify") agent._verifiedThisRun = true
|
|
405
405
|
if (toolCall.name === "advisor") {
|
|
406
406
|
agent._calledAdvisorThisRun = true
|
|
407
|
-
//
|
|
408
|
-
//
|
|
407
|
+
// All advisor calls (code and design) share the 5-round convergence
|
|
408
|
+
// budget — each advances _advisorRound toward MAX_ADVISOR_ROUNDS.
|
|
409
409
|
// Always advance the round — the convergence protocol cares about
|
|
410
410
|
// how many reviews have run (round 1→2→3→4→5), not how many succeeded.
|
|
411
411
|
// A failed/interrupted review is still a review attempt and should use
|
|
412
412
|
// the next round's prompt on retry.
|
|
413
413
|
try {
|
|
414
|
-
|
|
415
|
-
if (advArgs.type !== "design") agent._advisorRound++
|
|
414
|
+
JSON.parse(toolCall.arguments || "{}")
|
|
416
415
|
} catch {
|
|
417
|
-
|
|
416
|
+
/* arguments unparseable — still counts as a review attempt */
|
|
418
417
|
}
|
|
418
|
+
agent._advisorRound++
|
|
419
419
|
}
|
|
420
420
|
if (FILE_MUTATORS.has(toolCall.name)) {
|
|
421
421
|
const args = JSON.parse(toolCall.arguments)
|
package/src/config.mjs
CHANGED
|
@@ -43,6 +43,8 @@ const DEFAULTS = {
|
|
|
43
43
|
agent: {
|
|
44
44
|
maxTurns: 100,
|
|
45
45
|
subagentTurns: 100,
|
|
46
|
+
subagentModel: null, // default subagent model: "provider:model" | provider name | model name (parent provider); null = inherit parent provider
|
|
47
|
+
subagentModels: {}, // per-type override: { explore, plan, coder, "eng-coder" } — priority: subagent tool model arg > this[role] > subagentModel > parent provider
|
|
46
48
|
goalTurns: 200,
|
|
47
49
|
compactThreshold: 100000,
|
|
48
50
|
verifyGuard: false, // push model back to verify when files were mutated but verify not run (opt-in)
|
|
@@ -56,6 +58,7 @@ const DEFAULTS = {
|
|
|
56
58
|
projectDir: ".thincoder/memory",
|
|
57
59
|
team: null,
|
|
58
60
|
},
|
|
61
|
+
shell: null, // bash tool shell executable (e.g. "C:\\Program Files\\Git\\bin\\bash.exe" or "pwsh"); null = system default (cmd on Windows, /bin/sh elsewhere)
|
|
59
62
|
embedding: {
|
|
60
63
|
baseURL: "https://api.siliconflow.cn/v1",
|
|
61
64
|
model: "BAAI/bge-m3",
|
|
@@ -17,15 +17,21 @@ Budget rules:
|
|
|
17
17
|
- **Batch everything**: multiple `read` calls in one reply, multiple `grep` calls in one reply. Serializing tool calls wastes your round budget.
|
|
18
18
|
|
|
19
19
|
Rules:
|
|
20
|
-
- First judge the task from the conversation background: if the changes are clearly non-code
|
|
20
|
+
- First judge the task from the conversation background: if the changes are clearly non-code and cannot affect runtime behavior, reply immediately with the all-clear phrase — `"All clear — no code changes to review."` (the host recognizes it via the "all clear" / "no 🔴" / "review passed" / "no issues found" markers, matched case-insensitively) — do NOT spend tool calls exploring. This applies to static docs, README, and CHANGELOG files. Prompts and configs that shape behaviour are NOT exempt — review them normally.
|
|
21
|
+
- **Requirement fit**: check the implementation against what the user actually asked for — a review is not only about "is the code correct" but also "is this what the user wanted". Two comparisons:
|
|
22
|
+
- (a) **Claim vs implementation**: the implementer's stated intent (conversation background / response table / commit message) vs what the implementation actually does — claiming X but delivering Y is a gap.
|
|
23
|
+
- (b) **Expectation vs shape**: explicit user expectations in the background vs the delivered shape — "asked for A, got B" (e.g. "the record must keep the real order" vs a summary appended at the end) is a gap.
|
|
24
|
+
- **Known limit**: the conversation background only includes the last 3 user–assistant exchanges — older user expectations may not be visible. (a) is the primary check (needs only recent context); (b) is best-effort — check what the background shows, do NOT treat an invisible expectation as a gap.
|
|
25
|
+
- **Severity**: 🔴 = the user's explicit request was not fulfilled; 🟡 = fulfilled but in a suboptimal or misleading way. Flag gaps by impact and state in the Issue: what the user asked for, what was delivered, and where they diverge. Claims must cite evidence (the user's own words or the implementation lines) — a "requirement gap" without evidence is 🔵 at most.
|
|
21
26
|
- Reply in the same language as the conversation background.
|
|
22
27
|
- Respect the project's stated platform requirements — do not flag features as errors if they are valid under the project's target environment.
|
|
23
28
|
- Output a Markdown table. This table becomes the sole basis for convergence in later rounds — be thorough.
|
|
24
29
|
| # | File | Severity | Issue | Suggestion |
|
|
25
30
|
|---|------|----------|-------|------------|
|
|
26
|
-
| 1 | src/
|
|
31
|
+
| 1 | src/example.mjs | 🔴 | ... | ... |
|
|
27
32
|
- Order by severity: 🔴 Critical · 🟡 Advisory · 🔵 Style.
|
|
28
33
|
- For each issue state: which file, what the problem is, why it is a problem, how to fix it.
|
|
29
34
|
- Cover everything now. Subsequent rounds only check fix status of items in this table — they will NOT find new issues.
|
|
30
35
|
- Stop calling tools once you are ready to produce the review table.
|
|
36
|
+
- **Host verification**: every `file:line: content` reference in your table is mechanically checked against the CURRENT file state by the host — quote exactly what `read` returned; a mismatch marks the finding unverified.
|
|
31
37
|
- **Pass/fail**: if there are NO 🔴 (Critical) issues, the review passes. 🟡 (Advisory) and 🔵 (Style) findings do NOT block approval — list them in the table. If there is ANY 🔴 issue, list it and do not claim the review passed.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
You are
|
|
1
|
+
You are an independent review advisor.
|
|
2
2
|
Verify the prior issue table (provided in the review context).
|
|
3
3
|
You may note obvious new issues introduced by the fixes.
|
|
4
4
|
You have read-only tools to explore the codebase.
|
|
@@ -7,8 +7,8 @@ You have a budget of 30 tool rounds (chat turns). Hard mechanical cap: 100 round
|
|
|
7
7
|
Review workflow:
|
|
8
8
|
1. The affected files are named in the prior issue table — read them in full. The prior issue table is HISTORY from a previous review, not current state.
|
|
9
9
|
2. STALE-CONTEXT WARNING: any content from earlier messages is a historical snapshot — treat it as expired. Only fresh `read` results describe the current state.
|
|
10
|
-
3. Project conventions were established in round 1 — do NOT re-read AGENTS.md / design docs unless a fix appears to contradict the task itself.
|
|
11
|
-
4. **ALWAYS verify current file content with `read` before judging a prior-table item as fixed or unfixed — never decide based on the prior table alone.**
|
|
10
|
+
3. Project conventions were established in round 1 — do NOT re-read AGENTS.md / design docs unless a prior-table item names them or a fix appears to contradict the task itself.
|
|
11
|
+
4. **ALWAYS verify current file content with `read` before judging a prior-table item as fixed or unfixed — never decide based on the prior table alone.** Fixes may already be committed — `read` the files named in the prior table regardless. (Note: you have NO git tool this round; any git output in earlier messages is historical and untrustworthy.) Batch independent tool calls in one reply.
|
|
12
12
|
5. Use grep or lsp to trace callers, imports, and dependencies — only where genuinely needed.
|
|
13
13
|
6. Produce your review table.
|
|
14
14
|
|
|
@@ -19,7 +19,9 @@ Rules:
|
|
|
19
19
|
- Primarily check fix status of items in the prior issue table.
|
|
20
20
|
- For items marked "fixed": verify they were actually fixed.
|
|
21
21
|
- For items marked "not an issue": evaluate whether the reasoning is sound.
|
|
22
|
-
- Every "Unfixed" or "New" entry MUST quote the exact line content from THIS round's `read` output (e.g. `run.mjs:180: timeoutId = setTimeout(...)`). Line numbers alone are NOT evidence — they may
|
|
22
|
+
- Every "Unfixed" or "New" entry MUST quote the exact line content from THIS round's `read` output (e.g. `run.mjs:180: timeoutId = setTimeout(...)`). Line numbers alone are NOT evidence — they may be fabricated or stale. Findings without a fresh quoted line are treated as unverified and will not be accepted.
|
|
23
|
+
- **Host verification**: your `file:line: content` citations are mechanically checked against the CURRENT file state — quote exactly what `read` returned; a mismatch marks the finding unverified.
|
|
24
|
+
- **Fresh context**: this round's conversation contains NO read output from earlier rounds — every file must be re-read this round.
|
|
23
25
|
- You may flag obvious new problems — but only if clearly visible in the reviewed files and would cause crashes, data loss, or logic errors.
|
|
24
26
|
- Do NOT nitpick style or naming.
|
|
25
27
|
- Output a Markdown table listing all remaining problems (old or new):
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
You are
|
|
1
|
+
You are an independent review advisor.
|
|
2
2
|
Strictly verify only the prior issue table (provided in the review context).
|
|
3
3
|
Do NOT look for new issues.
|
|
4
4
|
You have read-only tools to explore the codebase.
|
|
@@ -7,8 +7,8 @@ You have a budget of 30 tool rounds (chat turns). Hard mechanical cap: 100 round
|
|
|
7
7
|
Review workflow:
|
|
8
8
|
1. The affected files are named in the prior issue table — read them in full. The prior issue table is HISTORY from a previous review, not current state.
|
|
9
9
|
2. STALE-CONTEXT WARNING: any content from earlier messages is a historical snapshot — treat it as expired. Only fresh `read` results describe the current state.
|
|
10
|
-
3. Project conventions were established in round 1 — do NOT re-read AGENTS.md / design docs.
|
|
11
|
-
4. **ALWAYS verify current file content with `read` before judging a prior-table item as fixed or unfixed — never decide based on the prior table alone.**
|
|
10
|
+
3. Project conventions were established in round 1 — do NOT re-read AGENTS.md / design docs unless a prior-table item names them or a fix appears to contradict the task itself.
|
|
11
|
+
4. **ALWAYS verify current file content with `read` before judging a prior-table item as fixed or unfixed — never decide based on the prior table alone.** Fixes may already be committed — `read` the files named in the prior table regardless. (Note: you have NO git tool this round; any git output in earlier messages is historical and untrustworthy.) Batch independent tool calls in one reply.
|
|
12
12
|
5. Verify fix status of each item in the prior issue table.
|
|
13
13
|
6. Produce your review table.
|
|
14
14
|
|
|
@@ -19,7 +19,9 @@ Rules:
|
|
|
19
19
|
- Only check fix status of items in the prior issue table.
|
|
20
20
|
- For items marked "fixed": verify they were actually fixed.
|
|
21
21
|
- For items marked "not an issue": evaluate whether the reasoning is sound.
|
|
22
|
-
- Every "Unfixed" entry MUST quote the exact line content from THIS round's `read` output (e.g. `run.mjs:180: timeoutId = setTimeout(...)`). Line numbers alone are NOT evidence — they may
|
|
22
|
+
- Every "Unfixed" entry MUST quote the exact line content from THIS round's `read` output (e.g. `run.mjs:180: timeoutId = setTimeout(...)`). Line numbers alone are NOT evidence — they may be fabricated or stale. Findings without a fresh quoted line are treated as unverified and will not be accepted.
|
|
23
|
+
- **Host verification**: your `file:line: content` citations are mechanically checked against the CURRENT file state — quote exactly what `read` returned; a mismatch marks the finding unverified.
|
|
24
|
+
- **Fresh context**: this round's conversation contains NO read output from earlier rounds — every file must be re-read this round.
|
|
23
25
|
- Output a Markdown table. Only list items that still have problems:
|
|
24
26
|
| # | Orig# | File | Severity | Status | Notes |
|
|
25
27
|
|---|-------|------|----------|--------|-------|
|
|
@@ -12,4 +12,4 @@ Debugging strategy:
|
|
|
12
12
|
- Don't get stuck reading code — write tests, add logs. Trust the runtime over your theories.
|
|
13
13
|
|
|
14
14
|
Review discipline (standard mode only — engineering mode has its own review timing rules):
|
|
15
|
-
- **Advisor:** call after changing code. Must provide scope: `paths` (files/dirs to review) or `documents` (context). Response table: `| # | Action | Detail |`. Round 2 verifies prior table.
|
|
15
|
+
- **Advisor:** call after changing code. Must provide scope: `paths` (files/dirs to review) or `documents` (context). Response table: `| # | Action | Detail |`. Round 2 verifies the prior issue table + flags obvious new issues; round 3+ strictly verifies only the prior issue table (no new-issue hunting). Max 5 rounds total.
|
package/src/session.mjs
CHANGED
|
@@ -282,6 +282,22 @@ export function switchToSlot(cwd, slot) {
|
|
|
282
282
|
return loadSession(cwd)
|
|
283
283
|
}
|
|
284
284
|
|
|
285
|
+
/** Delete a slot: remove its file and manifest entry. Deleting the active slot
|
|
286
|
+
* resets the manifest active pointer (the next claim re-creates one). */
|
|
287
|
+
export function deleteSlot(cwd, slot) {
|
|
288
|
+
const n = Number(slot)
|
|
289
|
+
if (!Number.isInteger(n) || n < 1) return false
|
|
290
|
+
const m = loadManifest(cwd)
|
|
291
|
+
if (!m.slots[n]) return false
|
|
292
|
+
delete m.slots[n]
|
|
293
|
+
delete m.slotSessions?.[n] // orphan session-id entries bloat the manifest forever
|
|
294
|
+
try { unlinkSync(slotPath(cwd, n)) } catch { /* missing file is fine */ }
|
|
295
|
+
if (m.active === n) delete m.active
|
|
296
|
+
saveManifest(cwd, m)
|
|
297
|
+
return true
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
|
|
285
301
|
// ========== legacy transient prefix cleanup ==========
|
|
286
302
|
|
|
287
303
|
const LEGACY_TRANSIENT_PREFIXES = [
|
|
@@ -297,6 +313,8 @@ function isLegacyTransient(m) {
|
|
|
297
313
|
)
|
|
298
314
|
}
|
|
299
315
|
|
|
316
|
+
export { isLegacyTransient }
|
|
317
|
+
|
|
300
318
|
// ========== core read/write ==========
|
|
301
319
|
|
|
302
320
|
/** Save agent state and display lines to the active slot file (atomic write) */
|
|
@@ -396,6 +414,7 @@ export function applySession(agent, data) {
|
|
|
396
414
|
// (possibly compacted) machine line. Restore each line from its own source — the machine
|
|
397
415
|
// context keeps its compaction savings across resume. Legacy files without contextHistory
|
|
398
416
|
// fall back to seeding the machine line from the full history (it re-compacts when needed).
|
|
417
|
+
agent.config ??= {} // ACP test mocks may omit config; be defensive like the ??= below
|
|
399
418
|
const full = Array.isArray(data.history) ? data.history : []
|
|
400
419
|
const machine = Array.isArray(data.contextHistory) ? data.contextHistory : full
|
|
401
420
|
agent._fullHistory = [...full]
|
package/src/tools/file.mjs
CHANGED
|
@@ -20,6 +20,19 @@ import { join, relative, dirname } from "node:path";
|
|
|
20
20
|
const MAX_FILE_READ_BYTES = 10_000_000
|
|
21
21
|
const MAX_IMAGE_BYTES = 15_000_000
|
|
22
22
|
|
|
23
|
+
// ────────────────────────────────────────
|
|
24
|
+
// Dirty-file tracking (read-before-insert guard)
|
|
25
|
+
// ────────────────────────────────────────
|
|
26
|
+
// insert_after anchors on LINE NUMBERS — the most drift-prone addressing.
|
|
27
|
+
// Every write tool marks the file dirty; insert_after refuses to run on a
|
|
28
|
+
// dirty file until the agent reads it again (fresh line numbers). This turns
|
|
29
|
+
// the "read after edit" discipline into a structural guarantee: a stale
|
|
30
|
+
// after_line can never silently land in the wrong place again.
|
|
31
|
+
const dirtyPaths = new Set()
|
|
32
|
+
export function markDirty(abs) { dirtyPaths.add(abs) }
|
|
33
|
+
export function clearDirty(abs) { dirtyPaths.delete(abs) }
|
|
34
|
+
export function isDirty(abs) { return dirtyPaths.has(abs) }
|
|
35
|
+
|
|
23
36
|
export const readTool = {
|
|
24
37
|
name: "read",
|
|
25
38
|
description: DESC("read"),
|
|
@@ -41,6 +54,8 @@ export const readTool = {
|
|
|
41
54
|
const st = await stat(abs).catch(() => null)
|
|
42
55
|
if (st && st.size > MAX_FILE_READ_BYTES) throw new Error(`File too large (${Math.round(st.size / 1_000_000)}MB > 10MB limit). Use bash with head/tail or grep for targeted extraction.`)
|
|
43
56
|
const content = normalizeEOL(await readFile(abs, "utf8"))
|
|
57
|
+
// A read refreshes the agent's view — line numbers are fresh again.
|
|
58
|
+
clearDirty(abs)
|
|
44
59
|
const lines = content.split("\n")
|
|
45
60
|
const offset = Math.max(1, args.offset ?? 1)
|
|
46
61
|
const limit = Math.min(args.limit ?? MAX_READ_LINES, MAX_READ_LINES)
|
|
@@ -129,6 +144,7 @@ export const writeTool = {
|
|
|
129
144
|
const st = await stat(abs).catch(() => null)
|
|
130
145
|
if (st?.isDirectory()) throw new Error(`Path is a directory: ${args.path}`)
|
|
131
146
|
await writeFile(abs, args.content, "utf8")
|
|
147
|
+
markDirty(abs)
|
|
132
148
|
const diff = gitDiffOne(ctx.cwd, abs)
|
|
133
149
|
return `Wrote ${args.content.length} chars to ${args.path}${diff ? "\n" + diff : ""}${await autoSyntaxCheck(abs)}`
|
|
134
150
|
},
|
|
@@ -175,6 +191,7 @@ export const editTool = {
|
|
|
175
191
|
// Functional replacement: avoid $-substitution patterns in new_string (match string / backreference) being expanded
|
|
176
192
|
: content.replace(args.old_string, () => args.new_string)
|
|
177
193
|
await writeFile(abs, updated, "utf8")
|
|
194
|
+
markDirty(abs)
|
|
178
195
|
const diff = gitDiffOne(ctx.cwd, abs)
|
|
179
196
|
return `Edited ${args.path}: replaced ${args.replace_all ? occurrences : 1} occurrence(s)${diff ? "\n" + diff : ""}${await autoSyntaxCheck(abs)}`
|
|
180
197
|
},
|
|
@@ -199,6 +216,17 @@ export const insertAfterTool = {
|
|
|
199
216
|
touchedPaths(args) { return args.path ? [args.path] : [] },
|
|
200
217
|
async execute(args, ctx) {
|
|
201
218
|
const abs = resolveInCwd(ctx, args.path)
|
|
219
|
+
// Read-before-insert guard: after_line anchors are line numbers, and any
|
|
220
|
+
// write since the last read made them stale. Refuse instead of silently
|
|
221
|
+
// inserting at a drifted position (the failure mode that corrupted test
|
|
222
|
+
// structure repeatedly). after_regex callers get the same gate — a stale
|
|
223
|
+
// target line is just as wrong, and the rule is simpler to reason about.
|
|
224
|
+
if (isDirty(abs)) {
|
|
225
|
+
throw new Error(
|
|
226
|
+
`${args.path} was modified since your last read — line numbers may be stale.\n` +
|
|
227
|
+
`Read the file again (read tool) to refresh line numbers, then retry insert_after.`
|
|
228
|
+
)
|
|
229
|
+
}
|
|
202
230
|
const text = normalizeEOL(await readFile(abs, "utf8"))
|
|
203
231
|
const lines = text.split("\n")
|
|
204
232
|
|
|
@@ -232,6 +260,7 @@ export const insertAfterTool = {
|
|
|
232
260
|
lines.splice(targetLine, 0, args.content)
|
|
233
261
|
const updated = lines.join("\n")
|
|
234
262
|
await writeFile(abs, updated, "utf8")
|
|
263
|
+
markDirty(abs)
|
|
235
264
|
const diff = gitDiffOne(ctx.cwd, abs)
|
|
236
265
|
return `Inserted after line ${targetLine} in ${args.path}${diff ? "\n" + diff : ""}${await autoSyntaxCheck(abs)}`
|
|
237
266
|
},
|
|
@@ -317,6 +346,7 @@ export const hashlineEditTool = {
|
|
|
317
346
|
lines.splice(pos, target.length, ...newLines)
|
|
318
347
|
const updated = lines.join("\n")
|
|
319
348
|
await writeFile(abs, updated, "utf8")
|
|
349
|
+
markDirty(abs)
|
|
320
350
|
const diff = gitDiffOne(ctx.cwd, abs)
|
|
321
351
|
return `Edited ${args.path}: replaced ${target.length} line(s) at L${pos + 1} with ${newLines.length} line(s)${diff ? "\n" + diff : ""}${await autoSyntaxCheck(abs)}`
|
|
322
352
|
},
|
|
@@ -11,3 +11,4 @@ Notes:
|
|
|
11
11
|
- Use this instead of `edit` when you're adding a new function, import, or block — no need to fabricate surrounding context for exact matching.
|
|
12
12
|
- The inserted content becomes its own line; it's equivalent to `lines.splice(targetLine, 0, content)`.
|
|
13
13
|
- Returns a diff of the change.
|
|
14
|
+
- **Read-before-insert guard**: if the file was modified by any write tool (write/edit/insert_after/hashline_edit/apply_patch/delete) since your last `read`, this tool REFUSES with an error — line numbers may be stale. Read the file again, then retry. This prevents after_line from silently landing at a drifted position.
|
package/src/tools/patch.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
autoSyntaxCheck,
|
|
4
4
|
resolveInCwd
|
|
5
5
|
} from "./shared.mjs";
|
|
6
|
+
import { markDirty } from "./file.mjs";
|
|
6
7
|
import { execFileSync } from "node:child_process";
|
|
7
8
|
import { mkdir } from "node:fs/promises";
|
|
8
9
|
import { readFile } from "node:fs/promises";
|
|
@@ -150,6 +151,8 @@ export const applyPatchTool = {
|
|
|
150
151
|
throw renameError
|
|
151
152
|
}
|
|
152
153
|
const summary = planned.map((p) => ` ${p.isNew ? "created " : "modified"} ${p.path}`).join("\n")
|
|
154
|
+
// Mark every touched file dirty — insert_after must not run on stale line numbers.
|
|
155
|
+
for (const p of planned) markDirty(p.abs)
|
|
153
156
|
const syntaxChecks = await Promise.all(planned.map(async (p) => {
|
|
154
157
|
const r = await autoSyntaxCheck(p.abs)
|
|
155
158
|
return r ? `${p.path}:${r.replace("Syntax: ", "")}` : ""
|
|
@@ -197,6 +200,7 @@ export const deleteTool = {
|
|
|
197
200
|
}
|
|
198
201
|
if (tracked && !args.force) throw new Error(`"${args.path}" is git-tracked. Set force=true to delete anyway.`)
|
|
199
202
|
await unlink(abs)
|
|
203
|
+
markDirty(abs)
|
|
200
204
|
return `Deleted ${args.path}`
|
|
201
205
|
},
|
|
202
206
|
}
|