@apmantza/greedysearch-pi 1.2.1 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,12 +4,13 @@ Pi extension that adds a `greedy_search` tool — fans out queries to Perplexity
4
4
 
5
5
  Forked from [GreedySearch-claude](https://github.com/apmantza/GreedySearch-claude).
6
6
 
7
- ## What's New (v1.2.0)
7
+ ## What's New (v1.4.0)
8
8
 
9
- - **Fixed parallel search race condition** multiple `greedy_search` calls can now run concurrently without tab conflicts
10
- - **Improved Bing Copilot verification** — better auto-handling of Turnstile challenges and modal dialogs
11
- - **Added test suite** — run `./test.sh` to verify all modes work correctly
12
- - **Atomic port file writes** — prevents corruption when multiple processes connect to Chrome
9
+ - **Grounded synthesis** Gemini now receives a normalized source registry with stable source IDs, agreement summaries, caveats, and cited claims
10
+ - **Real deep research** — top sources are fetched before synthesis so deep research answers are grounded in fetched evidence, not just engine summaries
11
+ - **Richer source metadata** — source output now includes canonical URLs, domains, source types, per-engine attribution, and confidence metadata
12
+ - **Cleaner tab lifecycle** — temporary Perplexity, Bing, and Google tabs are closed after each fan-out search, and synthesis finishes on the Gemini tab
13
+ - **Isolated Chrome targeting** — GreedySearch now refuses to fall back to your normal Chrome session, preventing stray remote-debugging prompts
13
14
 
14
15
  ## Install
15
16
 
@@ -69,7 +70,15 @@ For complex research questions, use `synthesize: true` with `engine: "all"`:
69
70
  greedy_search({ query: "best auth patterns for SaaS in 2026", engine: "all", synthesize: true })
70
71
  ```
71
72
 
72
- This deduplicates sources across engines and feeds them to Gemini for one clean, synthesized answer. Adds ~30s but produces the highest quality output with deduped sources showing consensus scores (`[2/3]`, `[3/3]`).
73
+ This deduplicates sources across engines, builds a normalized source registry, and feeds that context to Gemini for one clean synthesized answer. Adds ~30s but now returns agreement summaries, caveats, key claims, and better-labeled top sources.
74
+
75
+ For the most grounded mode, use deep research from the CLI:
76
+
77
+ ```bash
78
+ node search.mjs all "best auth patterns for SaaS in 2026" --deep-research
79
+ ```
80
+
81
+ Deep research fetches top source pages before synthesis and reports source confidence metadata such as agreement level, fetched-source success rate, and source mix.
73
82
 
74
83
  **Use synthesis when:**
75
84
  - You need one definitive answer, not multiple perspectives
@@ -112,7 +121,7 @@ greedy_search({ query: "Error: Cannot find module 'react-dom/client' Next.js 15"
112
121
 
113
122
  ## Requirements
114
123
 
115
- - **Chrome** — must be installed. The extension auto-launches a dedicated Chrome instance on port 9222 (separate from your main browser session).
124
+ - **Chrome** — must be installed. The extension auto-launches a dedicated Chrome instance on port 9222 with its own isolated profile and DevTools port file, separate from your main browser session.
116
125
  - **Node.js 22+** — for built-in `fetch` and WebSocket support.
117
126
 
118
127
  ## Setup (first time)
package/cdp.mjs CHANGED
@@ -37,21 +37,22 @@ function getDevToolsActivePortPath() {
37
37
  return join(homedir(), '.config', 'google-chrome', 'DevToolsActivePort');
38
38
  }
39
39
 
40
- function getWsUrl() {
41
- // If CDP_PROFILE_DIR is set (by search.mjs), prefer that profile's port file
42
- // so GreedySearch targets its own Chrome, not the user's main session.
43
- const profileDir = process.env.CDP_PROFILE_DIR;
44
- if (profileDir) {
45
- const p = profileDir.replace(/\\/g, '/') + '/DevToolsActivePort';
46
- if (existsSync(p)) {
47
- const lines = readFileSync(p, 'utf8').trim().split('\n');
48
- return `ws://127.0.0.1:${lines[0]}${lines[1]}`;
49
- }
50
- }
51
- const portFile = getDevToolsActivePortPath();
52
- const lines = readFileSync(portFile, 'utf8').trim().split('\n');
53
- return `ws://127.0.0.1:${lines[0]}${lines[1]}`;
54
- }
40
+ function getWsUrl() {
41
+ // If CDP_PROFILE_DIR is set (by search.mjs), prefer that profile's port file
42
+ // so GreedySearch targets its own Chrome, not the user's main session.
43
+ const profileDir = process.env.CDP_PROFILE_DIR;
44
+ if (profileDir) {
45
+ const p = profileDir.replace(/\\/g, '/') + '/DevToolsActivePort';
46
+ if (existsSync(p)) {
47
+ const lines = readFileSync(p, 'utf8').trim().split('\n');
48
+ return `ws://127.0.0.1:${lines[0]}${lines[1]}`;
49
+ }
50
+ throw new Error(`GreedySearch DevToolsActivePort not found at ${p}. Refusing to fall back to the main Chrome session.`);
51
+ }
52
+ const portFile = getDevToolsActivePortPath();
53
+ const lines = readFileSync(portFile, 'utf8').trim().split('\n');
54
+ return `ws://127.0.0.1:${lines[0]}${lines[1]}`;
55
+ }
55
56
 
