thincoder 0.11.1 → 0.12.0

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/src/skills.mjs CHANGED
@@ -1,52 +1,80 @@
1
1
  /**
2
2
  * skills.mjs — skill system
3
- * Discovers .md skill files from .thincoder/skills/ directory,
3
+ * Discovers .md skill files AND subdirectory SKILL.md from .thincoder/skills/ directory,
4
4
  * injects into system prompt for agent to load on demand.
5
5
  * Use the skill tool to activate a specific skill; content is written into conversation history wrapped in <skill-loaded>.
6
+ *
7
+ * Supported formats:
8
+ * .thincoder/skills/my-skill.md (flat, name = "my-skill")
9
+ * .thincoder/skills/my-skill/SKILL.md (subdirectory, name = "my-skill")
6
10
  */
7
11
 
8
12
  import { readFile, readdir, stat } from "node:fs/promises"
9
13
  import { join } from "node:path"
10
14
 
15
+ /** Valid skill name pattern (alphanumeric + hyphens/underscores) */
16
+ const NAME_RE = /^[a-zA-Z0-9_-]+$/
17
+
18
+ /**
19
+ * Try to read a skill from a path, return { name, path, description } or null.
20
+ */
21
+ async function tryReadSkill(dir, name, filePath) {
22
+ try {
23
+ const s = await stat(filePath)
24
+ if (!s.isFile()) return null
25
+ const head = await readFile(filePath, "utf8")
26
+ const body = head.slice(0, 400).split("\n")
27
+ let desc = ""
28
+ let inFrontmatter = false
29
+ for (const line of body) {
30
+ const t = line.trim()
31
+ if (t === "---") { inFrontmatter = !inFrontmatter; continue }
32
+ if (inFrontmatter) continue
33
+ if (t && !t.startsWith("#")) {
34
+ desc = t.slice(0, 120)
35
+ break
36
+ }
37
+ }
38
+ return { name, path: filePath, description: desc || "(no description)" }
39
+ } catch {
40
+ return null
41
+ }
42
+ }
43
+
11
44
  /**
12
45
  * Scan .thincoder/skills/ directory, return skill list.
13
- * Each skill: { name, path, description } name is the filename (without extension).
46
+ * Supports flat .md files and subdirectories with SKILL.md inside.
47
+ * Each skill: { name, path, description } — name derived from filename or directory.
14
48
  * Returns empty array if directory is missing or empty.
15
49
  */
