thincoder 0.12.29 → 0.12.31

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 CHANGED
@@ -21,6 +21,7 @@ Design philosophy (the entire meaning of the name): if the Node standard library
21
21
  - **Two-phase tool scheduling**: permission prompts serialized, read-only tools parallelized, side-effect tools serialized
22
22
  - **Session persistence** ⭐0.5.0: unlimited archive slots, `/session` to switch anytime, tool results visible after restore. Process-level isolation — multiple instances in the same directory each get their own session slot
23
23
  - **Concurrent subagents**: three roles — `explore`/`plan`/`coder` — dispatched in parallel, streaming output visible, reports land in the conversation; per-subagent model override (`subagent` tool `model` arg or `agent.subagentModel` config — e.g. discuss with `glm-5.2`, let `deepseek-v4-flash` implement)
24
+ - **Multi-model consultation + 飞刀 (escalate)** ⭐0.12.30: `consult_start`/`consult_check`/`consult_stop` run several configured models in parallel as independent read-only consultants (each with its own TUI activity card, `main_history` access to the failure trail); `escalate` flies in a stronger model for a single expert implementation run with full write access. Candidate pool = `agent.consultModels` ([{ provider, model, effort? }], up to 5); budgets via `agent.consultTurns` / `agent.consultTimeoutMs`
24
25
  - **Plan Mode**: read-only exploration + design, implement after user approval
25
26
  - **AUTO mode**: `/auto` full authorization, no confirmations on long tasks
26
27
  - **Task tracking**: `task` tool breaks down multi-step work, status bar ✓n/m live progress, auto-filters completed items
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thincoder",
3
- "version": "0.12.29",
3
+ "version": "0.12.31",
4
4
  "description": "Thin coding agent - zero dependencies, no build step, Node.js native. Sharp code, zero bloat.",
