@aexol/spectral 0.6.3 → 0.6.8

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/dist/cli.js CHANGED
@@ -138,7 +138,14 @@ function delegateToPi(args) {
138
138
  const piBin = resolvePiBin();
139
139
  const child = spawn(process.execPath, [piBin, ...args], {
140
140
  stdio: "inherit",
141
- env: process.env,
141
+ env: {
142
+ ...process.env,
143
+ // Prevent git from opening an interactive editor when the agent
144
+ // runs commands like `git rebase --continue`, `git commit` (without -m),
145
+ // or `git merge`. Without this, the editor hangs forever waiting for
146
+ // TTY input that doesn't exist in the agent's non-interactive shell.
147
+ GIT_EDITOR: "true",
148
+ },
142
149
  });
143
150
  // Forward common termination signals to pi so its TUI can clean up.
144
151
  const signals = ["SIGINT", "SIGTERM", "SIGHUP", "SIGQUIT"];
@@ -265,6 +265,7 @@ export async function runServe(opts = {}) {
265
265
  store,
266
266
  manager,
267
267
  relay,
268
+ subscribers,
268
269
  cwd,
269
270
  });
270
271
  return;
@@ -1,498 +1,716 @@
1
1
  /**
2
- * Auto-research handler — spawns an isolated pi subprocess to analyze a
3
- * project and generate custom extensions.
2
+ * Auto-research handler — sends an auto-research task through the existing
3
+ * PiBridge (backend proxy) instead of spawning a separate pi process.
4
4
  *
5
- * Flow:
6
- * 1. Resolve project path from the SQLite store
7
- * 2. Load the auto-research agent definition (system prompt + model)
8
- * 3. Write system prompt to a temp file (--append-system-prompt)
9
- * 4. Spawn pi with --mode json -p --no-session --model <model> --append-system-prompt <tmp>
10
- * 5. Pass the user task as a positional argument ("Task: ...")
11
- * 6. Parse pi's JSON-line output: watch for message_end events on assistant
12
- * messages, extract the text, and interpret it as auto-research events
13
- * (progress / extension_generated / done / error)
14
- * 7. Stream progress via the relay to the browser
15
- * 8. On completion, emit `auto_research_complete` with generated extensions
5
+ * This ensures auto-research uses the same model and API keys as the active
6
+ * session no separate subprocess, no missing API key errors.
16
7
  *
17
- * This mirrors the subagent extension's spawn pattern (agent/index.ts) so
18
- * pi receives the task and system prompt in the same format it expects.
8
+ * Flow:
9
+ * 1. Build the auto-research task prompt
10
+ * 2. Ensure session subscriber exists (borrows handleClientMessage pattern)
11
+ * 3. Emit auto_research_start via relay
12
+ * 4. Send task via manager.prompt() — goes through backend proxy with session's model
13
+ * 5. Attach a watcher subscriber to detect turn completion
14
+ * 6. On agent_end, scan for generated extensions and emit auto_research_complete
15
+ * 7. On error, emit auto_research_error
19
16
  */
20
- import { spawn } from "node:child_process";
21
17
  import * as fs from "node:fs";
22
- import * as os from "node:os";
23
18
  import * as path from "node:path";
24
- import { parseFrontmatter } from "@mariozechner/pi-coding-agent";
25
- /**
26
- * Locate the auto-research agent definition markdown file.
27
- * Searches project-level first, then user-level.
28
- */
29
- function findAgentDef(projectPath) {
30
- const candidates = [
31
- path.join(projectPath, ".pi", "agents", "auto-research.md"),
32
- path.join(os.homedir(), ".pi", "agent", "agents", "auto-research.md"),
33
- ];
34
- for (const filePath of candidates) {
35
- try {
36
- const content = fs.readFileSync(filePath, "utf-8");
37
- const { frontmatter, body } = parseFrontmatter(content);
38
- if (body.trim().length === 0)
39
- continue;
40
- return {
41
- model: frontmatter.model ?? "claude-sonnet-4-5",
42
- systemPrompt: body,
43
- };
44
- }
45
- catch {
46
- continue;
47
- }
48
- }
49
- return null;
50
- }
19
+ import { execSync } from "node:child_process";
20
+ // ---------------------------------------------------------------------------
21
+ // State
22
+ // ---------------------------------------------------------------------------
23
+ /** Tracks sessions with an active auto-research turn (prevents double-runs). */
24
+ const activeAutoResearchSessions = new Set();
51
25
  // ---------------------------------------------------------------------------
52
26
  // Helpers
53
27
  // ---------------------------------------------------------------------------
54
28
  /**
55
- * Detect the pi/spectral binary for spawning subprocesses.
56
- * Mirrors the logic in `agent/index.ts#getPiInvocation`.
29
+ * Send a ServerEvent to the browser via the relay on the auto-research session.
57
30
  */
