thincoder 0.12.28 → 0.12.30

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.28",
3
+ "version": "0.12.30",
4
4
  "description": "Thin coding agent - zero dependencies, no build step, Node.js native. Sharp code, zero bloat.",
5
5
  "keywords": [
6
6
  "ai",
@@ -80,12 +80,20 @@ export async function prepareRun(agent, input, callbacks, {
80
80
  const platform = { win32: 'Windows', darwin: 'macOS', linux: 'Linux' }[process.platform] ?? process.platform
81
81
  const wasRestored = agent._sessionStart != null
82
82
  agent._sessionStart ??= new Date().toISOString()
83
- if (tree) {
84
- agent.history.push({ role: "user", content: `[System reminder: OS: ${platform}. Working directory: ${agent.cwd}. Session start: ${agent._sessionStart}. Working directory snapshot:\n<untrusted_cwd_listing>\n${escapeXml(tree)}\n</untrusted_cwd_listing>]`, transient: true })
85
- } else {
86
- agent.history.push({ role: "user", content: `[System reminder: OS: ${platform}. Working directory: ${agent.cwd}. Session start: ${agent._sessionStart}.]`, transient: true })
83
+ // Inject OS/cwd + cwd-tree only ONCE per process (restored sessionStart keeps the
84
+ // content byte-identical across restarts 2026-08-16 cache audit). Without the guard
85
+ // every run re-pushed the same reminder (history bloat, wasted tokens).
86
+ if (!agent._osReminderInjected) {
87
+ agent._osReminderInjected = true
88
+ if (tree) {
89
+ agent.history.push({ role: "user", content: `[System reminder: OS: ${platform}. Working directory: ${agent.cwd}. Session start: ${agent._sessionStart}. Working directory snapshot:\n<untrusted_cwd_listing>\n${escapeXml(tree)}\n</untrusted_cwd_listing>]`, transient: true })
90
+ } else {
91
+ agent.history.push({ role: "user", content: `[System reminder: OS: ${platform}. Working directory: ${agent.cwd}. Session start: ${agent._sessionStart}.]`, transient: true })
92
+ }
93
+
87
94
  }
88
- if (wasRestored) {
95
+ if (wasRestored && !agent._restartReminderInjected) {
96
+ agent._restartReminderInjected = true
89
97
  agent.history.push({ role: "user", content: `[System reminder: process restarted at ${new Date().toISOString()}.]`, transient: true })
90
98
  }
91
99
  if (agent.memory && !agent.history.some((m) => typeof m.content === "string" && m.content.startsWith(OUTLINE_INJECT_PREFIX))) {
@@ -139,16 +147,16 @@ export async function prepareRun(agent, input, callbacks, {
139
147
  } catch { /* checklist not available — suppress error */ }
140
148
  }
141
149
  pushReal(agent, { role: "user", content: input })
142
- // Time grounding for EVERY agent depth, pushed LAST (after the user input): transient,
143
- // dropped on persist, fresh at every run start. Tail position keeps the second-precision
144
- // content out of any prefix — caches stay hit (plugin hit a position-drift regression
145
- // when the reminder was interleaved before the input; CLI is aligned preemptively).
146
- agent.history.push({
147
- role: "user",
148
- content: `[System reminder: current time is ${timeNowLocal()} (local; timezone ${Intl.DateTimeFormat().resolvedOptions().timeZone || "local"}).]`,
149
- transient: true,
150
- })
151
150
  }
151
+ // Time grounding for EVERY agent depth AND every resume, pushed LAST (after the user
152
+ // input): transient, dropped on persist, fresh at every run start — including resumes
153
+ // (an interrupt-continuation must know NOW, not the pre-interrupt time; 2026-08-16).
154
+ // Tail position keeps the second-precision content out of any prefix — caches stay hit.
155
+ agent.history.push({
156
+ role: "user",
157
+ content: `[System reminder: current time is ${timeNowLocal()} (local; timezone ${Intl.DateTimeFormat().resolvedOptions().timeZone || "local"}).`,
158
+ transient: true,
159
+ })
152
160
 
153
161
  if (agent._pendingReminders.length > 0) {
154
162
  for (const reminder of agent._pendingReminders) {
@@ -160,6 +168,17 @@ export async function prepareRun(agent, input, callbacks, {
160
168
  // task/plan tools are injected with the main loop; subagent/skill/goal/verify only at top level
161
169
  // eng-coder subagents get advisor for mandatory design review before coding
162
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
+ }
163
182
  // Role enum is mutually exclusive: normal mode has "coder", engineering mode has "eng-coder"
164
183
  const subagentRoles = (depth === 0 && agent.config?.agent?.engineering)
165
184
  ? {
@@ -184,8 +203,20 @@ export async function prepareRun(agent, input, callbacks, {
184
203
  },
185
204
  } : subagentTool
186
205
 
187
- const depthOnly = depth === 0 ? [filteredSubagent, skillTool, goalTool, engTool, verifyTool, recentChangesTool, advisorTool]
188
- : agent._role === "eng-coder" ? [advisorTool, verifyTool] : []
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).
216
+ : agent._role === "eng-coder" ? [advisorTool, verifyTool]
217
+ : agent._role === "coder" ? [verifyTool, advisorTool]
218
+ : agent._role === "consult" ? [recentChangesTool]
219
+ : []
189
220
  const tools = [...agent.tools, taskTool, planTool, timerTool, ...depthOnly]
190
221
  const toolSchemas = tools.map(toOpenAISchema)
191
222
  const toolByName = new Map(tools.map((t) => [t.name, t]))
@@ -194,7 +225,12 @@ export async function prepareRun(agent, input, callbacks, {
194
225
  // system prompt
195
226
  const needsDiscipline = depth === 0 || agent._role === "coder" || agent._role === "eng-coder"
196
227
  let base
197
- 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) {
198
234
  // Engineering mode: strict methodology, NO standard discipline injection.
199
235
  // Falling back to standard discipline on METHODOLOGY.md absence would leak
200
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
@@ -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,8 @@ 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
+ - When a value has a FIXED set of choices (an enum, a level, a mode, a flag), present it as OPTIONS — picker / menu / choices / buttons — never as free-text input. Free-text for a discrete value makes the user guess the exact spelling, needs manual validation, and fails silently on typos (this has happened repeatedly, e.g. reasoning-effort levels typed by hand). Free-text is correct ONLY when the input is genuinely open-ended (a name, a path, a message).
16
+
14
17
  Review discipline (standard mode only — engineering mode has its own review timing rules):
15
18
  - **Advisor:** call after changing code. Must provide scope: `paths` (files/dirs to review) or `documents` (context). Response table: `| # | Action | Detail |`. Round 2 verifies the prior issue table + flags obvious new issues; round 3+ strictly verifies only the prior issue table (no new-issue hunting). Max 5 rounds total.
package/src/skills.mjs CHANGED
@@ -52,6 +52,10 @@ async function loadSkillsFromDir(dir) {
52
52
  let entries
53
53
  try {
54
54
  entries = await readdir(dir, { withFileTypes: true })
55
+ // Deterministic order: readdir order is filesystem-dependent; an unsorted scan would
56
+ // reshuffle the skills listing → system prompt byte change with zero content change
57
+ // (2026-08-16 cache audit — that silently misses the provider prefix cache).
58
+ entries.sort((a, b) => a.name.localeCompare(b.name))
55
59
  } catch {
56
60
  return []
57
61
  }
@@ -142,16 +142,88 @@ 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
+ const pEntries = agent.providers.map((p) => ({ type: "item", text: `${p.name.padEnd(14)} ${p.model}`, action: "pick", provider: p.name, model: p.model }))
167
+ const p = await showPicker("Add consult model — pick provider", pEntries, {})
168
+ if (!p) continue
169
+ const modelIn = await askQuestion(`Model for ${p.provider} (default: ${p.model}):`)
170
+ const mname = modelIn?.trim() || p.model
171
+ const effort = await pickEffort(null)
172
+ const entry = { provider: p.provider, model: mname }
173
+ if (effort && effort !== "none") entry.effort = effort
174
+ const next = [...cm, entry]
175
+ await saveProxy((raw) => { raw.agent ??= {}; raw.agent.consultModels = next })
176
+ pushLabel("❯ Config", ansi.bold + C.tool)
177
+ pushLine(`Added ${entry.provider}:${entry.model}${entry.effort ? ` (${entry.effort})` : ""}`, C.tool)
178
+ idx = 0
179
+ } else if (c.action === "edit") {
180
+ // Per-model sub-menu: change effort or remove.
181
+ const m = cm[c.index]
182
+ const tag = `${m.provider}:${m.model}`
183
+ const subEntries = [
184
+ { type: "header", text: `${tag} — effort: ${m.effort ?? "(none)"}` },
185
+ { type: "item", text: `Change effort (current: ${m.effort ?? "none"})`, action: "effort" },
186
+ { type: "item", text: "Remove", action: "remove" },
187
+ ]
188
+ const s = await showPicker(tag, subEntries, {})
189
+ if (!s) continue
190
+ if (s.action === "remove") {
191
+ const next = cm.filter((_, i) => i !== c.index)
192
+ await saveProxy((raw) => { raw.agent ??= {}; raw.agent.consultModels = next })
193
+ pushLabel("❯ Config", ansi.bold + C.tool)
194
+ pushLine(`Removed ${tag}`, C.tool)
195
+ } else if (s.action === "effort") {
196
+ const effort = await pickEffort(m.effort)
197
+ if (effort === null) { continue } // Esc 保持
198
+ const next = cm.map((x, i) => {
199
+ if (i !== c.index) return x
200
+ if (effort === "none") { const { effort, ...rest } = x; return rest }
201
+ return { ...x, effort }
202
+ })
203
+ await saveProxy((raw) => { raw.agent ??= {}; raw.agent.consultModels = next })
204
+ pushLabel("❯ Config", ansi.bold + C.tool)
205
+ const after = next[c.index]
206
+ pushLine(`${tag} effort = ${after?.effort ?? "none"}`, C.tool)
207
+ }
208
+ idx = 0
209
+ }
210
+ }
211
+ }
212
+
145
213
  // ── Main config loop ──
146
214
  let running = true
147
215
  let mainIdx = 0 // 记住上次选中位置,改完一项回主菜单时恢复
148
216
  while (running) {
217
+ const consultCount = (ac.consultModels ?? []).length
149
218
  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"}` },
219
+ { 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
220
  { type: "item", text: `agent.maxTurns = ${ac.maxTurns ?? 100}`, action: "agent.maxTurns" },
152
221
  { type: "item", text: `agent.subagentTurns = ${ac.subagentTurns ?? 100}`, action: "agent.subagentTurns" },
153
222
  { type: "item", text: `agent.compactThreshold = ${ac.compactThreshold ?? 100000}${agent.config?.agent?.compactThresholdAuto ? " (auto)" : ""}`, action: "agent.compactThreshold" },
154
223
  { type: "item", text: `agent.verifyGuard = ${ac.verifyGuard === true ? "on" : "off"}`, action: "agent.verifyGuard" },
224
+ { type: "item", text: `agent.consultModels = ${consultCount} model(s)${consultCount ? ` (${(ac.consultModels ?? []).map((m) => m.provider + ":" + m.model).join(", ")})` : ""}`, action: "consult" },
225
+ { type: "item", text: `agent.consultTurns = ${ac.consultTurns ?? 40}`, action: "agent.consultTurns" },
226
+ { type: "item", text: `agent.consultTimeoutMs = ${Math.round((ac.consultTimeoutMs ?? 600000) / 60000)} min`, action: "agent.consultTimeoutMs" },
155
227
  { type: "item", text: "Set embedding API key", action: "embedkey" },
156
228
  { type: "item", text: `proxy = ${proxySummary()}`, action: "proxy" },
157
229
  { type: "item", text: "View full config", action: "view" },
@@ -169,6 +241,9 @@ export async function handleConfigCommand(ctx, args = []) {
169
241
  pushLine(`agent.subagentTurns: ${ac.subagentTurns ?? 100}`, C.dim)
170
242
  pushLine(`agent.compactThreshold: ${ac.compactThreshold ?? 100000}${agent.config?.agent?.compactThresholdAuto ? " (auto)" : ""}`, C.dim)
171
243
  pushLine(`agent.verifyGuard: ${ac.verifyGuard === true ? "on" : "off"}`, C.dim)
244
+ pushLine(`agent.consultModels: ${(ac.consultModels ?? []).map((m) => `${m.provider}:${m.model}${m.effort ? ` (${m.effort})` : ""}`).join(", ") || "(none)"}`, C.dim)
245
+ pushLine(`agent.consultTurns: ${ac.consultTurns ?? 40}`, C.dim)
246
+ pushLine(`agent.consultTimeoutMs: ${Math.round((ac.consultTimeoutMs ?? 600000) / 60000)} min`, C.dim)
172
247
  pushLine(`embedding: ${agent.memory?.embedder ? `enabled (${ec.model ?? ""})` : "disabled (FTS only)"}`, C.dim)
173
248
  pushLine(`proxy: ${proxySummary()}`, C.dim)
174
249
  pushLine(`Config file: ${configPath}`, C.dim)
@@ -181,6 +256,11 @@ export async function handleConfigCommand(ctx, args = []) {
181
256
  continue
182
257
  }
183
258
 
259
+ if (choice.action === "consult") {
260
+ await consultMenu()
261
+ continue
262
+ }
263
+
184
264
  if (choice.action === "embedkey") {
185
265
  if (await setEmbedKey()) running = false
186
266
  continue
@@ -205,23 +285,27 @@ export async function handleConfigCommand(ctx, args = []) {
205
285
 
206
286
  // Numeric config items
207
287
  const label = choice.action
288
+ const isTimeout = label === "agent.consultTimeoutMs"
208
289
  const current = label === "agent.maxTurns" ? (ac.maxTurns ?? 100)
209
290
  : label === "agent.subagentTurns" ? (ac.subagentTurns ?? 100)
210
291
  : label === "agent.compactThreshold" ? (ac.compactThreshold ?? 100000)
292
+ : label === "agent.consultTurns" ? (ac.consultTurns ?? 40)
293
+ : isTimeout ? Math.round((ac.consultTimeoutMs ?? 600000) / 60000)
211
294
  : ""
212
- const val = await askQuestion(`${label} (current: ${current}):`)
295
+ const val = await askQuestion(`${label} (current: ${current}${isTimeout ? " min" : ""}):`)
213
296
  if (!val) continue
214
297
  try {
215
298
  const num = Number(val)
216
299
  if (isNaN(num)) { pushLine("Value must be a number", C.error); continue }
300
+ const stored = isTimeout ? Math.round(num * 60000) : num
217
301
  await saveProxy((raw) => {
218
302
  const keys = label.split(".")
219
303
  let obj = raw
220
304
  for (let i = 0; i < keys.length - 1; i++) { obj[keys[i]] ??= {}; obj = obj[keys[i]] }
221
- obj[keys[keys.length - 1]] = num
305
+ obj[keys[keys.length - 1]] = stored
222
306
  })
223
307
  pushLabel("❯ Config", ansi.bold + C.tool)
224
- pushLine(`${label} = ${val}`, C.tool)
308
+ pushLine(`${label} = ${isTimeout ? `${val} min (${stored} ms)` : val}`, C.tool)
225
309
  pushLine("(restart to apply)", C.dim)
226
310
  running = false
227
311
  } 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" },