56
57
  const sleep = (ms) => new Promise(r => setTimeout(r, ms));
57
58
 
package/index.ts CHANGED
@@ -68,31 +68,164 @@ function runSearch(
68
68
  });
69
69
  }
70
70
 
71
+ function formatEngineName(engine: string): string {
72
+ if (engine === "bing") return "Bing Copilot";
73
+ if (engine === "google") return "Google AI";
74
+ return engine.charAt(0).toUpperCase() + engine.slice(1);
75
+ }
76
+
77
+ function humanizeSourceType(sourceType: string): string {
78
+ if (!sourceType) return "";
79
+ if (sourceType === "official-docs") return "official docs";
80
+ return sourceType.replace(/-/g, " ");
81
+ }
82
+
83
+ function sourceUrl(source: Record<string, unknown>): string {
84
+ return String(source.displayUrl || source.canonicalUrl || source.url || "");
85
+ }
86
+
87
+ function sourceLabel(source: Record<string, unknown>): string {
88
+ return String(source.title || source.domain || sourceUrl(source) || "Untitled source");
89
+ }
90
+
91
+ function sourceConsensus(source: Record<string, unknown>): number {
92
+ if (typeof source.engineCount === "number") return source.engineCount;
93
+ const engines = Array.isArray(source.engines) ? (source.engines as string[]) : [];
94
+ return engines.length;
95
+ }
96
+
97
+ function formatAgreementLevel(level: string): string {
98
+ if (!level) return "Mixed";
99
+ return level.charAt(0).toUpperCase() + level.slice(1);
100
+ }
101
+
102
+ function getSourceMap(sources: Array<Record<string, unknown>>): Map<string, Record<string, unknown>> {
103
+ return new Map(
104
+ sources
105
+ .map((source) => [String(source.id || ""), source] as const)
106
+ .filter(([id]) => id),
107
+ );
108
+ }
109
+
110
+ function formatSourceLine(source: Record<string, unknown>): string {
111
+ const id = String(source.id || "?");
112
+ const url = sourceUrl(source);
113
+ const title = sourceLabel(source);
114
+ const domain = String(source.domain || "");
115
+ const engines = Array.isArray(source.engines) ? (source.engines as string[]) : [];
116
+ const consensus = sourceConsensus(source);
117
+ const typeLabel = humanizeSourceType(String(source.sourceType || ""));
118
+ const fetch = source.fetch as Record<string, unknown> | undefined;
119
+ const fetchStatus = fetch?.ok ? `fetched ${fetch.status || 200}` : fetch?.attempted ? "fetch failed" : "";
120
+ const pieces = [
121
+ `${id} - [${title}](${url})`,
122
+ domain,
123
+ typeLabel,
124
+ engines.length ? `cited by ${engines.map(formatEngineName).join(", ")} (${consensus}/3)` : `${consensus}/3`,
125
+ fetchStatus,
126
+ ].filter(Boolean);
127
+ return `- ${pieces.join(" - ")}`;
128
+ }
129
+
130
+ function renderSourceEvidence(lines: string[], source: Record<string, unknown>): void {
131
+ const fetch = source.fetch as Record<string, unknown> | undefined;
132
+ if (!fetch?.attempted) return;
133
+
134
+ const snippet = String(fetch.snippet || "").trim();
135
+ const lastModified = String(fetch.lastModified || "").trim();
136
+ if (snippet) lines.push(` Evidence: ${snippet}`);
137
+ if (lastModified) lines.push(` Last-Modified: ${lastModified}`);
138
+ if (fetch.error) lines.push(` Fetch error: ${String(fetch.error)}`);
139
+ }
140
+
141
+ function pickSources(
142
+ sources: Array<Record<string, unknown>>,
143
+ recommendedIds: string[] = [],
144
+ max = 6,
145
+ ): Array<Record<string, unknown>> {
146
+ if (!sources.length) return [];
147
+ const sourceMap = getSourceMap(sources);
148
+ const recommended = recommendedIds
149
+ .map((id) => sourceMap.get(id))
150
+ .filter((source): source is Record<string, unknown> => Boolean(source));
151
+ if (recommended.length > 0) return recommended.slice(0, max);
152
+ return sources.slice(0, max);
153
+ }
154
+
155
+ function renderSynthesis(
156
+ lines: string[],
157
+ synthesis: Record<string, unknown>,
158
+ sources: Array<Record<string, unknown>>,
159
+ maxSources = 6,
160
+ ): void {
161
+ if (synthesis.answer) {
162
+ lines.push("## Answer");
163
+ lines.push(String(synthesis.answer));
164
+ lines.push("");
165
+ }
166
+
167
+ const agreement = synthesis.agreement as Record<string, unknown> | undefined;
168
+ const agreementSummary = String(agreement?.summary || "").trim();
169
+ const agreementLevel = String(agreement?.level || "").trim();
170
+ if (agreementSummary || agreementLevel) {
171
+ lines.push("## Consensus");
172
+ lines.push(`- ${formatAgreementLevel(agreementLevel)}${agreementSummary ? ` - ${agreementSummary}` : ""}`);
173
+ lines.push("");
174
+ }
175
+
176
+ const differences = Array.isArray(synthesis.differences) ? (synthesis.differences as string[]) : [];
177
+ if (differences.length > 0) {
178
+ lines.push("## Where Engines Differ");
179
+ for (const difference of differences) lines.push(`- ${difference}`);
180
+ lines.push("");
181
+ }
182
+
183
+ const caveats = Array.isArray(synthesis.caveats) ? (synthesis.caveats as string[]) : [];
184
+ if (caveats.length > 0) {
185
+ lines.push("## Caveats");
186
+ for (const caveat of caveats) lines.push(`- ${caveat}`);
187
+ lines.push("");
188
+ }
189
+
190
+ const claims = Array.isArray(synthesis.claims)
191
+ ? (synthesis.claims as Array<Record<string, unknown>>)
192
+ : [];
193
+ if (claims.length > 0) {
194
+ lines.push("## Key Claims");
195
+ for (const claim of claims) {
196
+ const sourceIds = Array.isArray(claim.sourceIds) ? (claim.sourceIds as string[]) : [];
197
+ const support = String(claim.support || "moderate");
198
+ lines.push(`- ${String(claim.claim || "")} [${support}${sourceIds.length ? `; ${sourceIds.join(", ")}` : ""}]`);
199
+ }
200
+ lines.push("");
201
+ }
202
+
203
+ const recommendedIds = Array.isArray(synthesis.recommendedSources)
204
+ ? (synthesis.recommendedSources as string[])
205
+ : [];
206
+ const topSources = pickSources(sources, recommendedIds, maxSources);
207
+ if (topSources.length > 0) {
208
+ lines.push("## Top Sources");
209
+ for (const source of topSources) lines.push(formatSourceLine(source));
210
+ lines.push("");
211
+ }
212
+ }
213
+
71
214
  function formatResults(engine: string, data: Record<string, unknown>): string {
72
215
  const lines: string[] = [];
73
216
 
74
217
  if (engine === "all") {
75
- // Synthesized output: prefer _synthesis + _sources
76
218
  const synthesis = data._synthesis as Record<string, unknown> | undefined;
77
219
  const dedupedSources = data._sources as Array<Record<string, unknown>> | undefined;
78
220
  if (synthesis?.answer) {
79
- lines.push("## Synthesis");
80
- lines.push(String(synthesis.answer));
81
- if (dedupedSources?.length) {
82
- lines.push("\n**Top sources by consensus:**");
83
- for (const s of dedupedSources.slice(0, 6)) {
84
- const engines = (s.engines as string[]) || [];
85
- lines.push(`- [${s.title || s.url}](${s.url}) [${engines.length}/3]`);
86
- }
87
- }
88
- lines.push("\n---\n*Synthesized from Perplexity, Bing Copilot, and Google AI*");
221
+ renderSynthesis(lines, synthesis, dedupedSources || [], 6);
222
+ lines.push("*Synthesized from Perplexity, Bing Copilot, and Google AI*\n");
89
223
  return lines.join("\n").trim();
90
224
  }
91
225
 
92
- // Standard output: per-engine answers
93
226
  for (const [eng, result] of Object.entries(data)) {
94
227
  if (eng.startsWith("_")) continue;
95
- lines.push(`\n## ${eng.charAt(0).toUpperCase() + eng.slice(1)}`);
228
+ lines.push(`\n## ${formatEngineName(eng)}`);
96
229
  const r = result as Record<string, unknown>;
97
230
  if (r.error) {
98
231
  lines.push(`Error: ${r.error}`);
@@ -125,6 +258,107 @@ function formatResults(engine: string, data: Record<string, unknown>): string {
125
258
  return lines.join("\n").trim();
126
259
  }
127
260
 
261
+ function formatDeepResearch(data: Record<string, unknown>): string {
262
+ const lines: string[] = [];
263
+ const confidence = data._confidence as Record<string, unknown> | undefined;
264
+ const dedupedSources = data._sources as Array<Record<string, unknown>> | undefined;
265
+ const synthesis = data._synthesis as Record<string, unknown> | undefined;
266
+
267
+ lines.push("# Deep Research Report\n");
268
+
269
+ if (confidence) {
270
+ const enginesResponded = (confidence.enginesResponded as string[]) || [];
271
+ const enginesFailed = (confidence.enginesFailed as string[]) || [];
272
+ const agreementLevel = String(confidence.agreementLevel || "mixed");
273
+ const firstPartySourceCount = Number(confidence.firstPartySourceCount || 0);
274
+ const sourceTypeBreakdown = confidence.sourceTypeBreakdown as Record<string, number> | undefined;
275
+
276
+ lines.push("## Confidence\n");
277
+ lines.push(`- Agreement: ${formatAgreementLevel(agreementLevel)}`);
278
+ lines.push(`- Engines responded: ${enginesResponded.map(formatEngineName).join(", ") || "none"}`);
279
+ if (enginesFailed.length > 0) {
280
+ lines.push(`- Engines failed: ${enginesFailed.map(formatEngineName).join(", ")}`);
281
+ }
282
+ lines.push(`- Top source consensus: ${confidence.topSourceConsensus || 0}/3 engines`);
283
+ lines.push(`- Total unique sources: ${confidence.sourcesCount || 0}`);
284
+ lines.push(`- Official sources: ${confidence.officialSourceCount || 0}`);
285
+ lines.push(`- First-party sources: ${firstPartySourceCount}`);
286
+ lines.push(`- Fetch success rate: ${confidence.fetchedSourceSuccessRate || 0}`);
287
+ if (sourceTypeBreakdown && Object.keys(sourceTypeBreakdown).length > 0) {
288
+ lines.push(`- Source mix: ${Object.entries(sourceTypeBreakdown).map(([type, count]) => `${humanizeSourceType(type)} ${count}`).join(", ")}`);
289
+ }
290
+ lines.push("");
291
+ }
292
+
293
+ if (synthesis?.answer) renderSynthesis(lines, synthesis, dedupedSources || [], 8);
294
+
295
+ lines.push("## Engine Perspectives\n");
296
+ for (const engine of ["perplexity", "bing", "google"]) {
297
+ const r = data[engine] as Record<string, unknown> | undefined;
298
+ if (!r) continue;
299
+ lines.push(`### ${formatEngineName(engine)}`);
300
+ if (r.error) {
301
+ lines.push(`⚠️ Error: ${r.error}`);
302
+ } else if (r.answer) {
303
+ lines.push(String(r.answer).slice(0, 2000));
304
+ }
305
+ lines.push("");
306
+ }
307
+
308
+ if (dedupedSources && dedupedSources.length > 0) {
309
+ lines.push("## Source Registry\n");
310
+ for (const source of dedupedSources) {
311
+ lines.push(formatSourceLine(source));
312
+ renderSourceEvidence(lines, source);
313
+ }
314
+ lines.push("");
315
+ }
316
+
317
+ return lines.join("\n").trim();
318
+ }
319
+
320
+ function formatCodingTask(data: Record<string, unknown> | Record<string, Record<string, unknown>>): string {
321
+ const lines: string[] = [];
322
+
323
+ // Check if it's multi-engine result
324
+ const hasMultipleEngines = "gemini" in data || "copilot" in data;
325
+
326
+ if (hasMultipleEngines) {
327
+ // Multi-engine result
328
+ for (const [engineName, result] of Object.entries(data)) {
329
+ const r = result as Record<string, unknown>;
330
+ lines.push(`## ${engineName.charAt(0).toUpperCase() + engineName.slice(1)}\n`);
331
+
332
+ if (r.error) {
333
+ lines.push(`⚠️ Error: ${r.error}\n`);
334
+ } else {
335
+ if (r.explanation) lines.push(String(r.explanation));
336
+ if (Array.isArray(r.code) && r.code.length > 0) {
337
+ for (const block of r.code) {
338
+ const b = block as { language: string; code: string };
339
+ lines.push(`\n\`\`\`${b.language}\n${b.code}\n\`\`\`\n`);
340
+ }
341
+ }
342
+ if (r.url) lines.push(`*Source: ${r.url}*`);
343
+ }
344
+ lines.push("");
345
+ }
346
+ } else {
347
+ // Single engine result
348
+ const r = data as Record<string, unknown>;
349
+ if (r.explanation) lines.push(String(r.explanation));
350
+ if (Array.isArray(r.code) && r.code.length > 0) {
351
+ for (const block of r.code) {
352
+ const b = block as { language: string; code: string };
353
+ lines.push(`\n\`\`\`${b.language}\n${b.code}\n\`\`\`\n`);
354
+ }
355
+ }
356
+ if (r.url) lines.push(`*Source: ${r.url}*`);
357
+ }
358
+
359
+ return lines.join("\n").trim();
360
+ }
361
+
128
362
  export default function greedySearchExtension(pi: ExtensionAPI) {
129
363
  pi.on("session_start", async (_event, ctx) => {
130
364
  if (!cdpAvailable()) {
@@ -219,4 +453,171 @@ export default function greedySearchExtension(pi: ExtensionAPI) {
219
453
  }
220
454
  },
221
455
  });
456
+
457
+ // ─── deep_research ─────────────────────────────────────────────────────────
458
+ pi.registerTool({
459
+ name: "deep_research",
460
+ label: "Deep Research",
461
+ description:
462
+ "Comprehensive multi-engine research with source fetching and synthesis. " +
463
+ "Runs Perplexity, Bing Copilot, and Google AI in parallel with full answers, " +
464
+ "deduplicates and ranks sources by consensus, fetches content from top sources, " +
465
+ "and synthesizes via Gemini. Returns a structured research document with confidence scores. " +
466
+ "Use for architecture decisions, library comparisons, best practices, or any research where the answer matters.",
467
+ promptSnippet: "Deep multi-engine research with source deduplication and synthesis",
468
+ parameters: Type.Object({
469
+ query: Type.String({ description: "The research question" }),
470
+ }),
471
+ execute: async (_toolCallId, params, signal, onUpdate) => {
472
+ const { query } = params as { query: string };
473
+
474
+ if (!cdpAvailable()) {
475
+ return {
476
+ content: [{ type: "text", text: "cdp.mjs missing — try reinstalling." }],
477
+ details: {} as { raw?: Record<string, unknown> },
478
+ };
479
+ }
480
+
481
+ const completed = new Set<string>();
482
+
483
+ const onProgress = (eng: string, status: "done" | "error") => {
484
+ completed.add(eng);
485
+ const parts: string[] = [];
486
+ for (const e of ALL_ENGINES) {
487
+ if (completed.has(e)) parts.push(`✅ ${e}`);
488
+ else parts.push(`⏳ ${e}`);
489
+ }
490
+ if (completed.size >= 3) parts.push("🔄 synthesizing");
491
+
492
+ onUpdate?.({
493
+ content: [{ type: "text", text: `**Researching...** ${parts.join(" · ")}` }],
494
+ details: { _progress: true },
495
+ } as any);
496
+ };
497
+
498
+ try {
499
+ // Run deep research (includes full answers, synthesis, and source fetching)
500
+ const data = await runSearch("all", query, ["--deep-research"], signal, onProgress);
501
+ const text = formatDeepResearch(data);
502
+ return {
503
+ content: [{ type: "text", text: text || "No results returned." }],
504
+ details: { raw: data },
505
+ };
506
+ } catch (e) {
507
+ const msg = e instanceof Error ? e.message : String(e);
508
+ return {
509
+ content: [{ type: "text", text: `Deep research failed: ${msg}` }],
510
+ details: {} as { raw?: Record<string, unknown> },
511
+ };
512
+ }
513
+ },
514
+ });
515
+
516
+ // ─── coding_task ───────────────────────────────────────────────────────────
517
+ pi.registerTool({
518
+ name: "coding_task",
519
+ label: "Coding Task",
520
+ description:
521
+ "Delegate a coding task to Gemini and/or Copilot via browser automation. " +
522
+ "Returns extracted code blocks and explanations. Supports multiple modes: " +
523
+ "'code' (write/modify code), 'review' (senior engineer code review), " +
524
+ "'plan' (architect risk assessment), 'test' (edge case testing), " +
525
+ "'debug' (fresh-eyes root cause analysis). " +
526
+ "Best for getting a 'second opinion' on hard problems, debugging tricky issues, " +
527
+ "or risk-assessing major refactors. Use engine 'all' for both perspectives.",
528
+ promptSnippet: "Browser-based coding assistant with Gemini and Copilot",
529
+ parameters: Type.Object({
530
+ task: Type.String({ description: "The coding task or question" }),
531
+ engine: Type.Union(
532
+ [
533
+ Type.Literal("all"),
534
+ Type.Literal("gemini"),
535
+ Type.Literal("copilot"),
536
+ ],
537
+ {
538
+ description: 'Engine to use. "all" runs both Gemini and Copilot in parallel.',
539
+ default: "gemini",
540
+ },
541
+ ),
542
+ mode: Type.Union(
543
+ [
544
+ Type.Literal("code"),
545
+ Type.Literal("review"),
546
+ Type.Literal("plan"),
547
+ Type.Literal("test"),
548
+ Type.Literal("debug"),
549
+ ],
550
+ {
551
+ description: "Task mode: code (default), review (code review), plan (architect review), test (write tests), debug (root cause analysis)",
552
+ default: "code",
553
+ },
554
+ ),
555
+ context: Type.Optional(Type.String({
556
+ description: "Optional code context/snippet to include with the task",
557
+ })),
558
+ }),
559
+ execute: async (_toolCallId, params, signal, onUpdate) => {
560
+ const { task, engine = "gemini", mode = "code", context } = params as {
561
+ task: string; engine: string; mode: string; context?: string;
562
+ };
563
+
564
+ if (!cdpAvailable()) {
565
+ return {
566
+ content: [{ type: "text", text: "cdp.mjs missing — try reinstalling." }],
567
+ details: {} as { raw?: Record<string, unknown> },
568
+ };
569
+ }
570
+
571
+ const flags: string[] = ["--engine", engine, "--mode", mode];
572
+ if (context) flags.push("--context", context);
573
+
574
+ try {
575
+ onUpdate?.({
576
+ content: [{ type: "text", text: `**Coding task...** 🔄 ${engine === "all" ? "Gemini + Copilot" : engine} (${mode} mode)` }],
577
+ details: { _progress: true },
578
+ } as any);
579
+
580
+ const data = await new Promise<Record<string, unknown>>((resolve, reject) => {
581
+ const proc = spawn("node", [__dir + "/coding-task.mjs", task, ...flags], {
582
+ stdio: ["ignore", "pipe", "pipe"],
583
+ });
584
+ let out = "";
585
+ let err = "";
586
+
587
+ const onAbort = () => { proc.kill("SIGTERM"); reject(new Error("Aborted")); };
588
+ signal?.addEventListener("abort", onAbort, { once: true });
589
+
590
+ proc.stdout.on("data", (d: Buffer) => (out += d));
591
+ proc.stderr.on("data", (d: Buffer) => { err += d; });
592
+ proc.on("close", (code: number) => {
593
+ signal?.removeEventListener("abort", onAbort);
594
+ if (code !== 0) {
595
+ reject(new Error(err.trim() || `coding-task.mjs exited with code ${code}`));
596
+ } else {
597
+ try {
598
+ resolve(JSON.parse(out.trim()));
599
+ } catch {
600
+ reject(new Error(`Invalid JSON from coding-task.mjs: ${out.slice(0, 200)}`));
601
+ }
602
+ }
603
+ });
604
+
605
+ // Timeout after 3 minutes
606
+ setTimeout(() => { proc.kill("SIGTERM"); reject(new Error("Coding task timed out after 180s")); }, 180000);
607
+ });
608
+
609
+ const text = formatCodingTask(data);
610
+ return {
611
+ content: [{ type: "text", text: text || "No response." }],
612
+ details: { raw: data },
613
+ };
614
+ } catch (e) {
615
+ const msg = e instanceof Error ? e.message : String(e);
616
+ return {
617
+ content: [{ type: "text", text: `Coding task failed: ${msg}` }],
618
+ details: {} as { raw?: Record<string, unknown> },
619
+ };
620
+ }
621
+ },
622
+ });
222
623
  }