@d3ara1n/pi-scout 1.1.0 → 1.1.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.
package/src/index.ts CHANGED
@@ -26,327 +26,338 @@ const STATUS_KEY = "scout";
26
26
 
27
27
  /** Build a one-line status summary from a scout decision. */
28
28
  function formatDecisionStatus(decision: ScoutDecision, theme: any): string {
29
- if (decision.source === "short-circuit") {
30
- return theme.fg("success", "✓ scout:") + " " + theme.fg("dim", `(skipped) ${decision.reasoning}`);
31
- }
32
-
33
- const parts: string[] = [];
34
-
35
- if (decision.skills.length > 0) {
36
- const names = decision.skills.length <= 3
37
- ? decision.skills.join(", ")
38
- : `${decision.skills.slice(0, 2).join(", ")} +${decision.skills.length - 2}`;
39
- parts.push(theme.fg("accent", `skills: ${names}`));
40
- }
41
- if (decision.role) {
42
- parts.push(theme.fg("warning", `→ ${decision.role}`));
43
- }
44
-
45
- if (parts.length === 0) {
46
- return theme.fg("dim", "✓ scout: no changes");
47
- }
48
-
49
- return theme.fg("success", "✓ scout:") + " " + parts.join(" | ");
29
+ if (decision.source === "short-circuit") {
30
+ return (
31
+ theme.fg("success", "✓ scout:") + " " + theme.fg("dim", `(skipped) ${decision.reasoning}`)
32
+ );
33
+ }
34
+
35
+ const parts: string[] = [];
36
+
37
+ if (decision.skills.length > 0) {
38
+ const names =
39
+ decision.skills.length <= 3
40
+ ? decision.skills.join(", ")
41
+ : `${decision.skills.slice(0, 2).join(", ")} +${decision.skills.length - 2}`;
42
+ parts.push(theme.fg("accent", `skills: ${names}`));
43
+ }
44
+ if (decision.role) {
45
+ parts.push(theme.fg("warning", `→ ${decision.role}`));
46
+ }
47
+
48
+ if (parts.length === 0) {
49
+ return theme.fg("dim", "✓ scout: no changes");
50
+ }
51
+
52
+ return theme.fg("success", "✓ scout:") + " " + parts.join(" | ");
50
53
  }
51
54
 
