thincoder 0.12.58 → 0.12.60
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/CHANGELOG.md +78 -2
- package/README.md +3 -3
- package/bin/thincoder.mjs +88 -19
- package/package.json +4 -3
- package/src/acp/bridge.mjs +135 -26
- package/src/advisor/messages.mjs +57 -4
- package/src/advisor/run.mjs +119 -79
- package/src/advisor.mjs +34 -7
- package/src/agent/completion.mjs +17 -11
- package/src/agent/dispatch.mjs +182 -22
- package/src/agent/helpers.mjs +71 -4
- package/src/agent/record-results.mjs +46 -10
- package/src/agent/run-stages.mjs +227 -0
- package/src/agent/setup-reminders.mjs +62 -0
- package/src/agent/setup.mjs +107 -20
- package/src/agent/spawn-child.mjs +54 -4
- package/src/agent-tools/advisor-async.mjs +456 -0
- package/src/agent-tools/advisor.mjs +133 -109
- package/src/agent-tools/async-settle.mjs +191 -0
- package/src/agent-tools/consult.mjs +154 -104
- package/src/agent-tools/design-token.mjs +104 -0
- package/src/agent-tools/eng.mjs +26 -30
- package/src/agent-tools/escalate-async.mjs +286 -0
- package/src/agent-tools/goal.mjs +11 -1
- package/src/agent-tools/read-history.mjs +284 -0
- package/src/agent-tools/recent-changes.mjs +2 -1
- package/src/agent-tools/settings.mjs +152 -0
- package/src/agent-tools/skill.mjs +2 -1
- package/src/agent-tools/subagent-actions.mjs +470 -0
- package/src/agent-tools/subagent-async.mjs +382 -0
- package/src/agent-tools/subagent-panel.mjs +153 -0
- package/src/agent-tools/subagent-run.mjs +202 -0
- package/src/agent-tools/subagent-scheduler.mjs +343 -0
- package/src/agent-tools/subagent-spawn.mjs +406 -0
- package/src/agent-tools/subagent.mjs +203 -377
- package/src/agent-tools/task.mjs +4 -3
- package/src/agent-tools/timer.mjs +9 -4
- package/src/agent-tools/verify.mjs +198 -238
- package/src/agent-tools.mjs +1 -0
- package/src/agent.mjs +145 -242
- package/src/auto-think.mjs +14 -0
- package/src/cli/distill-command.mjs +10 -4
- package/src/cli/make-agent.mjs +4 -1
- package/src/cli/memory-command.mjs +2 -1
- package/src/cli/permission.mjs +8 -1
- package/src/cli/setup-wizard.mjs +17 -12
- package/src/config.mjs +61 -8
- package/src/context.mjs +81 -163
- package/src/crash-reports.mjs +123 -0
- package/src/distill.mjs +30 -12
- package/src/escape.mjs +6 -4
- package/src/explore-distill.mjs +155 -0
- package/src/log.mjs +195 -0
- package/src/memory/code-sync.mjs +2 -1
- package/src/memory/core.mjs +11 -72
- package/src/memory/delete.mjs +234 -0
- package/src/memory/docs.mjs +206 -87
- package/src/memory.mjs +3 -1
- package/src/model-specs.mjs +15 -1
- package/src/peer-domains.mjs +265 -0
- package/src/peer-instances.mjs +231 -0
- package/src/prompt-overlays.mjs +25 -0
- package/src/prompts/advisor-design.md +18 -39
- package/src/prompts/advisor-round1.md +20 -32
- package/src/prompts/advisor-round2.md +16 -16
- package/src/prompts/advisor-round3.md +16 -16
- package/src/prompts/coder.md +7 -28
- package/src/prompts/consult-base.md +4 -11
- package/src/prompts/discipline.md +31 -44
- package/src/prompts/eng-coder.md +9 -34
- package/src/prompts/engineering-sub.md +10 -8
- package/src/prompts/engineering.md +61 -264
- package/src/prompts/explore.md +4 -14
- package/src/prompts/main.md +18 -35
- package/src/prompts/methodology-template.md +32 -38
- package/src/prompts/plan.md +2 -9
- package/src/prompts/system.md +18 -35
- package/src/provider/core.mjs +62 -69
- package/src/provider/errors.mjs +76 -0
- package/src/provider/retry.mjs +8 -45
- package/src/session-gc.mjs +214 -0
- package/src/session-guard.mjs +47 -0
- package/src/session-rename.mjs +38 -0
- package/src/session-slots.mjs +181 -58
- package/src/session.mjs +48 -89
- package/src/token-ttl.mjs +273 -0
- package/src/tools/apply_patch.md +3 -1
- package/src/tools/bash.md +1 -1
- package/src/tools/checklist-sync.mjs +181 -0
- package/src/tools/checklist.mjs +52 -39
- package/src/tools/delete.md +1 -0
- package/src/tools/edit-batch.mjs +131 -44
- package/src/tools/edit-diff.mjs +348 -0
- package/src/tools/edit.md +20 -13
- package/src/tools/execute.md +7 -7
- package/src/tools/execute.mjs +55 -24
- package/src/tools/file.mjs +25 -70
- package/src/tools/file_ops.md +2 -1
- package/src/tools/get_current_time.md +3 -1
- package/src/tools/git.mjs +14 -6
- package/src/tools/glob-dialect.mjs +130 -0
- package/src/tools/glob.md +3 -3
- package/src/tools/grep.md +1 -1
- package/src/tools/hashline_edit.md +2 -0
- package/src/tools/index.mjs +3 -3
- package/src/tools/insert_after.md +2 -1
- package/src/tools/lint.md +2 -0
- package/src/tools/lsp.md +4 -1
- package/src/tools/ops.mjs +175 -3
- package/src/tools/patch.mjs +84 -13
- package/src/tools/question.md +5 -1
- package/src/tools/repomap.mjs +1 -1
- package/src/tools/shared.mjs +18 -25
- package/src/tools/system.mjs +50 -30
- package/src/tools/tree.md +2 -1
- package/src/tools/wait_for.md +22 -0
- package/src/tools/web.mjs +5 -3
- package/src/tools/websearch.md +2 -1
- package/src/tools/write.md +2 -0
- package/src/traces/trace-store.mjs +224 -0
- package/src/tui/agent-turn.mjs +179 -27
- package/src/tui/clipboard.mjs +15 -4
- package/src/tui/cmd-config.mjs +77 -16
- package/src/tui/cmd-eng.mjs +20 -16
- package/src/tui/cmd-extract.mjs +1 -1
- package/src/tui/cmd-mcp.mjs +17 -2
- package/src/tui/cmd-new.mjs +3 -2
- package/src/tui/cmd-session.mjs +19 -4
- package/src/tui/cmd-think.mjs +11 -11
- package/src/tui/cmd-upgrade.mjs +19 -4
- package/src/tui/config-helpers.mjs +28 -16
- package/src/tui/distill-cmd.mjs +1 -1
- package/src/tui/index.mjs +31 -96
- package/src/tui/interaction.mjs +13 -2
- package/src/tui/key-handler.mjs +105 -155
- package/src/tui/key-modes.mjs +215 -0
- package/src/tui/layout.mjs +22 -1
- package/src/tui/mouse.mjs +46 -0
- package/src/tui/pickers.mjs +51 -25
- package/src/tui/render-conversation.mjs +13 -161
- package/src/tui/render-frame.mjs +27 -10
- package/src/tui/render-loop.mjs +4 -1
- package/src/tui/render-segments.mjs +182 -0
- package/src/tui/startup.mjs +40 -0
- package/src/tui/subagent-blocks.mjs +272 -262
- package/src/tui/subagent-children.mjs +176 -0
- package/src/tui/subagent-freeze.mjs +172 -0
- package/src/tui/subagent-panel.mjs +125 -12
- package/src/tui/suspension-drive.mjs +351 -0
- package/src/tui/tool-args.mjs +10 -2
- package/src/tui/tool-display.mjs +142 -0
- package/src/tui/tool-events.mjs +127 -231
- package/src/tui/tui-lifecycle.mjs +29 -0
- package/src/tui/update-notice.mjs +76 -0
- package/src/tui/wizard.mjs +48 -12
- package/src/agent-tools/escalate.mjs +0 -179
- package/src/agent-tools/subagent-check.mjs +0 -107
- package/src/tools/exec-prelude.mjs +0 -84
package/src/tui/wizard.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* wizard.mjs — first-launch config wizard
|
|
3
|
-
* Extracted from index.mjs: provider select → step-by-step input (name/baseURL/model/key/embedkey)
|
|
3
|
+
* Extracted from index.mjs: provider select → step-by-step input (name/baseURL/model/format/key/embedkey)
|
|
4
|
+
* → persist → then model picker. Custom 分支含 API format 步(D-C2,TUI.md §10.6D)。
|
|
4
5
|
* Accesses shared state and UI functions from the startTUI closure via ctx object.
|
|
5
6
|
* ctx: { agent, state, pushLine, pushLabel, render, persistRaw, openModelPicker }
|
|
6
7
|
*/
|
|
@@ -23,7 +24,13 @@ export function createWizard(ctx) {
|
|
|
23
24
|
}
|
|
24
25
|
for (const [name, p] of Object.entries(PRESETS)) {
|
|
25
26
|
if (!agent.providers.some((x) => x.name === name)) {
|
|
26
|
-
items.push({
|
|
27
|
+
items.push({
|
|
28
|
+
kind: "preset", name, baseURL: p.baseURL, model: p.model, label: `${name} (${p.desc})`,
|
|
29
|
+
// 预设自身声明的扩展字段随 preset 直达落盘(code review 🟡——与 pickers preset 路径同构;
|
|
30
|
+
// claude/gemini 缺 format、deepseek/glm 缺 thinking/maxTokens 会静默错配);不新增提问步。
|
|
31
|
+
format: p.format, thinking: p.thinking, reasoningEffort: p.reasoningEffort,
|
|
32
|
+
maxTokens: p.maxTokens, chatPath: p.chatPath,
|
|
33
|
+
})
|
|
27
34
|
}
|
|
28
35
|
}
|
|
29
36
|
items.push({ kind: "custom", name: null, label: "Custom endpoint…" })
|
|
@@ -45,6 +52,14 @@ export function createWizard(ctx) {
|
|
|
45
52
|
prompt: "Enter model name (e.g. gpt-4o)",
|
|
46
53
|
validate: (v) => v.length > 0 || "Model name required",
|
|
47
54
|
},
|
|
55
|
+
// D-C2(TUI.md §10.6D):Custom 分支的 API format 步(endpoint 后 key 前——与 Add Provider
|
|
56
|
+
// picker 路径两入口一致;默认 openai)。空输入 = openai 直过(Enter 即默认——同 D-C1 index=0);
|
|
57
|
+
// Esc 在该步沿用 wizard 既有“Esc 随时跳过”语义(取消整个向导——无半配置落盘)。
|
|
58
|
+
format: {
|
|
59
|
+
prompt: "API format [openai/anthropic/google] (Enter = openai default)",
|
|
60
|
+
// 大小写不敏感(旧手输口径——输入统一 toLowerCase 后落盘);空输入 = openai 默认
|
|
61
|
+
validate: (v) => !v || /^(openai|anthropic|google)$/i.test(v) || "API format must be one of: openai, anthropic, google",
|
|
62
|
+
},
|
|
48
63
|
key: {
|
|
49
64
|
prompt: "Enter API key",
|
|
50
65
|
validate: (v) => v.length > 0 || "Key must not be empty",
|
|
@@ -54,7 +69,7 @@ export function createWizard(ctx) {
|
|
|
54
69
|
validate: () => true, // skippable
|
|
55
70
|
},
|
|
56
71
|
}
|
|
57
|
-
const WIZARD_NEXT = { name: "baseURL", baseURL: "model", model: "key", key: "embedkey", embedkey: null }
|
|
72
|
+
const WIZARD_NEXT = { name: "baseURL", baseURL: "model", model: "format", format: "key", key: "embedkey", embedkey: null }
|
|
58
73
|
|
|
59
74
|
function startWizard() {
|
|
60
75
|
state.wizard = { step: "provider", index: 0, scroll: 0, selectedLine: 0, fields: {}, error: null, lines: [] }
|
|
@@ -94,6 +109,11 @@ export function createWizard(ctx) {
|
|
|
94
109
|
w.step = "name"
|
|
95
110
|
} else {
|
|
96
111
|
w.fields = { name: item.name, baseURL: item.baseURL, model: item.model }
|
|
112
|
+
// preset 直达:预设声明的扩展字段(format/thinking/maxTokens/chatPath…)直接带进 fields——
|
|
113
|
+
// 无 format 提问步(T-C4)但落盘不丢字段(code review 🟡——picker preset 路径同款复制)。
|
|
114
|
+
for (const k of ["format", "thinking", "reasoningEffort", "maxTokens", "chatPath"]) {
|
|
115
|
+
if (item[k]) w.fields[k] = item[k]
|
|
116
|
+
}
|
|
97
117
|
w.step = "key"
|
|
98
118
|
}
|
|
99
119
|
renderWizard()
|
|
@@ -111,7 +131,9 @@ export function createWizard(ctx) {
|
|
|
111
131
|
w.error = null
|
|
112
132
|
state.input = []
|
|
113
133
|
state.cursor = 0
|
|
114
|
-
w.fields[w.step === "key" ? "key" : w.step] = w.step === "baseURL" ? value.replace(/\/+$/, "")
|
|
134
|
+
w.fields[w.step === "key" ? "key" : w.step] = w.step === "baseURL" ? value.replace(/\/+$/, "")
|
|
135
|
+
: w.step === "format" ? (value.toLowerCase() || "openai") // 空输入 = openai 默认(D-C2)
|
|
136
|
+
: value
|
|
115
137
|
const next = WIZARD_NEXT[w.step]
|
|
116
138
|
if (next) {
|
|
117
139
|
w.step = next
|
|
@@ -131,9 +153,27 @@ export function createWizard(ctx) {
|
|
|
131
153
|
async function finishWizard() {
|
|
132
154
|
const f = state.wizard.fields
|
|
133
155
|
state.wizard = null
|
|
156
|
+
// D-C2:format 非默认(anthropic/google)时落盘;openai = 默认省略(与 D-C1 picker 路径同构)
|
|
157
|
+
const providerRec = { name: f.name, baseURL: f.baseURL, model: f.model, apiKey: f.key }
|
|
158
|
+
if (f.format && f.format !== "openai") providerRec.format = f.format
|
|
159
|
+
// code review 🟡:preset 直达带来的扩展字段一并落盘(truthy 语义与 pickers preset 分支一致——
|
|
160
|
+
// thinking: null 不落;Custom 路径无这些字段不受影响)
|
|
161
|
+
for (const k of ["thinking", "reasoningEffort", "maxTokens", "chatPath"]) {
|
|
162
|
+
if (f[k]) providerRec[k] = f[k]
|
|
163
|
+
}
|
|
164
|
+
// D-F5a(wizard finishWizard——清单外同型写回补正)先盘后存:磁盘 fresh raw 单操作
|
|
165
|
+
// (upsert 目标项 + active 指针)——冲突放弃不留下内存 ghost(F5 约定)
|
|
166
|
+
await persistRaw((raw) => {
|
|
167
|
+
raw.providers ??= []
|
|
168
|
+
const existing = raw.providers.find((p) => p?.name === f.name)
|
|
169
|
+
if (existing) Object.assign(existing, providerRec)
|
|
170
|
+
else raw.providers.push(providerRec)
|
|
171
|
+
raw.activeProvider = f.name
|
|
172
|
+
raw.activeModel = undefined // reset to default model
|
|
173
|
+
})
|
|
134
174
|
const existing = agent.providers.find((p) => p.name === f.name)
|
|
135
|
-
if (existing) Object.assign(existing,
|
|
136
|
-
else agent.providers.push(
|
|
175
|
+
if (existing) Object.assign(existing, providerRec)
|
|
176
|
+
else agent.providers.push(providerRec)
|
|
137
177
|
agent.activeProvider = f.name
|
|
138
178
|
agent.activeModel = null
|
|
139
179
|
agent.provider = { ...agent.providers.find((p) => p.name === f.name) }
|
|
@@ -141,20 +181,16 @@ export function createWizard(ctx) {
|
|
|
141
181
|
const { resolveCompactThreshold } = await import("../config.mjs")
|
|
142
182
|
agent.config.agent.compactThreshold = resolveCompactThreshold(null, f.model).value
|
|
143
183
|
}
|
|
144
|
-
await persistRaw((raw) => {
|
|
145
|
-
raw.providers = agent.providers
|
|
146
|
-
raw.activeProvider = f.name
|
|
147
|
-
raw.activeModel = undefined // reset to default model
|
|
148
|
-
})
|
|
149
184
|
agent.config.activeProvider = f.name
|
|
150
185
|
agent.config.activeModel = null
|
|
151
186
|
pushLabel(`❯ Setup`, ansi.bold + C.tool)
|
|
152
187
|
pushLine(`Setup complete: ${f.name} / ${f.model} (saved to config)`, C.tool)
|
|
153
188
|
// embedding key: if provided, enable vector search; if not, show how to enable later
|
|
154
189
|
if (f.embedkey) {
|
|
190
|
+
// D-F5b 语义先盘后存(embedding 单键补丁——冲突放弃不留 ghost)
|
|
191
|
+
await persistRaw((raw) => { raw.embedding = { ...(raw.embedding ?? {}), apiKey: f.embedkey } })
|
|
155
192
|
agent.config.embedding ??= {}
|
|
156
193
|
agent.config.embedding.apiKey = f.embedkey
|
|
157
|
-
await persistRaw((raw) => { raw.embedding = { ...(raw.embedding ?? {}), apiKey: f.embedkey } })
|
|
158
194
|
if (agent.memory && !agent.memory.embedder) {
|
|
159
195
|
const { createEmbedder } = await import("../embedding.mjs")
|
|
160
196
|
agent.memory.embedder = createEmbedder(agent.config.embedding)
|
|
@@ -1,179 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* escalate.mjs — 飞刀 (the "flying knife", docs/design/ESCALATE.md). CLI port.
|
|
3
|
-
*
|
|
4
|
-
* Hand an implementation task to a STRONGER model — like a hospital flying in an
|
|
5
|
-
* outside expert (飞刀): the expert arrives, operates personally (WRITE access),
|
|
6
|
-
* hands back the post-op report, leaves. Complementary to consult (parallel
|
|
7
|
-
* READ-ONLY opinions for judgment calls).
|
|
8
|
-
*
|
|
9
|
-
* Candidate pool = all consultModels rows. The tool is only registered when the
|
|
10
|
-
* pool is non-empty (setup.mjs).
|
|
11
|
-
*
|
|
12
|
-
* CLI adaptation (vs the VS Code plugin): the child runner is CLI's runAgent
|
|
13
|
-
* (runAgent(child, input, callbacks, opts) — an agent object, not provider+cwd);
|
|
14
|
-
* the child is createAgent({ role: "coder", CODER_OVERLAY }); activity streams to
|
|
15
|
-
* the parent TUI via the relay prefix `escalate#<id>/`; mutations merge via the
|
|
16
|
-
* CLI mergeChildMutations(parent, child) (agent object, not a state sink).
|
|
17
|
-
*/
|
|
18
|
-
import { isAbsolute, relative } from "node:path"
|
|
19
|
-
import { createAgent, runAgent, CODER_OVERLAY, DEFAULT_SUBAGENT_TURNS } from "../agent.mjs"
|
|
20
|
-
import { resolveChildProvider, mergeChildMutations } from "./subagent.mjs"
|
|
21
|
-
import { makeRelay, wrapChildCallbacks, runWithContinue, ensureChildApiKey, clampEffort, TURN_CAP_MARK } from "../agent/spawn-child.mjs"
|
|
22
|
-
|
|
23
|
-
const label = (m) => `${m.provider}:${m.model}`
|
|
24
|
-
|
|
25
|
-
export const escalateTool = {
|
|
26
|
-
name: "escalate",
|
|
27
|
-
sideEffectExempt: true, // the child's mutations are tracked and reviewed, like subagent
|
|
28
|
-
description:
|
|
29
|
-
"TERMINOLOGY (one word for one thing): 'escalate' is the ONLY name — the tool, and the " +
|
|
30
|
-
"role of the expert sub-agent it spawns, are both called 'escalate'; 飞刀 is the Chinese " +
|
|
31
|
-
"alias. When the user says 飞刀 / escalate / 'fly in <model>', call THIS tool directly — " +
|
|
32
|
-
"never via a script importing this module. " +
|
|
33
|
-
"Hand an implementation task to a stronger model (飞刀 — a flown-in expert). " +
|
|
34
|
-
"It gets WRITE access and does the work itself — reads, edits, runs tests — then returns " +
|
|
35
|
-
"a post-op report (what changed, why, verification). You review the report and report to " +
|
|
36
|
-
"the user. Use it when YOU judge the task calls for stronger hands (complex multi-file " +
|
|
37
|
-
"refactoring, an intractable bug, intricate algorithm work — or work beyond your " +
|
|
38
|
-
"comfortable ability). Early or late, your judgment; the cost is one expert run, " +
|
|
39
|
-
"comparable to doing it yourself. For parallel READ-ONLY opinions use consult_start instead. " +
|
|
40
|
-
"Not available in engineering mode (implementation goes through eng-coder subagents there).\n" +
|
|
41
|
-
"Parameters:\n" +
|
|
42
|
-
"- task (required): the task description — goal, constraints, entry files, acceptance criteria\n" +
|
|
43
|
-
"- model (optional): pick a specific consultant as 'provider:model'; default = the first consult model",
|
|
44
|
-
parameters: {
|
|
45
|
-
type: "object",
|
|
46
|
-
properties: {
|
|
47
|
-
task: { type: "string", description: "Task description with acceptance criteria" },
|
|
48
|
-
model: { type: "string", description: "Candidate 'provider:model' from the consult models (optional)" },
|
|
49
|
-
},
|
|
50
|
-
required: ["task"],
|
|
51
|
-
},
|
|
52
|
-
async execute({ task, model }, ctx) {
|
|
53
|
-
const parent = ctx.agent
|
|
54
|
-
if ((ctx.depth ?? 0) > 0) return "Error: escalate is only available at depth 0 (an escalate's work cannot be delegated again)"
|
|
55
|
-
if (parent?.config?.agent?.engineering) {
|
|
56
|
-
return "Error: engineering mode is ON — escalate is unavailable (it spawns a coder sub-agent, which engineering mode forbids). Use subagent with role='eng-coder' and a designToken from advisor(type='design') instead."
|
|
57
|
-
}
|
|
58
|
-
const pool = parent?.config?.agent?.consultModels ?? []
|
|
59
|
-
if (pool.length === 0) return "Error: no escalate candidates — configure at least one consult model (agent.consultModels)"
|
|
60
|
-
|
|
61
|
-
const wanted = typeof model === "string" ? model.replace(/\s+\([^)]*\)\s*$/, "").trim() : model
|
|
62
|
-
const pick = wanted ? pool.find((m) => label(m) === wanted) : pool[0]
|
|
63
|
-
if (!pick) {
|
|
64
|
-
return `Error: "${model}" is not a consult candidate. Available: ${pool.map(label).join(", ")}`
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
let provider
|
|
68
|
-
try {
|
|
69
|
-
provider = resolveChildProvider(parent, `${pick.provider}:${pick.model}`)
|
|
70
|
-
} catch (e) {
|
|
71
|
-
return `Error: ${e.message}`
|
|
72
|
-
}
|
|
73
|
-
if (!ensureChildApiKey(provider)) {
|
|
74
|
-
return `Error: provider "${pick.provider}" has no API key — set it in config.json before flying it in`
|
|
75
|
-
}
|
|
76
|
-
let effortNote = ""
|
|
77
|
-
if (pick.effort && !clampEffort(provider, pick.model, pick.effort)) {
|
|
78
|
-
// Out-of-enum effort dropped (see clampEffort): the provider preset default may ALSO be
|
|
79
|
-
// out-of-enum for this override model — e.g. qwenplan preset default "high" is
|
|
80
|
-
// invalid for qwen3.8-max, enum xhigh/medium/low.
|
|
81
|
-
effortNote = ` (effort "${pick.effort}" unsupported by ${pick.model}, dropped)`
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
const tag = label(pick)
|
|
85
|
-
const relayPrefix = makeRelay(parent, "escalate", ctx.callbacks?.onToken, provider.model ?? tag)
|
|
86
|
-
// Report the escalated model to the display layer (it may differ from the parent's)
|
|
87
|
-
// — makeRelay already emitted the `[model]` metadata token above.
|
|
88
|
-
|
|
89
|
-
// No wall-clock watchdog — turn cap only, exactly like subagent (the verified write
|
|
90
|
-
// path). Rationale (2026-08-16): a fixed wall-clock aborts NORMAL-but-slow surgery —
|
|
91
|
-
// two max-effort consultants hit a 10min wall just READING files. Hang protection is
|
|
92
|
-
// already covered by FETCH_TIMEOUT_MS (per LLM call) and the user's Stop (parent
|
|
93
|
-
// signal propagates directly below). maxTurns is the cost budget; hitting it asks
|
|
94
|
-
// the user whether to continue (main-agent parity), falling back to partial work.
|
|
95
|
-
|
|
96
|
-
// No custom onToken here (consult P2, 2026-08-30): wrapChildCallbacks already
|
|
97
|
-
// applies the prefixed relay + D7 sentinel strip for the display path, and
|
|
98
|
-
// runWithContinue owns the capture (stripEventTokensForCapture) for the
|
|
99
|
-
// partial-output return — a hand-rolled duplicate ran the strip twice and
|
|
100
|
-
// maintained a second output buffer.
|
|
101
|
-
const childCallbacks = wrapChildCallbacks(relayPrefix, ctx.callbacks ?? {})
|
|
102
|
-
|
|
103
|
-
// Declared outside try so the catch can merge mutations even on a partial failure.
|
|
104
|
-
let child = null
|
|
105
|
-
try {
|
|
106
|
-
// Full write path (role "coder"): permission gate via the parent's onPermissionRequest,
|
|
107
|
-
// recent-changes tracking, mutations merge into the parent below.
|
|
108
|
-
child = createAgent({
|
|
109
|
-
provider,
|
|
110
|
-
tools: parent.tools,
|
|
111
|
-
config: parent.config,
|
|
112
|
-
cwd: parent.cwd,
|
|
113
|
-
memory: parent.memory,
|
|
114
|
-
overlay: CODER_OVERLAY,
|
|
115
|
-
role: "coder",
|
|
116
|
-
})
|
|
117
|
-
const runner = ctx.runAgent ?? runAgent
|
|
118
|
-
const runOpts = {
|
|
119
|
-
depth: 1,
|
|
120
|
-
maxTurns: parent.config?.agent?.subagentTurns ?? DEFAULT_SUBAGENT_TURNS, // review #7: constant, not literal (single source with subagent.mjs)
|
|
121
|
-
signal: ctx.signal ?? null,
|
|
122
|
-
}
|
|
123
|
-
// Turn-cap continue via runWithContinue (§7.2 D3), main-agent parity (tui/agent-turn.mjs):
|
|
124
|
-
// when the child hits ContinueError, ask the user through the SAME channel as child
|
|
125
|
-
// write approval (ctx.onPermissionRequest). The name "continue" renders the TUI's
|
|
126
|
-
// dedicated y/n Continue panel. The resumed run passes resume:true, so runAgent does
|
|
127
|
-
// NOT re-inject the task text (setup.mjs skips input on resume) and keeps the child's
|
|
128
|
-
// history + mutation bookkeeping, with a fresh maxTurns budget per run. No permission
|
|
129
|
-
// handler (headless) or a declined prompt falls through to the partial-work return.
|
|
130
|
-
// Continues are UNLIMITED — the user can decline at any prompt.
|
|
131
|
-
const report = await runWithContinue(
|
|
132
|
-
async (childAgent, input, cbs, opts) => {
|
|
133
|
-
// Merge mid-run mutations even when the run throws — the outer catch keeps
|
|
134
|
-
// handling createAgent failures; AbortError still propagates (user Stop).
|
|
135
|
-
try {
|
|
136
|
-
return await runner(childAgent, input, cbs, opts)
|
|
137
|
-
} catch (e) {
|
|
138
|
-
mergeChildMutations(parent, childAgent)
|
|
139
|
-
throw e
|
|
140
|
-
}
|
|
141
|
-
},
|
|
142
|
-
child, task, { ...childCallbacks, onPermissionRequest: parent.autoApprove ? async () => true : (ctx.onPermissionRequest ?? null) },
|
|
143
|
-
runOpts,
|
|
144
|
-
{
|
|
145
|
-
// escalate has NO permQueue: prompts go straight to the user (T-L spec).
|
|
146
|
-
askContinue: (e) => (ctx.onPermissionRequest
|
|
147
|
-
? ctx.onPermissionRequest("continue", { turns: e.turn, agent: tag })
|
|
148
|
-
: Promise.resolve(false)),
|
|
149
|
-
onDeclined: (e, output) => `escalate (${tag}) ${TURN_CAP_MARK} (${e.turn} turns) — work may be partial; review recent_changes before deciding next steps.\nPartial output: ${output.slice(0, 2000)}`,
|
|
150
|
-
},
|
|
151
|
-
).catch((e) => {
|
|
152
|
-
// Generic run failure (not ContinueError): match the original loop's return shape —
|
|
153
|
-
// error text + partial output, mutations already merged in the runner wrapper.
|
|
154
|
-
if (ctx.signal?.aborted || e?.name === "AbortError") throw e
|
|
155
|
-
return `escalate (${tag}) error: ${e?.message ?? String(e)}\nPartial output: ${(child._capturedOutput ?? "").slice(0, 2000)}`
|
|
156
|
-
})
|
|
157
|
-
// Escalate mutations are the parent's mutations: verify/advisor guards must see them
|
|
158
|
-
mergeChildMutations(parent, child)
|
|
159
|
-
return `escalate (${tag})${effortNote} post-op report:\n${report || (child._capturedOutput ?? "").slice(0, 4000)}${touchedFilesNote(child, parent.cwd)}`
|
|
160
|
-
} catch (e) {
|
|
161
|
-
// Reached only when createAgent itself fails or the continue prompt throws —
|
|
162
|
-
// run failures are handled above (mutations merge inside the runner wrapper).
|
|
163
|
-
if (child) mergeChildMutations(parent, child)
|
|
164
|
-
if (ctx.signal?.aborted || e?.name === "AbortError") throw e
|
|
165
|
-
return `escalate (${tag}) error: ${e?.message ?? String(e)}`
|
|
166
|
-
}
|
|
167
|
-
},
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
/** Relative touched-file list appended to every return (child paths are absolute). */
|
|
171
|
-
function touchedFilesNote(child, cwd) {
|
|
172
|
-
const touched = child?._touchedFiles ?? []
|
|
173
|
-
if (touched.length === 0) return ""
|
|
174
|
-
const shown = touched.map((f) => {
|
|
175
|
-
const r = relative(cwd ?? process.cwd(), f)
|
|
176
|
-
return r && !r.startsWith("..") && !isAbsolute(r) ? r : f
|
|
177
|
-
})
|
|
178
|
-
return `\nTouched files: ${shown.join(", ")}`
|
|
179
|
-
}
|
|
@@ -1,107 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* subagent-check.mjs — async subagent 结果消费侧(AGENT-LOOP.md §15 D-A2)。
|
|
3
|
-
*
|
|
4
|
-
* 与 subagent.mjs 的生成侧(async 分支 + 槽位队列)分离,保持 subagent.mjs
|
|
5
|
-
* 在 500 行硬限内;本模块只承载 subagent_check 工具 + 其等待机制。
|
|
6
|
-
*
|
|
7
|
-
* 语义(D-A2):
|
|
8
|
-
* - id 缺省 → 按 ARRIVAL ORDER 返回下一个已完成的子代理(先完成先返回)
|
|
9
|
-
* - id 给定 → 阻塞到该 id 完成(queued 项先等启动再等完成)
|
|
10
|
-
* - n(必填)→ 1-based 递增读数,per-run 计数器(runAgent 非 resume 重置、
|
|
11
|
-
* turn-end 清空);乱序/重复 n 拒绝且不消费结果;超 MAX_ASYNC_CHECKS 上限
|
|
12
|
-
* 报错引导走回合收尾自动等待
|
|
13
|
-
* - 消费后从 map 删除——已消费 id 再查 = 与未知 id 同款错误(T12)
|
|
14
|
-
*/
|
|
15
|
-
import { MAX_ASYNC_CHECKS } from "./subagent.mjs"
|
|
16
|
-
|
|
17
|
-
/** Wait for an async entry to settle (or the parent signal to abort), parked on
|
|
18
|
-
* the agent's waiter list — the entry settle finally wakes every waiter (same
|
|
19
|
-
* pattern as consult_check's session waiters). Returns "aborted" on signal. */
|
|
20
|
-
function wakeOnAsyncSettle(agent, ctx) {
|
|
21
|
-
return new Promise((resolve) => {
|
|
22
|
-
const cleanup = () => {
|
|
23
|
-
const i = (agent._asyncWaiters ?? []).indexOf(w)
|
|
24
|
-
if (i >= 0) agent._asyncWaiters.splice(i, 1)
|
|
25
|
-
ctx.signal?.removeEventListener("abort", onAbort)
|
|
26
|
-
}
|
|
27
|
-
const w = () => { cleanup(); resolve("settled") }
|
|
28
|
-
const onAbort = () => { cleanup(); resolve("aborted") }
|
|
29
|
-
;(agent._asyncWaiters ??= []).push(w)
|
|
30
|
-
if (ctx.signal) {
|
|
31
|
-
if (ctx.signal.aborted) { onAbort(); return }
|
|
32
|
-
ctx.signal.addEventListener("abort", onAbort, { once: true })
|
|
33
|
-
}
|
|
34
|
-
})
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* subagent_check (AGENT-LOOP.md §15 D-A2) — fetch async subagent results.
|
|
39
|
-
* - id omitted → the next completed child in ARRIVAL order (first finished first)
|
|
40
|
-
* - id given → block until THAT child finishes (queued items wait for their start)
|
|
41
|
-
* - n (required) → 1-based read counter, strictly incrementing per run (loop
|
|
42
|
-
* guard); capped at MAX_ASYNC_CHECKS per turn — beyond that, use the turn-end
|
|
43
|
-
* auto-wait. Errors never consume results.
|
|
44
|
-
* Consumed entries are deleted from the map — a re-check of the same id is the
|
|
45
|
-
* same "unknown async subagent id" error (T12).
|
|
46
|
-
*/
|
|
47
|
-
export const subagentCheckTool = {
|
|
48
|
-
name: "subagent_check",
|
|
49
|
-
readonly: true,
|
|
50
|
-
description:
|
|
51
|
-
"Fetch the result of an async subagent (subagent with async:true). Spawn async children to keep working in your own turn while they run in the background, then collect their reports here. Multiple async children return in completion (arrival) order — the first finished is returned first, so fast results are handled immediately instead of waiting for the slowest. Blocks until the target finishes.\n" +
|
|
52
|
-
"When done is true, no more results are coming (all finished and consumed) — anything left arrives automatically at turn end.\n" +
|
|
53
|
-
"Parameters:\n" +
|
|
54
|
-
"- id (optional): the subagent id from the async spawn return. Omit to fetch the next completed child (arrival order).\n" +
|
|
55
|
-
"- n (required): 1-based read counter — pass 1 on the first check, 2 on the next, and so on. Consecutive checks must be distinct tool calls (loop detector); at most 3 checks per turn — use the turn-end auto-wait for the rest.",
|
|
56
|
-
parameters: {
|
|
57
|
-
type: "object",
|
|
58
|
-
properties: {
|
|
59
|
-
id: { type: "string", description: "Optional subagent id (from the async spawn return). Omit = next completed child (arrival order)." },
|
|
60
|
-
n: { type: "number", description: "1-based read counter: 1 for the first check of the turn, incrementing with each subsequent check (loop detector — consecutive checks must be distinct tool calls)." },
|
|
61
|
-
},
|
|
62
|
-
required: ["n"],
|
|
63
|
-
},
|
|
64
|
-
async execute({ id, n }, ctx) {
|
|
65
|
-
const agent = ctx.agent
|
|
66
|
-
const map = agent._asyncSubagents ?? new Map()
|
|
67
|
-
// Strict 1-based incrementing read counter (D-A2, review #1): out-of-order /
|
|
68
|
-
// repeated n is rejected WITHOUT consuming a result (T14).
|
|
69
|
-
const lastN = agent._asyncCheckLastN ?? 0
|
|
70
|
-
if (!Number.isInteger(n) || n !== lastN + 1) {
|
|
71
|
-
return JSON.stringify({ status: "error", error: "invalid read counter — pass n = lastN+1" })
|
|
72
|
-
}
|
|
73
|
-
if (n > MAX_ASYNC_CHECKS) {
|
|
74
|
-
return JSON.stringify({ status: "error", error: "check limit exceeded — use turn-end auto-wait for the rest" })
|
|
75
|
-
}
|
|
76
|
-
agent._asyncCheckLastN = n
|
|
77
|
-
|
|
78
|
-
let target = null
|
|
79
|
-
if (id !== undefined && id !== null && String(id) !== "") {
|
|
80
|
-
target = map.get(String(id))
|
|
81
|
-
// Unknown OR already-consumed ids (consumed entries are deleted) — same error (T12).
|
|
82
|
-
if (!target) return JSON.stringify({ id: String(id), status: "error", error: `unknown async subagent id: ${id}` })
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
// Block until the target settles (specific id / next completed in arrival order).
|
|
86
|
-
for (;;) {
|
|
87
|
-
if (target) {
|
|
88
|
-
if (target.done) break
|
|
89
|
-
const woke = await wakeOnAsyncSettle(agent, ctx)
|
|
90
|
-
if (woke === "aborted") return JSON.stringify({ done: true, stopped: true })
|
|
91
|
-
continue
|
|
92
|
-
}
|
|
93
|
-
const completed = [...map.values()].filter((e) => e.done)
|
|
94
|
-
if (completed.length > 0) {
|
|
95
|
-
target = completed.sort((a, b) => (a._settleSeq ?? 0) - (b._settleSeq ?? 0))[0]
|
|
96
|
-
break
|
|
97
|
-
}
|
|
98
|
-
if (map.size === 0) return JSON.stringify({ done: true })
|
|
99
|
-
const woke = await wakeOnAsyncSettle(agent, ctx)
|
|
100
|
-
if (woke === "aborted") return JSON.stringify({ done: true, stopped: true })
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
map.delete(String(target.id))
|
|
104
|
-
if (target.error) return JSON.stringify({ id: String(target.id), status: "error", error: target.error })
|
|
105
|
-
return JSON.stringify({ id: String(target.id), role: target.role, status: "done", report: target.report ?? "" })
|
|
106
|
-
},
|
|
107
|
-
}
|
|
@@ -1,84 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* exec-prelude.mjs — sandbox API injected into `execute`'s child process.
|
|
3
|
-
*
|
|
4
|
-
* The `execute` tool spawns `node --input-type=module --eval` and `import()`-s
|
|
5
|
-
* this file first, so the user's code gets readFile/writeFile/glob/grep/log/require
|
|
6
|
-
* without hand-writing fs boilerplate. Confines file paths to the workspace root
|
|
7
|
-
* (THINCODER_EXEC_ROOT, default cwd) — an orthopedic guard, NOT a sandbox:
|
|
8
|
-
* require()/process/import()/fetch() are full Node, the same boundary as bash
|
|
9
|
-
* (project philosophy: no fake sandbox; transparency + audit).
|
|
10
|
-
*/
|
|
11
|
-
import { createRequire } from "node:module"
|
|
12
|
-
import { readFileSync, writeFileSync, existsSync, statSync, mkdirSync, readdirSync } from "node:fs"
|
|
13
|
-
import { resolve, relative, dirname, join, isAbsolute, sep } from "node:path"
|
|
14
|
-
|
|
15
|
-
const require = createRequire(join(process.cwd(), "__exec__.js"))
|
|
16
|
-
const root = process.env.THINCODER_EXEC_ROOT || process.cwd()
|
|
17
|
-
|
|
18
|
-
/** Resolve a path against the working dir, asserting it stays within the workspace root. */
|
|
19
|
-
function safe(p) {
|
|
20
|
-
if (typeof p !== "string") throw new Error(`Path must be a string, got ${typeof p}`)
|
|
21
|
-
const abs = resolve(process.cwd(), p)
|
|
22
|
-
const rel = relative(root, abs)
|
|
23
|
-
// isAbsolute(rel) covers cross-drive (relative() returns an absolute path then)
|
|
24
|
-
if (isAbsolute(rel) || rel === ".." || rel.startsWith(".." + sep)) {
|
|
25
|
-
throw new Error(`Path traversal denied: ${p}`)
|
|
26
|
-
}
|
|
27
|
-
return abs
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
function globToRegex(pattern) {
|
|
31
|
-
const DS = "\u0001", DP = "\u0002"
|
|
32
|
-
const escaped = pattern
|
|
33
|
-
.replace(/\*\*\//g, DS).replace(/\*\*/g, DP)
|
|
34
|
-
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
|
35
|
-
.replace(/\*/g, "[^/]*").replace(/\?/g, "[^/]")
|
|
36
|
-
.replace(new RegExp(DS, "g"), "(?:.+/)?").replace(new RegExp(DP, "g"), ".*")
|
|
37
|
-
return new RegExp(`^${escaped}$`)
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
globalThis.require = require
|
|
41
|
-
globalThis.readFile = (p) => {
|
|
42
|
-
const abs = safe(p)
|
|
43
|
-
if (!existsSync(abs)) throw new Error(`File not found: ${p}`)
|
|
44
|
-
if (statSync(abs).size > 5_000_000) throw new Error(`File too large: ${p}`)
|
|
45
|
-
return readFileSync(abs, "utf8").replace(/\r\n/g, "\n")
|
|
46
|
-
}
|
|
47
|
-
globalThis.writeFile = (p, content) => {
|
|
48
|
-
const abs = safe(p)
|
|
49
|
-
mkdirSync(dirname(abs), { recursive: true })
|
|
50
|
-
writeFileSync(abs, String(content), "utf8")
|
|
51
|
-
}
|
|
52
|
-
globalThis.glob = (pattern) => {
|
|
53
|
-
if (typeof pattern !== "string") throw new Error("glob pattern must be a string")
|
|
54
|
-
const re = globToRegex(pattern)
|
|
55
|
-
const out = []
|
|
56
|
-
function walk(dir, rel) {
|
|
57
|
-
let entries
|
|
58
|
-
try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return }
|
|
59
|
-
for (const e of entries) {
|
|
60
|
-
if (e.name.startsWith(".") || e.name === "node_modules") continue
|
|
61
|
-
const rp = rel ? `${rel}/${e.name}` : e.name
|
|
62
|
-
if (e.isDirectory()) walk(join(dir, e.name), rp)
|
|
63
|
-
else if (re.test(rp)) out.push(rp)
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
walk(process.cwd(), "")
|
|
67
|
-
const capped = out.slice(0, 200)
|
|
68
|
-
if (out.length > 200) capped.push(`... (${out.length - 200} more)`)
|
|
69
|
-
return capped
|
|
70
|
-
}
|
|
71
|
-
globalThis.grep = (pattern, file) => {
|
|
72
|
-
if (typeof pattern !== "string") throw new Error("grep pattern must be a string")
|
|
73
|
-
if (typeof file !== "string") throw new Error("grep file must be a string")
|
|
74
|
-
const abs = safe(file)
|
|
75
|
-
if (!existsSync(abs)) throw new Error(`File not found: ${file}`)
|
|
76
|
-
const re = new RegExp(pattern)
|
|
77
|
-
const lines = readFileSync(abs, "utf8").replace(/\r\n/g, "\n").split("\n")
|
|
78
|
-
const m = []
|
|
79
|
-
for (let i = 0; i < lines.length; i++) if (re.test(lines[i])) m.push(`${i + 1}: ${lines[i].slice(0, 200)}`)
|
|
80
|
-
const capped = m.slice(0, 100)
|
|
81
|
-
if (m.length > 100) capped.push(`... (${m.length - 100} more)`)
|
|
82
|
-
return capped
|
|
83
|
-
}
|
|
84
|
-
globalThis.log = (...a) => console.log(a.map((x) => (x && typeof x === "object" ? JSON.stringify(x) : String(x))).join(" "))
|