@ia-ccun/code-agent-cli 0.0.51-beta.13 → 0.0.51-beta.15

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.
@@ -0,0 +1,707 @@
1
+ /**
2
+ * aicode-advisor extension
3
+ *
4
+ * @author xujianjiang
5
+ * 让工作模型在采取行动之前,向更强的审查模型征求第二意见
6
+ *
7
+ * Registers the `advisor` tool, `/advisor` command, and lifecycle
8
+ * hooks that implement the advisor-strategy pattern.
9
+ */
10
+
11
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
12
+ import { join, dirname } from "node:path";
13
+ import { fileURLToPath } from "node:url";
14
+ import type { Api, Model, StopReason, Usage } from "@earendil-works/pi-ai";
15
+ import { completeSimple, getSupportedThinkingLevels, type Message, type ThinkingLevel } from "@earendil-works/pi-ai";
16
+ import {
17
+ type AgentToolResult,
18
+ type AgentToolUpdateCallback,
19
+ buildSessionContext,
20
+ convertToLlm,
21
+ type ExtensionAPI,
22
+ type ExtensionContext,
23
+ type ToolInfo,
24
+ } from "@earendil-works/pi-coding-agent";
25
+ import type { SelectItem } from "@earendil-works/pi-tui";
26
+ import { DynamicBorder, type Theme } from "@earendil-works/pi-coding-agent";
27
+ import {
28
+ Container,
29
+ SelectList,
30
+ Spacer,
31
+ Text,
32
+ } from "@earendil-works/pi-tui";
33
+ import { Type } from "typebox";
34
+
35
+ // ============== 常量 ==============
36
+ const ADVISOR_TOOL_NAME = "advisor";
37
+ const TOOL_LABEL = "Advisor";
38
+ const CHECKMARK = " ✓";
39
+ const NO_ADVISOR_VALUE = "__no_advisor__";
40
+ const OFF_VALUE = "__off__";
41
+ const BASE_EFFORT_LEVELS: ThinkingLevel[] = ["minimal", "low", "medium", "high"];
42
+ const XHIGH_EFFORT_LEVEL: ThinkingLevel = "xhigh";
43
+ const EFFORT_ORDINAL: readonly ThinkingLevel[] = ["minimal", "low", "medium", "high", "xhigh"];
44
+ const DEFAULT_EFFORT: ThinkingLevel = "high";
45
+ const RECOMMENDED_EFFORT_SUFFIX = " (recommended)";
46
+
47
+ // 消息
48
+ const MSG_ADVISOR_DISABLED = "Advisor disabled";
49
+ const MSG_REQUIRES_INTERACTIVE = "/advisor requires interactive mode";
50
+ const MSG_ADVISOR_NUDGE = "Please advise on the executor's situation above.";
51
+ const MSG_PERSIST_FAILED = "Failed to save advisor selection";
52
+ const ERR_NO_MODEL = "No advisor model is configured. Use /advisor to enable one.";
53
+ const ERR_CALL_ABORTED = "Advisor call was cancelled.";
54
+ const ERR_EMPTY_RESPONSE = "Advisor returned no text content.";
55
+ const ERR_NO_MODEL_SELECTED = "no advisor model selected";
56
+ const ERR_EMPTY_RESPONSE_DETAIL = "empty response";
57
+ const ERR_ABORTED_DETAIL = "aborted";
58
+ const ERR_UNKNOWN = "unknown error";
59
+
60
+ // 系统提示词
61
+ const ADVISOR_SYSTEM_PROMPT = [
62
+ "You are a senior reviewer providing guidance to a working agent.",
63
+ "The working agent will share their full conversation context with you.",
64
+ "Your role is to provide strategic guidance, identify blind spots, suggest better approaches, or warn about risks.",
65
+ "Be concise but thorough. Focus on high-level strategy and critical issues.",
66
+ "Do not attempt to do the work yourself — provide guidance the working agent can act on.",
67
+ ].join(" ");
68
+
69
+ // ============== 配置持久化 ==============
70
+ interface AdvisorConfig {
71
+ modelKey?: string;
72
+ effort?: ThinkingLevel;
73
+ disabledForModels?: Array<string | { model: string; minEffort?: ThinkingLevel }>;
74
+ }
75
+
76
+ function getConfigPath(): string {
77
+ const configDir = join(process.env.HOME || process.env.USERPROFILE || "~", ".aicode-cli", "agent", "aicode-advisor");
78
+ if (!existsSync(configDir)) {
79
+ mkdirSync(configDir, { recursive: true, mode: 0o700 });
80
+ }
81
+ return join(configDir, "advisor.json");
82
+ }
83
+
84
+ function loadAdvisorConfig(): AdvisorConfig {
85
+ const configPath = getConfigPath();
86
+ try {
87
+ if (existsSync(configPath)) {
88
+ const content = readFileSync(configPath, "utf-8");
89
+ const config: AdvisorConfig = JSON.parse(content);
90
+ return config;
91
+ }
92
+ } catch {
93
+ // Ignore read errors
94
+ }
95
+ return {};
96
+ }
97
+
98
+ function saveAdvisorConfig(key: string | undefined, effort: ThinkingLevel | undefined): boolean {
99
+ const configPath = getConfigPath();
100
+ const existing = loadAdvisorConfig();
101
+ const config: AdvisorConfig = { ...existing };
102
+
103
+ if (key) config.modelKey = key;
104
+ else delete config.modelKey;
105
+
106
+ if (effort) config.effort = effort;
107
+ else delete config.effort;
108
+
109
+ try {
110
+ writeFileSync(configPath, JSON.stringify(config, null, 2), { mode: 0o600 });
111
+ return true;
112
+ } catch {
113
+ return false;
114
+ }
115
+ }
116
+
117
+ function parseModelKey(key: string): { provider: string; modelId: string } | undefined {
118
+ const idx = key.indexOf(":");
119
+ if (idx < 1) return undefined;
120
+ return { provider: key.slice(0, idx), modelId: key.slice(idx + 1) };
121
+ }
122
+
123
+ // ============== 运行时状态 ==============
124
+ let selectedAdvisor: Model<Api> | undefined;
125
+ let selectedAdvisorEffort: ThinkingLevel | undefined;
126
+ let disabledForModelsCache: Array<string | { model: string; minEffort?: ThinkingLevel }> = [];
127
+
128
+ function getAdvisorModel(): Model<Api> | undefined {
129
+ return selectedAdvisor;
130
+ }
131
+
132
+ function setAdvisorModel(model: Model<Api> | undefined): void {
133
+ selectedAdvisor = model;
134
+ }
135
+
136
+ function getAdvisorEffort(): ThinkingLevel | undefined {
137
+ return selectedAdvisorEffort;
138
+ }
139
+
140
+ function setAdvisorEffort(effort: ThinkingLevel | undefined): void {
141
+ selectedAdvisorEffort = effort;
142
+ }
143
+
144
+ function setDisabledForModels(models: Array<string | { model: string; minEffort?: ThinkingLevel }>): void {
145
+ disabledForModelsCache = models;
146
+ }
147
+
148
+ // ============== 工具清单序列化 ==============
149
+ function stableStringify(value: unknown): string {
150
+ if (value === null || typeof value !== "object") {
151
+ return JSON.stringify(value);
152
+ }
153
+ if (Array.isArray(value)) {
154
+ return `[${value.map((v) => (v === undefined ? "null" : stableStringify(v))).join(",")}]`;
155
+ }
156
+ const obj = value as Record<string, unknown>;
157
+ const entries: string[] = [];
158
+ for (const k of Object.keys(obj).sort()) {
159
+ const v = obj[k];
160
+ if (v === undefined) continue;
161
+ entries.push(`${JSON.stringify(k)}:${stableStringify(v)}`);
162
+ }
163
+ return `{${entries.join(",")}}`;
164
+ }
165
+
166
+ function buildInventoryBlock(tools: ToolInfo[]): string {
167
+ return tools
168
+ .map((t) => `### ${t.name}\n${t.description}\n\nParameters: ${stableStringify(t.parameters)}`)
169
+ .join("\n\n---\n\n");
170
+ }
171
+
172
+ function stripInflightAdvisorCall(messages: Message[]): Message[] {
173
+ if (messages.length === 0) return messages;
174
+ const last = messages[messages.length - 1];
175
+ if (last.role !== "assistant") return messages;
176
+ const filtered = last.content.filter((c) => !(c.type === "toolCall" && c.name === ADVISOR_TOOL_NAME));
177
+ if (filtered.length === last.content.length) return messages;
178
+ if (filtered.length === 0) return messages.slice(0, -1);
179
+ return [...messages.slice(0, -1), { ...last, content: filtered }];
180
+ }
181
+
182
+ function ensureUserTailForAdvisor(messages: Message[]): Message[] {
183
+ if (messages.length === 0) return messages;
184
+ const last = messages[messages.length - 1];
185
+ if (last.role !== "assistant") return messages;
186
+ const nudge: Message = {
187
+ role: "user",
188
+ content: [{ type: "text", text: MSG_ADVISOR_NUDGE }],
189
+ timestamp: Date.now(),
190
+ };
191
+ return [...messages, nudge];
192
+ }
193
+
194
+ function getInventoryMessage(tools: ToolInfo[]): Message | undefined {
195
+ if (tools.length === 0) return undefined;
196
+ const sorted = [...tools].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
197
+ const text = `## Available Executor Tools\n\n${buildInventoryBlock(sorted)}`;
198
+ return {
199
+ role: "user",
200
+ content: [{ type: "text", text }],
201
+ timestamp: Date.now(),
202
+ };
203
+ }
204
+
205
+ // ============== 会话恢复 ==============
206
+ function restoreAdvisorState(ctx: ExtensionContext, pi: ExtensionAPI): void {
207
+ const config = loadAdvisorConfig();
208
+ setDisabledForModels(config.disabledForModels || []);
209
+
210
+ if (!config.modelKey) return;
211
+
212
+ const parsed = parseModelKey(config.modelKey);
213
+ if (!parsed) return;
214
+
215
+ const model = ctx.modelRegistry.find(parsed.provider, parsed.modelId);
216
+ if (!model) {
217
+ if (ctx.hasUI) {
218
+ ctx.ui.notify(`Previously configured advisor model ${config.modelKey} is no longer available`, "warning");
219
+ }
220
+ return;
221
+ }
222
+
223
+ setAdvisorModel(model);
224
+ if (config.effort) {
225
+ setAdvisorEffort(config.effort);
226
+ }
227
+
228
+ if (isExecutorBlocked(ctx, pi.getThinkingLevel())) {
229
+ if (ctx.hasUI) {
230
+ ctx.ui.notify(`Advisor restored: ${model.provider}:${model.id} (inactive for current executor)`, "info");
231
+ }
232
+ return;
233
+ }
234
+
235
+ const active = pi.getActiveTools();
236
+ if (!active.includes(ADVISOR_TOOL_NAME)) {
237
+ pi.setActiveTools([...active, ADVISOR_TOOL_NAME]);
238
+ }
239
+
240
+ if (ctx.hasUI) {
241
+ ctx.ui.notify(`Advisor restored: ${model.provider}:${model.id}${config.effort ? `, ${config.effort}` : ""}`, "info");
242
+ }
243
+ }
244
+
245
+ // ============== Advisor 工具执行 ==============
246
+ interface AdvisorDetails {
247
+ advisorModel?: string;
248
+ effort?: ThinkingLevel;
249
+ usage?: Usage;
250
+ stopReason?: StopReason;
251
+ errorMessage?: string;
252
+ }
253
+
254
+ function buildErrorResult(
255
+ advisorLabel: string | undefined,
256
+ userText: string,
257
+ errorMessage: string,
258
+ ): AgentToolResult<AdvisorDetails> {
259
+ const effort = getAdvisorEffort();
260
+ return {
261
+ content: [{ type: "text", text: userText }],
262
+ details: advisorLabel ? { advisorModel: advisorLabel, effort, errorMessage } : { effort, errorMessage },
263
+ };
264
+ }
265
+
266
+ async function executeAdvisor(
267
+ ctx: ExtensionContext,
268
+ pi: ExtensionAPI,
269
+ signal: AbortSignal | undefined,
270
+ onUpdate: AgentToolUpdateCallback<AdvisorDetails> | undefined,
271
+ ): Promise<AgentToolResult<AdvisorDetails>> {
272
+ const advisor = getAdvisorModel();
273
+ if (!advisor) {
274
+ return buildErrorResult(undefined, ERR_NO_MODEL, ERR_NO_MODEL_SELECTED);
275
+ }
276
+ const advisorLabel = `${advisor.provider}:${advisor.id}`;
277
+ const effort = getAdvisorEffort();
278
+
279
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(advisor);
280
+ if (!auth.ok) {
281
+ return buildErrorResult(advisorLabel, `Advisor (${advisorLabel}) is misconfigured: ${auth.error}`, auth.error);
282
+ }
283
+ if (!auth.apiKey) {
284
+ return buildErrorResult(advisorLabel, `Advisor (${advisorLabel}) has no API key available.`, `no API key for ${advisor.provider}`);
285
+ }
286
+
287
+ const { messages: sessionMessages } = buildSessionContext(
288
+ ctx.sessionManager.getEntries(),
289
+ ctx.sessionManager.getLeafId(),
290
+ );
291
+ const branchMessages = ensureUserTailForAdvisor(stripInflightAdvisorCall(convertToLlm(sessionMessages)));
292
+ const inventoryMessage = getInventoryMessage(pi.getAllTools());
293
+ const messages: Message[] = inventoryMessage ? [inventoryMessage, ...branchMessages] : branchMessages;
294
+
295
+ onUpdate?.({
296
+ content: [{ type: "text", text: `Consulting advisor (${advisorLabel}${effort ? `, ${effort}` : ""})…` }],
297
+ details: { advisorModel: advisorLabel, effort },
298
+ });
299
+
300
+ try {
301
+ const response = await completeSimple(
302
+ advisor,
303
+ { systemPrompt: ADVISOR_SYSTEM_PROMPT, messages, tools: [] },
304
+ { apiKey: auth.apiKey, headers: auth.headers, signal, reasoning: effort },
305
+ );
306
+
307
+ if (response.stopReason === "aborted") {
308
+ return {
309
+ content: [{ type: "text", text: ERR_CALL_ABORTED }],
310
+ details: {
311
+ advisorModel: advisorLabel,
312
+ effort,
313
+ usage: response.usage,
314
+ stopReason: response.stopReason,
315
+ errorMessage: response.errorMessage ?? ERR_ABORTED_DETAIL,
316
+ },
317
+ };
318
+ }
319
+
320
+ if (response.stopReason === "error") {
321
+ return {
322
+ content: [{ type: "text", text: `Advisor call failed: ${response.errorMessage}` }],
323
+ details: {
324
+ advisorModel: advisorLabel,
325
+ effort,
326
+ usage: response.usage,
327
+ stopReason: response.stopReason,
328
+ errorMessage: response.errorMessage,
329
+ },
330
+ };
331
+ }
332
+
333
+ const advisorText = response.content
334
+ .filter((c): c is { type: "text"; text: string } => c.type === "text")
335
+ .map((c) => c.text)
336
+ .join("\n")
337
+ .trim();
338
+
339
+ if (!advisorText) {
340
+ return {
341
+ content: [{ type: "text", text: ERR_EMPTY_RESPONSE }],
342
+ details: {
343
+ advisorModel: advisorLabel,
344
+ effort,
345
+ usage: response.usage,
346
+ stopReason: response.stopReason,
347
+ errorMessage: ERR_EMPTY_RESPONSE_DETAIL,
348
+ },
349
+ };
350
+ }
351
+
352
+ return {
353
+ content: [{ type: "text", text: advisorText }],
354
+ details: {
355
+ advisorModel: advisorLabel,
356
+ effort,
357
+ usage: response.usage,
358
+ stopReason: response.stopReason,
359
+ },
360
+ };
361
+ } catch (err) {
362
+ const message = err instanceof Error ? err.message : String(err);
363
+ return buildErrorResult(advisorLabel, `Advisor call threw: ${message}`, message);
364
+ }
365
+ }
366
+
367
+ // ============== 工具注册 ==============
368
+ const AdvisorParams = Type.Object({});
369
+
370
+ const ADVISOR_DESCRIPTION =
371
+ "Escalate to a stronger reviewer model for guidance. When you need " +
372
+ "stronger judgment — a complex decision, an ambiguous failure, a problem " +
373
+ "you're circling without progress — escalate to the advisor model for " +
374
+ "guidance, then resume. Takes NO parameters — when you call advisor(), " +
375
+ "your entire conversation history is automatically forwarded. The advisor " +
376
+ "sees the task, every tool call you've made, every result you've seen.";
377
+
378
+ const DEFAULT_PROMPT_SNIPPET =
379
+ "Escalate to a stronger reviewer model for guidance when stuck, before substantive work, or before declaring done";
380
+
381
+ const DEFAULT_PROMPT_GUIDELINES: string[] = [
382
+ "Call `advisor` BEFORE substantive work — before writing, before committing to an interpretation, before building on an assumption.",
383
+ "Also call `advisor` when you believe the task is complete. BEFORE this call, make your deliverable durable.",
384
+ "Also call `advisor` when stuck — errors recurring, approach not converging, results that don't fit.",
385
+ "On tasks longer than a few steps, call `advisor` at least once before committing to an approach and once before declaring done.",
386
+ "Give the advisor's advice serious weight. Adapt if empirical evidence contradicts it.",
387
+ "If advisor points another direction than your data, reconcile with another `advisor` call before switching.",
388
+ ];
389
+
390
+ function registerAdvisorTool(pi: ExtensionAPI): void {
391
+ pi.registerTool({
392
+ name: ADVISOR_TOOL_NAME,
393
+ label: TOOL_LABEL,
394
+ description: ADVISOR_DESCRIPTION,
395
+ promptSnippet: DEFAULT_PROMPT_SNIPPET,
396
+ promptGuidelines: DEFAULT_PROMPT_GUIDELINES,
397
+ parameters: AdvisorParams,
398
+ async execute(_toolCallId, _params, signal, onUpdate, ctx) {
399
+ return executeAdvisor(ctx, pi, signal, onUpdate);
400
+ },
401
+ });
402
+ }
403
+
404
+ // ============== before_agent_start 处理器 ==============
405
+ function registerAdvisorBeforeAgentStart(pi: ExtensionAPI): void {
406
+ pi.on("before_agent_start", async (_event, ctx) => {
407
+ const advisor = getAdvisorModel();
408
+ if (!advisor) {
409
+ const active = pi.getActiveTools();
410
+ if (active.includes(ADVISOR_TOOL_NAME)) {
411
+ pi.setActiveTools(active.filter((n) => n !== ADVISOR_TOOL_NAME));
412
+ }
413
+ return;
414
+ }
415
+ const blocked = isExecutorBlocked(ctx, pi.getThinkingLevel());
416
+ const active = pi.getActiveTools();
417
+ const hasTool = active.includes(ADVISOR_TOOL_NAME);
418
+ if (blocked && hasTool) {
419
+ pi.setActiveTools(active.filter((n) => n !== ADVISOR_TOOL_NAME));
420
+ } else if (!blocked && !hasTool) {
421
+ pi.setActiveTools([...active, ADVISOR_TOOL_NAME]);
422
+ }
423
+ });
424
+ }
425
+
426
+ // ============== model_select 处理器 ==============
427
+ function modelKey(m: { provider: string; id: string }): string {
428
+ return `${m.provider}:${m.id}`;
429
+ }
430
+
431
+ function isModelBlocked(model: Model<Api> | undefined, thinkingLevel?: string): boolean {
432
+ if (!model) return false;
433
+ const key = modelKey(model);
434
+ for (const entry of disabledForModelsCache) {
435
+ if (typeof entry === "string") {
436
+ if (entry === key) return true;
437
+ } else {
438
+ if (entry.model !== key) continue;
439
+ if (entry.minEffort === undefined) return true;
440
+ const thresholdOrdinal = EFFORT_ORDINAL.indexOf(entry.minEffort);
441
+ const executorOrdinal = EFFORT_ORDINAL.indexOf(thinkingLevel as ThinkingLevel);
442
+ if (executorOrdinal >= thresholdOrdinal) return true;
443
+ }
444
+ }
445
+ return false;
446
+ }
447
+
448
+ function isExecutorBlocked(ctx: ExtensionContext, thinkingLevel?: string): boolean {
449
+ return isModelBlocked(ctx?.model, thinkingLevel);
450
+ }
451
+
452
+ function registerModelSelectHandler(pi: ExtensionAPI): void {
453
+ pi.on("model_select", async (event, ctx) => {
454
+ if (event.source === "restore") return;
455
+
456
+ const advisor = getAdvisorModel();
457
+ if (!advisor) return;
458
+
459
+ const blocked = isModelBlocked(event.model, pi.getThinkingLevel());
460
+ const active = pi.getActiveTools();
461
+ const hasTool = active.includes(ADVISOR_TOOL_NAME);
462
+
463
+ if (blocked && hasTool) {
464
+ pi.setActiveTools(active.filter((n) => n !== ADVISOR_TOOL_NAME));
465
+ if (ctx.hasUI) {
466
+ ctx.ui.notify(`Advisor disabled for ${modelKey(event.model)}`, "info");
467
+ }
468
+ } else if (!blocked && !hasTool) {
469
+ pi.setActiveTools([...active, ADVISOR_TOOL_NAME]);
470
+ if (ctx.hasUI) {
471
+ ctx.ui.notify(`Advisor restored: ${modelKey(advisor)}`, "info");
472
+ }
473
+ }
474
+ });
475
+ }
476
+
477
+ // ============== /advisor 命令 UI (使用原版 SelectList 实现) ==============
478
+ const MAX_VISIBLE_ROWS = 10;
479
+ const NAV_HINT = "↑↓ navigate • enter select • esc cancel";
480
+
481
+ const ADVISOR_HEADER_TITLE = "Advisor Tool";
482
+ const ADVISOR_HEADER_PROSE_1 =
483
+ "当工作模型遇到复杂决策、难以判断的失败、或反复尝试却无法推进的困境时," +
484
+ "可以向更强的审查模型征求第二意见。审查模型在云端运行,会额外消耗 Token。";
485
+ const ADVISOR_HEADER_PROSE_2 =
486
+ "最佳实践:主模型选用速度型,审查模型选用能力型 —— 既能获得顶级模型的判断力," +
487
+ "又能控制 Token 成本。";
488
+
489
+ const ADVISOR_HELP_TITLE = "💡 使用场景";
490
+ const ADVISOR_HELP_EXAMPLES = [
491
+ " • 复杂决策前:「这个架构方案有没有潜在风险?」",
492
+ " • 遇到模糊错误:「为什么测试一直失败?我是不是漏了什么?」",
493
+ " • 反复尝试无果:「我已经试了 3 种方法都不行,请帮我分析」",
494
+ " • 完成任务前:「我准备提交了,请帮我检查有没有遗漏」",
495
+ " • 长任务关键节点:「我打算这么做,请确认方向是否正确」",
496
+ ];
497
+
498
+ const EFFORT_HEADER_TITLE = "Reasoning Level";
499
+ const EFFORT_HEADER_PROSE =
500
+ "推理能力级别越高,判断越深入,但 Token 消耗也越多。";
501
+
502
+ function selectListTheme(theme: Theme) {
503
+ return {
504
+ selectedPrefix: (t: string) => theme.bg("selectedBg", theme.fg("accent", t)),
505
+ selectedText: (t: string) => theme.bg("selectedBg", theme.bold(t)),
506
+ description: (t: string) => theme.fg("muted", t),
507
+ scrollInfo: (t: string) => theme.fg("dim", t),
508
+ noMatch: (t: string) => theme.fg("warning", t),
509
+ };
510
+ }
511
+
512
+ function buildSelectPanel(theme: Theme, title: string, proseLines: string[], selectList: SelectList, helpLines?: string[]): Container {
513
+ const container = new Container();
514
+ const border = () => new DynamicBorder((s: string) => theme.fg("accent", s));
515
+
516
+ container.addChild(border());
517
+ container.addChild(new Spacer(1));
518
+ container.addChild(new Text(theme.fg("accent", theme.bold(title)), 1, 0));
519
+ container.addChild(new Spacer(1));
520
+ for (const line of proseLines) {
521
+ container.addChild(new Text(line, 1, 0));
522
+ container.addChild(new Spacer(1));
523
+ }
524
+
525
+ // 添加帮助信息(如果有)
526
+ if (helpLines && helpLines.length > 0) {
527
+ container.addChild(new Text(theme.fg("accent", theme.bold(helpLines[0])), 1, 0));
528
+ container.addChild(new Spacer(1));
529
+ for (let i = 1; i < helpLines.length; i++) {
530
+ container.addChild(new Text(helpLines[i], 1, 0));
531
+ }
532
+ container.addChild(new Spacer(1));
533
+ }
534
+
535
+ container.addChild(selectList);
536
+ container.addChild(new Spacer(1));
537
+ container.addChild(new Text(theme.fg("dim", NAV_HINT), 1, 0));
538
+ container.addChild(new Spacer(1));
539
+ container.addChild(border());
540
+ return container;
541
+ }
542
+
543
+ function showAdvisorPicker(
544
+ ctx: ExtensionContext,
545
+ items: SelectItem[],
546
+ ): Promise<string | null> {
547
+ return ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
548
+ const selectList = new SelectList(items, Math.min(items.length, MAX_VISIBLE_ROWS), selectListTheme(theme));
549
+ selectList.onSelect = (item) => done(item.value);
550
+ selectList.onCancel = () => done(null);
551
+
552
+ const container = buildSelectPanel(
553
+ theme,
554
+ ADVISOR_HEADER_TITLE,
555
+ [ADVISOR_HEADER_PROSE_1, ADVISOR_HEADER_PROSE_2],
556
+ selectList,
557
+ [ADVISOR_HELP_TITLE, ...ADVISOR_HELP_EXAMPLES],
558
+ );
559
+
560
+ return {
561
+ render: (w: number) => container.render(w),
562
+ invalidate: () => container.invalidate(),
563
+ handleInput: (data: string) => {
564
+ selectList.handleInput(data);
565
+ tui.requestRender();
566
+ },
567
+ };
568
+ });
569
+ }
570
+
571
+ function showEffortPicker(
572
+ ctx: ExtensionContext,
573
+ items: SelectItem[],
574
+ currentEffort?: ThinkingLevel,
575
+ defaultEffort?: ThinkingLevel,
576
+ ): Promise<string | null> {
577
+ return ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
578
+ const selectList = new SelectList(items, Math.min(items.length, MAX_VISIBLE_ROWS), selectListTheme(theme));
579
+ const preferredIdx = currentEffort ? items.findIndex((item) => item.value === currentEffort) : -1;
580
+ selectList.setSelectedIndex(
581
+ preferredIdx >= 0 ? preferredIdx : items.findIndex((item) => item.value === defaultEffort),
582
+ );
583
+ selectList.onSelect = (item) => done(item.value);
584
+ selectList.onCancel = () => done(null);
585
+
586
+ const container = buildSelectPanel(theme, EFFORT_HEADER_TITLE, [EFFORT_HEADER_PROSE], selectList);
587
+
588
+ return {
589
+ render: (w: number) => container.render(w),
590
+ invalidate: () => container.invalidate(),
591
+ handleInput: (data: string) => {
592
+ selectList.handleInput(data);
593
+ tui.requestRender();
594
+ },
595
+ };
596
+ });
597
+ }
598
+
599
+ // ============== /advisor 命令注册 ==============
600
+ function registerAdvisorCommand(pi: ExtensionAPI): void {
601
+ pi.registerCommand("advisor", {
602
+ description: "Configure the advisor model for the advisor-strategy pattern",
603
+ handler: async (_args, ctx) => {
604
+ if (!ctx.hasUI) {
605
+ ctx.ui.notify(MSG_REQUIRES_INTERACTIVE, "error");
606
+ return;
607
+ }
608
+
609
+ const availableModels = ctx.modelRegistry.getAvailable();
610
+ const current = getAdvisorModel();
611
+ const currentKey = current ? modelKey(current) : undefined;
612
+
613
+ const items: SelectItem[] = availableModels.map((m) => {
614
+ const key = modelKey(m);
615
+ const check = key === currentKey ? CHECKMARK : "";
616
+ return { value: key, label: `${m.name} (${m.provider})${check}` };
617
+ });
618
+ items.push({
619
+ value: NO_ADVISOR_VALUE,
620
+ label: currentKey === undefined ? `No advisor${CHECKMARK}` : "No advisor",
621
+ });
622
+
623
+ const choice = await showAdvisorPicker(ctx, items);
624
+ if (!choice) {
625
+ return;
626
+ }
627
+
628
+ const activeTools = pi.getActiveTools();
629
+ const activeHas = activeTools.includes(ADVISOR_TOOL_NAME);
630
+
631
+ if (choice === NO_ADVISOR_VALUE) {
632
+ if (!saveAdvisorConfig(undefined, undefined)) {
633
+ ctx.ui.notify(MSG_PERSIST_FAILED, "error");
634
+ return;
635
+ }
636
+ setAdvisorModel(undefined);
637
+ setAdvisorEffort(undefined);
638
+ if (activeHas) {
639
+ pi.setActiveTools(activeTools.filter((n) => n !== ADVISOR_TOOL_NAME));
640
+ }
641
+ ctx.ui.notify(MSG_ADVISOR_DISABLED, "info");
642
+ return;
643
+ }
644
+
645
+ const picked = availableModels.find((m) => modelKey(m) === choice);
646
+ if (!picked) {
647
+ ctx.ui.notify(`Advisor selection not found: ${choice}`, "error");
648
+ return;
649
+ }
650
+
651
+ // Effort picker for reasoning-capable models
652
+ let effortChoice: ThinkingLevel | undefined;
653
+ if (picked.reasoning) {
654
+ const levels = getSupportedThinkingLevels(picked).includes("xhigh")
655
+ ? [...BASE_EFFORT_LEVELS, XHIGH_EFFORT_LEVEL]
656
+ : BASE_EFFORT_LEVELS;
657
+
658
+ const effortItems: SelectItem[] = [
659
+ { value: OFF_VALUE, label: "off" },
660
+ ...levels.map((level) => ({
661
+ value: level,
662
+ label: level === DEFAULT_EFFORT ? `${level}${RECOMMENDED_EFFORT_SUFFIX}` : level,
663
+ })),
664
+ ];
665
+
666
+ const effortResult = await showEffortPicker(ctx, effortItems, getAdvisorEffort(), DEFAULT_EFFORT);
667
+ if (!effortResult) {
668
+ return;
669
+ }
670
+ effortChoice = effortResult === OFF_VALUE ? undefined : (effortResult as ThinkingLevel);
671
+ }
672
+
673
+ if (!saveAdvisorConfig(modelKey(picked), effortChoice)) {
674
+ ctx.ui.notify(MSG_PERSIST_FAILED, "error");
675
+ return;
676
+ }
677
+ setAdvisorEffort(effortChoice);
678
+ setAdvisorModel(picked);
679
+
680
+ const activeToolsNow = pi.getActiveTools();
681
+ const activeHasNow = activeToolsNow.includes(ADVISOR_TOOL_NAME);
682
+ const blocked = isExecutorBlocked(ctx, pi.getThinkingLevel());
683
+
684
+ if (!activeHasNow && !blocked) {
685
+ pi.setActiveTools([...activeToolsNow, ADVISOR_TOOL_NAME]);
686
+ }
687
+
688
+ if (blocked) {
689
+ ctx.ui.notify(`Advisor: ${modelKey(picked)}${effortChoice ? `, ${effortChoice}` : ""} (inactive for current executor)`, "info");
690
+ } else {
691
+ ctx.ui.notify(`Advisor: ${modelKey(picked)}${effortChoice ? `, ${effortChoice}` : ""}`, "info");
692
+ }
693
+ },
694
+ });
695
+ }
696
+
697
+ // ============== 导出 ==============
698
+ export default function (pi: ExtensionAPI) {
699
+ registerAdvisorTool(pi);
700
+ registerAdvisorCommand(pi);
701
+ registerAdvisorBeforeAgentStart(pi);
702
+ registerModelSelectHandler(pi);
703
+
704
+ pi.on("session_start", async (_event, ctx) => {
705
+ restoreAdvisorState(ctx, pi);
706
+ });
707
+ }