@oh-my-pi/pi-coding-agent 15.13.3 → 16.0.1

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.
Files changed (93) hide show
  1. package/CHANGELOG.md +155 -133
  2. package/dist/cli.js +621 -530
  3. package/dist/types/advisor/__tests__/advisor.test.d.ts +1 -0
  4. package/dist/types/advisor/advise-tool.d.ts +58 -0
  5. package/dist/types/advisor/index.d.ts +3 -0
  6. package/dist/types/advisor/runtime.d.ts +52 -0
  7. package/dist/types/advisor/watchdog.d.ts +5 -0
  8. package/dist/types/config/model-roles.d.ts +1 -1
  9. package/dist/types/config/settings-schema.d.ts +66 -5
  10. package/dist/types/discovery/helpers.d.ts +7 -0
  11. package/dist/types/eval/__tests__/prelude-agent.test.d.ts +1 -0
  12. package/dist/types/extensibility/plugins/runtime-config.d.ts +3 -0
  13. package/dist/types/modes/components/advisor-message.d.ts +9 -0
  14. package/dist/types/modes/components/assistant-message.d.ts +1 -0
  15. package/dist/types/modes/controllers/command-controller.d.ts +3 -1
  16. package/dist/types/modes/interactive-mode.d.ts +3 -1
  17. package/dist/types/modes/types.d.ts +8 -1
  18. package/dist/types/sdk.d.ts +3 -3
  19. package/dist/types/session/agent-session.d.ts +81 -2
  20. package/dist/types/session/session-history-format.d.ts +4 -0
  21. package/dist/types/session/session-manager.d.ts +4 -1
  22. package/dist/types/session/yield-queue.d.ts +2 -0
  23. package/dist/types/task/index.d.ts +21 -0
  24. package/dist/types/tools/github-cache.d.ts +5 -4
  25. package/dist/types/tools/job.d.ts +1 -0
  26. package/dist/types/tools/path-utils.d.ts +1 -0
  27. package/dist/types/tools/report-tool-issue.d.ts +0 -1
  28. package/dist/types/web/search/index.d.ts +2 -2
  29. package/dist/types/web/search/provider.d.ts +2 -0
  30. package/package.json +13 -13
  31. package/src/advisor/__tests__/advisor.test.ts +586 -0
  32. package/src/advisor/advise-tool.ts +87 -0
  33. package/src/advisor/index.ts +3 -0
  34. package/src/advisor/runtime.ts +248 -0
  35. package/src/advisor/watchdog.ts +83 -0
  36. package/src/cli/args.ts +1 -0
  37. package/src/collab/host.ts +1 -1
  38. package/src/config/model-roles.ts +13 -1
  39. package/src/config/settings-schema.ts +65 -6
  40. package/src/discovery/claude-plugins.ts +3 -42
  41. package/src/discovery/github.ts +101 -6
  42. package/src/discovery/helpers.ts +11 -0
  43. package/src/eval/__tests__/prelude-agent.test.ts +73 -0
  44. package/src/eval/js/shared/prelude.txt +12 -3
  45. package/src/eval/py/prelude.py +26 -2
  46. package/src/extensibility/custom-commands/bundled/review/index.ts +289 -80
  47. package/src/extensibility/plugins/loader.ts +3 -2
  48. package/src/extensibility/plugins/manager.ts +4 -3
  49. package/src/extensibility/plugins/marketplace/fetcher.ts +32 -34
  50. package/src/extensibility/plugins/runtime-config.ts +9 -0
  51. package/src/internal-urls/docs-index.generated.ts +10 -9
  52. package/src/internal-urls/issue-pr-protocol.ts +8 -4
  53. package/src/main.ts +9 -1
  54. package/src/modes/acp/acp-agent.ts +3 -3
  55. package/src/modes/components/advisor-message.ts +99 -0
  56. package/src/modes/components/agent-hub.ts +7 -0
  57. package/src/modes/components/assistant-message.ts +86 -0
  58. package/src/modes/components/settings-defs.ts +7 -0
  59. package/src/modes/components/status-line/segments.ts +20 -7
  60. package/src/modes/components/tips.txt +1 -1
  61. package/src/modes/controllers/command-controller.ts +69 -2
  62. package/src/modes/controllers/extension-ui-controller.ts +4 -3
  63. package/src/modes/controllers/input-controller.ts +1 -0
  64. package/src/modes/controllers/selector-controller.ts +7 -0
  65. package/src/modes/interactive-mode.ts +59 -2
  66. package/src/modes/rpc/rpc-mode.ts +3 -3
  67. package/src/modes/runtime-init.ts +2 -1
  68. package/src/modes/types.ts +8 -1
  69. package/src/modes/utils/ui-helpers.ts +9 -0
  70. package/src/prompts/advisor/advise-tool.md +1 -0
  71. package/src/prompts/advisor/system.md +31 -0
  72. package/src/prompts/agents/designer.md +8 -0
  73. package/src/prompts/review-request.md +1 -1
  74. package/src/prompts/system/subagent-system-prompt.md +4 -1
  75. package/src/prompts/tools/eval.md +13 -3
  76. package/src/prompts/tools/irc.md +1 -1
  77. package/src/sdk.ts +61 -14
  78. package/src/session/agent-session.ts +667 -13
  79. package/src/session/session-dump-format.ts +15 -131
  80. package/src/session/session-history-format.ts +30 -11
  81. package/src/session/session-manager.ts +3 -1
  82. package/src/session/yield-queue.ts +5 -1
  83. package/src/slash-commands/builtin-registry.ts +105 -4
  84. package/src/system-prompt.ts +1 -1
  85. package/src/task/executor.ts +5 -4
  86. package/src/task/index.ts +70 -9
  87. package/src/tools/github-cache.ts +32 -7
  88. package/src/tools/job.ts +14 -1
  89. package/src/tools/path-utils.ts +33 -2
  90. package/src/tools/report-tool-issue.ts +2 -7
  91. package/src/web/scrapers/docs-rs.ts +2 -3
  92. package/src/web/search/index.ts +2 -2
  93. package/src/web/search/provider.ts +14 -2