52
55
  export default function scoutExtension(pi: ExtensionAPI) {
53
- let config: ScoutConfig = DEFAULT_CONFIG;
54
- let lastDecision: ScoutDecision | undefined;
55
-
56
- /** Cache of previous turn context for better routing decisions. */
57
- let prevTurn: { userPrompt: string; assistantSummary: string } | undefined;
58
-
59
- function tryGetRolesApi(ctx: ExtensionContext): ModelRolesAPI | undefined {
60
- try {
61
- return getModelRolesAPI();
62
- } catch {
63
- ctx.ui.notify(
64
- "pi-model-roles not loaded. Ensure @d3ara1n/pi-model-roles is in extensions and restart.",
65
- "error",
66
- );
67
- return undefined;
68
- }
69
- }
70
-
71
- // ── /scout — show status ────────────────────────────────────────
72
- pi.registerCommand("scout", {
73
- description: "Show scout status and last decision",
74
- handler: async (_args, ctx) => {
75
- const rolesApi = tryGetRolesApi(ctx);
76
- const sideRole = rolesApi?.getRole(config.sideAgentRole);
77
- const theme = ctx.ui.theme;
78
- const lines = [
79
- `Scout: ${config.enabled ? "enabled" : "disabled"}`,
80
- `Side agent role: ${config.sideAgentRole} (${sideRole?.model ?? "current model"})`,
81
- ``,
82
- `Modules:`,
83
- ` skill-router: ${config.modules.skillRouter ? "on" : "off"}`,
84
- ` model-router: ${config.modules.modelRouter ? "on" : "off"}`,
85
- ` short-circuit: ${config.modules.shortCircuit ? "on" : "off"}`,
86
- ];
87
-
88
- if (lastDecision) {
89
- lines.push(``);
90
- lines.push(`Last decision:`);
91
- lines.push(` skills: ${lastDecision.skills.length > 0 ? lastDecision.skills.join(", ") : "(none)"}`);
92
- lines.push(` role: ${lastDecision.role ?? "(no change)"}`);
93
- lines.push(` reasoning: ${lastDecision.reasoning}`);
94
- }
95
-
96
- ctx.ui.notify(lines.join("\n"), "info");
97
- },
98
- });
99
-
100
- // ── /scout:skill-router on/off ──────────────────────────────────
101
- pi.registerCommand("scout:skill-router", {
102
- description: "Toggle skill-router module (on/off)",
103
- handler: async (args, ctx) => {
104
- const value = (args ?? "").trim().toLowerCase();
105
- if (value === "on") {
106
- config.modules.skillRouter = true;
107
- ctx.ui.notify("Scout: skill-router enabled", "info");
108
- } else if (value === "off") {
109
- config.modules.skillRouter = false;
110
- ctx.ui.notify("Scout: skill-router disabled", "info");
111
- } else {
112
- ctx.ui.notify("Usage: /scout:skill-router on|off", "info");
113
- }
114
- },
115
- });
116
-
117
- // ── /scout:model-router on/off ──────────────────────────────────
118
- pi.registerCommand("scout:model-router", {
119
- description: "Toggle model-router module (on/off)",
120
- handler: async (args, ctx) => {
121
- const value = (args ?? "").trim().toLowerCase();
122
- if (value === "on") {
123
- config.modules.modelRouter = true;
124
- ctx.ui.notify("Scout: model-router enabled", "info");
125
- } else if (value === "off") {
126
- config.modules.modelRouter = false;
127
- ctx.ui.notify("Scout: model-router disabled", "info");
128
- } else {
129
- ctx.ui.notify("Usage: /scout:model-router on|off", "info");
130
- }
131
- },
132
- });
133
-
134
- // ── /scout:short-circuit on/off ─────────────────────────────────
135
- pi.registerCommand("scout:short-circuit", {
136
- description: "Toggle short-circuit module (on/off)",
137
- handler: async (args, ctx) => {
138
- const value = (args ?? "").trim().toLowerCase();
139
- if (value === "on") {
140
- config.modules.shortCircuit = true;
141
- ctx.ui.notify("Scout: short-circuit enabled", "info");
142
- } else if (value === "off") {
143
- config.modules.shortCircuit = false;
144
- ctx.ui.notify("Scout: short-circuit disabled", "info");
145
- } else {
146
- ctx.ui.notify("Usage: /scout:short-circuit on|off", "info");
147
- }
148
- },
149
- });
150
-
151
- // ── list_skills tool ───────────────────────────────────────────
152
- let cachedAllSkills: Array<{ name: string; description: string; filePath: string }> = [];
153
-
154
- pi.registerTool({
155
- name: "list_skills",
156
- label: "List all skills",
157
- description: "List all available skills with name and description. Use this when the user asks what skills are installed or you need to discover skills beyond those currently active.",
158
- parameters: { type: "object", properties: {}, required: [] } as any,
159
- async execute() {
160
- if (cachedAllSkills.length === 0) {
161
- return { content: [{ type: "text", text: "No skills available." }], details: undefined as any };
162
- }
163
- const lines = cachedAllSkills.map((s) => `- **${s.name}**: ${s.description}`);
164
- return { content: [{ type: "text", text: lines.join("\n") }], details: undefined as any };
165
- },
166
- });
167
-
168
- // ── session_start: load config ──────────────────────────────────
169
- pi.on("session_start", async (_event, ctx) => {
170
- config = loadScoutConfig(ctx.cwd);
171
- resetSkillCache();
172
- prevTurn = undefined;
173
- });
174
-
175
- // ── turn_end: cache assistant response for next turn's context ──
176
- pi.on("turn_end", async (event) => {
177
- const msg = event.message;
178
- if (msg.role !== "assistant") return;
179
-
180
- // Extract text content, skip thinking blocks and tool calls
181
- const textParts: string[] = [];
182
- for (const block of msg.content) {
183
- if ("type" in block && block.type === "text" && "text" in block) {
184
- textParts.push(block.text);
185
- }
186
- }
187
- const fullText = textParts.join("");
188
- if (!fullText.trim()) return;
189
-
190
- // Truncate to avoid bloating the side agent context
191
- const MAX_SUMMARY = 500;
192
- const assistantSummary = fullText.length > MAX_SUMMARY
193
- ? fullText.slice(0, MAX_SUMMARY) + "..."
194
- : fullText;
195
-
196
- // before_agent_start already created/updated prevTurn with the user prompt.
197
- // We just fill in the assistant summary here.
198
- if (prevTurn) {
199
- prevTurn.assistantSummary = assistantSummary;
200
- }
201
- });
202
-
203
- // ── before_agent_start: core scout logic ────────────────────────
204
- pi.on("before_agent_start", async (event, ctx) => {
205
- if (!config.enabled) return;
206
- if (!config.modules.skillRouter && !config.modules.modelRouter) return;
207
-
208
- let rolesApi: ModelRolesAPI;
209
- try {
210
- rolesApi = getModelRolesAPI();
211
- } catch {
212
- console.warn("[pi-scout] pi-model-roles not initialized — skipping scout");
213
- return;
214
- }
215
-
216
- const theme = ctx.ui.theme;
217
-
218
- // Skills available this turn — used by both the short-circuit layer
219
- // and the side-agent path.
220
- const skills = event.systemPromptOptions?.skills ?? [];
221
- if (cachedAllSkills.length === 0 && skills.length > 0) {
222
- cachedAllSkills = skills.map((s: any) => ({
223
- name: s.name,
224
- description: s.description ?? "",
225
- filePath: s.filePath,
226
- }));
227
- }
228
- const skillEntries = skills.map((s: any) => ({
229
- name: s.name,
230
- description: s.description ?? "",
231
- filePath: s.filePath,
232
- }));
233
-
234
- // ── Short-circuit layer ───────────────────────────────────
235
- // Skip the side model on trivial acknowledgments. A trivial ack means
236
- // "no skills, don't switch models" — both answers are certain, so this
237
- // is safe even with model-router on. Runs before model resolution to
238
- // save that cost too.
239
- if (config.modules.shortCircuit) {
240
- const skip = evaluateShortCircuit(event.prompt, config.shortCircuit);
241
- if (skip) {
242
- const decision: ScoutDecision = {
243
- skills: [],
244
- role: null,
245
- reasoning: skip.reasoning,
246
- source: "short-circuit",
247
- };
248
- lastDecision = decision;
249
-
250
- let systemPrompt = event.systemPrompt;
251
- if (config.modules.skillRouter) {
252
- systemPrompt = filterSkillsBlock(systemPrompt, [], skillEntries);
253
- }
254
-
255
- // Keep prevTurn current so the next turn still has context.
256
- prevTurn = { userPrompt: event.prompt, assistantSummary: "" };
257
-
258
- ctx.ui.setStatus(STATUS_KEY, formatDecisionStatus(decision, theme));
259
- if (systemPrompt !== event.systemPrompt) return { systemPrompt };
260
- return;
261
- }
262
- }
263
-
264
- // Show "Scouting..." indicator
265
- ctx.ui.setStatus(STATUS_KEY, theme.fg("accent", "◎") + theme.fg("dim", " Scouting..."));
266
-
267
- // Resolve side agent model
268
- const sideResolved = await rolesApi.resolveRoleAsync(config.sideAgentRole);
269
- if (!sideResolved.model) {
270
- ctx.ui.setStatus(STATUS_KEY, theme.fg("warning", "◎ scout: side model unavailable"));
271
- console.warn(`[pi-scout] Side agent role "${config.sideAgentRole}" not available skipping`);
272
- return;
273
- }
274
-
275
- // Update status: resolving
276
- ctx.ui.setStatus(STATUS_KEY, theme.fg("accent", "◎") + theme.fg("dim", ` Scouting via ${sideResolved.model.provider}/${sideResolved.model.id}...`));
277
-
278
- // 1. Build the skills list for the side agent prompt
279
- const skillsList = skills
280
- .map((s: any) => `- ${s.name}: ${s.description ?? "(no description)"}`)
281
- .join("\n");
282
-
283
- // 2. Determine current role (use getCurrentRole: it recognizes the
284
- // default role even when model=null, so the router has a real
285
- // baseline instead of an opaque "unknown".)
286
- const currentModel = ctx.model;
287
- const currentRole = currentModel
288
- ? (rolesApi.getCurrentRole(`${currentModel.provider}/${currentModel.id}`) ?? "unknown")
289
- : "unknown";
290
-
291
- // 3. Build roles list
292
- const visibleRoles = rolesApi.getVisibleRoles();
293
- const rolesList = Object.entries(visibleRoles)
294
- .map(([name, cfg]: [string, any]) => `- ${name}: ${cfg.description ?? "(no description)"}${cfg.model ? ` (model: ${cfg.model})` : " (current model)"}`)
295
- .join("\n");
296
-
297
- // 4. Build user message with conversation context
298
- const prevTurnContext = prevTurn?.assistantSummary
299
- ? { userPrompt: prevTurn.userPrompt, assistantSummary: prevTurn.assistantSummary }
300
- : undefined;
301
- const userMessage = buildScoutUserMessage(event.prompt, currentRole, prevTurnContext);
302
-
303
- // Reset prevTurn for the current turn — user prompt now, assistant filled by turn_end
304
- prevTurn = { userPrompt: event.prompt, assistantSummary: "" };
305
-
306
- // 5. Call side agent
307
- const scoutSystemPrompt = buildScoutSystemPrompt(config, skillsList, rolesList);
308
- const decision = await callSideAgent(
309
- sideResolved.model,
310
- sideResolved.apiKey,
311
- sideResolved.headers,
312
- scoutSystemPrompt,
313
- userMessage,
314
- );
315
-
316
- // Enforce module toggles at the application layer, regardless of what
317
- // the side agent returned. A disabled module's decision field is zeroed
318
- // so the status bar, /scout command, and apply logic all see clean
319
- // values (no misleading "→ fast" when model-router is off).
320
- if (!config.modules.modelRouter) decision.role = null;
321
- if (!config.modules.skillRouter) decision.skills = [];
322
- lastDecision = decision;
323
-
324
- let systemPrompt = event.systemPrompt;
325
- let switchedRole: string | undefined;
326
-
327
- // 6. skill-router: filter skills XML to only selected ones
328
- if (config.modules.skillRouter) {
329
- systemPrompt = filterSkillsBlock(systemPrompt, decision.skills, skillEntries);
330
- }
331
-
332
- // 7. model-router: switch model if side agent recommends a different role
333
- if (config.modules.modelRouter && decision.role && decision.role !== currentRole) {
334
- const switched = await switchToRole(pi, decision.role, rolesApi);
335
- if (switched) {
336
- switchedRole = decision.role;
337
- const newModel = await rolesApi.resolveRoleAsync(decision.role);
338
- if (newModel?.model) {
339
- systemPrompt += `\n\n<current_model>${newModel.model.provider}/${newModel.model.id} (role: ${decision.role})</current_model>`;
340
- }
341
- }
342
- }
343
-
344
- // 8. Show result in status bar
345
- ctx.ui.setStatus(STATUS_KEY, formatDecisionStatus(decision, theme));
346
-
347
- // 9. Return modified system prompt
348
- if (systemPrompt !== event.systemPrompt) {
349
- return { systemPrompt };
350
- }
351
- });
56
+ let config: ScoutConfig = DEFAULT_CONFIG;
57
+ let lastDecision: ScoutDecision | undefined;
58
+
59
+ /** Cache of previous turn context for better routing decisions. */
60
+ let prevTurn: { userPrompt: string; assistantSummary: string } | undefined;
61
+
62
+ function tryGetRolesApi(ctx: ExtensionContext): ModelRolesAPI | undefined {
63
+ try {
64
+ return getModelRolesAPI();
65
+ } catch {
66
+ ctx.ui.notify(
67
+ "pi-model-roles not loaded. Ensure @d3ara1n/pi-model-roles is in extensions and restart.",
68
+ "error",
69
+ );
70
+ return undefined;
71
+ }
72
+ }
73
+
74
+ // ── /scout — show status ────────────────────────────────────────
75
+ pi.registerCommand("scout", {
76
+ description: "Show scout status and last decision",
77
+ handler: async (_args, ctx) => {
78
+ const rolesApi = tryGetRolesApi(ctx);
79
+ const sideRole = rolesApi?.getRole(config.sideAgentRole);
80
+ const lines = [
81
+ `Scout: ${config.enabled ? "enabled" : "disabled"}`,
82
+ `Side agent role: ${config.sideAgentRole} (${sideRole?.model ?? "current model"})`,
83
+ ``,
84
+ `Modules:`,
85
+ ` skill-router: ${config.modules.skillRouter ? "on" : "off"}`,
86
+ ` model-router: ${config.modules.modelRouter ? "on" : "off"}`,
87
+ ` short-circuit: ${config.modules.shortCircuit ? "on" : "off"}`,
88
+ ];
89
+
90
+ if (lastDecision) {
91
+ lines.push(``);
92
+ lines.push(`Last decision:`);
93
+ lines.push(
94
+ ` skills: ${lastDecision.skills.length > 0 ? lastDecision.skills.join(", ") : "(none)"}`,
95
+ );
96
+ lines.push(` role: ${lastDecision.role ?? "(no change)"}`);
97
+ lines.push(` reasoning: ${lastDecision.reasoning}`);
98
+ }
99
+
100
+ ctx.ui.notify(lines.join("\n"), "info");
101
+ },
102
+ });
103
+
104
+ // ── /scout:skill-router on/off ──────────────────────────────────
105
+ pi.registerCommand("scout:skill-router", {
106
+ description: "Toggle skill-router module (on/off)",
107
+ handler: async (args, ctx) => {
108
+ const value = (args ?? "").trim().toLowerCase();
109
+ if (value === "on") {
110
+ config.modules.skillRouter = true;
111
+ ctx.ui.notify("Scout: skill-router enabled", "info");
112
+ } else if (value === "off") {
113
+ config.modules.skillRouter = false;
114
+ ctx.ui.notify("Scout: skill-router disabled", "info");
115
+ } else {
116
+ ctx.ui.notify("Usage: /scout:skill-router on|off", "info");
117
+ }
118
+ },
119
+ });
120
+
121
+ // ── /scout:model-router on/off ──────────────────────────────────
122
+ pi.registerCommand("scout:model-router", {
123
+ description: "Toggle model-router module (on/off)",
124
+ handler: async (args, ctx) => {
125
+ const value = (args ?? "").trim().toLowerCase();
126
+ if (value === "on") {
127
+ config.modules.modelRouter = true;
128
+ ctx.ui.notify("Scout: model-router enabled", "info");
129
+ } else if (value === "off") {
130
+ config.modules.modelRouter = false;
131
+ ctx.ui.notify("Scout: model-router disabled", "info");
132
+ } else {
133
+ ctx.ui.notify("Usage: /scout:model-router on|off", "info");
134
+ }
135
+ },
136
+ });
137
+
138
+ // ── /scout:short-circuit on/off ─────────────────────────────────
139
+ pi.registerCommand("scout:short-circuit", {
140
+ description: "Toggle short-circuit module (on/off)",
141
+ handler: async (args, ctx) => {
142
+ const value = (args ?? "").trim().toLowerCase();
143
+ if (value === "on") {
144
+ config.modules.shortCircuit = true;
145
+ ctx.ui.notify("Scout: short-circuit enabled", "info");
146
+ } else if (value === "off") {
147
+ config.modules.shortCircuit = false;
148
+ ctx.ui.notify("Scout: short-circuit disabled", "info");
149
+ } else {
150
+ ctx.ui.notify("Usage: /scout:short-circuit on|off", "info");
151
+ }
152
+ },
153
+ });
154
+
155
+ // ── list_skills tool ───────────────────────────────────────────
156
+ let cachedAllSkills: Array<{ name: string; description: string; filePath: string }> = [];
157
+
158
+ pi.registerTool({
159
+ name: "list_skills",
160
+ label: "List all skills",
161
+ description:
162
+ "List all available skills with name and description. Use this when the user asks what skills are installed or you need to discover skills beyond those currently active.",
163
+ parameters: { type: "object", properties: {}, required: [] } as any,
164
+ async execute() {
165
+ if (cachedAllSkills.length === 0) {
166
+ return {
167
+ content: [{ type: "text", text: "No skills available." }],
168
+ details: undefined as any,
169
+ };
170
+ }
171
+ const lines = cachedAllSkills.map((s) => `- **${s.name}**: ${s.description}`);
172
+ return { content: [{ type: "text", text: lines.join("\n") }], details: undefined as any };
173
+ },
174
+ });
175
+
176
+ // ── session_start: load config ──────────────────────────────────
177
+ pi.on("session_start", async (_event, ctx) => {
178
+ config = loadScoutConfig(ctx.cwd);
179
+ resetSkillCache();
180
+ prevTurn = undefined;
181
+ });
182
+
183
+ // ── turn_end: cache assistant response for next turn's context ──
184
+ pi.on("turn_end", async (event) => {
185
+ const msg = event.message;
186
+ if (msg.role !== "assistant") return;
187
+
188
+ // Extract text content, skip thinking blocks and tool calls
189
+ const textParts: string[] = [];
190
+ for (const block of msg.content) {
191
+ if ("type" in block && block.type === "text" && "text" in block) {
192
+ textParts.push(block.text);
193
+ }
194
+ }
195
+ const fullText = textParts.join("");
196
+ if (!fullText.trim()) return;
197
+
198
+ // Truncate to avoid bloating the side agent context
199
+ const MAX_SUMMARY = 500;
200
+ const assistantSummary =
201
+ fullText.length > MAX_SUMMARY ? fullText.slice(0, MAX_SUMMARY) + "..." : fullText;
202
+
203
+ // before_agent_start already created/updated prevTurn with the user prompt.
204
+ // We just fill in the assistant summary here.
205
+ if (prevTurn) {
206
+ prevTurn.assistantSummary = assistantSummary;
207
+ }
208
+ });
209
+
210
+ // ── before_agent_start: core scout logic ────────────────────────
211
+ pi.on("before_agent_start", async (event, ctx) => {
212
+ if (!config.enabled) return;
213
+ if (!config.modules.skillRouter && !config.modules.modelRouter) return;
214
+
215
+ let rolesApi: ModelRolesAPI;
216
+ try {
217
+ rolesApi = getModelRolesAPI();
218
+ } catch {
219
+ console.warn("[pi-scout] pi-model-roles not initialized — skipping scout");
220
+ return;
221
+ }
222
+
223
+ const theme = ctx.ui.theme;
224
+
225
+ // Skills available this turn — used by both the short-circuit layer
226
+ // and the side-agent path.
227
+ const skills = event.systemPromptOptions?.skills ?? [];
228
+ if (cachedAllSkills.length === 0 && skills.length > 0) {
229
+ cachedAllSkills = skills.map((s: any) => ({
230
+ name: s.name,
231
+ description: s.description ?? "",
232
+ filePath: s.filePath,
233
+ }));
234
+ }
235
+ const skillEntries = skills.map((s: any) => ({
236
+ name: s.name,
237
+ description: s.description ?? "",
238
+ filePath: s.filePath,
239
+ }));
240
+
241
+ // ── Short-circuit layer ───────────────────────────────────
242
+ // Skip the side model on trivial acknowledgments. A trivial ack means
243
+ // "no skills, don't switch models" — both answers are certain, so this
244
+ // is safe even with model-router on. Runs before model resolution to
245
+ // save that cost too.
246
+ if (config.modules.shortCircuit) {
247
+ const skip = evaluateShortCircuit(event.prompt, config.shortCircuit);
248
+ if (skip) {
249
+ const decision: ScoutDecision = {
250
+ skills: [],
251
+ role: null,
252
+ reasoning: skip.reasoning,
253
+ source: "short-circuit",
254
+ };
255
+ lastDecision = decision;
256
+
257
+ let systemPrompt = event.systemPrompt;
258
+ if (config.modules.skillRouter) {
259
+ systemPrompt = filterSkillsBlock(systemPrompt, [], skillEntries);
260
+ }
261
+
262
+ // Keep prevTurn current so the next turn still has context.
263
+ prevTurn = { userPrompt: event.prompt, assistantSummary: "" };
264
+
265
+ ctx.ui.setStatus(STATUS_KEY, formatDecisionStatus(decision, theme));
266
+ if (systemPrompt !== event.systemPrompt) return { systemPrompt };
267
+ return;
268
+ }
269
+ }
270
+
271
+ // Show "Scouting..." indicator
272
+ ctx.ui.setStatus(STATUS_KEY, theme.fg("accent", "◎") + theme.fg("dim", " Scouting..."));
273
+
274
+ // Resolve side agent model (sync auth is resolved inside complete())
275
+ const sideResolved = rolesApi.resolveRole(config.sideAgentRole);
276
+ if (!sideResolved.model) {
277
+ ctx.ui.setStatus(STATUS_KEY, theme.fg("warning", "◎ scout: side model unavailable"));
278
+ console.warn(`[pi-scout] Side agent role "${config.sideAgentRole}" not available — skipping`);
279
+ return;
280
+ }
281
+
282
+ // Update status: resolving
283
+ ctx.ui.setStatus(
284
+ STATUS_KEY,
285
+ theme.fg("accent", "◎") +
286
+ theme.fg("dim", ` Scouting via ${sideResolved.model.provider}/${sideResolved.model.id}...`),
287
+ );
288
+
289
+ // 1. Build the skills list for the side agent prompt
290
+ const skillsList = skills
291
+ .map((s: any) => `- ${s.name}: ${s.description ?? "(no description)"}`)
292
+ .join("\n");
293
+
294
+ // 2. Determine current role (use getCurrentRole: it recognizes the
295
+ // default role even when model=null, so the router has a real
296
+ // baseline instead of an opaque "unknown".)
297
+ const currentModel = ctx.model;
298
+ const currentRole = currentModel
299
+ ? (rolesApi.getCurrentRole(`${currentModel.provider}/${currentModel.id}`) ?? "unknown")
300
+ : "unknown";
301
+
302
+ // 3. Build roles list
303
+ const visibleRoles = rolesApi.getVisibleRoles();
304
+ const rolesList = Object.entries(visibleRoles)
305
+ .map(
306
+ ([name, cfg]: [string, any]) =>
307
+ `- ${name}: ${cfg.description ?? "(no description)"}${cfg.model ? ` (model: ${cfg.model})` : " (current model)"}`,
308
+ )
309
+ .join("\n");
310
+
311
+ // 4. Build user message with conversation context
312
+ const prevTurnContext = prevTurn?.assistantSummary
313
+ ? { userPrompt: prevTurn.userPrompt, assistantSummary: prevTurn.assistantSummary }
314
+ : undefined;
315
+ const userMessage = buildScoutUserMessage(event.prompt, currentRole, prevTurnContext);
316
+
317
+ // Reset prevTurn for the current turn — user prompt now, assistant filled by turn_end
318
+ prevTurn = { userPrompt: event.prompt, assistantSummary: "" };
319
+
320
+ // 5. Call side agent
321
+ const scoutSystemPrompt = buildScoutSystemPrompt(config, skillsList, rolesList);
322
+ const decision = await callSideAgent(
323
+ rolesApi,
324
+ config.sideAgentRole,
325
+ scoutSystemPrompt,
326
+ userMessage,
327
+ );
328
+
329
+ // Enforce module toggles at the application layer, regardless of what
330
+ // the side agent returned. A disabled module's decision field is zeroed
331
+ // so the status bar, /scout command, and apply logic all see clean
332
+ // values (no misleading "→ fast" when model-router is off).
333
+ if (!config.modules.modelRouter) decision.role = null;
334
+ if (!config.modules.skillRouter) decision.skills = [];
335
+ lastDecision = decision;
336
+
337
+ let systemPrompt = event.systemPrompt;
338
+
339
+ // 6. skill-router: filter skills XML to only selected ones
340
+ if (config.modules.skillRouter) {
341
+ systemPrompt = filterSkillsBlock(systemPrompt, decision.skills, skillEntries);
342
+ }
343
+
344
+ // 7. model-router: switch model if side agent recommends a different role
345
+ if (config.modules.modelRouter && decision.role && decision.role !== currentRole) {
346
+ const switched = await switchToRole(pi, decision.role, rolesApi);
347
+ if (switched) {
348
+ const newModel = await rolesApi.resolveRoleAsync(decision.role);
349
+ if (newModel?.model) {
350
+ systemPrompt += `\n\n<current_model>${newModel.model.provider}/${newModel.model.id} (role: ${decision.role})</current_model>`;
351
+ }
352
+ }
353
+ }
354
+
355
+ // 8. Show result in status bar
356
+ ctx.ui.setStatus(STATUS_KEY, formatDecisionStatus(decision, theme));
357
+
358
+ // 9. Return modified system prompt
359
+ if (systemPrompt !== event.systemPrompt) {
360
+ return { systemPrompt };
361
+ }
362
+ });
352
363
  }