5
5
  "keywords": [
6
6
  "ai",
@@ -168,6 +168,17 @@ export async function prepareRun(agent, input, callbacks, {
168
168
  // task/plan tools are injected with the main loop; subagent/skill/goal/verify only at top level
169
169
  // eng-coder subagents get advisor for mandatory design review before coding
170
170
  const { planTool, subagentTool, taskTool, skillTool, goalTool, verifyTool, recentChangesTool, timerTool, advisorTool, engTool } = await import("../agent-tools.mjs")
171
+ const { consultStartTool, consultCheckTool, consultStopTool } = await import("../agent-tools/consult.mjs")
172
+ const { escalateTool } = await import("../agent-tools/escalate.mjs")
173
+ const { CONSULT_BASE } = await import("../agent.mjs")
174
+ // withPool: decorate consult_start/escalate descriptions with the CURRENT candidate pool
175
+ // so the model knows which models it can pick (CLI parity with the plugin).
176
+ const withPool = (tool) => {
177
+ const models = agent.config?.agent?.consultModels ?? []
178
+ const list = models.map((m) => `${m.provider}:${m.model}${m.effort ? ` (${m.effort})` : ""}`).join(", ")
179
+ if (!list) return tool
180
+ return { ...tool, description: tool.description + `\nCurrently configured consultants (this tool's pool): ${list}` }
181
+ }
171
182
  // Role enum is mutually exclusive: normal mode has "coder", engineering mode has "eng-coder"
172
183
  const subagentRoles = (depth === 0 && agent.config?.agent?.engineering)
173
184
  ? {
@@ -192,13 +203,19 @@ export async function prepareRun(agent, input, callbacks, {
192
203
  },
193
204
  } : subagentTool
194
205
 
195
- const depthOnly = depth === 0 ? [filteredSubagent, skillTool, goalTool, engTool, verifyTool, recentChangesTool, advisorTool]
196
- // Write-permission coder sub-agents (subagent role="coder" + escalate surgeon): the
197
- // system prompt names verify (system.md) and advisor (discipline.md) — without them a
198
- // surgeon hit "unknown tool" and fell back to bash node --check / npm test to
199
- // self-verify (2026-08-16 deepseek surgeon diagnosis; plugin parity).
206
+ // Consult/escalate tools registered only when configured an unconfigured pool would
207
+ // otherwise make the model call them and eat an error turn (plugin parity).
208
+ const consultTools = (agent.config?.agent?.consultModels ?? []).length
209
+ ? [withPool(consultStartTool), consultCheckTool, consultStopTool, withPool(escalateTool)]
210
+ : []
211
+ const depthOnly = depth === 0 ? [filteredSubagent, skillTool, goalTool, engTool, verifyTool, recentChangesTool, advisorTool, ...consultTools]
212
+ // Write-permission coder sub-agents (subagent role="coder" + escalate): the
213
+ // system prompt names verify (system.md) and advisor (discipline.md) — without them an
214
+ // escalate hit "unknown tool" and fell back to bash node --check / npm test to
215
+ // self-verify (2026-08-16 deepseek escalate diagnosis; plugin parity).
200
216
  : agent._role === "eng-coder" ? [advisorTool, verifyTool]
201
217
  : agent._role === "coder" ? [verifyTool, advisorTool]
218
+ : agent._role === "consult" ? [recentChangesTool]
202
219
  : []
203
220
  const tools = [...agent.tools, taskTool, planTool, timerTool, ...depthOnly]
204
221
  const toolSchemas = tools.map(toOpenAISchema)
@@ -208,7 +225,12 @@ export async function prepareRun(agent, input, callbacks, {
208
225
  // system prompt
209
226
  const needsDiscipline = depth === 0 || agent._role === "coder" || agent._role === "eng-coder"
210
227
  let base
211
- if ((depth === 0 || agent._role === "eng-coder") && agent.config?.agent?.engineering) {
228
+ if (agent._role === "consult") {
229
+ // consult children: a lean, purpose-built base prompt (consult-base.md) — NOT the full
230
+ // main-agent system.md (whose coding-agent persona, checklist/task/verify workflows and
231
+ // tool references conflict with a read-only diagnosis and cost tokens every turn).
232
+ base = CONSULT_BASE
233
+ } else if ((depth === 0 || agent._role === "eng-coder") && agent.config?.agent?.engineering) {
212
234
  // Engineering mode: strict methodology, NO standard discipline injection.
213
235
  // Falling back to standard discipline on METHODOLOGY.md absence would leak
214
236
  // advisor enforcement into engineering mode — the two prompt sets stay separate.
@@ -0,0 +1,288 @@
1
+ /**
2
+ * consult.mjs — multi-model consultation ("会诊", docs/design/CONSULTATION.md). CLI port.
3
+ *
4
+ * Three tools: consult_start (non-blocking spawn) / consult_check (read the next
5
+ * reply as it arrives) / consult_stop (abort the rest). The mechanism does ZERO
6
+ * judging — the main agent reads replies and verifies with its own tools.
7
+ *
8
+ * CLI adaptation (vs the VS Code plugin): the child runner is CLI's runAgent
9
+ * (runAgent(child, input, callbacks, opts) — an agent object, not provider+cwd);
10
+ * children are built with createAgent({ role: "consult", readonly tools,
11
+ * CONSULT_BASE overlay }); activity streams to the parent TUI via the relay
12
+ * prefix `consult#<id>/` (same channel subagent uses), not onSubagent/onToolPanel.
13
+ */
14
+ import { createAgent, runAgent, readonlyToolNames } from "../agent.mjs"
15
+ import { resolveChildProvider } from "./subagent.mjs"
16
+
17
+ function consultLabel(m) {
18
+ return `${m.provider}:${m.model}`
19
+ }
20
+
21
+ /** Read-only tool injected into consultation children (via createAgent's tools).
22
+ * Lets the consultant pull the main agent's conversation history on demand —
23
+ * the failure trail is first-class evidence, not a retelling. */
24
+ export function makeMainHistoryTool(parentAgent) {
25
+ return {
26
+ name: "main_history",
27
+ readonly: true,
28
+ description:
29
+ "Read the main agent's conversation history — what has been tried, the exact errors, recent context. " +
30
+ "Use it to ground your analysis in the actual failure trail instead of guessing.\n" +
31
+ "Parameters:\n" +
32
+ "- limit: Number of recent messages to return (default 20, max 100)",
33
+ parameters: {
34
+ type: "object",
35
+ properties: { limit: { type: "number", description: "Recent messages (default 20, max 100)" } },
36
+ },
37
+ async execute({ limit }) {
38
+ const n = Math.min(Math.max(limit ?? 20, 1), 100)
39
+ const h = parentAgent?.history ?? []
40
+ const slice = h.slice(-n)
41
+ if (slice.length === 0) return "(empty history)"
42
+ const render = (m) => {
43
+ let content
44
+ if (typeof m.content === "string") content = m.content
45
+ else if (Array.isArray(m.content)) {
46
+ content = m.content.map((part) => {
47
+ if (part?.type === "image_url" || part?.type === "image") return "[image omitted]"
48
+ if (part?.type === "text") return part.text ?? ""
49
+ return JSON.stringify(part)
50
+ }).join("\n")
51
+ } else content = m.content == null ? "" : JSON.stringify(m.content)
52
+ const calls = Array.isArray(m.tool_calls)
53
+ ? m.tool_calls.map((c) => `[tool: ${c.function?.name ?? c.name}(${String(c.function?.arguments ?? c.args ?? "").slice(0, 200)})]`).join("\n")
54
+ : ""
55
+ return `--- [${m.role}] ---\n${content}${calls ? "\n" + calls : ""}`
56
+ }
57
+ const BUDGET = 60_000
58
+ let out = ""
59
+ for (let i = slice.length - 1; i >= 0; i--) {
60
+ const line = render(slice[i])
61
+ if (out.length + line.length > BUDGET) { out = `(earlier messages trimmed — budget ${BUDGET} chars)\n\n` + out; break }
62
+ out = out ? line + "\n\n" + out : line
63
+ }
64
+ return out
65
+ },
66
+ }
67
+ }
68
+
69
+ /** Wake every parked consult_check waiter. */
70
+ function wakeWaiters(session) {
71
+ const w = session.waiters.splice(0)
72
+ for (const resolve of w) { try { resolve(false) } catch { /* noop */ } }
73
+ }
74
+
75
+ function settleChild(session, id, label, ok, payload) {
76
+ if (ok) {
77
+ session.received++
78
+ session.replies.push({ model: label, reply: payload })
79
+ } else if (session.stopped) {
80
+ session.terminated = (session.terminated ?? 0) + 1
81
+ } else {
82
+ session.failed++
83
+ session.replies.push({ model: label, reply: `(consultation failed: ${payload})`, failed: true })
84
+ }
85
+ session.pending--
86
+ wakeWaiters(session)
87
+ }
88
+
89
+ async function runConsultChild(ctx, session, id, m, problem, ctrl) {
90
+ const agent = ctx.agent
91
+ const timeoutMs = agent?.config?.agent?.consultTimeoutMs ?? 600_000
92
+ let timedOut = false
93
+ const watchdog = setTimeout(() => {
94
+ timedOut = true
95
+ try { ctrl.abort() } catch { /* already settled */ }
96
+ }, timeoutMs)
97
+ const label = consultLabel(m)
98
+ try {
99
+ // Provider resolution: consultModels entries are { provider, model, effort? } — resolve
100
+ // via the subagent's provider resolver ("provider:model" handles cross-provider picks).
101
+ const provider = resolveChildProvider(agent, `${m.provider}:${m.model}`)
102
+ if (!provider?.apiKey?.trim() && !process.env.THINCODER_API_KEY) {
103
+ // resolveChildProvider may still lack a key; fail loudly like the plugin precheck
104
+ }
105
+ if (m.effort) provider.reasoningEffort = m.effort
106
+
107
+ // Read-only consultant: filter the parent tool set down to readonly tools + main_history.
108
+ const allowed = readonlyToolNames(agent.tools ?? [])
109
+ const tools = [
110
+ ...(agent.tools ?? []).filter((t) => allowed.has(t.name)),
111
+ makeMainHistoryTool(agent),
112
+ ]
113
+
114
+ const child = createAgent({
115
+ provider,
116
+ tools,
117
+ config: agent.config,
118
+ cwd: agent.cwd,
119
+ memory: agent.memory,
120
+ // No overlay: setup.mjs already selects CONSULT_BASE as the base prompt for
121
+ // role "consult" (overlay + base would concatenate it twice).
122
+ role: "consult",
123
+ })
124
+
125
+ // Activity relay: `consult#<subId>/` prefix → the parent TUI's subTasks panel
126
+ // (same channel subagent uses — parallel consultants stay independent).
127
+ agent._subAgentCounter = (agent._subAgentCounter ?? 0) + 1
128
+ const subId = agent._subAgentCounter
129
+ const relayPrefix = `consult#${subId}/`
130
+ const childCallbacks = {
131
+ onToken: ctx.callbacks?.onToken ? (t) => ctx.callbacks.onToken(`${relayPrefix}${t}`) : null,
132
+ onReasoning: ctx.callbacks?.onReasoning ? (r) => ctx.callbacks.onReasoning(`${relayPrefix}${r}`) : null,
133
+ onToolCall: ctx.callbacks?.onToolCall ? (name, args) => ctx.callbacks.onToolCall(`${relayPrefix}${name}`, args) : null,
134
+ }
135
+
136
+ const runner = ctx.runAgent ?? runAgent
137
+ const result = await runner(child, "# Problem\n" + problem, childCallbacks, {
138
+ depth: 1,
139
+ maxTurns: agent?.config?.agent?.consultTurns ?? 40,
140
+ signal: ctrl.signal,
141
+ })
142
+ settleChild(session, id, label, true, String(result ?? ""))
143
+ } catch (e) {
144
+ const note = timedOut ? `consultation timed out after ${Math.round(timeoutMs / 60000)}min (agent.consultTimeoutMs)` : e?.message ?? String(e)
145
+ settleChild(session, id, label, false, note)
146
+ } finally {
147
+ clearTimeout(watchdog)
148
+ }
149
+ }
150
+
151
+ /** Turn-end cleanup (called from runAgent's finally): abort every leftover
152
+ * consultation controller, wake parked waiters, clear the session map. */
153
+ export function cleanupConsultSessions(agent) {
154
+ for (const s of agent._consultSessions?.values() ?? []) {
155
+ s.stopped = true
156
+ for (const c of s.controllers ?? []) { try { c.abort() } catch { /* already settled */ } }
157
+ for (const w of s.waiters?.splice(0) ?? []) { try { w() } catch { /* noop */ } }
158
+ }
159
+ agent._consultSessions?.clear()
160
+ }
161
+
162
+ export const consultStartTool = {
163
+ name: "consult_start",
164
+ readonly: false,
165
+ sideEffectExempt: true,
166
+ description:
167
+ "Start a parallel multi-model consultation (会诊) for a hard problem you are stuck on (repeated failures, no headway). " +
168
+ "Several configured models (agent.consultModels) analyze the same problem INDEPENDENTLY and in parallel. " +
169
+ "Non-blocking: returns immediately with a consult id. Then call consult_check(id) to read each reply as it " +
170
+ "arrives, judge/verify it yourself with your own tools, and call consult_stop(id) once a reply is good enough.\n" +
171
+ "Parameters:\n" +
172
+ "- problem (required): a brief — the symptom, what you already tried (failure trail), and entry-point files. " +
173
+ "Do NOT paste raw error logs; consultants pull the main session history themselves via their main_history tool.",
174
+ parameters: {
175
+ type: "object",
176
+ properties: { problem: { type: "string", description: "Problem brief (symptom + failure trail + entry files)" } },
177
+ required: ["problem"],
178
+ },
179
+ async execute({ problem }, ctx) {
180
+ if (typeof problem !== "string" || !problem.trim()) return "Error: problem is required and must be a non-empty string"
181
+ const agent = ctx.agent
182
+ if (!agent) return "Error: consult requires an agent context"
183
+ const models = agent.config?.agent?.consultModels ?? []
184
+ if (!Array.isArray(models) || models.length === 0)
185
+ return "Consultation is not configured — add agent.consultModels ([{ provider, model }], up to 5) to ~/.thincoder/config.json"
186
+ if (models.length > 5) return `Error: consultModels supports at most 5 models (got ${models.length})`
187
+
188
+ agent._consultSessions ??= new Map()
189
+ const id = String((agent._consultIdCounter = (agent._consultIdCounter ?? 0) + 1))
190
+ const session = {
191
+ id, controllers: [], replies: [], pending: 0, waiters: [],
192
+ failed: 0, terminated: 0, stopped: false, received: 0, total: models.length,
193
+ models: models.map(consultLabel),
194
+ }
195
+ agent._consultSessions.set(id, session)
196
+
197
+ for (const m of models) {
198
+ session.pending++
199
+ const ctrl = new AbortController()
200
+ session.controllers.push(ctrl)
201
+ if (ctx.signal) {
202
+ if (ctx.signal.aborted) ctrl.abort()
203
+ else ctx.signal.addEventListener("abort", () => ctrl.abort(), { once: true })
204
+ }
205
+ // Fire and forget — each child settles itself into the session queue.
206
+ runConsultChild(ctx, session, id, m, problem, ctrl)
207
+ }
208
+ return JSON.stringify({ id, models: session.models })
209
+ },
210
+ }
211
+
212
+ export const consultCheckTool = {
213
+ name: "consult_check",
214
+ readonly: true,
215
+ description:
216
+ "Read the NEXT consultation reply (whichever model answered first). Blocks until a reply arrives or all models " +
217
+ "have settled. The reply is raw and unjudged — verify/adopt it with your own tools. When done is true, no more " +
218
+ "replies are coming.\n" +
219
+ "Call it ALONE in a turn — do NOT batch it with calls that depend on its reply (readonly tools run in parallel).\n" +
220
+ "Parameters:\n" +
221
+ "- id (required): the consult id from consult_start",
222
+ parameters: {
223
+ type: "object",
224
+ properties: { id: { type: "string", description: "Consult id" } },
225
+ required: ["id"],
226
+ },
227
+ async execute({ id }, ctx) {
228
+ const s = ctx.agent?._consultSessions?.get(String(id))
229
+ if (!s) return JSON.stringify({ error: "unknown consult id" })
230
+ const abortAll = () => { for (const c of s.controllers) { try { c.abort() } catch { /* noop */ } } }
231
+ if (ctx.signal?.aborted) abortAll()
232
+
233
+ for (;;) {
234
+ if (s.replies.length > 0) {
235
+ const r = s.replies.shift()
236
+ return JSON.stringify({
237
+ reply: r.reply, model: r.model, failedReply: r.failed === true,
238
+ received: s.received,
239
+ failed: s.failed,
240
+ terminated: s.terminated ?? 0, total: s.total,
241
+ done: s.replies.length === 0 && s.pending === 0,
242
+ })
243
+ }
244
+ if (s.pending === 0) {
245
+ return JSON.stringify({ done: true, received: s.received, failed: s.failed, total: s.total })
246
+ }
247
+ const stopped = await new Promise((resolve) => {
248
+ function cleanup() {
249
+ const i = s.waiters.indexOf(w)
250
+ if (i >= 0) s.waiters.splice(i, 1)
251
+ ctx.signal?.removeEventListener("abort", onAbort)
252
+ }
253
+ function w() { cleanup(); resolve(false) }
254
+ function onAbort() { cleanup(); abortAll(); resolve(true) }
255
+ s.waiters.push(w)
256
+ if (ctx.signal) {
257
+ if (ctx.signal.aborted) { onAbort(); return }
258
+ ctx.signal.addEventListener("abort", onAbort, { once: true })
259
+ }
260
+ })
261
+ if (stopped) return JSON.stringify({ done: true, stopped: true, received: s.received, failed: s.failed, total: s.total })
262
+ }
263
+ },
264
+ }
265
+
266
+ export const consultStopTool = {
267
+ name: "consult_stop",
268
+ readonly: false,
269
+ sideEffectExempt: true,
270
+ description:
271
+ "Terminate the still-running consultations of a session once a reply is good enough — saves tokens and time. " +
272
+ "Already-answered replies stay available for consult_check.\n" +
273
+ "Parameters:\n" +
274
+ "- id (required): the consult id from consult_start",
275
+ parameters: {
276
+ type: "object",
277
+ properties: { id: { type: "string", description: "Consult id" } },
278
+ required: ["id"],
279
+ },
280
+ async execute({ id }, ctx) {
281
+ const s = ctx.agent?._consultSessions?.get(String(id))
282
+ if (!s) return JSON.stringify({ error: "unknown consult id" })
283
+ const n = s.pending
284
+ s.stopped = true
285
+ for (const c of s.controllers) { try { c.abort() } catch { /* already settled */ } }
286
+ return JSON.stringify({ stopped: n })
287
+ },
288
+ }
@@ -0,0 +1,152 @@
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, ContinueError, CODER_OVERLAY } from "../agent.mjs"
20
+ import { resolveChildProvider, mergeChildMutations } from "./subagent.mjs"
21
+
22
+ const label = (m) => `${m.provider}:${m.model}`
23
+
24
+ export const escalateTool = {
25
+ name: "escalate",
26
+ sideEffectExempt: true, // the child's mutations are tracked and reviewed, like subagent
27
+ description:
28
+ "TERMINOLOGY (one word for one thing): 'escalate' is the ONLY name — the tool, and the " +
29
+ "role of the expert sub-agent it spawns, are both called 'escalate'; 飞刀 is the Chinese " +
30
+ "alias. When the user says 飞刀 / escalate / 'fly in <model>', call THIS tool directly — " +
31
+ "never via a script importing this module. " +
32
+ "Hand an implementation task to a stronger model (飞刀 — a flown-in expert). " +
33
+ "It gets WRITE access and does the work itself — reads, edits, runs tests — then returns " +
34
+ "a post-op report (what changed, why, verification). You review the report and report to " +
35
+ "the user. Use it when YOU judge the task calls for stronger hands (complex multi-file " +
36
+ "refactoring, an intractable bug, intricate algorithm work — or work beyond your " +
37
+ "comfortable ability). Early or late, your judgment; the cost is one expert run, " +
38
+ "comparable to doing it yourself. For parallel READ-ONLY opinions use consult_start instead. " +
39
+ "Not available in engineering mode (implementation goes through eng-coder subagents there).\n" +
40
+ "Parameters:\n" +
41
+ "- task (required): the task description — goal, constraints, entry files, acceptance criteria\n" +
42
+ "- model (optional): pick a specific consultant as 'provider:model'; default = the first consult model",
43
+ parameters: {
44
+ type: "object",
45
+ properties: {
46
+ task: { type: "string", description: "Task description with acceptance criteria" },
47
+ model: { type: "string", description: "Candidate 'provider:model' from the consult models (optional)" },
48
+ },
49
+ required: ["task"],
50
+ },
51
+ async execute({ task, model }, ctx) {
52
+ const parent = ctx.agent
53
+ if ((ctx.depth ?? 0) > 0) return "Error: escalate is only available at depth 0 (an escalate's work cannot be delegated again)"
54
+ if (parent?.config?.agent?.engineering) {
55
+ 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."
56
+ }
57
+ const pool = parent?.config?.agent?.consultModels ?? []
58
+ if (pool.length === 0) return "Error: no escalate candidates — configure at least one consult model (agent.consultModels)"
59
+
60
+ const wanted = typeof model === "string" ? model.replace(/\s+\([^)]*\)\s*$/, "").trim() : model
61
+ const pick = wanted ? pool.find((m) => label(m) === wanted) : pool[0]
62
+ if (!pick) {
63
+ return `Error: "${model}" is not a consult candidate. Available: ${pool.map(label).join(", ")}`
64
+ }
65
+
66
+ let provider
67
+ try {
68
+ provider = resolveChildProvider(parent, `${pick.provider}:${pick.model}`)
69
+ } catch (e) {
70
+ return `Error: ${e.message}`
71
+ }
72
+ if (!provider?.apiKey?.trim() && !process.env.THINCODER_API_KEY) {
73
+ return `Error: provider "${pick.provider}" has no API key — set it in config.json (or THINCODER_API_KEY) before flying it in`
74
+ }
75
+ if (pick.effort) provider.reasoningEffort = pick.effort
76
+
77
+ parent._subAgentCounter = (parent._subAgentCounter ?? 0) + 1
78
+ const subId = parent._subAgentCounter
79
+ const tag = label(pick)
80
+ const relayPrefix = `escalate#${subId}/`
81
+
82
+ const timeoutMs = parent?.config?.agent?.consultTimeoutMs ?? 600_000
83
+ let timedOut = false
84
+ const ctrl = new AbortController()
85
+ const watchdog = setTimeout(() => {
86
+ timedOut = true
87
+ try { ctrl.abort() } catch { /* already settled */ }
88
+ }, timeoutMs)
89
+ if (ctx.signal) {
90
+ if (ctx.signal.aborted) ctrl.abort()
91
+ else ctx.signal.addEventListener("abort", () => ctrl.abort(), { once: true })
92
+ }
93
+
94
+ let output = ""
95
+ const childCallbacks = {
96
+ onToken: ctx.callbacks?.onToken ? (t) => { output += t; ctx.callbacks.onToken(`${relayPrefix}${t}`) } : (t) => { output += t },
97
+ onReasoning: ctx.callbacks?.onReasoning ? (r) => ctx.callbacks.onReasoning(`${relayPrefix}${r}`) : null,
98
+ onToolCall: ctx.callbacks?.onToolCall ? (name, args) => ctx.callbacks.onToolCall(`${relayPrefix}${name}`, args) : null,
99
+ }
100
+
101
+ // Declared outside try so the catch can merge mutations even on a partial failure.
102
+ let child = null
103
+ try {
104
+ // Full write path (role "coder"): permission gate via the parent's onPermissionRequest,
105
+ // recent-changes tracking, mutations merge into the parent below.
106
+ child = createAgent({
107
+ provider,
108
+ tools: parent.tools,
109
+ config: parent.config,
110
+ cwd: parent.cwd,
111
+ memory: parent.memory,
112
+ overlay: CODER_OVERLAY,
113
+ role: "coder",
114
+ })
115
+ const runner = ctx.runAgent ?? runAgent
116
+ const report = await runner(child, task, {
117
+ ...childCallbacks,
118
+ onPermissionRequest: ctx.onPermissionRequest ?? null,
119
+ }, {
120
+ depth: 1,
121
+ maxTurns: parent.config?.agent?.subagentTurns ?? 100,
122
+ signal: ctrl.signal,
123
+ })
124
+ // Escalate mutations are the parent's mutations: verify/advisor guards must see them
125
+ mergeChildMutations(parent, child)
126
+ return `escalate (${tag}) post-op report:\n${report || output.slice(0, 4000)}${touchedFilesNote(child, parent.cwd)}`
127
+ } catch (e) {
128
+ // Even a failed surgery may have written files — merge whatever the child touched.
129
+ if (child) mergeChildMutations(parent, child)
130
+ const msg = e?.message ?? String(e)
131
+ if (ctx.signal?.aborted || (!timedOut && e?.name === "AbortError")) throw e
132
+ if (e instanceof ContinueError) {
133
+ return `escalate (${tag}) stopped: turn cap reached (${e.turns} turns) — work may be partial; review recent_changes before deciding next steps.\nPartial output: ${output.slice(0, 2000)}`
134
+ }
135
+ const note = timedOut ? `timed out after ${Math.round(timeoutMs / 60000)}min (agent.consultTimeoutMs)` : msg
136
+ return `escalate (${tag}) error: ${note}\nPartial output: ${output.slice(0, 2000)}`
137
+ } finally {
138
+ clearTimeout(watchdog)
139
+ }
140
+ },
141
+ }
142
+
143
+ /** Relative touched-file list appended to every return (child paths are absolute). */
144
+ function touchedFilesNote(child, cwd) {
145
+ const touched = child?._touchedFiles ?? []
146
+ if (touched.length === 0) return ""
147
+ const shown = touched.map((f) => {
148
+ const r = relative(cwd ?? process.cwd(), f)
149
+ return r && !r.startsWith("..") && !isAbsolute(r) ? r : f
150
+ })
151
+ return `\nTouched files: ${shown.join(", ")}`
152
+ }
package/src/agent.mjs CHANGED
@@ -13,6 +13,7 @@ import { executeToolCalls } from "./agent/dispatch.mjs"
13
13
  import { prepareRun } from "./agent/setup.mjs"
14
14
  import { injectPostTurn, STALL_WINDOW_SIZE, STALL_THRESHOLD, GOAL_BUDGET_WARN_RATIO } from "./agent/post-turn.mjs"
15
15
  import { handleCompletion } from "./agent/completion.mjs"
16
+ import { cleanupConsultSessions } from "./agent-tools/consult.mjs"
16
17
  import {
17
18
  escapeXml, tryCanonicalize, repairHistory, listWorkDir,
18
19
  readonlyToolNames, collectGitContext, loadProjectInstructions,
@@ -26,15 +27,17 @@ const __dirname = dirname(fileURLToPath(import.meta.url))
26
27
  const SYSTEM_PROMPT = readFileSync(join(__dirname, "prompts", "system.md"), "utf8")
27
28
  const DISCIPLINE_RULES = readFileSync(join(__dirname, "prompts", "discipline.md"), "utf8")
28
29
  const MAIN_OVERLAY = readFileSync(join(__dirname, "prompts", "main.md"), "utf8")
29
- let _EXPLORE, _CODER, _PLAN, _ENG_CODER
30
+ let _EXPLORE, _CODER, _PLAN, _ENG_CODER, _CONSULT_BASE
30
31
  try { _EXPLORE = readFileSync(join(__dirname, "prompts", "explore.md"), "utf8") } catch { _EXPLORE = "" }
31
32
  try { _CODER = readFileSync(join(__dirname, "prompts", "coder.md"), "utf8") } catch { _CODER = "" }
32
33
  try { _PLAN = readFileSync(join(__dirname, "prompts", "plan.md"), "utf8") } catch { _PLAN = "" }
33
34
  try { _ENG_CODER = readFileSync(join(__dirname, "prompts", "eng-coder.md"), "utf8") } catch { _ENG_CODER = "" }
35
+ try { _CONSULT_BASE = readFileSync(join(__dirname, "prompts", "consult-base.md"), "utf8") } catch { _CONSULT_BASE = "" }
34
36
  export const EXPLORE_OVERLAY = _EXPLORE
35
37
  export const CODER_OVERLAY = _CODER
36
38
  export const PLAN_OVERLAY = _PLAN
37
39
  export const ENG_CODER_OVERLAY = _ENG_CODER
40
+ export const CONSULT_BASE = _CONSULT_BASE
38
41
 
39
42
  // exported for consumption by agent-tools.mjs
40
43
  export {
@@ -142,7 +145,8 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
142
145
  tools: toolSchemas,
143
146
  }
144
147
 
145
- for (let turn = 0; turn < maxTurns; turn++) {
148
+ try {
149
+ for (let turn = 0; turn < maxTurns; turn++) {
146
150
  // Update turn counter for status bar display
147
151
  agent._currentTurn = turn + 1
148
152
  agent._maxTurns = maxTurns
@@ -439,7 +443,12 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
439
443
  }
440
444
 
441
445
  injectPostTurn(agent, results, recentCallSigs, callbacks, turn)
442
- }
446
+ }
443
447
 
444
- throw new ContinueError(maxTurns)
448
+ throw new ContinueError(maxTurns)
449
+ } finally {
450
+ // Turn-end cleanup: abort any leftover consultation children (consult_start spawns
451
+ // fire-and-forget runners; a completed turn must not let them keep burning tokens).
452
+ cleanupConsultSessions(agent)
453
+ }
445
454
  }
package/src/config.mjs CHANGED
@@ -49,6 +49,11 @@ const DEFAULTS = {
49
49
  goalTurns: 200,
50
50
  compactThreshold: 100000,
51
51
  verifyGuard: false, // push model back to verify when files were mutated but verify not run (opt-in)
52
+ // Multi-model consultation ("会诊") + escalate ("飞刀") — CLI parity with the VS Code plugin.
53
+ // consultModels: candidate pool for BOTH consult and escalate ({ provider, model, effort? }, up to 5).
54
+ consultModels: [],
55
+ consultTurns: 40, // per-consultant tool-turn budget (diagnosis tasks)
56
+ consultTimeoutMs: 600000, // wall-clock ceiling per consultant (10min)
52
57
  streamRules: [], // time-traveling stream rules: [{ pattern: "regex", message: "reminder", action: "abort"|"warn", repeat: "always"|"once" }]
53
58
  advisor: { enabled: false }, // code review; { enabled: true, provider: "deepseek", model: "deepseek-chat", thinking: { type: "enabled" }, reasoningEffort: "max", guard: true }
54
59
  autoThink: false, // auto-classify task difficulty and set reasoning effort per-turn
@@ -234,6 +239,15 @@ export function loadConfig() {
234
239
  embedding: { ...DEFAULTS.embedding, ...config.embedding },
235
240
  }
236
241
 
242
+ // Consult/escalate pool validation (CLI parity with the plugin): up to 5 candidates.
243
+ const cm = merged.agent.consultModels
244
+ if (cm !== undefined && !Array.isArray(cm)) {
245
+ throw new Error(`agent.consultModels must be an array of { provider, model } entries (got ${typeof cm})`)
246
+ }
247
+ if (Array.isArray(cm) && cm.length > 5) {
248
+ throw new Error(`agent.consultModels supports at most 5 models (got ${cm.length})`)
249
+ }
250
+
237
251
  // Backward compatibility: promote root-level config fields to agent sub-object
238
252
  if (config.verifyGuard !== undefined) {
239
253
  merged.agent.verifyGuard = config.verifyGuard
@@ -5,7 +5,11 @@ You have a budget of 30 tool rounds (chat turns) — plan your exploration accor
5
5
 
6
6
  Review workflow:
7
7
  1. The files to review are listed in the review scope. Read them in full. The review scope defines exactly which files to inspect.
8
- 2. **READ THE PROJECT GUIDE FIRST** — the `## Project Guide (AGENTS.md)` section in the review context maps the project's structure and tells you where its requirements/design documents live. Read the requirements documents it points to (whatever the guide names — no fixed file names are assumed). **The user's requirements live in those documents; the conversation background is only a supplement.** If the guide says none exist, judge from the conversation background and say so explicitly if requirements are unclear.
8
+ 2. **READ THE PROJECT GUIDE FIRST** — the `## Project Guide (AGENTS.md)` section in the review context maps the project's structure.
9
+ - It tells you where the requirements/design documents live.
10
+ - Read whatever documents the guide names — no fixed file names are assumed.
11
+ - **The user's requirements live in those documents; the conversation background is only a supplement.**
12
+ - If the guide names none, judge from the conversation background and say so explicitly if requirements are unclear.
9
13
  3. Read the specified files for full context. **Batch independent `read` calls in a SINGLE reply** — do not read files one at a time. Each round-trip counts against your limit.
10
14
  4. Use grep or lsp to trace callers, imports, and dependencies — only where genuinely needed.
11
15
  5. Produce your review table.
@@ -17,11 +21,18 @@ Budget rules:
17
21
  - **Batch everything**: multiple `read` calls in one reply, multiple `grep` calls in one reply. Serializing tool calls wastes your round budget.
18
22
 
19
23
  Rules:
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.
24
+ - First judge the task from the conversation background.
25
+ - If the changes are clearly non-code (static docs, README, CHANGELOG), reply immediately with the all-clear phrase — `"All clear — no code changes to review."` — and do NOT spend tool calls exploring.
26
+ - The host recognizes it via the "all clear" / "no 🔴" / "review passed" / "no issues found" markers, matched case-insensitively.
27
+ - Prompts and configs that shape behaviour are NOT exempt — review them normally.
21
28
  - **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
29
  - (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**: the requirements documents named by the Project Guide (AGENTS.md) and explicit user expectations 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. **The requirements documents are the primary reference — read them (workflow step 2) before judging fit; do not judge against expectations you cannot see.**
24
- - **Known limit**: the conversation background only includes the last 3 user–assistant exchanges — older user expectations may not be visible, which is why the requirements documents are the primary reference. (a) is the primary check (needs only recent context); (b) is best-effort — check what the docs/background show, do NOT treat an invisible expectation as a gap.
30
+ - (b) **Expectation vs shape**: the requirements documents named by the Project Guide (AGENTS.md) and explicit user expectations vs the delivered shape.
31
+ - "asked for A, got B" is a gap (e.g. "the record must keep the real order" vs a summary appended at the end).
32
+ - **The requirements documents are the primary reference — read them (workflow step 2) before judging fit. Do not judge against expectations you cannot see.**
33
+ - **Known limit**: the conversation background only includes the last 3 user–assistant exchanges — older user expectations may not be visible, which is why the requirements documents are the primary reference.
34
+ - (a) is the primary check (needs only recent context).
35
+ - (b) is best-effort — check what the docs/background show, do NOT treat an invisible expectation as a gap.
25
36
  - **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.
26
37
  - Reply in the same language as the conversation background.
27
38
  - Respect the project's stated platform requirements — do not flag features as errors if they are valid under the project's target environment.
@@ -8,7 +8,10 @@ Review workflow:
8
8
  1. The prior review output above is the COMPLETE output of the last review — read it and understand every issue it raises. The affected files are named in it — read them in full. The prior review output 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
10
  3. Project conventions were established in round 1 — do NOT re-read AGENTS.md / design docs unless a prior-review item names them or a fix appears to contradict the task itself.
11
- 4. **ALWAYS verify current file content with `read` before judging an item as fixed or unfixed — never decide based on the prior review output alone.** Fixes may already be committed — `read` the files named there 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.
11
+ 4. **ALWAYS `read` the current file before judging an item fixed or unfixed.**
12
+ - Never decide from the prior review output alone — fixes may already be committed.
13
+ - (You have NO git tool this round; any git output in earlier messages is historical and untrustworthy.)
14
+ - Batch independent tool calls in one reply.
12
15
  5. Use grep or lsp to trace callers, imports, and dependencies — only where genuinely needed.
13
16
  6. Produce your review table.
14
17
 
@@ -7,7 +7,10 @@ Review workflow:
7
7
  1. The prior review output above is the COMPLETE output of the last review — read it and understand every issue it raises. The affected files are named in it — read them in full. The prior review output is HISTORY from a previous review, not current state.
8
8
  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.
9
9
  3. Project conventions were established in round 1 — do NOT re-read AGENTS.md / design docs unless a prior-review item names them.
10
- 4. **ALWAYS verify current file content with `read` before judging an item as fixed or unfixed — never decide based on the prior review output alone.** Fixes may already be committed — `read` the files named there 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.
10
+ 4. **ALWAYS `read` the current file before judging an item fixed or unfixed.**
11
+ - Never decide from the prior review output alone — fixes may already be committed.
12
+ - (You have NO git tool this round; any git output in earlier messages is historical and untrustworthy.)
13
+ - Batch independent tool calls in one reply.
11
14
  5. Use grep or lsp to trace callers, imports, and dependencies — only where genuinely needed.
12
15
  6. Produce your review table.
13
16
 
@@ -0,0 +1,23 @@
1
+ You are one of several independent expert consultants analyzing the same problem in parallel — each on a different model. Your value is a perspective the main agent may be missing.
2
+
3
+ **Language:** reply in the user's language; keep code, commands, identifiers, file paths, and technical terms in their original form.
4
+
5
+ **Rules:**
6
+ - You are READ-ONLY: analyze and recommend, never modify files. The main agent implements.
7
+ - You have a `main_history` tool — pull the main agent's conversation history (what was tried, exact errors) BEFORE theorizing. Ground your analysis in the actual failure trail.
8
+ - Do not wait for or coordinate with the other consultants; they cannot see you.
9
+ - Work within your budget (~40 tool turns, up to ~10 minutes wall-clock): pull main_history first, read the 2–5 entry-point files it points at, and STOP. Reading targeted files is the expected behavior; full-repo scans are over budget — but do NOT skip reading entirely and theorize from the brief alone.
10
+ - Brief paths can be wrong (missing a directory prefix, renamed files) — verify with glob/ls before concluding a file "does not exist".
11
+ - Prefer local files first; use web search only when the question needs external facts (an API's current behavior, an upstream doc) — never to rediscover what is in the repo.
12
+ - Be concrete: root cause first, then a specific, actionable fix. If verification is possible, state exactly how the main agent can verify your recommendation (commands, files to check, expected outcome).
13
+ - Be honest: do not fabricate file contents or line numbers you did not actually read.
14
+
15
+ Structure your final answer as:
16
+ ## Diagnosis
17
+ (root cause analysis)
18
+ ## Recommendation
19
+ (the concrete fix)
20
+ ## Verification
21
+ (how to prove it — commands / files / expected outcome; omit only if the question is purely conceptual)
22
+
23
+ Keep the whole answer concise — it is pasted verbatim into the main agent's context, so ~500 words is ideal; no filler.
@@ -11,5 +11,10 @@ Debugging strategy:
11
11
  - Fix one thing at a time. Don't change multiple things at once.
12
12
  - Don't get stuck reading code — write tests, add logs. Trust the runtime over your theories.
13
13
 
14
+ UI & interface design:
15
+ - A value with a FIXED set of choices (enum, level, mode, flag) must be OPTIONS — picker / menu / choices / buttons. Never free-text input.
16
+ - Free-text for a discrete value forces the user to guess the exact spelling, needs manual validation, and fails silently on typos. This has happened repeatedly (e.g. reasoning-effort levels typed by hand).
17
+ - Free-text is correct ONLY when the input is genuinely open-ended (a name, a path, a message).
18
+
14
19
  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 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.
20
+ - **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.
@@ -13,7 +13,8 @@ Delegate well — spawn subagents for independent subtasks.
13
13
  - Delegate breadth-first exploration; do precision edits yourself.
14
14
  - Never give parallel subagents tasks that edit the same files — conflicts waste everyone's time.
15
15
  - When a coder subagent finishes, verify its report: read the files it claims to have changed, run the tests — do not trust subagent reports blindly.
16
- - If a subagent fails or returns ambiguous results, don't spin: either narrow the task and retry, or handle it yourself. Three failed attempts on the same task is the signal to escalate.
16
+ - If a subagent fails or returns ambiguous results, don't spin: narrow the task and retry, or handle it yourself.
17
+ - Escalate EARLY, on up-front ability judgment — if the task is beyond your comfortable ability, hand it to a stronger model (escalate) before burning attempts, not after.
17
18
  - When multiple subagent reports conflict, read the relevant code yourself to arbitrate — never merge conflicting claims.
18
19
 
19
20
  Set goals for autonomous work — long-running tasks need a verifiable completion criterion (a machine-checkable proof, not vague effort).
@@ -21,6 +22,23 @@ Completion claims are audited; declaring blocked requires 3 genuine attempts aga
21
22
 
22
23
  Load skills when relevant — project skills (.thincoder/skills/) contain reusable workflows and reference material.
23
24
 
25
+ Consult for independent perspectives (会诊) — a second opinion when YOU judge it pays for itself:
26
+ - Fits a stubborn bug, a judgment call with real tradeoffs, or a design decision worth cross-checking.
27
+ - Requires agent.consultModels configured.
28
+ - Flow: consult_start with a brief → consult_check to read each reply as it arrives → judge/verify with your own tools → consult_stop the rest once one is good enough.
29
+ - The brief decides the quality: symptom + what you already tried + entry-point files, ~150 words max.
30
+ - Each consult runs N parallel sessions — weigh the cost yourself.
31
+
32
+ Escalate to a stronger model (飞刀) — hand implementation to a stronger model when YOU judge the task needs stronger hands:
33
+ - Fits a complex multi-file refactor, an intractable bug, intricate algorithm work — or work beyond your comfortable ability.
34
+ - Escalate EARLY, on up-front judgment — not after burning failed attempts.
35
+ - `escalate(task)` gets WRITE access and does the work itself; you review its report (read the changed files, run the tests).
36
+ - Terminology: `escalate` is the only technical name; 飞刀 is the Chinese alias.
37
+ - When the user says "飞刀" / "会诊" / "consult", call the `escalate` or `consult_start` tool directly — they are in YOUR tool table. Never write a script that imports the module.
38
+ - Contrast with consult_start: parallel READ-ONLY opinions for judgment calls, not write access.
39
+
40
+ Consultations are bound to the current turn: a user interrupt (or turn end) terminates them — after an interruption, start a fresh consultation instead of referencing the old consult id.
41
+
24
42
  **How you finish:**
25
43
 
26
44
  After a batch of edits, follow the self-review checklist from the Coding discipline.
@@ -3,7 +3,7 @@ import { ansi, C } from "./ansi.mjs"
3
3
 
4
4
  /** /config command: view and set agent/embedding/proxy config. */
5
5
  export async function handleConfigCommand(ctx, args = []) {
6
- const { agent, pushLine, pushLabel, showPicker, askQuestion, persistRaw, maskKey } = ctx
6
+ const { agent, pushLine, pushLabel, showPicker, askQuestion, persistRaw, maskKey, pickModelForSlot } = ctx
7
7
  const { configPath } = await import("../config.mjs")
8
8
  const ac = agent.config?.agent ?? {}
9
9
  const ec = agent.config?.embedding ?? {}
@@ -142,16 +142,87 @@ export async function handleConfigCommand(ctx, args = []) {
142
142
  }
143
143
  if (sub) { pushLine("Usage: /config [embedkey]", C.error); return }
144
144
 
145
+ /** 会诊/飞刀候选池子菜单:列出 / 添加 / 编辑 effort / 删除 consultModels 条目。 */
146
+ async function pickEffort(current) {
147
+ const levels = ["none", "min", "low", "medium", "high", "max"]
148
+ const entries = levels.map((l) => ({ type: "item", text: l === current ? `${l} ← current` : l, action: l }))
149
+ const c = await showPicker("Reasoning effort", entries, { defaultIndex: Math.max(0, levels.indexOf(current ?? "none")) })
150
+ return c ? c.action : null // Esc → null (keep unchanged)
151
+ }
152
+
153
+ async function consultMenu() {
154
+ let idx = 0
155
+ for (;;) {
156
+ const cm = agent.config?.agent?.consultModels ?? []
157
+ const entries = [
158
+ { type: "header", text: `Consult/escalate pool: ${cm.length} model(s) (max 5)` },
159
+ ...cm.map((m, i) => ({ type: "item", text: `${m.provider}:${m.model}${m.effort ? ` (${m.effort})` : ""}`, action: "edit", index: i })),
160
+ { type: "item", text: cm.length ? "+ Add model" : "+ Add model (none yet)", action: "add" },
161
+ ]
162
+ const c = await showPicker("Consult models", entries, { defaultIndex: idx })
163
+ if (!c) return // Esc → 返回主菜单
164
+ if (c.action === "add") {
165
+ if (cm.length >= 5) { pushLine("At most 5 consult models", C.error); continue }
166
+ // BOTH provider and model are pickers (discipline: options, never free-text) —
167
+ // pickModelForSlot reuses /model's provider list + async-fetched model list.
168
+ const picked = await pickModelForSlot()
169
+ if (!picked) continue
170
+ const effort = await pickEffort(null)
171
+ const entry = { provider: picked.provider, model: picked.model }
172
+ if (effort && effort !== "none") entry.effort = effort
173
+ const next = [...cm, entry]
174
+ await saveProxy((raw) => { raw.agent ??= {}; raw.agent.consultModels = next })
175
+ pushLabel("❯ Config", ansi.bold + C.tool)
176
+ pushLine(`Added ${entry.provider}:${entry.model}${entry.effort ? ` (${entry.effort})` : ""}`, C.tool)
177
+ idx = 0
178
+ } else if (c.action === "edit") {
179
+ // Per-model sub-menu: change effort or remove.
180
+ const m = cm[c.index]
181
+ const tag = `${m.provider}:${m.model}`
182
+ const subEntries = [
183
+ { type: "header", text: `${tag} — effort: ${m.effort ?? "(none)"}` },
184
+ { type: "item", text: `Change effort (current: ${m.effort ?? "none"})`, action: "effort" },
185
+ { type: "item", text: "Remove", action: "remove" },
186
+ ]
187
+ const s = await showPicker(tag, subEntries, {})
188
+ if (!s) continue
189
+ if (s.action === "remove") {
190
+ const next = cm.filter((_, i) => i !== c.index)
191
+ await saveProxy((raw) => { raw.agent ??= {}; raw.agent.consultModels = next })
192
+ pushLabel("❯ Config", ansi.bold + C.tool)
193
+ pushLine(`Removed ${tag}`, C.tool)
194
+ } else if (s.action === "effort") {
195
+ const effort = await pickEffort(m.effort)
196
+ if (effort === null) { continue } // Esc 保持
197
+ const next = cm.map((x, i) => {
198
+ if (i !== c.index) return x
199
+ if (effort === "none") { const { effort, ...rest } = x; return rest }
200
+ return { ...x, effort }
201
+ })
202
+ await saveProxy((raw) => { raw.agent ??= {}; raw.agent.consultModels = next })
203
+ pushLabel("❯ Config", ansi.bold + C.tool)
204
+ const after = next[c.index]
205
+ pushLine(`${tag} effort = ${after?.effort ?? "none"}`, C.tool)
206
+ }
207
+ idx = 0
208
+ }
209
+ }
210
+ }
211
+
145
212
  // ── Main config loop ──
146
213
  let running = true
147
214
  let mainIdx = 0 // 记住上次选中位置,改完一项回主菜单时恢复
148
215
  while (running) {
216
+ const consultCount = (ac.consultModels ?? []).length
149
217
  const mainEntries = [
150
- { type: "header", text: `proxy=${proxySummary()} | maxTurns=${ac.maxTurns ?? 100} | compactThreshold=${ac.compactThreshold ?? 100000} | verifyGuard=${ac.verifyGuard === true ? "on" : "off"} | embedding=${agent.memory?.embedder ? "on" : "off"}` },
218
+ { type: "header", text: `proxy=${proxySummary()} | maxTurns=${ac.maxTurns ?? 100} | compactThreshold=${ac.compactThreshold ?? 100000} | verifyGuard=${ac.verifyGuard === true ? "on" : "off"} | consult=${consultCount} model(s) | embedding=${agent.memory?.embedder ? "on" : "off"}` },
151
219
  { type: "item", text: `agent.maxTurns = ${ac.maxTurns ?? 100}`, action: "agent.maxTurns" },
152
220
  { type: "item", text: `agent.subagentTurns = ${ac.subagentTurns ?? 100}`, action: "agent.subagentTurns" },
153
221
  { type: "item", text: `agent.compactThreshold = ${ac.compactThreshold ?? 100000}${agent.config?.agent?.compactThresholdAuto ? " (auto)" : ""}`, action: "agent.compactThreshold" },
154
222
  { type: "item", text: `agent.verifyGuard = ${ac.verifyGuard === true ? "on" : "off"}`, action: "agent.verifyGuard" },
223
+ { type: "item", text: `agent.consultModels = ${consultCount} model(s)${consultCount ? ` (${(ac.consultModels ?? []).map((m) => m.provider + ":" + m.model).join(", ")})` : ""}`, action: "consult" },
224
+ { type: "item", text: `agent.consultTurns = ${ac.consultTurns ?? 40}`, action: "agent.consultTurns" },
225
+ { type: "item", text: `agent.consultTimeoutMs = ${Math.round((ac.consultTimeoutMs ?? 600000) / 60000)} min`, action: "agent.consultTimeoutMs" },
155
226
  { type: "item", text: "Set embedding API key", action: "embedkey" },
156
227
  { type: "item", text: `proxy = ${proxySummary()}`, action: "proxy" },
157
228
  { type: "item", text: "View full config", action: "view" },
@@ -169,6 +240,9 @@ export async function handleConfigCommand(ctx, args = []) {
169
240
  pushLine(`agent.subagentTurns: ${ac.subagentTurns ?? 100}`, C.dim)
170
241
  pushLine(`agent.compactThreshold: ${ac.compactThreshold ?? 100000}${agent.config?.agent?.compactThresholdAuto ? " (auto)" : ""}`, C.dim)
171
242
  pushLine(`agent.verifyGuard: ${ac.verifyGuard === true ? "on" : "off"}`, C.dim)
243
+ pushLine(`agent.consultModels: ${(ac.consultModels ?? []).map((m) => `${m.provider}:${m.model}${m.effort ? ` (${m.effort})` : ""}`).join(", ") || "(none)"}`, C.dim)
244
+ pushLine(`agent.consultTurns: ${ac.consultTurns ?? 40}`, C.dim)
245
+ pushLine(`agent.consultTimeoutMs: ${Math.round((ac.consultTimeoutMs ?? 600000) / 60000)} min`, C.dim)
172
246
  pushLine(`embedding: ${agent.memory?.embedder ? `enabled (${ec.model ?? ""})` : "disabled (FTS only)"}`, C.dim)
173
247
  pushLine(`proxy: ${proxySummary()}`, C.dim)
174
248
  pushLine(`Config file: ${configPath}`, C.dim)
@@ -181,6 +255,11 @@ export async function handleConfigCommand(ctx, args = []) {
181
255
  continue
182
256
  }
183
257
 
258
+ if (choice.action === "consult") {
259
+ await consultMenu()
260
+ continue
261
+ }
262
+
184
263
  if (choice.action === "embedkey") {
185
264
  if (await setEmbedKey()) running = false
186
265
  continue
@@ -205,23 +284,27 @@ export async function handleConfigCommand(ctx, args = []) {
205
284
 
206
285
  // Numeric config items
207
286
  const label = choice.action
287
+ const isTimeout = label === "agent.consultTimeoutMs"
208
288
  const current = label === "agent.maxTurns" ? (ac.maxTurns ?? 100)
209
289
  : label === "agent.subagentTurns" ? (ac.subagentTurns ?? 100)
210
290
  : label === "agent.compactThreshold" ? (ac.compactThreshold ?? 100000)
291
+ : label === "agent.consultTurns" ? (ac.consultTurns ?? 40)
292
+ : isTimeout ? Math.round((ac.consultTimeoutMs ?? 600000) / 60000)
211
293
  : ""
212
- const val = await askQuestion(`${label} (current: ${current}):`)
294
+ const val = await askQuestion(`${label} (current: ${current}${isTimeout ? " min" : ""}):`)
213
295
  if (!val) continue
214
296
  try {
215
297
  const num = Number(val)
216
298
  if (isNaN(num)) { pushLine("Value must be a number", C.error); continue }
299
+ const stored = isTimeout ? Math.round(num * 60000) : num
217
300
  await saveProxy((raw) => {
218
301
  const keys = label.split(".")
219
302
  let obj = raw
220
303
  for (let i = 0; i < keys.length - 1; i++) { obj[keys[i]] ??= {}; obj = obj[keys[i]] }
221
- obj[keys[keys.length - 1]] = num
304
+ obj[keys[keys.length - 1]] = stored
222
305
  })
223
306
  pushLabel("❯ Config", ansi.bold + C.tool)
224
- pushLine(`${label} = ${val}`, C.tool)
307
+ pushLine(`${label} = ${isTimeout ? `${val} min (${stored} ms)` : val}`, C.tool)
225
308
  pushLine("(restart to apply)", C.dim)
226
309
  running = false
227
310
  } catch (error) { pushLine(`Save failed: ${error.message}`, C.error) }
@@ -46,7 +46,7 @@ export const SLASH_COMMANDS = [
46
46
  { name: "/goal", group: "Agent", desc: "set/view/cancel long-term goal" },
47
47
  { name: "/think", group: "Agent", desc: "thinking mode & reasoning effort" },
48
48
  { name: "/upgrade", group: "System", desc: "check for updates & upgrade" },
49
- { name: "/config", group: "System", desc: "agent config (embedding, proxy, turns, thresholds)" },
49
+ { name: "/config", group: "System", desc: "agent config (embedding, proxy, turns, threshold, consult pool)" },
50
50
  { name: "/new", group: "Session", desc: "new session (old one archived to slot)" },
51
51
  { name: "/session", group: "Session", desc: "list/switch archived sessions" },
52
52
  { name: "/clear", group: "Session", desc: "clear screen" },