@@ -0,0 +1,87 @@
1
+ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
2
+ import { z } from "zod/v4";
3
+ import adviseDescription from "../prompts/advisor/advise-tool.md" with { type: "text" };
4
+
5
+ const adviseSchema = z.object({
6
+ note: z
7
+ .string()
8
+ .describe("One concrete piece of advice for the agent you are watching. Terse, specific, actionable."),
9
+ severity: z
10
+ .enum(["nit", "concern", "blocker"])
11
+ .optional()
12
+ .describe("How strongly to weigh this. Omit for a plain nit."),
13
+ });
14
+
15
+ export type AdviseParams = z.infer<typeof adviseSchema>;
16
+
17
+ export type AdvisorSeverity = "nit" | "concern" | "blocker";
18
+
19
+ export interface AdviseDetails {
20
+ note: string;
21
+ severity?: AdvisorSeverity;
22
+ }
23
+
24
+ /** One queued advice note. */
25
+ export interface AdvisorNote {
26
+ note: string;
27
+ severity?: AdvisorSeverity;
28
+ }
29
+
30
+ /** Details payload on the batched `advisor` custom message rendered in the transcript. */
31
+ export interface AdvisorMessageDetails {
32
+ notes: AdvisorNote[];
33
+ }
34
+
35
+ /**
36
+ * Prose framing prepended to every batched advisor message. Kept here so the
37
+ * non-interrupting YieldQueue dispatcher and the interrupting steer path build
38
+ * byte-identical content.
39
+ */
40
+ const ADVISOR_BATCH_PREFIX = "Advisor (a senior reviewer watching your work — weigh it, don't blindly obey):";
41
+
42
+ /** Render one advisor card body from a batch of notes (prefix + one bullet per note). */
43
+ export function formatAdvisorBatchContent(notes: readonly AdvisorNote[]): string {
44
+ return `${ADVISOR_BATCH_PREFIX}\n${notes.map(n => `- ${n.severity ? `[${n.severity}] ` : ""}${n.note}`).join("\n")}`;
45
+ }
46
+
47
+ /**
48
+ * Whether advice at this severity should interrupt the running agent (delivered
49
+ * via the steering channel, aborting in-flight tools) rather than ride the
50
+ * non-interrupting aside queue that lands at the next step boundary. `concern`
51
+ * and `blocker` interrupt; a plain `nit` queues.
52
+ */
53
+ export function isInterruptingSeverity(severity: AdvisorSeverity | undefined): boolean {
54
+ return severity === "concern" || severity === "blocker";
55
+ }
56
+
57
+ /**
58
+ * Side-effect-free investigation tools handed to the advisor agent so it can
59
+ * inspect the workspace before weighing in. Names match the primary session's
60
+ * tool instances, which the advisor reuses.
61
+ */
62
+ export const ADVISOR_READONLY_TOOL_NAMES: ReadonlySet<string> = new Set(["read", "search", "find"]);
63
+
64
+ export class AdviseTool implements AgentTool<typeof adviseSchema, AdviseDetails> {
65
+ readonly name = "advise";
66
+ readonly label = "Advise";
67
+ readonly description = adviseDescription;
68
+ readonly parameters = adviseSchema;
69
+ readonly intent = "omit" as const;
70
+
71
+ constructor(private readonly onAdvice: (note: string, severity?: AdviseDetails["severity"]) => void) {}
72
+
73
+ async execute(
74
+ _toolCallId: string,
75
+ args: AdviseParams,
76
+ _signal?: AbortSignal,
77
+ _onUpdate?: AgentToolUpdateCallback<AdviseDetails>,
78
+ _context?: AgentToolContext,
79
+ ): Promise<AgentToolResult<AdviseDetails>> {
80
+ this.onAdvice(args.note, args.severity);
81
+ return {
82
+ content: [{ type: "text", text: "Recorded." }],
83
+ details: { note: args.note, severity: args.severity },
84
+ useless: true,
85
+ };
86
+ }
87
+ }
@@ -0,0 +1,3 @@
1
+ export * from "./advise-tool";
2
+ export * from "./runtime";
3
+ export * from "./watchdog";
@@ -0,0 +1,248 @@
1
+ import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
2
+ import { estimateTokens } from "@oh-my-pi/pi-agent-core/compaction";
3
+ import { logger } from "@oh-my-pi/pi-utils";
4
+ import { formatSessionHistoryMarkdown } from "../session/session-history-format";
5
+
6
+ /** Minimal slice of `Agent` the runtime drives — satisfied by pi-agent-core `Agent`. */
7
+ export interface AdvisorAgent {
8
+ prompt(input: string): Promise<void>;
9
+ abort(reason?: unknown): void;
10
+ reset(): void;
11
+ readonly state: { messages: AgentMessage[] };
12
+ }
13
+
14
+ export interface AdvisorRuntimeHost {
15
+ /** Live primary transcript (use `agent.state.messages`). */
16
+ snapshotMessages(): AgentMessage[];
17
+ /** Surface one advice note to the primary (enqueues into the session YieldQueue). */
18
+ enqueueAdvice(note: string, severity?: "nit" | "concern" | "blocker"): void;
19
+ /**
20
+ * Pre-prompt context maintenance for the advisor's own append-only context.
21
+ * Promotes the advisor model to a larger sibling when its context nears the
22
+ * window (mirroring the primary's promote-first policy) and resolves `true`
23
+ * when the advisor should re-prime — reset and replay the current
24
+ * primary-bounded transcript — because promotion did not free enough room.
25
+ * Optional: hosts that omit it get no maintenance (context only shrinks when
26
+ * the primary's next compaction triggers {@link AdvisorRuntime.reset}).
27
+ */
28
+ maintainContext?(incomingTokens: number): Promise<boolean>;
29
+ }
30
+
31
+ interface PendingDelta {
32
+ text: string;
33
+ turns: number;
34
+ }
35
+
36
+ interface CatchupWaiter {
37
+ threshold: number;
38
+ resolve: () => void;
39
+ finish: () => void;
40
+ timer?: NodeJS.Timeout;
41
+ }
42
+
43
+ export class AdvisorRuntime {
44
+ #lastCount = 0;
45
+ #pending: PendingDelta[] = [];
46
+ #busy = false;
47
+ #backlog = 0;
48
+ #consecutiveFailures = 0;
49
+ #latestMessages?: AgentMessage[];
50
+ #waiters: CatchupWaiter[] = [];
51
+ disposed = false;
52
+
53
+ constructor(
54
+ private readonly agent: AdvisorAgent,
55
+ private readonly host: AdvisorRuntimeHost,
56
+ private readonly retryDelayMs = 1000,
57
+ ) {}
58
+
59
+ get backlog(): number {
60
+ return this.#backlog;
61
+ }
62
+
63
+ onTurnEnd(messages?: AgentMessage[]): void {
64
+ if (this.disposed) return;
65
+ const all = messages ?? this.host.snapshotMessages();
66
+ this.#latestMessages = all;
67
+ const render = this.#renderDelta(all);
68
+ if (render) {
69
+ this.#pending.push({ text: render, turns: 1 });
70
+ this.#backlog++;
71
+ this.#notifyWaiters();
72
+ void this.#drain();
73
+ }
74
+ }
75
+
76
+ waitForCatchup(maxMs: number, threshold: number, signal?: AbortSignal): Promise<void> {
77
+ if (this.disposed || signal?.aborted || this.#backlog < threshold) return Promise.resolve();
78
+ const { promise, resolve } = Promise.withResolvers<void>();
79
+ let waiter!: CatchupWaiter;
80
+ const finish = (): void => {
81
+ const idx = this.#waiters.indexOf(waiter);
82
+ if (idx >= 0) this.#waiters.splice(idx, 1);
83
+ clearTimeout(waiter.timer);
84
+ signal?.removeEventListener("abort", finish);
85
+ resolve();
86
+ };
87
+ waiter = { threshold, resolve, finish, timer: setTimeout(finish, maxMs) };
88
+ this.#waiters.push(waiter);
89
+ signal?.addEventListener("abort", finish, { once: true });
90
+ if (signal?.aborted) {
91
+ finish();
92
+ }
93
+ return promise;
94
+ }
95
+
96
+ dispose(): void {
97
+ this.disposed = true;
98
+ this.#pending = [];
99
+ this.#backlog = 0;
100
+ this.#consecutiveFailures = 0;
101
+ this.#wakeAllWaiters();
102
+ try {
103
+ this.agent.abort("advisor disposed");
104
+ } catch {}
105
+ }
106
+
107
+ #resetAdvisorContext(clearBacklog: boolean, wakeWaiters: boolean): void {
108
+ this.#lastCount = 0;
109
+ this.#pending = [];
110
+ this.#consecutiveFailures = 0;
111
+ if (clearBacklog) {
112
+ this.#backlog = 0;
113
+ }
114
+ if (wakeWaiters) {
115
+ this.#wakeAllWaiters();
116
+ }
117
+ try {
118
+ this.agent.reset();
119
+ } catch {}
120
+ try {
121
+ this.agent.abort("advisor reset");
122
+ } catch {}
123
+ }
124
+
125
+ /**
126
+ * Re-prime the advisor after a history rewrite (compaction, session
127
+ * switch/resume, branch). Clears the advisor's own (non-persisted) context
128
+ * and rewinds the cursor to 0 so the NEXT turn replays the full current —
129
+ * post-compaction — transcript, giving the advisor fresh context instead of
130
+ * leaving it blind to everything before the rewrite.
131
+ */
132
+ reset(): void {
133
+ this.#resetAdvisorContext(true, true);
134
+ }
135
+
136
+ /**
137
+ * Seed the cursor to the current transcript length when the advisor is enabled
138
+ * mid-session. Prevents the next turn from replaying the entire history to the
139
+ * advisor (which would be expensive and likely stale).
140
+ */
141
+ seedTo(count: number): void {
142
+ this.#lastCount = count;
143
+ this.#pending = [];
144
+ this.#backlog = 0;
145
+ this.#consecutiveFailures = 0;
146
+ this.#wakeAllWaiters();
147
+ }
148
+
149
+ #renderDelta(messages?: AgentMessage[]): string | null {
150
+ const all = messages ?? this.#latestMessages ?? this.host.snapshotMessages();
151
+ if (all.length < this.#lastCount) {
152
+ this.#lastCount = all.length;
153
+ return null;
154
+ }
155
+ const delta = all
156
+ .slice(this.#lastCount)
157
+ .filter(m => !(m.role === "custom" && (m as { customType?: string }).customType === "advisor"));
158
+ this.#lastCount = all.length;
159
+ if (delta.length === 0) return null;
160
+ const md = formatSessionHistoryMarkdown(delta, { includeThinking: true, includeToolIntent: true });
161
+ return md.trim() ? md : null;
162
+ }
163
+
164
+ #notifyWaiters(): void {
165
+ for (let i = this.#waiters.length - 1; i >= 0; i--) {
166
+ const w = this.#waiters[i];
167
+ if (this.#backlog < w.threshold) {
168
+ w.finish();
169
+ }
170
+ }
171
+ }
172
+
173
+ #wakeAllWaiters(): void {
174
+ for (const w of [...this.#waiters]) {
175
+ w.finish();
176
+ }
177
+ }
178
+
179
+ async #drain(): Promise<void> {
180
+ if (this.#busy) return;
181
+ this.#busy = true;
182
+ try {
183
+ while (!this.disposed && this.#pending.length) {
184
+ const popped = this.#pending.splice(0);
185
+ const candidateBatch = popped.map(b => b.text).join("\n\n---\n\n");
186
+ const turnsCovered = popped.reduce((sum, b) => sum + b.turns, 0);
187
+ const incomingTokens = estimateTokens({
188
+ role: "user",
189
+ content: candidateBatch,
190
+ timestamp: Date.now(),
191
+ });
192
+
193
+ let shouldReprime = false;
194
+ if (this.host.maintainContext) {
195
+ try {
196
+ shouldReprime = await this.host.maintainContext(incomingTokens);
197
+ } catch (err) {
198
+ logger.debug("advisor context maintenance failed", { err: String(err) });
199
+ }
200
+ }
201
+
202
+ let batch: string | null;
203
+ let finalTurns: number;
204
+ if (shouldReprime) {
205
+ // Promotion could not fit the advisor's context — re-prime.
206
+ const newTurns = this.#pending.reduce((sum, b) => sum + b.turns, 0);
207
+ this.#resetAdvisorContext(false, false);
208
+ batch = this.#renderDelta(this.#latestMessages);
209
+ finalTurns = turnsCovered + newTurns;
210
+ } else {
211
+ batch = candidateBatch;
212
+ finalTurns = turnsCovered;
213
+ }
214
+
215
+ if (this.disposed || batch === null) {
216
+ this.#backlog = Math.max(0, this.#backlog - finalTurns);
217
+ this.#notifyWaiters();
218
+ continue;
219
+ }
220
+
221
+ let success = false;
222
+ try {
223
+ await this.agent.prompt(batch);
224
+ success = true;
225
+ this.#consecutiveFailures = 0;
226
+ } catch (err) {
227
+ logger.debug("advisor turn failed", { err: String(err) });
228
+ this.#consecutiveFailures++;
229
+ if (this.#consecutiveFailures >= 3) {
230
+ logger.warn("advisor failed consecutively 3 times; dropping backlog to prevent stall");
231
+ this.#consecutiveFailures = 0;
232
+ success = true;
233
+ } else {
234
+ this.#pending.unshift({ text: batch, turns: finalTurns });
235
+ await Bun.sleep(this.retryDelayMs);
236
+ }
237
+ }
238
+
239
+ if (success) {
240
+ this.#backlog = Math.max(0, this.#backlog - finalTurns);
241
+ this.#notifyWaiters();
242
+ }
243
+ }
244
+ } finally {
245
+ this.#busy = false;
246
+ }
247
+ }
248
+ }
@@ -0,0 +1,83 @@
1
+ import * as os from "node:os";
2
+ import * as path from "node:path";
3
+ import { getAgentDir, isEnoent, logger } from "@oh-my-pi/pi-utils";
4
+ import { expandAtImports } from "../discovery/at-imports";
5
+ import { repo } from "../utils/git";
6
+
7
+ /**
8
+ * Discover and load WATCHDOG.md files walking up from cwd, project .omp folder, and user agent dir.
9
+ * Returns formatted watchdog file blocks ready to be appended to the advisor system prompt.
10
+ */
11
+ export async function discoverWatchdogFiles(cwd: string, agentDir?: string): Promise<string[]> {
12
+ const home = os.homedir();
13
+ const resolvedAgentDir = agentDir ?? getAgentDir();
14
+ const userPath = resolvedAgentDir ? path.resolve(resolvedAgentDir, "WATCHDOG.md") : null;
15
+ let repoRoot: string | null = null;
16
+ try {
17
+ repoRoot = await repo.root(cwd);
18
+ } catch (err) {
19
+ logger.debug("Failed to resolve git root for watchdog discovery", { err: String(err) });
20
+ }
21
+
22
+ const candidates = new Set<string>();
23
+
24
+ // 1. User level: ~/.omp/WATCHDOG.md (or active profile agent dir)
25
+ if (resolvedAgentDir) {
26
+ candidates.add(path.resolve(resolvedAgentDir, "WATCHDOG.md"));
27
+ }
28
+
29
+ // 2. Project levels (both standalone and native config .omp/): walk up from cwd to repoRoot / home
30
+ let current = cwd;
31
+ while (true) {
32
+ candidates.add(path.resolve(current, ".omp", "WATCHDOG.md"));
33
+ candidates.add(path.resolve(current, "WATCHDOG.md"));
34
+
35
+ if (current === (repoRoot ?? home)) break;
36
+ const parent = path.dirname(current);
37
+ if (parent === current) break;
38
+ current = parent;
39
+ }
40
+
41
+ const items: Array<{ path: string; content: string; level: "user" | "project"; depth: number }> = [];
42
+
43
+ for (const candidate of candidates) {
44
+ try {
45
+ const content = await Bun.file(candidate).text();
46
+ const expanded = await expandAtImports(content, candidate);
47
+ const parent = path.dirname(candidate);
48
+ const baseName = parent.split(path.sep).pop() ?? "";
49
+
50
+ const isUser = userPath !== null && candidate === userPath;
51
+ const ownerDir = baseName === ".omp" ? path.dirname(parent) : parent;
52
+ const ownerBaseName = ownerDir.split(path.sep).pop() ?? "";
53
+
54
+ if (isUser || !ownerBaseName.startsWith(".") || baseName === ".omp") {
55
+ const relative = path.relative(cwd, ownerDir);
56
+ const depth = relative === "" ? 0 : relative.split(path.sep).filter(Boolean).length;
57
+ items.push({
58
+ path: candidate,
59
+ content: expanded,
60
+ level: isUser ? "user" : "project",
61
+ depth,
62
+ });
63
+ }
64
+ } catch (err) {
65
+ if (!isEnoent(err)) {
66
+ logger.warn("Failed to read WATCHDOG.md candidate", { path: candidate, error: String(err) });
67
+ }
68
+ }
69
+ }
70
+
71
+ // Sort files so that user level comes first, then project level sorted by depth (descending).
72
+ // This means user-level rules are first, then project-level rules from ancestor directories down to the leaf directory (depth 0 is last/most prominent).
73
+ items.sort((a, b) => {
74
+ if (a.level !== b.level) {
75
+ return a.level === "user" ? -1 : 1;
76
+ }
77
+ return b.depth - a.depth;
78
+ });
79
+
80
+ return items.map(item => {
81
+ return `Especially pay attention to:\n<attention>\n${item.content}\n</attention>`;
82
+ });
83
+ }
package/src/cli/args.ts CHANGED
@@ -279,6 +279,7 @@ export function getExtraHelpText(): string {
279
279
  KILO_API_KEY - Kilo Gateway models
280
280
  MISTRAL_API_KEY - Mistral models
281
281
  ZAI_API_KEY - z.ai models (ZhipuAI/GLM)
282
+ UMANS_AI_CODING_PLAN_API_KEY - Umans AI Coding Plan models
282
283
  MINIMAX_API_KEY - MiniMax models
283
284
  OPENCODE_API_KEY - OpenCode Zen/OpenCode Go models
284
285
  CURSOR_ACCESS_TOKEN - Cursor AI models
@@ -396,7 +396,7 @@ export class CollabHost {
396
396
  }
397
397
  const name = peer.name;
398
398
  void this.#ctx.session
399
- .abort()
399
+ .abort({ reason: USER_INTERRUPT_LABEL })
400
400
  .then(() => this.#ctx.session.emitNotice("info", `${name} interrupted`, "collab"))
401
401
  .catch(err => logger.warn("collab guest abort failed", { error: String(err) }));
402
402
  }
@@ -5,7 +5,17 @@
5
5
  import { isValidThemeColor, type ThemeColor } from "../modes/theme/theme";
6
6
  import type { Settings } from "./settings";
7
7
 
8
- export type ModelRole = "default" | "smol" | "slow" | "vision" | "plan" | "designer" | "commit" | "title" | "task";
8
+ export type ModelRole =
9
+ | "default"
10
+ | "smol"
11
+ | "slow"
12
+ | "vision"
13
+ | "plan"
14
+ | "designer"
15
+ | "commit"
16
+ | "title"
17
+ | "task"
18
+ | "advisor";
9
19
 
10
20
  export interface ModelRoleInfo {
11
21
  tag?: string;
@@ -25,6 +35,7 @@ export const MODEL_ROLES: Record<ModelRole, ModelRoleInfo> = {
25
35
  commit: { tag: "COMMIT", name: "Commit", color: "dim" },
26
36
  title: { tag: "TITLE", name: "Title", color: "dim", hidden: true },
27
37
  task: { tag: "TASK", name: "Subtask", color: "muted" },
38
+ advisor: { tag: "ADVISOR", name: "Advisor", color: "accent" },
28
39
  };
29
40
 
30
41
  export const MODEL_ROLE_IDS: ModelRole[] = [
@@ -37,6 +48,7 @@ export const MODEL_ROLE_IDS: ModelRole[] = [
37
48
  "commit",
38
49
  "title",
39
50
  "task",
51
+ "advisor",
40
52
  ];
41
53
 
42
54
  export type RoleInfo = ModelRoleInfo;
@@ -34,7 +34,7 @@ import {
34
34
  TTS_LOCAL_VOICE_VALUES,
35
35
  } from "../tts/models";
36
36
  import { EDIT_MODES } from "../utils/edit-mode";
37
- import { SEARCH_PROVIDER_OPTIONS, SEARCH_PROVIDER_PREFERENCES } from "../web/search/types";
37
+ import { SEARCH_PROVIDER_OPTIONS, SEARCH_PROVIDER_PREFERENCES, type SearchProviderId } from "../web/search/types";
38
38
 
39
39
  /** Unified settings schema - single source of truth for all settings.
40
40
  *
@@ -106,7 +106,7 @@ export const TAB_METADATA: Record<SettingTab, { label: string; icon: `tab.${stri
106
106
  */
107
107
  export const TAB_GROUPS: Record<SettingTab, readonly string[]> = {
108
108
  appearance: ["Theme", "Status Line", "Display", "Images"],
109
- model: ["Thinking", "Sampling", "Prompt", "Retry & Fallback"],
109
+ model: ["Thinking", "Sampling", "Prompt", "Retry & Fallback", "Advisor"],
110
110
  interaction: [
111
111
  "Input",
112
112
  "Approvals",
@@ -381,6 +381,39 @@ export const SETTINGS_SCHEMA = {
381
381
  description: "Keep the display from idle-sleeping while a session is open (caffeinate -d)",
382
382
  },
383
383
  },
384
+ "advisor.enabled": {
385
+ type: "boolean",
386
+ default: false,
387
+ ui: {
388
+ tab: "model",
389
+ group: "Advisor",
390
+ label: "Enable Advisor",
391
+ description:
392
+ "Pair a second model (assigned to the 'advisor' role) that passively reviews each turn and injects notes.",
393
+ },
394
+ },
395
+ "advisor.subagents": {
396
+ type: "boolean",
397
+ default: false,
398
+ ui: {
399
+ tab: "model",
400
+ group: "Advisor",
401
+ label: "Advisor for Subagents",
402
+ description: "Also enable the advisor on spawned task/eval subagents.",
403
+ },
404
+ },
405
+ "advisor.syncBacklog": {
406
+ type: "enum",
407
+ values: ["off", "1", "3", "5"] as const,
408
+ default: "off",
409
+ ui: {
410
+ tab: "model",
411
+ group: "Advisor",
412
+ label: "Advisor Sync Backlog",
413
+ description:
414
+ "Pause the main agent for up to 30 seconds if the advisor falls behind by this many turns. Off disables catch-up delays.",
415
+ },
416
+ },
384
417
  shellPath: { type: "string", default: undefined },
385
418
 
386
419
  extensions: { type: "array", default: EMPTY_STRING_ARRAY },
@@ -1734,14 +1767,16 @@ export const SETTINGS_SCHEMA = {
1734
1767
  "harmony",
1735
1768
  "pi",
1736
1769
  "qwen3",
1770
+ "gemini",
1771
+ "gemma",
1737
1772
  ] as const,
1738
1773
  default: "auto",
1739
1774
  ui: {
1740
1775
  tab: "context",
1741
1776
  group: "Experimental",
1742
- label: "Tool Call Format",
1777
+ label: "Tool Calling Mode",
1743
1778
  description:
1744
- "Controls how tools are exposed to the model. Auto uses native tool calls unless the selected model is marked as not supporting tools, then falls back to GLM-style in-band tool calls. Native forces provider-native tools; the other values force the named in-band syntax. Applies on session start.",
1779
+ "Controls how tools are exposed to the model. Auto uses provider-native tool calls unless the selected model is marked as not supporting them, then falls back to the GLM owned dialect. Native forces provider-native tools; the other values force the named owned dialect. Applies on session start.",
1745
1780
  options: [
1746
1781
  {
1747
1782
  value: "auto",
@@ -1756,8 +1791,10 @@ export const SETTINGS_SCHEMA = {
1756
1791
  { value: "anthropic", label: "Anthropic", description: "Use Anthropic-style in-band tool calls." },
1757
1792
  { value: "deepseek", label: "DeepSeek", description: "Use DeepSeek-style in-band tool calls." },
1758
1793
  { value: "harmony", label: "Harmony", description: "Use Harmony-style in-band tool calls." },
1759
- { value: "pi", label: "Pi", description: "Use Pi-style in-band tool calls." },
1760
- { value: "qwen3", label: "Qwen3", description: "Use Qwen3-style in-band tool calls." },
1794
+ { value: "pi", label: "Pi", description: "Use the Pi owned dialect." },
1795
+ { value: "qwen3", label: "Qwen3", description: "Use the Qwen3 owned dialect." },
1796
+ { value: "gemini", label: "Gemini", description: "Use the Gemini owned dialect." },
1797
+ { value: "gemma", label: "Gemma", description: "Use the Gemma owned dialect." },
1761
1798
  ],
1762
1799
  },
1763
1800
  },
@@ -3417,6 +3454,18 @@ export const SETTINGS_SCHEMA = {
3417
3454
  },
3418
3455
  },
3419
3456
 
3457
+ "plan.defaultOnStartup": {
3458
+ type: "boolean",
3459
+ default: false,
3460
+ ui: {
3461
+ tab: "tasks",
3462
+ group: "Modes",
3463
+ label: "Start in Plan Mode",
3464
+ description: "Automatically enter plan mode at the start of every new session",
3465
+ condition: "planModeEnabled",
3466
+ },
3467
+ },
3468
+
3420
3469
  "goal.enabled": {
3421
3470
  type: "boolean",
3422
3471
  default: true,
@@ -3809,6 +3858,16 @@ export const SETTINGS_SCHEMA = {
3809
3858
  options: SEARCH_PROVIDER_OPTIONS,
3810
3859
  },
3811
3860
  },
3861
+ "providers.webSearchExclude": {
3862
+ type: "array",
3863
+ default: [] as SearchProviderId[],
3864
+ ui: {
3865
+ tab: "providers",
3866
+ group: "Services",
3867
+ label: "Excluded Web Search Providers",
3868
+ description: "Providers that web_search should never use, even as fallbacks",
3869
+ },
3870
+ },
3812
3871
  "providers.image": {
3813
3872
  type: "enum",
3814
3873
  values: ["auto", "openai", "antigravity", "xai", "gemini", "openrouter"] as const,
@@ -124,43 +124,6 @@ async function loadSkills(ctx: LoadContext): Promise<LoadResult<Skill>> {
124
124
  }
125
125
  return { items, warnings };
126
126
  }
127
- async function loadSkillSlashCommands(ctx: LoadContext, root: ClaudePluginRoot): Promise<LoadResult<SlashCommand>> {
128
- const { dir: skillsDir, warning } = await resolvePluginDir(root, ["skills"], "skills");
129
- const warnings: string[] = warning ? [warning] : [];
130
- const skillsResult = await scanSkillsFromDir(ctx, {
131
- dir: skillsDir,
132
- providerId: PROVIDER_ID,
133
- level: root.scope,
134
- });
135
- warnings.push(...(skillsResult.warnings ?? []));
136
-
137
- const commands = await Promise.all(
138
- skillsResult.items.map(async skill => {
139
- const content = await readFile(skill.path);
140
- if (content === null) {
141
- warnings.push(`Failed to read skill slash command: ${skill.path}`);
142
- return null;
143
- }
144
- // Slash command name MUST come from the skill directory basename, not
145
- // frontmatter `name`: `expandSlashCommand` splits the command at the first
146
- // whitespace, so a display name like "Understand Anything" would never match
147
- // `/understand`. The documented layout is `skills/<name>/SKILL.md` → `/<name>`.
148
- const command: SlashCommand = {
149
- name: path.basename(path.dirname(skill.path)),
150
- path: skill.path,
151
- content,
152
- level: skill.level,
153
- _source: skill._source,
154
- };
155
- return command;
156
- }),
157
- );
158
-
159
- return {
160
- items: commands.filter((command): command is SlashCommand => command !== null),
161
- warnings,
162
- };
163
- }
164
127
 
165
128
  // =============================================================================
166
129
  // Slash Commands
@@ -189,16 +152,14 @@ async function loadSlashCommands(ctx: LoadContext): Promise<LoadResult<SlashComm
189
152
  };
190
153
  },
191
154
  });
192
- const skillCommandResult = await loadSkillSlashCommands(ctx, root);
193
- return { commandResult, skillCommandResult, warning };
155
+ return { commandResult, warning };
194
156
  }),
195
157
  );
196
158
 
197
- for (const { commandResult, skillCommandResult, warning } of results) {
159
+ for (const { commandResult, warning } of results) {
198
160
  if (warning) warnings.push(warning);
199
- items.push(...commandResult.items, ...skillCommandResult.items);
161
+ items.push(...commandResult.items);
200
162
  if (commandResult.warnings) warnings.push(...commandResult.warnings);
201
- if (skillCommandResult.warnings) warnings.push(...skillCommandResult.warnings);
202
163
  }
203
164
 
204
165
  return { items, warnings };