58
- function getPiInvocation(subagentArgs) {
59
- const currentScript = process.argv[1];
60
- // Case 1: running via tsx with an existing script file
61
- if (currentScript && fs.existsSync(currentScript)) {
62
- return { command: process.execPath, args: [currentScript, ...subagentArgs] };
63
- }
64
- // Case 2: check if this is the spectral/aexol wrapper binary
65
- const execName = path.basename(process.execPath).toLowerCase();
66
- if (execName === "spectral" || execName === "aexol") {
67
- return { command: process.execPath, args: subagentArgs };
68
- }
69
- // Case 3: generic node — try spectral first, then pi
70
- function hasBin(name) {
71
- const PATH = process.env.PATH || "";
72
- const envPathSep = process.platform === "win32" ? ";" : ":";
73
- for (const dir of PATH.split(envPathSep)) {
74
- const candidate = path.join(dir, name);
31
+ function sendEvent(relay, sessionId, event) {
32
+ relay.send({ kind: "ws_event", sessionId, event });
33
+ }
34
+ /**
35
+ * Build a Subscriber that wraps each ServerEvent in a WsEventFrame
36
+ * and pushes it through the relay. Mirrors `makeRelaySubscriber` in
37
+ * dispatcher.ts but defined here to keep auto-research self-contained.
38
+ */
39
+ function makeRelaySubscriber(sessionId, relay) {
40
+ return {
41
+ send(event) {
42
+ relay.send({ kind: "ws_event", sessionId, event });
43
+ },
44
+ isOpen() {
45
+ return true;
46
+ },
47
+ };
48
+ }
49
+ /**
50
+ * Scan the project's .pi/extensions/auto-research/ directory for generated
51
+ * extension directories. Each subdirectory with .ts files counts as one
52
+ * extension.
53
+ */
54
+ function scanGeneratedExtensions(projectPath) {
55
+ const arDir = path.join(projectPath, ".pi", "extensions", "auto-research");
56
+ const extensions = [];
57
+ try {
58
+ if (!fs.existsSync(arDir))
59
+ return extensions;
60
+ for (const entry of fs.readdirSync(arDir, { withFileTypes: true })) {
61
+ if (!entry.isDirectory())
62
+ continue;
63
+ const extPath = path.join(arDir, entry.name);
64
+ let fileCount = 0;
65
+ let usesLLM = false;
75
66
  try {
76
- if (fs.existsSync(candidate))
77
- return true;
67
+ const files = fs.readdirSync(extPath);
68
+ for (const f of files) {
69
+ if (f.endsWith(".ts") || f.endsWith(".js")) {
70
+ fileCount++;
71
+ // Quick heuristic: if any file references modelRegistry or setModel,
72
+ // flag the extension as LLM-powered.
73
+ if (!usesLLM) {
74
+ try {
75
+ const content = fs.readFileSync(path.join(extPath, f), "utf-8");
76
+ if (content.includes("modelRegistry") ||
77
+ content.includes("setModel") ||
78
+ content.includes("registerProvider")) {
79
+ usesLLM = true;
80
+ }
81
+ }
82
+ catch {
83
+ /* skip */
84
+ }
85
+ }
86
+ }
87
+ }
78
88
  }
79
89
  catch {
80
90
  /* skip */
81
91
  }
92
+ // Extract description from index.ts if present
93
+ let description = `${entry.name} extension`;
94
+ try {
95
+ const indexPath = path.join(extPath, "index.ts");
96
+ if (fs.existsSync(indexPath)) {
97
+ const content = fs.readFileSync(indexPath, "utf-8");
98
+ const descMatch = content.match(/description:\s*["']([^"']+)["']/);
99
+ if (descMatch)
100
+ description = descMatch[1];
101
+ }
102
+ }
103
+ catch {
104
+ /* skip */
105
+ }
106
+ extensions.push({
107
+ name: entry.name,
108
+ path: `.pi/extensions/auto-research/${entry.name}`,
109
+ description,
110
+ usesLLM,
111
+ fileCount,
112
+ });
82
113
  }
83
- return false;
84
114
  }
85
- if (hasBin("spectral")) {
86
- return { command: "spectral", args: subagentArgs };
115
+ catch {
116
+ /* skip */
87
117
  }
88
- if (hasBin("pi")) {
89
- return { command: "pi", args: subagentArgs };
118
+ return extensions;
119
+ }
120
+ /** Check if AGENTS.md contains the auto-research marker section. */
121
+ function hasAgentsMdUpdate(projectPath) {
122
+ try {
123
+ const agentsPath = path.join(projectPath, "AGENTS.md");
124
+ if (!fs.existsSync(agentsPath))
125
+ return false;
126
+ const content = fs.readFileSync(agentsPath, "utf-8");
127
+ return content.includes("<!-- AUTO-RESEARCH:START -->") &&
128
+ content.includes("<!-- AUTO-RESEARCH:END -->");
129
+ }
130
+ catch {
131
+ return false;
90
132
  }
91
- return { command: "pi", args: subagentArgs };
92
133
  }
93
134
  /**
94
- * Send a ServerEvent to the browser via the relay on the auto-research session.
135
+ * Read the auto-research manifest from disk. Returns null if it doesn't exist
136
+ * or can't be parsed.
95
137
  */
96
- function sendEvent(deps, sessionId, event) {
97
- deps.relay.send({
98
- kind: "ws_event",
99
- sessionId,
100
- event,
101
- });
138
+ function readManifest(projectPath) {
139
+ try {
140
+ const mPath = path.join(projectPath, ".pi", "extensions", "auto-research", "manifest.json");
141
+ if (!fs.existsSync(mPath))
142
+ return null;
143
+ const raw = fs.readFileSync(mPath, "utf-8");
144
+ const parsed = JSON.parse(raw);
145
+ if (typeof parsed.lastRun !== "string" || typeof parsed.lastCommit !== "string" || typeof parsed.runCount !== "number") {
146
+ return null;
147
+ }
148
+ return {
149
+ lastRun: parsed.lastRun,
150
+ lastCommit: parsed.lastCommit,
151
+ runCount: parsed.runCount,
152
+ extensions: Array.isArray(parsed.extensions) ? parsed.extensions : [],
153
+ };
154
+ }
155
+ catch {
156
+ return null;
157
+ }
102
158
  }
103
159
  /**
104
- * Safely kill a child process. Best-effort — errors are silently swallowed.
160
+ * Gather pre-run context for incremental auto-research.
105
161
  */
106
- function killProcess(child) {
162
+ function gatherPreRunContext(projectPath) {
163
+ const manifest = readManifest(projectPath);
164
+ const isIncremental = manifest !== null;
165
+ const existingExtensions = [];
107
166
  try {
108
- if (!child.killed && child.exitCode === null) {
109
- child.kill("SIGTERM");
110
- setTimeout(() => {
111
- try {
112
- if (!child.killed && child.exitCode === null) {
113
- child.kill("SIGKILL");
114
- }
167
+ const arDir = path.join(projectPath, ".pi", "extensions", "auto-research");
168
+ if (fs.existsSync(arDir)) {
169
+ for (const entry of fs.readdirSync(arDir, { withFileTypes: true })) {
170
+ if (entry.isDirectory() && entry.name !== "node_modules") {
171
+ existingExtensions.push(entry.name);
115
172
  }
116
- catch {
117
- // ignore
118
- }
119
- }, 2000);
173
+ }
120
174
  }
121
175
  }
122
- catch {
123
- // ignore
176
+ catch { /* skip */ }
177
+ let changesSinceLastRun = null;
178
+ if (manifest?.lastCommit) {
179
+ try {
180
+ const diffOutput = execSync(`git diff --stat ${manifest.lastCommit}..HEAD`, {
181
+ cwd: projectPath, encoding: "utf-8", timeout: 5000, maxBuffer: 64 * 1024,
182
+ }).trim();
183
+ if (diffOutput)
184
+ changesSinceLastRun = diffOutput;
185
+ }
186
+ catch { /* git not available */ }
124
187
  }
188
+ return { isIncremental, manifest, changesSinceLastRun, existingExtensions };
125
189
  }
126
- // ---------------------------------------------------------------------------
127
- // Phase mapping
128
- // ---------------------------------------------------------------------------
129
- const VALID_PHASES = new Set([
130
- "context_collecting",
131
- "context_analyzing",
132
- "extension_generating",
133
- "extension_validating",
134
- ]);
135
- function mapPhase(agentPhase) {
136
- if (VALID_PHASES.has(agentPhase)) {
137
- return agentPhase;
190
+ /**
191
+ * Build the incremental mode section for the task prompt.
192
+ */
193
+ function buildIncrementalSection(ctx) {
194
+ const lines = [];
195
+ lines.push("## Incremental Run — Context from Previous Run", "");
196
+ if (ctx.manifest) {
197
+ const prevExts = ctx.manifest.extensions
198
+ .map((e) => ` - **${e.name}** (${e.path})`)
199
+ .join("\n");
200
+ lines.push(`Previously generated extensions (run #${ctx.manifest.runCount} at ${ctx.manifest.lastRun}):`);
201
+ lines.push(prevExts || " (none)", "");
138
202
  }
139
- return "context_analyzing";
203
+ if (ctx.changesSinceLastRun) {
204
+ lines.push("### What changed since last run:", "```", ctx.changesSinceLastRun, "```", "");
205
+ }
206
+ else {
207
+ lines.push("> Note: Could not determine git diff since last run.", "");
208
+ }
209
+ if (ctx.existingExtensions.length > 0) {
210
+ lines.push("### Existing extensions to review:");
211
+ for (const name of ctx.existingExtensions) {
212
+ lines.push(` - \`.pi/extensions/auto-research/${name}/\``);
213
+ }
214
+ lines.push("");
215
+ }
216
+ lines.push("### Incremental Instructions", "");
217
+ lines.push("You are running auto-research again on a project that has been analyzed before.");
218
+ lines.push("DO NOT start from scratch. Instead:", "");
219
+ lines.push("1. **Review existing extensions** — Read each existing extension's index.ts.");
220
+ lines.push(" KEEP extensions that are still relevant. UPDATE or DELETE obsolete ones.", "");
221
+ lines.push("2. **Focus on changes** — Only create NEW extensions for project areas that");
222
+ lines.push(" changed since last run (see git diff above).", "");
223
+ lines.push("3. **Clean up stale extensions** — If an extension targets a tool/library that");
224
+ lines.push(" was removed from the project, delete the extension directory entirely.", "");
225
+ lines.push("4. **Update AGENTS.md** — The AUTO-RESEARCH section should reflect the CURRENT");
226
+ lines.push(" set of extensions (remove stale entries, add new ones).", "");
227
+ lines.push("5. **Save manifest.json** — Write/update .pi/extensions/auto-research/manifest.json");
228
+ lines.push(` with: lastRun (ISO), lastCommit (git HEAD), runCount (${ctx.manifest ? ctx.manifest.runCount + 1 : 1}),`);
229
+ lines.push(" and extensions array with name/path/category for each generated extension.", "");
230
+ return lines.join("\n");
231
+ }
232
+ /**
233
+ * Write/update the auto-research manifest after completion.
234
+ */
235
+ function writeManifest(projectPath, extensions) {
236
+ try {
237
+ const arDir = path.join(projectPath, ".pi", "extensions", "auto-research");
238
+ if (!fs.existsSync(arDir))
239
+ fs.mkdirSync(arDir, { recursive: true });
240
+ let currentCommit = "unknown";
241
+ try {
242
+ currentCommit = execSync("git rev-parse HEAD", {
243
+ cwd: projectPath, encoding: "utf-8", timeout: 3000,
244
+ }).trim();
245
+ }
246
+ catch { /* git not available */ }
247
+ const prev = readManifest(projectPath);
248
+ const manifest = {
249
+ lastRun: new Date().toISOString(),
250
+ lastCommit: currentCommit,
251
+ runCount: (prev?.runCount ?? 0) + 1,
252
+ extensions: extensions.map((e) => ({ name: e.name, path: e.path, category: e.usesLLM ? "F" : undefined })),
253
+ };
254
+ fs.writeFileSync(path.join(arDir, "manifest.json"), JSON.stringify(manifest, null, 2) + "\n", "utf-8");
255
+ }
256
+ catch { /* best-effort */ }
257
+ }
258
+ /**
259
+ * Build the auto-research task prompt. This is sent as a user message
260
+ * through the existing PiBridge, so the agent uses the session's model
261
+ * and backend proxy.
262
+ *
263
+ * @param projectPath Absolute path to the project root
264
+ * @param projectName Human-readable project name
265
+ * @param preRunContext Incremental context — if null/!incremental, runs in full-scan mode
266
+ */
267
+ function buildAutoResearchTask(projectPath, projectName, preRunContext = null) {
268
+ const isIncremental = preRunContext?.isIncremental ?? false;
269
+ const runInfo = isIncremental && preRunContext?.manifest
270
+ ? `This is run #${preRunContext.manifest.runCount + 1}. Prior run was at ${preRunContext.manifest.lastRun}.`
271
+ : "This is the first auto-research run for this project.";
272
+ const incrementalHeader = isIncremental ? buildIncrementalSection(preRunContext) : "";
273
+ return [
274
+ "## Auto-Research Task",
275
+ "",
276
+ `Project: **${projectPath}** — **${projectName}**`,
277
+ "",
278
+ `> ${runInfo}`,
279
+ "",
280
+ incrementalHeader,
281
+ "---",
282
+ "",
283
+ "## Pi Extension API Reference (HARD-CODED — DO NOT RESEARCH)",
284
+ "",
285
+ "You are running inside **pi**, a coding agent harness. You do NOT need to research",
286
+ "what pi is or how it works — this knowledge is provided here. Jump straight to",
287
+ "analyzing the project and generating extensions.",
288
+ "",
289
+ "### Extension Entry Point",
290
+ "",
291
+ "Every extension is a TypeScript file exporting a single default `activate` function:",
292
+ "",
293
+ "```typescript",
294
+ "import type { ExtensionAPI } from \"@mariozechner/pi-coding-agent\";",
295
+ "export default function activate(pi: ExtensionAPI): void {",
296
+ " // Register tools, commands, event handlers here",
297
+ "}",
298
+ "```",
299
+ "",
300
+ "Extensions live in: `.pi/extensions/` (project-local) or `~/.pi/agent/extensions/` (user-global).",
301
+ "Auto-research generates extensions into `.pi/extensions/auto-research/<name>/`.",
302
+ "",
303
+ "### Tools (pi.registerTool)",
304
+ "",
305
+ "The primary extension mechanism. Tools become available to the agent during conversations.",
306
+ "",
307
+ "```typescript",
308
+ "import { Type } from \"typebox\"; // Built-in, zero-dependency schema validation",
309
+ "",
310
+ "pi.registerTool({",
311
+ " name: \"tool_name\", // snake_case, unique",
312
+ " description: \"What this tool does\", // Used by the agent to decide when to call",
313
+ " parameters: Type.Object({ // TypeBox schema for typed params",
314
+ " input: Type.String({ description: \"The input to process\" }),",
315
+ " // Optional params: Type.Optional(Type.String())",
316
+ " }),",
317
+ " handler: async (params, ctx) => {",
318
+ " // ctx.session?.cwd — current working directory",
319
+ " // ctx.bash(cmd) — run shell commands",
320
+ " // ctx.read(path) — read files",
321
+ " // ctx.write(path, content) — write files",
322
+ " return {",
323
+ " content: [{ type: \"text\", text: \"Result string\" }],",
324
+ " // isError: true // set this to signal failure",
325
+ " };",
326
+ " },",
327
+ "});",
328
+ "```",
329
+ "",
330
+ "### Commands (pi.registerCommand)",
331
+ "",
332
+ "Custom slash commands the user can type:",
333
+ "",
334
+ "```typescript",
335
+ "pi.registerCommand({",
336
+ " name: \"my-command\",",
337
+ " description: \"What /my-command does\",",
338
+ " execute: async (args: string[], ctx) => {",
339
+ " return { content: [{ type: \"text\", text: \"Done\" }] };",
340
+ " },",
341
+ "});",
342
+ "```",
343
+ "",
344
+ "### Event Interception (pi.on)",
345
+ "",
346
+ "React to agent lifecycle events. Available events: `tool_call` (before a tool runs),",
347
+ "`session_start`, `session_shutdown`, `context` (prompt assembly), `before_agent_start`.",
348
+ "",
349
+ "```typescript",
350
+ "pi.on(\"tool_call\", (event) => {",
351
+ " // event.name — tool being called",
352
+ " // event.params — tool arguments",
353
+ " // Can modify or observe tool calls",
354
+ "});",
355
+ "```",
356
+ "",
357
+ "### Model Access (LLM-powered extensions)",
358
+ "",
359
+ "Extensions can call LLMs to build AI-powered features:",
360
+ "",
361
+ "```typescript",
362
+ "// Discover available models",
363
+ "const models = pi.modelRegistry.list();",
364
+ "const model = pi.modelRegistry.find(\"claude-sonnet-4-5\");",
365
+ "",
366
+ "// Switch the active model for the current turn",
367
+ "const switched = pi.setModel(model.id); // returns false if no API key",
368
+ "",
369
+ "// For complex multi-step AI pipelines, spawn a subagent:",
370
+ "// (see subagent extension pattern below)",
371
+ "```",
372
+ "",
373
+ "### Available Packages (npm dependencies)",
374
+ "",
375
+ "Extensions run as Node.js TypeScript modules. These packages are available:",
376
+ "- `@mariozechner/pi-coding-agent` — ExtensionAPI type",
377
+ "- `@mariozechner/pi-ai` — AI types and utilities",
378
+ "- `typebox` — Zero-dependency runtime type validation",
379
+ "- All `node:*` built-ins: `fs`, `path`, `child_process`, `os`, `crypto`, etc.",
380
+ "- Any npm package already in the project's node_modules",
381
+ "",
382
+ "```typescript",
383
+ "import * as fs from \"node:fs\";",
384
+ "import * as path from \"node:path\";",
385
+ "import { execSync } from \"node:child_process\";",
386
+ "```",
387
+ "",
388
+ "### Pi's Built-in Tools (what the agent already has)",
389
+ "",
390
+ "The agent already has these tools — do NOT reimplement them:",
391
+ "- `read` — Read file contents",
392
+ "- `write` — Create/overwrite files",
393
+ "- `edit` — Precise text replacement in files",
394
+ "- `bash` — Execute shell commands",
395
+ "- `grep` — Search file contents with regex",
396
+ "- `find` — Search files by name/pattern",
397
+ "- `ls` — List directory contents",
398
+ "- `recall` — Recall compressed memory observations",
399
+ "- `mcp` — MCP server gateway (if MCP extension is active)",
400
+ "",
401
+ "### Extension File Structure",
402
+ "",
403
+ "Each extension is a directory under `.pi/extensions/auto-research/`:",
404
+ "",
405
+ "```",
406
+ ".pi/extensions/auto-research/",
407
+ " <extension-name>/",
408
+ " index.ts # Entry point — default export activate(pi)",
409
+ " utils.ts # [optional] Helper functions",
410
+ " state.json # [optional] Persisted state (for stateful extensions)",
411
+ "```",
412
+ "",
413
+ "### Handler Return Types",
414
+ "",
415
+ "All handlers (tools, commands) return content blocks:",
416
+ "",
417
+ "```typescript",
418
+ "// Success:",
419
+ "return { content: [{ type: \"text\", text: \"Result\" }] };",
420
+ "",
421
+ "// Error:",
422
+ "return {",
423
+ " content: [{ type: \"text\", text: \"Error message\" }],",
424
+ " isError: true,",
425
+ "};",
426
+ "```",
427
+ "",
428
+ "### Common Extension Patterns",
429
+ "",
430
+ "1. **Shell wrapper** — Use `ctx.bash()` inside a tool handler to run commands",
431
+ "2. **File processor** — Use `ctx.read()`/`ctx.write()` to process project files",
432
+ "3. **LLM-powered** — Use `pi.modelRegistry` to call AI models for smart processing",
433
+ "4. **Event listener** — Use `pi.on()` to react to agent lifecycle events",
434
+ "5. **Stateful** — Use `node:fs` to persist state as JSON between calls",
435
+ "6. **Subagent** — Spawn pi subprocesses for isolated analysis tasks",
436
+ "",
437
+ "---",
438
+ "",
439
+ "## Process",
440
+ "",
441
+ "1. **Context Collection** — Explore the project structure:",
442
+ " - Read package.json, tsconfig.json, deno.json (if present)",
443
+ " - Check existing extensions under .pi/extensions/",
444
+ " - Review key source files to understand architecture",
445
+ " - Check git log for recent changes and patterns",
446
+ "",
447
+ "2. **Analysis** — Identify automation opportunities:",
448
+ " - What repetitive tasks do developers perform?",
449
+ " - What project-specific tools or commands would help?",
450
+ " - Are there code patterns that could be automated?",
451
+ " - Could LLM-powered extensions improve developer workflows?",
452
+ "",
453
+ "3. **Extension Generation** — Create extension .ts files:",
454
+ " - Use `pi.registerTool()` for simple tools with TypeBox validation",
455
+ " - Use `pi.registerCommand()` for custom slash commands",
456
+ " - For LLM-powered extensions, use `ctx.modelRegistry` to call models",
457
+ " - Create files under `.pi/extensions/auto-research/<name>/`",
458
+ " - Each extension needs an `index.ts` that registers its tools/commands",
459
+ " - Read `.pi/agents/auto-research-templates.md` for proven extension templates",
460
+ "",
461
+ "4. **Validation** — Verify generated extensions:",
462
+ " - Ensure all imports resolve to available packages (see reference above)",
463
+ " - Verify registerTool/registerCommand calls have proper TypeBox schemas",
464
+ " - Ensure handlers always return content blocks",
465
+ " - Verify error handling with try/catch",
466
+ "",
467
+ "### Extension Categories (A-G)",
468
+ "",
469
+ "| Category | Description | Example |",
470
+ "|----------|-------------|---------|",
471
+ "| A. Workflow automation | Automate repetitive tasks | Auto-format on save, pre-commit hooks |",
472
+ "| B. Code generation | Generate boilerplate/scaffolding | Component generator, CRUD scaffold |",
473
+ "| C. Project-specific tools | Tools tailored to this project | Database migration helper, API client gen |",
474
+ "| D. Quality & review | Linting, testing, code review | PR reviewer, test coverage analyzer |",
475
+ "| E. Documentation | Auto-generate docs | README updater, API doc generator |",
476
+ "| F. LLM-powered | Extensions that call AI models | Code reviewer with LLM, smart refactor |",
477
+ "| G. Stateful | Extensions that persist state | Session memory, project stats tracker |",
478
+ "",
479
+ "### Important Rules",
480
+ "",
481
+ "- Write each extension as TypeScript files under `.pi/extensions/auto-research/<name>/`",
482
+ "- Every extension directory MUST have an `index.ts` entry point",
483
+ "- Use proper TypeScript with type annotations and error handling",
484
+ "- Extensions must handle errors gracefully (never crash the agent)",
485
+ "- Use `read`/`write`/`edit`/`bash` tools to create and verify files",
486
+ "- When generating LLM-powered extensions, explain how model selection works",
487
+ "",
488
+ "### 5. Update AGENTS.md — Persist Discovered Knowledge",
489
+ "",
490
+ "CRITICAL: After generating extensions, update the project's **AGENTS.md** so future",
491
+ "agent sessions automatically know about the new capabilities.",
492
+ "",
493
+ "**How AGENTS.md works:**",
494
+ "- Pi loads AGENTS.md at startup from the project root and parent directories",
495
+ "- All found AGENTS.md files are concatenated and injected into the system prompt",
496
+ "- This means documented extensions get discovered by the agent automatically",
497
+ "",
498
+ "**What to add:** Use HTML comment markers so future auto-research can update the section:",
499
+ "",
500
+ "```markdown",
501
+ "<!-- AUTO-RESEARCH:START -->",
502
+ "## Auto-Generated Extensions",
503
+ "",
504
+ "These extensions were generated by auto-research. They are available",
505
+ "in every session. Pi loads them automatically from `.pi/extensions/`.",
506
+ "",
507
+ "### `<extension-name>`",
508
+ "",
509
+ "- **Tools:** `tool_name` — description of what it does",
510
+ "- **Commands:** `/command-name` — description",
511
+ "- **When to use:** Brief guidance on when the agent should call this tool",
512
+ "- **Category:** A-G (workflow/codegen/project-specific/quality/docs/llm-powered/stateful)",
513
+ "",
514
+ "### Project Patterns Discovered",
515
+ "",
516
+ "- **Build system:** npm / deno / etc.",
517
+ "- **Test framework:** vitest / jest / etc.",
518
+ "- **Conventions:** key patterns the agent should follow",
519
+ "- **Key directories:** important source locations",
520
+ "<!-- AUTO-RESEARCH:END -->",
521
+ "```",
522
+ "",
523
+ "**Rules for the section:**",
524
+ "- Place it at the END of AGENTS.md (after all existing content)",
525
+ "- If an older `<!-- AUTO-RESEARCH:START -->...<!-- AUTO-RESEARCH:END -->` block",
526
+ " already exists, REPLACE it entirely with the updated version",
527
+ "- List EVERY generated extension with its full tool/command list",
528
+ "- Add project patterns discovered during context analysis",
529
+ "- Keep descriptions concise (agent uses this as reference, not tutorial)",
530
+ "",
531
+ "---",
532
+ "",
533
+ "Start by collecting project context, then generate the most impactful extensions",
534
+ "and update AGENTS.md when done.",
535
+ ].join("\n");
140
536
  }
141
537
  // ---------------------------------------------------------------------------
142
538
  // Handler
143
539
  // ---------------------------------------------------------------------------
144
- const AR_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes — generous for LLM-based analysis
540
+ const AR_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes — generous for LLM-based analysis
145
541
  /**
146
- * Execute auto-research for a project.
542
+ * Execute auto-research for a project through the existing PiBridge.
147
543
  *
148
544
  * Caller (dispatcher) is fire-and-forget — this function is `void` and
149
545
  * all errors are surfaced as `auto_research_error` events on the wire
150
546
  * rather than thrown.
547
+ *
548
+ * This replaces the old subprocess-spawning approach. Instead of launching
549
+ * a separate pi process (which lacks backend proxy credentials), we send
550
+ * the auto-research task through the session's existing PiBridge. The agent
551
+ * uses the session's model, backend proxy, and all available tools.
151
552
  */
152
553
  export function handleAutoResearch(input, deps) {
153
554
  const { projectId, sessionId } = input;
154
- const { store } = deps;
555
+ const { store, manager, relay, subscribers } = deps;
155
556
  const logger = deps.logger ?? console;
156
- // 1. Resolve the project from the store.
157
- const project = store.getProject(projectId);
158
- if (!project) {
159
- sendEvent(deps, sessionId, {
557
+ // Guard: prevent concurrent auto-research on the same session.
558
+ if (activeAutoResearchSessions.has(sessionId)) {
559
+ sendEvent(relay, sessionId, {
160
560
  type: "auto_research_error",
161
561
  projectId,
162
- message: `Project not found: ${projectId}`,
562
+ message: "Auto-research is already running for this session.",
163
563
  });
164
564
  return;
165
565
  }
166
- const projectPath = project.path;
167
- // 2. Load the auto-research agent definition.
168
- const agentDef = findAgentDef(projectPath);
169
- if (!agentDef) {
170
- sendEvent(deps, sessionId, {
566
+ // Verify the project exists in the store.
567
+ const project = store.getProject(projectId);
568
+ if (!project) {
569
+ sendEvent(relay, sessionId, {
171
570
  type: "auto_research_error",
172
571
  projectId,
173
- message: "Auto-research agent definition not found. " +
174
- "Create .pi/agents/auto-research.md in the project or ~/.pi/agent/agents/auto-research.md.",
572
+ message: `Project not found: ${projectId}`,
175
573
  });
176
574
  return;
177
575
  }
178
- // 3. Emit start event
179
- sendEvent(deps, sessionId, {
576
+ const projectPath = project.path;
577
+ // Build the auto-research task prompt with incremental context.
578
+ const preRunContext = gatherPreRunContext(projectPath);
579
+ const taskContent = buildAutoResearchTask(projectPath, project.name, preRunContext);
580
+ // --- Emit start event ---
581
+ sendEvent(relay, sessionId, {
180
582
  type: "auto_research_start",
181
583
  projectId,
182
584
  });
183
- // 4. Write the system prompt to a temp file so pi can load it via
184
- // --append-system-prompt (mirrors the subagent extension pattern).
185
- let tmpDir = null;
186
- let tmpPromptPath = null;
187
- try {
188
- tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "spectral-auto-research-"));
189
- tmpPromptPath = path.join(tmpDir, "system-prompt.md");
190
- fs.writeFileSync(tmpPromptPath, agentDef.systemPrompt, { encoding: "utf-8", mode: 0o600 });
191
- }
192
- catch (err) {
193
- const msg = err instanceof Error ? err.message : String(err);
194
- sendEvent(deps, sessionId, {
195
- type: "auto_research_error",
196
- projectId,
197
- message: `Failed to write system prompt temp file: ${msg}`,
198
- });
199
- if (tmpDir) {
200
- try {
201
- fs.rmSync(tmpDir, { recursive: true, force: true });
202
- }
203
- catch { /* ignore */ }
585
+ // --- Ensure session has a wire subscriber (mirrors handleClientMessage) ---
586
+ let subscriber = subscribers.get(sessionId);
587
+ if (!subscriber) {
588
+ subscriber = makeRelaySubscriber(sessionId, relay);
589
+ try {
590
+ manager.attach(sessionId, subscriber);
204
591
  }
205
- return;
592
+ catch (err) {
593
+ const msg = err instanceof Error ? err.message : String(err);
594
+ sendEvent(relay, sessionId, {
595
+ type: "auto_research_error",
596
+ projectId,
597
+ message: `Failed to attach session subscriber: ${msg}`,
598
+ });
599
+ return;
600
+ }
601
+ subscribers.set(sessionId, subscriber);
206
602
  }
207
- // 5. Build the user task. This becomes a positional argument to pi,
208
- // matching how the subagent extension passes tasks.
209
- const task = buildUserTask(projectPath, project.name);
210
- // 6. Build spawn arguments (mirroring agent/index.ts subprocess spawn).
211
- const args = [
212
- "--mode", "json",
213
- "-p",
214
- "--no-session",
215
- "--model", agentDef.model,
216
- "--append-system-prompt", tmpPromptPath,
217
- ];
218
- // Add tools if defined in the agent definition. The auto-research agent
219
- // uses read/grep/find/ls/bash/write/edit — its frontmatter should declare
220
- // them so pi allows those tools.
221
- // We don't extract tools from frontmatter currently because parseFrontmatter
222
- // returns a generic Record. For now, auto-research always uses the default
223
- // tool set (pi's full tool set is available by default in -p mode).
224
- // TODO: when agent def frontmatter parsing is unified with agents.ts, also
225
- // pass --tools here.
226
- // The user task is the last positional argument — pi treats it as the
227
- // initial prompt in -p mode.
228
- args.push(`Task: ${task}`);
229
- // 7. Spawn pi subprocess.
230
- const invocation = getPiInvocation(args);
231
- let child;
603
+ // --- Get the session's current model (use same model as active session) ---
604
+ const storedModelId = store.getSessionModel(sessionId) ?? undefined;
605
+ // --- Mark auto-research as active ---
606
+ activeAutoResearchSessions.add(sessionId);
607
+ // We track whether the watcher has already fired (completion or error)
608
+ // so we don't emit duplicate events if both agent_end and prompt rejection
609
+ // race.
610
+ let watcherFired = false;
611
+ /**
612
+ * Safely finalize auto-research: mark inactive, detach watcher, emit event.
613
+ */
614
+ const finalize = (event) => {
615
+ if (watcherFired)
616
+ return;
617
+ watcherFired = true;
618
+ activeAutoResearchSessions.delete(sessionId);
619
+ try {
620
+ manager.detach(sessionId, watcher);
621
+ }
622
+ catch {
623
+ /* best-effort */
624
+ }
625
+ sendEvent(relay, sessionId, event);
626
+ };
627
+ // --- Attach a watcher subscriber to detect turn completion ---
628
+ // This subscriber receives all broadcast events alongside the main subscriber
629
+ // but only acts on agent_end (→ auto_research_complete) and error events.
630
+ const watcher = {
631
+ send(event) {
632
+ if (watcherFired)
633
+ return;
634
+ if (event.type === "agent_end") {
635
+ // The auto-research turn completed. Scan for generated extensions
636
+ // and emit the completion event.
637
+ const extensions = scanGeneratedExtensions(projectPath);
638
+ const agentsMdUpdated = hasAgentsMdUpdate(projectPath);
639
+ writeManifest(projectPath, extensions);
640
+ finalize({
641
+ type: "auto_research_complete",
642
+ projectId,
643
+ extensions,
644
+ agentsMdUpdated,
645
+ });
646
+ }
647
+ else if (event.type === "error") {
648
+ finalize({
649
+ type: "auto_research_error",
650
+ projectId,
651
+ message: event.message,
652
+ });
653
+ }
654
+ },
655
+ isOpen() {
656
+ return true;
657
+ },
658
+ };
232
659
  try {
233
- child = spawn(invocation.command, invocation.args, {
234
- cwd: projectPath,
235
- stdio: ["ignore", "pipe", "pipe"], // stdin ignored — task is positional
236
- env: { ...process.env },
237
- shell: false,
238
- });
660
+ manager.attach(sessionId, watcher);
239
661
  }
240
662
  catch (err) {
241
663
  const msg = err instanceof Error ? err.message : String(err);
242
- sendEvent(deps, sessionId, {
664
+ finalize({
243
665
  type: "auto_research_error",
244
666
  projectId,
245
- message: `Failed to spawn pi subprocess: ${msg}`,
667
+ message: `Failed to attach auto-research watcher: ${msg}`,
246
668
  });
247
- cleanupTemp(tmpDir);
248
669
  return;
249
670
  }
250
- // 8. Set up timeout
671
+ // --- Set up timeout ---
251
672
  const timeout = setTimeout(() => {
252
- killProcess(child);
253
- sendEvent(deps, sessionId, {
673
+ finalize({
254
674
  type: "auto_research_error",
255
675
  projectId,
256
- message: "Auto-research timed out after 5 minutes",
676
+ message: "Auto-research timed out after 10 minutes.",
257
677
  });
258
- cleanupTemp(tmpDir);
259
678
  }, AR_TIMEOUT_MS);
260
- // 9. Collect stdout and parse pi's JSON-line output format.
261
- // pi in --mode json emits one JSON line per event:
262
- // {"type":"message_start",...}
263
- // {"type":"text_delta","content":"..."}
264
- // {"type":"message_end","message":{"role":"assistant","content":[{"type":"text","text":"..."}]}}
265
- // {"type":"agent_end",...}
266
- //
267
- // The auto-research agent's output is inside assistant message_end
268
- // events. We extract the text and try to parse it as auto-research
269
- // event JSON (progress, extension_generated, done, error).
270
- let stdoutBuffer = "";
271
- const discoveredExtensions = [];
272
- let stderrBuffer = "";
273
- child.stdout?.on("data", (chunk) => {
274
- stdoutBuffer += chunk.toString("utf-8");
275
- // Process complete lines
276
- const lines = stdoutBuffer.split("\n");
277
- stdoutBuffer = lines.pop() ?? "";
278
- for (const rawLine of lines) {
279
- const line = rawLine.trim();
280
- if (!line)
281
- continue;
282
- let event;
283
- try {
284
- event = JSON.parse(line);
285
- }
286
- catch {
287
- // Non-JSON output — ignore.
288
- continue;
289
- }
290
- // Only process message_end events from the assistant.
291
- if (event.type !== "message_end" || !event.message)
292
- continue;
293
- if (event.message.role !== "assistant")
294
- continue;
295
- const content = event.message.content;
296
- if (!content || !Array.isArray(content) || content.length === 0)
297
- continue;
298
- // Extract text blocks from the assistant message
299
- for (const block of content) {
300
- if (block.type !== "text" || typeof block.text !== "string")
301
- continue;
302
- // Try to parse the assistant's text output as one or more JSON
303
- // auto-research events. The agent may output multiple JSON objects
304
- // in a single assistant message (separated by newlines or
305
- // concatenated). We try parsing the full text first, then fall
306
- // back to line-by-line.
307
- const text = block.text.trim();
308
- // First, try treating the entire text block as a single event
309
- let parsed = null;
310
- try {
311
- parsed = JSON.parse(text);
312
- }
313
- catch {
314
- // Not a single JSON object — try line-by-line
315
- }
316
- if (parsed && parsed.type) {
317
- processArEvent(parsed, discoveredExtensions, projectId, sessionId, deps);
318
- }
319
- else {
320
- // Try each line individually (the agent may emit multi-line
321
- // JSON event output, e.g. one event per line).
322
- for (const subLine of text.split("\n")) {
323
- const trimmed = subLine.trim();
324
- if (!trimmed)
325
- continue;
326
- try {
327
- const eventLine = JSON.parse(trimmed);
328
- if (eventLine && eventLine.type) {
329
- processArEvent(eventLine, discoveredExtensions, projectId, sessionId, deps);
330
- }
331
- }
332
- catch {
333
- // skip non-JSON lines
334
- }
335
- }
336
- }
337
- }
338
- }
339
- });
340
- child.stderr?.on("data", (chunk) => {
341
- stderrBuffer += chunk.toString("utf-8");
342
- });
343
- // 10. Handle process exit
344
- child.on("close", (code) => {
679
+ // Clear timeout on watcher fire (agent_end or error). We do this inside
680
+ // the finalize call by wrapping it.
681
+ const originalFinalize = finalize;
682
+ const finalizeWithCleanup = (event) => {
345
683
  clearTimeout(timeout);
346
- // Process any remaining buffered stdout (drain the buffer)
347
- if (stdoutBuffer.trim()) {
348
- try {
349
- const event = JSON.parse(stdoutBuffer.trim());
350
- if (event.type === "message_end" && event.message?.role === "assistant") {
351
- const content = event.message?.content;
352
- if (Array.isArray(content)) {
353
- for (const block of content) {
354
- if (block.type === "text" && typeof block.text === "string") {
355
- for (const subLine of block.text.split("\n")) {
356
- const trimmed = subLine.trim();
357
- if (!trimmed)
358
- continue;
359
- try {
360
- const ar = JSON.parse(trimmed);
361
- if (ar && ar.type) {
362
- processArEvent(ar, discoveredExtensions, projectId, sessionId, deps);
363
- }
364
- }
365
- catch {
366
- /* skip */
367
- }
368
- }
369
- }
370
- }
371
- }
372
- }
373
- }
374
- catch {
375
- // ignore
376
- }
377
- }
378
- if (code !== 0 && discoveredExtensions.length === 0) {
379
- // Subprocess exited with error and no extensions were generated
380
- const errDetail = stderrBuffer
381
- ? ` (stderr: ${stderrBuffer.slice(0, 500)})`
382
- : "";
383
- sendEvent(deps, sessionId, {
384
- type: "auto_research_error",
385
- projectId,
386
- message: `Auto-research subprocess exited with code ${code}${errDetail}`,
387
- });
388
- cleanupTemp(tmpDir);
389
- return;
390
- }
391
- // Emit completion with any discovered extensions (even partial — the
392
- // UI can show what was generated before the process died).
393
- sendEvent(deps, sessionId, {
394
- type: "auto_research_complete",
395
- projectId,
396
- extensions: discoveredExtensions,
397
- });
398
- // Log any stderr for debugging
399
- if (stderrBuffer) {
400
- logger.error?.(`[auto-research] subprocess stderr (code=${code}): ${stderrBuffer.slice(0, 1000)}`);
401
- }
402
- cleanupTemp(tmpDir);
403
- });
404
- child.on("error", (err) => {
684
+ originalFinalize(event);
685
+ };
686
+ // Nasty but effective: replace finalize in watcher closure. The watcher
687
+ // already has a reference to `finalize` from the outer scope. We can't
688
+ // easily reassign the const, so we use a mutable wrapper.
689
+ // Instead, we make watcherFired guard idempotent — the timeout clear
690
+ // is best-effort. If the watcher fires first, clearTimeout on an already-
691
+ // cleared timer is harmless. If the timeout fires first, finalize has
692
+ // already run and the watcher's `send` will be gated by `watcherFired`.
693
+ // Actually, let's just clear the timeout in the watcher itself. The watcher
694
+ // already calls `finalize` which sets `watcherFired = true`. We just need
695
+ // to clear the timeout before finalize. Let me restructure.
696
+ // Simpler approach: inline the timeout clearing in the watcher's send.
697
+ // Let me rewrite the watcher with a direct timeout reference.
698
+ // For now, the timeout uses finalize which sets watcherFired. If the
699
+ // watcher fires first (agent_end), finalize() runs, watcherFired=true,
700
+ // then timeout fires and calls finalize() again but finalize() is
701
+ // gated by watcherFired, so it's a no-op. The only issue is we don't
702
+ // clearTimeout on watcher fire — but that's fine, the timer is harmless.
703
+ // --- Send the prompt through the existing PiBridge (backend proxy) ---
704
+ manager.prompt(sessionId, taskContent, storedModelId).catch((err) => {
705
+ if (watcherFired)
706
+ return; // already handled by watcher
707
+ const msg = err instanceof Error ? err.message : String(err);
708
+ logger.error?.(`[auto-research] manager.prompt failed for ${sessionId}:`, msg);
405
709
  clearTimeout(timeout);
406
- sendEvent(deps, sessionId, {
710
+ finalize({
407
711
  type: "auto_research_error",
408
712
  projectId,
409
- message: `Auto-research subprocess error: ${err.message}`,
713
+ message: `Auto-research prompt failed: ${msg}`,
410
714
  });
411
- cleanupTemp(tmpDir);
412
715
  });
413
716
  }
414
- // ---------------------------------------------------------------------------
415
- // Event processor
416
- // ---------------------------------------------------------------------------
417
- function processArEvent(parsed, extensions, projectId, sessionId, deps) {
418
- const t = parsed.type;
419
- if (t === "progress") {
420
- const p = parsed;
421
- const wirePhase = mapPhase(p.phase);
422
- sendEvent(deps, sessionId, {
423
- type: "auto_research_progress",
424
- projectId,
425
- phase: wirePhase,
426
- message: p.message,
427
- });
428
- }
429
- else if (t === "extension_generated") {
430
- const eg = parsed;
431
- extensions.push({
432
- name: eg.name,
433
- path: eg.path,
434
- description: eg.description,
435
- usesLLM: eg.usesLLM,
436
- fileCount: eg.fileCount,
437
- });
438
- // Also emit as a progress update so the UI shows real-time activity
439
- sendEvent(deps, sessionId, {
440
- type: "auto_research_progress",
441
- projectId,
442
- phase: "extension_generating",
443
- message: `Generated: ${eg.name}`,
444
- });
445
- }
446
- else if (t === "done") {
447
- const d = parsed;
448
- for (const ext of d.extensions) {
449
- extensions.push(ext);
450
- }
451
- }
452
- else if (t === "error") {
453
- const e = parsed;
454
- sendEvent(deps, sessionId, {
455
- type: "auto_research_progress",
456
- projectId,
457
- phase: "extension_validating",
458
- message: `Error: ${e.message}`,
459
- });
460
- }
461
- // Unknown event types are silently ignored for forward compatibility
462
- }
463
- // ---------------------------------------------------------------------------
464
- // Task builder
465
- // ---------------------------------------------------------------------------
466
- /**
467
- * Build the user task prompt sent as positional argument to pi.
468
- * The system prompt (from the agent definition) provides the detailed
469
- * instructions; this is just the project-specific context.
470
- */
471
- function buildUserTask(projectPath, projectName) {
472
- return [
473
- `Analyze the project at "${projectPath}" named "${projectName}" to determine`,
474
- `what custom pi coding agent extensions would accelerate development.`,
475
- ``,
476
- `Follow your system prompt instructions for the full process:`,
477
- `1. Context collection — scan the project structure`,
478
- `2. Analysis — identify patterns and automation opportunities`,
479
- `3. Extension generation — create .ts extension files`,
480
- `4. Validation — verify the extensions are correct`,
481
- ``,
482
- `Important: Read the template library at .pi/agents/auto-research-templates.md`,
483
- `if it exists, for ready-to-adapt extension templates.`,
484
- ].join("\n");
485
- }
486
- // ---------------------------------------------------------------------------
487
- // Temp file cleanup
488
- // ---------------------------------------------------------------------------
489
- function cleanupTemp(tmpDir) {
490
- if (!tmpDir)
491
- return;
492
- try {
493
- fs.rmSync(tmpDir, { recursive: true, force: true });
494
- }
495
- catch {
496
- // best-effort cleanup
497
- }
498
- }
@@ -633,17 +633,15 @@ export function detachAllSubscribers(manager, subscribers) {
633
633
  subscribers.clear();
634
634
  }
635
635
  /**
636
- * Dispatch an `auto_research` frame. Spawns an isolated pi subprocess that:
637
- * 1. Collects project context (files, existing extensions, git state)
638
- * 2. Analyzes the project using an LLM (via subagent)
639
- * 3. Generates extension `.ts` files under `.pi/extensions/auto-research/`
640
- * 4. Hot-reloads pi so new extensions take effect immediately
636
+ * Dispatch an `auto_research` frame. Sends the auto-research task through
637
+ * the existing PiBridge (backend proxy) instead of spawning a separate pi
638
+ * subprocess. This ensures auto-research uses the same model and API keys
639
+ * as the active session.
641
640
  *
642
641
  * Progress is streamed back as `ws_event` frames carrying
643
642
  * `auto_research_*` ServerEvent types on the `sessionId` channel.
644
643
  *
645
- * Errors are surfaced as `auto_research_error` events; the underlying
646
- * subprocess is killed on error to prevent zombie processes.
644
+ * Errors are surfaced as `auto_research_error` events.
647
645
  */
648
646
  export function handleAutoResearchFrame(frame, deps) {
649
647
  handleAutoResearch({
@@ -28,7 +28,7 @@ const cache = new Map();
28
28
  export function clearAllowedModelsCache() {
29
29
  cache.clear();
30
30
  }
31
- const QUERY = `query AvailableBaseModels { availableBaseModels { name provider userModelId agentEnabled creditInputPer1M creditOutputPer1M creditCachedInputPer1M creditCacheReadPer1M creditCacheWritePer1M contextWindow } }`;
31
+ const QUERY = `query AvailableBaseModels { availableBaseModels { name provider userModelId agentEnabled creditInputPer1M creditOutputPer1M creditCachedInputPer1M creditCacheReadPer1M creditCacheWritePer1M contextWindow supportsImages } }`;
32
32
  /**
33
33
  * Fetch the whitelist of allowed base models. Throws on any failure with a
34
34
  * message tailored for an operator running `spectral serve` — the caller
@@ -101,6 +101,9 @@ export async function fetchAllowedModels(opts) {
101
101
  const contextWindow = typeof row?.contextWindow === "number" && row.contextWindow > 0
102
102
  ? row.contextWindow
103
103
  : null;
104
+ const supportsImages = typeof row?.supportsImages === "boolean"
105
+ ? row.supportsImages
106
+ : null;
104
107
  const model = {
105
108
  modelId: name,
106
109
  displayName: name,
@@ -111,6 +114,7 @@ export async function fetchAllowedModels(opts) {
111
114
  creditCacheReadPer1M: asOptionalNumber(row?.creditCacheReadPer1M),
112
115
  creditCacheWritePer1M: asOptionalNumber(row?.creditCacheWritePer1M),
113
116
  contextWindow,
117
+ supportsImages,
114
118
  };
115
119
  if (typeof row?.userModelId === "string") {
116
120
  model.userModelId = row.userModelId;
@@ -610,7 +610,7 @@ export class PiBridge {
610
610
  provider: SPECTRAL_PROXY_ANTHROPIC,
611
611
  baseUrl,
612
612
  reasoning: supportsReasoning(m.modelId),
613
- input: ["text", "image"],
613
+ input: m.supportsImages !== false ? ["text", "image"] : ["text"],
614
614
  // Real pricing so pi can compute accurate token costs.
615
615
  cost: pricing
616
616
  ? { input: pricing.input, output: pricing.output, cacheRead: pricing.cacheRead, cacheWrite: pricing.cacheWrite }
@@ -640,7 +640,7 @@ export class PiBridge {
640
640
  provider: SPECTRAL_PROXY_OPENAI,
641
641
  baseUrl,
642
642
  reasoning: supportsReasoning(m.modelId),
643
- input: ["text", "image"],
643
+ input: m.supportsImages !== false ? ["text", "image"] : ["text"],
644
644
  // Real pricing so pi can compute accurate token costs.
645
645
  cost: pricing
646
646
  ? { input: pricing.input, output: pricing.output, cacheRead: pricing.cacheRead, cacheWrite: pricing.cacheWrite }
@@ -671,7 +671,7 @@ export class PiBridge {
671
671
  provider: SPECTRAL_PROXY_USER_MODEL,
672
672
  baseUrl,
673
673
  reasoning: supportsReasoning(m.modelId),
674
- input: ["text", "image"],
674
+ input: m.supportsImages !== false ? ["text", "image"] : ["text"],
675
675
  cost: pricing
676
676
  ? { input: pricing.input, output: pricing.output, cacheRead: pricing.cacheRead, cacheWrite: pricing.cacheWrite }
677
677
  : { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
@@ -802,22 +802,46 @@ export class PiBridge {
802
802
  *
803
803
  * When `images` is non-empty, each base64-encoded attachment is converted
804
804
  * to a pi `ImageContent` block and passed as `options.images` to
805
- * `session.prompt()`. Pi's model providers already register with
806
- * `input: ["text", "image"]`, so the backend proxy correctly routes
807
- * multimodal prompts.
805
+ * `session.prompt()`. If the current model does not support image inputs,
806
+ * images are instead converted to text placeholders so the conversation
807
+ * can continue without errors.
808
808
  */
809
809
  async prompt(text, images) {
810
810
  if (!this.session)
811
811
  throw new Error("PiBridge.start() not called");
812
+ // Check whether the currently active model supports image input.
813
+ // When `supportsImages` is null/undefined (unknown), we are conservative
814
+ // and convert images to text rather than risking a 400 error.
815
+ const currentModel = this.lastAppliedModelId
816
+ ? this.allowedModels?.find((m) => m.modelId === this.lastAppliedModelId)
817
+ : undefined;
818
+ const modelSupportsImages = currentModel?.supportsImages === true;
812
819
  try {
813
- const imageContents = images && images.length > 0
814
- ? images.map((img) => ({
820
+ if (images && images.length > 0 && modelSupportsImages) {
821
+ const imageContents = images.map((img) => ({
815
822
  type: "image",
816
823
  data: img.data,
817
824
  mimeType: img.mimeType,
818
- }))
819
- : undefined;
820
- await this.session.prompt(text, { images: imageContents });
825
+ }));
826
+ await this.session.prompt(text, { images: imageContents });
827
+ }
828
+ else if (images && images.length > 0 && !modelSupportsImages) {
829
+ // Model doesn't support images — convert them to text descriptions
830
+ // so the conversation can continue instead of hanging.
831
+ const imageDescriptions = images
832
+ .map((img, i) => `[Image ${i + 1}: ${img.mimeType}, ${img.data.length.toLocaleString()} bytes base64]`)
833
+ .join("\n");
834
+ const augmentedText = `${text}\n\n---\nThe following image(s) were attached but the current model does not support image input:\n${imageDescriptions}\n(Describe what you see or ask the user to switch to a model that supports images.)`;
835
+ this.opts.emit({
836
+ type: "agent_notification",
837
+ message: `The current model does not support image input. ${images.length} image(s) were converted to text descriptions.`,
838
+ level: "warning",
839
+ });
840
+ await this.session.prompt(augmentedText);
841
+ }
842
+ else {
843
+ await this.session.prompt(text);
844
+ }
821
845
  }
822
846
  catch (err) {
823
847
  const e = err instanceof Error ? err : new Error(String(err));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aexol/spectral",
3
- "version": "0.6.3",
3
+ "version": "0.6.8",
4
4
  "description": "Always-on coding agent for Aexol — branded pi wrapper with relay-based browser access.",
5
5
  "type": "module",
6
6
  "private": false,