16
50
  export async function loadSkills(cwd) {
17
51
  const dir = join(cwd, ".thincoder", "skills")
18
52
  let entries
19
53
  try {
20
- entries = await readdir(dir)
54
+ entries = await readdir(dir, { withFileTypes: true })
21
55
  } catch {
22
56
  return []
23
57
  }
24
58
  const skills = []
25
- for (const name of entries) {
26
- if (!/^[a-zA-Z0-9_-]+\.md$/.test(name)) continue // must match readSkill's name validation; prevents "listed but unreadable"
27
- const p = join(dir, name)
28
- try {
29
- const s = await stat(p)
30
- if (!s.isFile()) continue
31
- // Extract description (first non-empty, non-heading line in first 400 chars);
32
- // skip entire frontmatter block, otherwise frontmatter fields (e.g. "name: x") get mistaken for description
33
- const head = await readFile(p, "utf8")
34
- const body = head.slice(0, 400).split("\n")
35
- let desc = ""
36
- let inFrontmatter = false
37
- for (const line of body) {
38
- const t = line.trim()
39
- if (t === "---") { inFrontmatter = !inFrontmatter; continue }
40
- if (inFrontmatter) continue
41
- if (t && !t.startsWith("#")) {
42
- desc = t.slice(0, 120)
43
- break
44
- }
45
- }
46
- skills.push({ name: name.replace(/\.md$/, ""), path: p, description: desc || "(no description)" })
47
- } catch {
48
- // Read failure — skip
49
- }
59
+ const added = new Set()
60
+
61
+ // Pass 1: subdirectories (higher priority — standard convention)
62
+ for (const entry of entries) {
63
+ if (!entry.isDirectory()) continue
64
+ if (!NAME_RE.test(entry.name)) continue
65
+ const skill = await tryReadSkill(dir, entry.name, join(dir, entry.name, "SKILL.md"))
66
+ if (skill) { skills.push(skill); added.add(entry.name) }
67
+ }
68
+
69
+ // Pass 2: flat .md files (backward compat; skipped if subdirectory with same name exists)
70
+ for (const entry of entries) {
71
+ if (!entry.isFile()) continue
72
+ const m = entry.name.match(/^([a-zA-Z0-9_-]+)\.md$/)
73
+ if (!m) continue
74
+ const name = m[1]
75
+ if (added.has(name)) continue
76
+ const skill = await tryReadSkill(dir, name, join(dir, entry.name))
77
+ if (skill) { skills.push(skill); added.add(name) }
50
78
  }
51
79
  return skills
52
80
  }
@@ -66,13 +94,21 @@ export function formatSkillListing(skills) {
66
94
 
67
95
  /**
68
96
  * Read the full content of a specific skill file.
97
+ * Tries subdirectory format (name/SKILL.md) first, then flat format (name.md).
69
98
  * Returns text, or null if not found.
70
99
  */
71
100
  export async function readSkill(cwd, name) {
72
- // Safety check: skill name must be alphanumeric + hyphens/underscores only
73
- if (!/^[a-zA-Z0-9_-]+$/.test(name)) return null
74
- const p = join(cwd, ".thincoder", "skills", `${name}.md`)
101
+ if (!NAME_RE.test(name)) return null
102
+
103
+ // Try subdirectory format: name/SKILL.md
104
+ try {
105
+ const p = join(cwd, ".thincoder", "skills", name, "SKILL.md")
106
+ return await readFile(p, "utf8")
107
+ } catch { /* not found, try flat */ }
108
+
109
+ // Fallback to flat format: name.md
75
110
  try {
111
+ const p = join(cwd, ".thincoder", "skills", `${name}.md`)
76
112
  return await readFile(p, "utf8")
77
113
  } catch {
78
114
  return null
@@ -203,9 +203,34 @@ export function shellSegments(command) {
203
203
  return command.split(/&&|\|\||>>|\$\(|[;|\n<>]|`|[(]/)
204
204
  }
205
205
 
206
- /** Detect shell output/input redirection (> >> < followed by filename) — not excluded inside quotes, conservative block */
206
+ /**
207
+ * Blank out quoted regions (single/double/backtick) with spaces, preserving length.
208
+ * Lets safety checks ignore shell metacharacters inside quoted script bodies —
209
+ * e.g. `node -e "if (a > b) …"` comparisons are not redirections.
210
+ */
211
+ function blankQuoted(command) {
212
+ let out = ""
213
+ let quote = null
214
+ for (let i = 0; i < command.length; i++) {
215
+ const ch = command[i]
216
+ if (quote) {
217
+ if (ch === "\\" && quote !== "'") { out += " "; i++; out += " "; continue }
218
+ if (ch === quote) quote = null
219
+ out += " "
220
+ } else if (ch === "'" || ch === '"' || ch === "`") {
221
+ quote = ch
222
+ out += " "
223
+ } else {
224
+ out += ch
225
+ }
226
+ }
227
+ return out
228
+ }
229
+
230
+ /** Detect shell output/input redirection (> >> < followed by filename) outside quoted regions */
207
231
  export function hasFileRedirection(command) {
208
- return /(^|[\s;&|])>{1,2}\s*\S/.test(command) || /(^|[\s;&|])<\s*\S/.test(command)
232
+ const bare = blankQuoted(command)
233
+ return /(^|[\s;&|])>{1,2}\s*\S/.test(bare) || /(^|[\s;&|])<\s*\S/.test(bare)
209
234
  }
210
235
 
211
236
  /** Whether a single command segment is a destructive non-git command (conservative: prefer false positives) */
@@ -105,7 +105,11 @@ export async function runAgentTurn(ctx, text) {
105
105
  flushStream()
106
106
  ensureAssistantLabel()
107
107
  state.currentTool = name
108
- pushLine(` [tool] ${name} ${summarize(args)}`, C.tool)
108
+ // Advisor: tag the round in the tool title the model's own "第N轮" narration
109
+ // is unreliable (it glues onto the previous line), so the round belongs here.
110
+ const roundTag = name === "advisor" ? ` (round ${(agent._advisorRound || 0) + 1})` : ""
111
+ const argSummary = summarize(args)
112
+ pushLine(` [tool] ${name}${roundTag}${argSummary ? ` ${argSummary}` : ""}`, C.tool)
109
113
  },
110
114
  onToolResult: (name, result) => {
111
115
  state.currentTool = null
@@ -137,8 +141,12 @@ export async function runAgentTurn(ctx, text) {
137
141
  const panel = state.outputPanels[name]
138
142
  if (panel) {
139
143
  delete state.toolStreams[name]
140
- panel._pendingDone = true // defer done until next render cycle flushes it
141
- scheduleRender() // trigger one final render while panel is still alive
144
+ // Keep the panel visible for a 3s grace period (layout filters by closeAt);
145
+ // the render loop prunes it once expired. No defer hacks needed — row-diff
146
+ // repaints whatever should be on screen.
147
+ panel.done = true
148
+ panel.closeAt = Date.now() + 3000
149
+ scheduleRender()
142
150
  if (name === "advisor") {
143
151
  const text = String(result ?? "")
144
152
  const lines = text.split("\n")
@@ -151,10 +159,8 @@ export async function runAgentTurn(ctx, text) {
151
159
  const summary = formatPanelSummary(name, result)
152
160
  if (summary) pushLine(` ${summary}`, C.dim)
153
161
  }
154
- setTimeout(() => {
155
- delete state.outputPanels[name]
156
- if (state.processing) render()
157
- }, 3000)
162
+ // Trigger a repaint after the grace period so the pruned panel disappears
163
+ setTimeout(() => render(), 3000)
158
164
  } else if (stream) {
159
165
  const tail = stream.trimEnd().slice(-4000)
160
166
  if (tail) pushLine(tail, C.dim)
@@ -167,22 +173,45 @@ export async function runAgentTurn(ctx, text) {
167
173
  },
168
174
  onToolOutput: (name, chunk) => {
169
175
  // Route streaming output to a panel if one exists or was requested via outputPanel flag.
176
+ // Chunk may be a string or { kind, text } — kind ("think" | "text" | "tool") drives
177
+ // per-kind coloring in renderOutput so reasoning / answer / tool progress are distinct.
170
178
  let panel = state.outputPanels[name]
171
179
  if (!panel) {
172
180
  // Lazy-create panel: defensive against race conditions where setupOutputPanel
173
181
  // hasn't fired yet or the callbacks chain dropped it (subagent relay, reconnect, etc.)
174
- state.outputPanels[name] = { text: "", done: false }
182
+ state.outputPanels[name] = { parts: [], len: 0, done: false }
175
183
  panel = state.outputPanels[name]
176
184
  }
177
- panel.text = (panel.text ?? "") + chunk
178
- if (panel.text.length > 4000) panel.text = panel.text.slice(-4000)
179
- panel.seq = (panel.seq ?? 0) + 1 // render-loop cache key: survives the 4000-char cap
185
+ const part = typeof chunk === "string"
186
+ ? { kind: "text", text: chunk }
187
+ : { kind: chunk?.kind ?? "text", text: String(chunk?.text ?? "") }
188
+ if (!part.text) return
189
+ // Separate phase transitions with a newline — think → answer → tool progress
190
+ // would otherwise glue onto each other mid-line.
191
+ const last = panel.parts[panel.parts.length - 1]
192
+ if (last && last.kind !== part.kind && !last.text.endsWith("\n") && !part.text.startsWith("\n")) {
193
+ part.text = "\n" + part.text
194
+ }
195
+ panel.parts.push(part)
196
+ panel.len += part.text.length
197
+ // Cap at 4000 chars, trimming oldest parts first
198
+ while (panel.len > 4000 && panel.parts.length > 1) {
199
+ const first = panel.parts[0]
200
+ const excess = panel.len - 4000
201
+ if (first.text.length <= excess) {
202
+ panel.len -= first.text.length
203
+ panel.parts.shift()
204
+ } else {
205
+ first.text = first.text.slice(excess)
206
+ panel.len -= excess
207
+ }
208
+ }
180
209
  scheduleRender()
181
210
  },
182
211
  onPermissionRequest: (name, args) => askPermission(name, args),
183
212
  onQuestion: (text, options) => askQuestion(text, options),
184
213
  setupOutputPanel: (name) => {
185
- state.outputPanels[name] = { text: "", done: false }
214
+ state.outputPanels[name] = { parts: [], len: 0, done: false }
186
215
  scheduleRender()
187
216
  },
188
217
  onCompress: () => {
@@ -307,11 +336,27 @@ export async function runAgentTurn(ctx, text) {
307
336
  /** Extract a one-line summary from a panel tool's output */
308
337
  function formatPanelSummary(name, result) {
309
338
  if (name === "verify") return _verifySummary(result)
339
+ if (name === "bash") return _bashSummary(result)
310
340
  // Default: first non-empty line
311
341
  const first = result.split("\n").find((l) => l.trim())
312
342
  return first ? `${name}: ${first.slice(0, 100)}` : null
313
343
  }
314
344
 
345
+ /**
346
+ * bash result format: "[stdout]:\n<out>\n\n[stderr]:\n<err>\n\n(exit code 0)".
347
+ * The first non-empty line is always the "[stdout]:" marker — useless as a summary.
348
+ * Show the LAST output line (usually the meaningful tail) plus the exit status.
349
+ */
350
+ function _bashSummary(result) {
351
+ const isMarker = (l) => /^\[(stdout|stderr)\]:$/.test(l) || /^\((exit code|killed)/.test(l)
352
+ const lines = result.split("\n").map((l) => l.trim()).filter((l) => l && !isMarker(l))
353
+ const status = result.match(/\((?:exit code|killed)[^)]*\)/)?.[0]
354
+ const parts = []
355
+ if (lines.length > 0) parts.push(lines[lines.length - 1].slice(0, 100))
356
+ if (status) parts.push(status)
357
+ return parts.length > 0 ? `bash: ${parts.join(" ")}` : null
358
+ }
359
+
315
360
  function _verifySummary(result) {
316
361
  const lines = result.split("\n")
317
362
  const summary = []
@@ -1,26 +1,11 @@
1
1
  /** /advisor command: toggle advisor on/off, select model, configure thinking.
2
- * ctx: { agent, showPicker, pushLine, persistRaw } */
3
- import { C } from "./ansi.mjs"
2
+ * Interactive loop UX stays in menu after each action, Esc to exit.
3
+ * ctx: { agent, showPicker, pushLine, pushLabel, persistRaw } */
4
+ import { ansi, C } from "./ansi.mjs"
4
5
 
5
6
  export async function handleAdvisorCommand(ctx) {
6
- const { agent, showPicker, pushLine } = ctx
7
+ const { agent, showPicker, pushLine, pushLabel } = ctx
7
8
  const cfg = agent.config.advisor ??= {}
8
- const enabled = cfg.enabled === true
9
- const curProvider = cfg.provider || "(main)"
10
- const curModel = cfg.model || agent.provider.model
11
- const thinkInfo = cfg.thinking === null ? "off"
12
- : cfg.thinking?.type === "disabled" ? "off"
13
- : cfg.reasoningEffort ? `on (${cfg.reasoningEffort})`
14
- : cfg.thinking ? `on (${cfg.thinking.type})` : "(main)"
15
-
16
- const entries = [
17
- { type: "item", text: `Advisor: ${enabled ? "ON" : "OFF"}`, action: "toggle" },
18
- { type: "item", text: `Model: ${curModel}`, action: "model", note: `Provider: ${curProvider}` },
19
- { type: "item", text: `Thinking: ${thinkInfo}`, action: "thinking" },
20
- ]
21
-
22
- const e = await showPicker("Advisor", entries)
23
- if (!e) return
24
9
 
25
10
  const persist = async () => {
26
11
  if (ctx.persistRaw) {
@@ -31,82 +16,211 @@ export async function handleAdvisorCommand(ctx) {
31
16
  }
32
17
  }
33
18
 
34
- if (e.action === "toggle") {
35
- cfg.enabled = !cfg.enabled
36
- agent._pendingReminders = agent._pendingReminders ?? []
37
- if (cfg.enabled) {
38
- agent._pendingReminders.push("[System reminder: Advisor review is now ON. You can call the `advisor` tool to get an independent code review before finalising your work. The advisor is an independent read-only sub-agent that explores the codebase, runs git diff, reads files, and traces callers via grep/lsp.]")
39
- } else {
40
- agent._pendingReminders.push("[System reminder: Advisor review is now OFF. The `advisor` tool will not produce results.]")
41
- }
42
- await persist().catch(err => pushLine(`[error] Advisor toggle: ${err.message}`, C.error))
43
- } else if (e.action === "model") {
44
- await openAdvisorModelPicker(ctx, persist).catch(err => pushLine(`[error] ${err.message}`, C.error))
45
- } else if (e.action === "thinking") {
46
- await openAdvisorThinkingPicker(ctx, persist).catch(err => pushLine(`[error] ${err.message}`, C.error))
19
+ // Lazy model cache — fetched once per /advisor session
20
+ let modelCache = null
21
+
22
+ // ── State helpers ──
23
+ function advisorStatus() {
24
+ const enabled = cfg.enabled === true
25
+ const curModel = cfg.model || agent.provider.model
26
+ const thinkInfo = cfg.thinking === null ? "off"
27
+ : cfg.thinking?.type === "disabled" ? "off"
28
+ : cfg.reasoningEffort ? `on (${cfg.reasoningEffort})`
29
+ : cfg.thinking ? `on (${cfg.thinking.type})` : "(main)"
30
+ return `Advisor: ${enabled ? "ON" : "OFF"} | Model: ${curModel} | Think: ${thinkInfo}`
47
31
  }
48
- }
49
32
 
50
- async function openAdvisorModelPicker(ctx, persist) {
51
- const { agent, showPicker, pushLine } = ctx
52
- const providers = agent.providers || []
53
- const cfg = agent.config.advisor ??= {}
33
+ function headerLine() {
34
+ return ` ${advisorStatus()}`.replace(/\|/g, ansi.dim + "|" + ansi.reset)
35
+ }
54
36
 
55
- const entries = [
56
- { type: "item", text: "Use main model", action: "inherit", marker: !cfg.provider ? "●" : "" },
57
- ]
58
- for (const p of providers) {
59
- entries.push({ type: "header", text: p.name, note: `${p.baseURL}${agent.activeProvider === p.name ? " ← active" : ""} loading…` })
60
- const mark = cfg.provider === p.name && cfg.model === p.model ? "● " : " "
61
- entries.push({ type: "item", text: `${mark}${p.model}`, action: "switch", provider: p.name, model: p.model })
37
+ // ── Model picker sub-loop ──
38
+ async function modelPicker() {
39
+ if (!modelCache) {
40
+ modelCache = await fetchAdvisorModels(agent)
41
+ }
42
+ let modelIdx = 0
43
+ for (;;) {
44
+ const entries = buildModelEntries(agent, cfg, modelCache)
45
+ const c = await showPicker("Advisor Model", entries, { defaultIndex: modelIdx })
46
+ if (!c) return
47
+ modelIdx = Math.max(0, entries.filter((e) => e.type === "item").indexOf(c))
48
+
49
+ if (c.action === "inherit") {
50
+ delete cfg.provider
51
+ delete cfg.model
52
+ await persist()
53
+ pushLabel("❯ Advisor", ansi.bold + C.tool)
54
+ pushLine("Model: using main model", C.tool)
55
+ } else if (c.action === "switch") {
56
+ cfg.provider = c.provider
57
+ cfg.model = c.model
58
+ await persist()
59
+ pushLabel("❯ Advisor", ansi.bold + C.tool)
60
+ pushLine(`Model: ${c.provider}/${c.model}`, C.tool)
61
+ }
62
+ }
62
63
  }
63
64
 
64
- // Fetch models first, then show picker
65
- await fetchAdvisorModels(entries, providers, agent)
65
+ // ── Thinking picker sub-loop ──
66
+ async function thinkingPicker() {
67
+ let thinkIdx = 0
68
+ for (;;) {
69
+ const entries = buildThinkingEntries(agent, cfg)
70
+ const c = await showPicker("Advisor Thinking", entries, { defaultIndex: thinkIdx })
71
+ if (!c) return
72
+ thinkIdx = Math.max(0, entries.filter((e) => e.type === "item").indexOf(c))
73
+
74
+ if (c.action === "inherit") {
75
+ delete cfg.thinking
76
+ delete cfg.reasoningEffort
77
+ await persist()
78
+ pushLabel("❯ Advisor", ansi.bold + C.tool)
79
+ pushLine("Thinking: using main model settings", C.tool)
80
+ } else if (c.action === "think_on") {
81
+ const { specForModel } = await import("../config.mjs")
82
+ const spec = specForModel(getEffectiveModel(agent, cfg))
83
+ cfg.thinking = { type: spec.thinkEnabledValue ?? "enabled" }
84
+ if (spec.thinkApi === "effort") delete cfg.thinking
85
+ await persist()
86
+ pushLabel("❯ Advisor", ansi.bold + C.tool)
87
+ pushLine(`Thinking: ON`, C.tool)
88
+ } else if (c.action === "think_off") {
89
+ const { specForModel } = await import("../config.mjs")
90
+ const spec = specForModel(getEffectiveModel(agent, cfg))
91
+ const isCustomThink = (spec.thinkEnabledValue ?? "enabled") !== "enabled"
92
+ cfg.thinking = isCustomThink ? null : { type: "disabled" }
93
+ await persist()
94
+ pushLabel("❯ Advisor", ansi.bold + C.tool)
95
+ pushLine("Thinking: OFF", C.tool)
96
+ } else if (c.action.startsWith("effort_")) {
97
+ cfg.reasoningEffort = c.action.slice(7)
98
+ await persist()
99
+ pushLabel("❯ Advisor", ansi.bold + C.tool)
100
+ pushLine(`Reasoning effort: ${cfg.reasoningEffort}`, C.tool)
101
+ }
102
+ }
103
+ }
104
+
105
+ // ── Main loop ──
106
+ let mainIdx = 0
107
+ for (;;) {
108
+ const enabled = cfg.enabled === true
109
+ const curProvider = cfg.provider || "(main)"
110
+ const curModel = cfg.model || agent.provider.model
111
+ const guardInfo = cfg.guard === true ? "on" : "off"
112
+
113
+ const entries = [
114
+ { type: "header", text: headerLine() },
115
+ { type: "item", text: `Advisor: ${enabled ? "ON" : "OFF"}`, action: "toggle" },
116
+ { type: "item", text: `Model: ${curModel}`, action: "model", note: `Provider: ${curProvider}` },
117
+ { type: "item", text: `Thinking: ${advisorStatus().split("|")[2]?.trim() || "(main)"}`, action: "thinking" },
118
+ { type: "item", text: `Guard: ${guardInfo}`, action: "guard" },
119
+ { type: "item", text: "View full config", action: "view" },
120
+ ]
121
+
122
+ const choice = await showPicker("Advisor", entries, { defaultIndex: mainIdx })
123
+ if (!choice) return // Esc
124
+ mainIdx = Math.max(0, entries.filter((e) => e.type === "item").indexOf(choice))
125
+
126
+ if (choice.action === "view") {
127
+ pushLabel("❯ Advisor", ansi.bold + C.tool)
128
+ pushLine(`Status: ${enabled ? "ON" : "OFF"}`, C.dim)
129
+ pushLine(`Model: ${curModel} (provider: ${curProvider})`, C.dim)
130
+ pushLine(`Guard: ${guardInfo}`, C.dim)
131
+ pushLine(`Thinking: ${advisorStatus().split("|")[2]?.trim() || "(main)"}`, C.dim)
132
+ continue
133
+ }
134
+
135
+ if (choice.action === "toggle") {
136
+ cfg.enabled = !cfg.enabled
137
+ await persist().catch(err => pushLine(`[error] Advisor toggle: ${err.message}`, C.error))
138
+ pushLabel("❯ Advisor", ansi.bold + C.tool)
139
+ pushLine(`Advisor: ${cfg.enabled ? "ON" : "OFF"}`, C.tool)
140
+ continue
141
+ }
142
+
143
+ if (choice.action === "guard") {
144
+ cfg.guard = !(cfg.guard === true)
145
+ await persist().catch(err => pushLine(`[error] ${err.message}`, C.error))
146
+ pushLabel("❯ Advisor", ansi.bold + C.tool)
147
+ pushLine(`Guard: ${cfg.guard === true ? "on" : "off"}`, C.tool)
148
+ continue
149
+ }
66
150
 
67
- const e = await showPicker("Advisor Model", entries)
68
- if (!e) return
151
+ if (choice.action === "model") {
152
+ await modelPicker()
153
+ continue
154
+ }
69
155
 
70
- if (e.action === "inherit") {
71
- delete cfg.provider
72
- delete cfg.model
73
- pushLine("Advisor: using main model", C.dim)
74
- } else if (e.action === "switch") {
75
- cfg.provider = e.provider
76
- cfg.model = e.model
77
- pushLine(`Advisor: ${e.provider}/${e.model}`, C.dim)
156
+ if (choice.action === "thinking") {
157
+ await thinkingPicker()
158
+ continue
159
+ }
78
160
  }
79
- await persist()
80
161
  }
81
162
 
82
- async function fetchAdvisorModels(entries, providers, agent) {
163
+ // ── Model helpers ──
164
+
165
+ function getEffectiveModel(agent, cfg) {
166
+ const providerForDefaults = cfg.provider
167
+ ? agent.providers?.find(p => p.name === cfg.provider) || agent.provider
168
+ : agent.provider
169
+ return cfg.model || providerForDefaults.model
170
+ }
171
+
172
+ async function fetchAdvisorModels(agent) {
83
173
  const { listModels } = await import("../provider/index.mjs")
84
- await Promise.all(providers.map(async (p) => {
174
+ const result = new Map()
175
+ await Promise.all((agent.providers || []).map(async (p) => {
85
176
  try {
86
177
  const envKey = { deepseek: "DEEPSEEK_API_KEY", openai: "OPENAI_API_KEY" }[p.name]
87
178
  let apiKey = p.apiKey
88
179
  if (!apiKey && envKey && process.env[envKey]) apiKey = process.env[envKey]
89
180
  if (!apiKey) apiKey = process.env.THINCODER_API_KEY
90
181
  const models = await listModels({ baseURL: p.baseURL, apiKey: apiKey ?? "" }, { signal: AbortSignal.timeout(10000) })
91
- const at = entries.findLastIndex((e) => e.type === "header" && e.text === p.name)
92
- if (at < 0) return
93
- entries.splice(at + 2, 0, ...models
94
- .filter((m) => m !== p.model)
95
- .map((m) => ({ type: "item", text: ` ${m}`, action: "switch", provider: p.name, model: m })))
96
- const header = entries[at]
97
- header.note = `${p.baseURL}${p.apiKey ? "" : " (no key)"}${agent.activeProvider === p.name ? " ← active" : ""}`
98
- } catch (err) {
99
- const header = entries.find((e) => e.type === "header" && e.text === p.name)
100
- if (header) header.note = `${p.baseURL}${p.apiKey ? "" : " (no key)"}${agent.activeProvider === p.name ? " ← active" : ""} (fetch failed: ${err.message.slice(0, 40)})`
182
+ result.set(p.name, { models, error: null })
183
+ } catch (err) {
184
+ result.set(p.name, { models: [], error: err.message.slice(0, 40) })
101
185
  }
102
186
  }))
187
+ return result
103
188
  }
104
189
 
105
- async function openAdvisorThinkingPicker(ctx, persist) {
106
- const { agent, showPicker, pushLine } = ctx
107
- const { specForModel } = await import("../config.mjs")
108
- const cfg = agent.config.advisor ??= {}
190
+ function buildModelEntries(agent, cfg, cache) {
191
+ const entries = []
192
+ entries.push({ type: "item", text: (!cfg.provider ? "● " : " ") + "Use main model", action: "inherit" })
193
+
194
+ for (const p of agent.providers || []) {
195
+ const cached = cache.get(p.name)
196
+ const hasKey = !!(p.apiKey
197
+ || (p.name === "deepseek" && process.env.DEEPSEEK_API_KEY)
198
+ || (p.name === "openai" && process.env.OPENAI_API_KEY)
199
+ || process.env.THINCODER_API_KEY)
200
+ const noteParts = [p.baseURL]
201
+ if (!hasKey) noteParts.push("(no key)")
202
+ if (agent.activeProvider === p.name) noteParts.push("← active")
203
+ if (cached?.error) noteParts.push(`(fetch failed: ${cached.error})`)
204
+ entries.push({ type: "header", text: p.name, note: noteParts.join(" ") })
205
+
206
+ // Default model
207
+ const isDefault = cfg.provider === p.name && cfg.model === p.model
208
+ entries.push({ type: "item", text: `${isDefault ? "● " : " "}${p.model}`, action: "switch", provider: p.name, model: p.model })
209
+
210
+ // Additional models from API, excluding the default model
211
+ if (cached?.models) {
212
+ for (const m of cached.models) {
213
+ if (m === p.model) continue
214
+ const isSelected = cfg.provider === p.name && cfg.model === m
215
+ entries.push({ type: "item", text: `${isSelected ? "● " : " "}${m}`, action: "switch", provider: p.name, model: m })
216
+ }
217
+ }
218
+ }
219
+ return entries
220
+ }
109
221
 
222
+ async function buildThinkingEntries(agent, cfg) {
223
+ const { specForModel } = await import("../config.mjs")
110
224
  const providerForDefaults = cfg.provider
111
225
  ? agent.providers?.find(p => p.name === cfg.provider) || agent.provider
112
226
  : agent.provider
@@ -127,31 +241,12 @@ async function openAdvisorThinkingPicker(ctx, persist) {
127
241
  ]
128
242
  if (!isEffortOnly) {
129
243
  entries.push({ type: "header", text: "Thinking mode" })
130
- entries.push({ type: "item", text: `Enabled ${thinkingEnabled ? "← current" : ""}`, action: "think_on" })
131
- entries.push({ type: "item", text: `Disabled ${curThinking?.type === "disabled" || curThinking === null ? "← current" : ""}`, action: "think_off" })
244
+ entries.push({ type: "item", text: `Enabled ${thinkingEnabled ? "← current" : ""}`, action: "think_on" })
245
+ entries.push({ type: "item", text: `Disabled ${(curThinking?.type === "disabled" || curThinking === null) ? "← current" : ""}`, action: "think_off" })
132
246
  }
133
247
  entries.push({ type: "header", text: "Reasoning effort" })
134
248
  for (const level of effortLevels) {
135
249
  entries.push({ type: "item", text: `${level} ${curEffort === level ? "← current" : ""}`, action: `effort_${level}` })
136
250
  }
137
-
138
- const e = await showPicker("Advisor Thinking", entries)
139
- if (!e) return
140
-
141
- if (e.action === "inherit") {
142
- delete cfg.thinking
143
- delete cfg.reasoningEffort
144
- pushLine("Advisor: using main model thinking settings", C.dim)
145
- } else if (e.action === "think_on") {
146
- cfg.thinking = { type: thinkOnValue }
147
- if (isEffortOnly) delete cfg.thinking
148
- pushLine(`Advisor: thinking ON (${thinkOnValue})`, C.dim)
149
- } else if (e.action === "think_off") {
150
- cfg.thinking = isCustomThink ? null : { type: "disabled" }
151
- pushLine("Advisor: thinking OFF", C.dim)
152
- } else if (e.action.startsWith("effort_")) {
153
- cfg.reasoningEffort = e.action.slice(7)
154
- pushLine(`Advisor: reasoning effort = ${cfg.reasoningEffort}`, C.dim)
155
- }
156
- await persist()
251
+ return entries
157
252
  }
@@ -1,12 +1,10 @@
1
1
  /** /auto command: toggle auto-approve mode.
2
- * ctx: { agent } */
2
+ * ctx: { agent, pushLine, pushLabel } */
3
+ import { ansi, C } from "./ansi.mjs"
4
+
3
5
  export async function handleAutoCommand(ctx) {
4
- const { agent } = ctx
6
+ const { agent, pushLine, pushLabel } = ctx
5
7
  agent.autoApprove = !agent.autoApprove
6
- agent._pendingReminders = agent._pendingReminders ?? []
7
- if (agent.autoApprove) {
8
- agent._pendingReminders.push("[System reminder: AUTO mode is now ON. All tool calls are automatically approved — you may write, edit, and run commands without asking. Use this for long autonomous tasks. The user can still interrupt.]")
9
- } else {
10
- agent._pendingReminders.push("[System reminder: AUTO mode is now OFF. Destructive tool calls now require user approval again. Confirm before writing files, running commands, or spawning subagents.]")
11
- }
8
+ pushLabel("❯ Auto", ansi.bold + C.tool)
9
+ pushLine(`Auto-approve: ${agent.autoApprove ? "ON" : "OFF"}`, C.tool)
12
10
  }