pi-agentic-search 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1778 @@
1
+ /**
2
+ * pi-agentic-search — Deep research agent extension for pi
3
+ *
4
+ * Spawns a cheap research agent ("Search") that autonomously searches,
5
+ * fetches, and synthesizes information on a given topic. Runs as a
6
+ * background process with a live progress widget.
7
+ *
8
+ * Features:
9
+ * - Real-time tool progress tracking
10
+ * - Throttled UI updates
11
+ * - Context window usage tracking
12
+ * - Long task handling via temp files
13
+ * - Auto-retry on transient errors (429, 5xx, network)
14
+ */
15
+
16
+ import { spawn } from "node:child_process";
17
+ import * as fs from "node:fs";
18
+ import * as os from "node:os";
19
+ import * as path from "node:path";
20
+
21
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
22
+ import {
23
+ getMarkdownTheme,
24
+ truncateHead,
25
+ DEFAULT_MAX_BYTES,
26
+ DEFAULT_MAX_LINES,
27
+ } from "@earendil-works/pi-coding-agent";
28
+ import { Box, Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
29
+ import { Type } from "typebox";
30
+
31
+ // ── Constants ──────────────────────────────────────────────────────────
32
+
33
+ const UPDATE_THROTTLE_MS = 150;
34
+ const TASK_LIMIT = 8000; // Write to file if task exceeds this length
35
+
36
+ // Retry config for transient errors (429, 5xx, network)
37
+ const MAX_RETRIES = 3;
38
+ const INITIAL_RETRY_DELAY_MS = 5000; // 5s, then 10s, 20s
39
+
40
+ // Activity timeout — if no events for this long, consider the process stuck
41
+ const ACTIVITY_TIMEOUT_MS = 120_000; // 2 minutes
42
+
43
+ // ── Types ──────────────────────────────────────────────────────────────
44
+
45
+ interface UsageStats {
46
+ input: number;
47
+ output: number;
48
+ cacheRead: number;
49
+ cacheWrite: number;
50
+ cost: number;
51
+ contextTokens: number;
52
+ contextWindow?: number;
53
+ turns: number;
54
+ }
55
+
56
+ interface ToolEvent {
57
+ tool: string;
58
+ args: string;
59
+ toolCallId?: string;
60
+ status: "running" | "done";
61
+ }
62
+
63
+ interface SearchProgress {
64
+ status: "pending" | "running" | "completed" | "failed";
65
+ recentTools: ToolEvent[];
66
+ toolCount: number;
67
+ tokens: number;
68
+ contextWindow?: number;
69
+ durationMs: number;
70
+ lastMessage: string;
71
+ error?: string;
72
+ }
73
+
74
+ interface SearchResult {
75
+ task: string;
76
+ exitCode: number;
77
+ messages: any[];
78
+ stderr: string;
79
+ usage: UsageStats;
80
+ model?: string;
81
+ stopReason?: string;
82
+ errorMessage?: string;
83
+ progress: SearchProgress;
84
+ }
85
+
86
+ interface SearchDetails {
87
+ mode: "widget";
88
+ task: string;
89
+ goal: string;
90
+ result?: SearchResult;
91
+ status?: "started" | "running" | "completed" | "failed";
92
+ error?: string;
93
+ }
94
+
95
+ interface SearchSettings {
96
+ model?: string;
97
+ keybinding?: number; // 1-9, default: 2
98
+ }
99
+
100
+ // ── Agent Registry ────────────────────────────────────────────────────
101
+
102
+ type AgentStatus = "running" | "stopped" | "completed" | "failed";
103
+
104
+ interface RegisteredAgent {
105
+ id: string;
106
+ widgetId: string;
107
+ task: string;
108
+ goal: string;
109
+ status: AgentStatus;
110
+ startTime: number;
111
+ abort: () => void;
112
+ retry: () => void;
113
+ result?: SearchResult;
114
+ progress: SearchProgress;
115
+ model?: string;
116
+ canceledByUser?: boolean;
117
+ stoppedByUser?: boolean;
118
+ }
119
+
120
+ class AgentRegistry {
121
+ private agents = new Map<string, RegisteredAgent>();
122
+ private listeners: Array<() => void> = [];
123
+
124
+ register(agent: RegisteredAgent) {
125
+ this.agents.set(agent.id, agent);
126
+ this.notify();
127
+ }
128
+
129
+ unregister(id: string) {
130
+ this.agents.delete(id);
131
+ this.notify();
132
+ }
133
+
134
+ get(id: string): RegisteredAgent | undefined {
135
+ return this.agents.get(id);
136
+ }
137
+
138
+ getAll(): RegisteredAgent[] {
139
+ return Array.from(this.agents.values());
140
+ }
141
+
142
+ getRunning(): RegisteredAgent[] {
143
+ return this.getAll().filter((a) => a.status === "running");
144
+ }
145
+
146
+ stopAll() {
147
+ for (const agent of this.getRunning()) {
148
+ agent.abort();
149
+ agent.status = "stopped";
150
+ }
151
+ this.notify();
152
+ }
153
+
154
+ onChange(listener: () => void) {
155
+ this.listeners.push(listener);
156
+ return () => {
157
+ this.listeners = this.listeners.filter((l) => l !== listener);
158
+ };
159
+ }
160
+
161
+ private notify() {
162
+ for (const listener of this.listeners) {
163
+ listener();
164
+ }
165
+ }
166
+ }
167
+
168
+ // Global registry instance
169
+ const agentRegistry = new AgentRegistry();
170
+
171
+ // ── Agent Control Panel ──────────────────────────────────────────────
172
+
173
+ import { matchesKey, Key } from "@earendil-works/pi-tui";
174
+
175
+ class AgentControlPanel {
176
+ private selectedIndex = 0;
177
+ private agents: RegisteredAgent[] = [];
178
+ private tui: any;
179
+ private theme: any;
180
+ private done: (value: void) => void;
181
+ private disposeListener: () => void;
182
+ private pi: ExtensionAPI;
183
+ private ui: any;
184
+
185
+ constructor(tui: any, theme: any, done: (value: void) => void, pi: ExtensionAPI, ui: any) {
186
+ this.tui = tui;
187
+ this.theme = theme;
188
+ this.done = done;
189
+ this.pi = pi;
190
+ this.ui = ui;
191
+ this.agents = agentRegistry.getAll();
192
+ this.disposeListener = agentRegistry.onChange(() => {
193
+ this.agents = agentRegistry.getAll();
194
+ if (this.selectedIndex >= this.agents.length) {
195
+ this.selectedIndex = Math.max(0, this.agents.length - 1);
196
+ }
197
+ tui.requestRender();
198
+ });
199
+ }
200
+
201
+ handleInput(data: string): void {
202
+ if (matchesKey(data, Key.escape)) {
203
+ this.disposeListener();
204
+ this.done();
205
+ return;
206
+ }
207
+
208
+ if (this.agents.length === 0) return;
209
+
210
+ if (matchesKey(data, Key.up) && this.selectedIndex > 0) {
211
+ this.selectedIndex--;
212
+ this.tui.requestRender();
213
+ } else if (matchesKey(data, Key.down) && this.selectedIndex < this.agents.length - 1) {
214
+ this.selectedIndex++;
215
+ this.tui.requestRender();
216
+ } else if (data === "s" || data === "S") {
217
+ // Stop — pause, no parent feedback, can retry later
218
+ const agent = this.agents[this.selectedIndex];
219
+ if (agent && agent.status === "running") {
220
+ agent.stoppedByUser = true;
221
+ agent.abort();
222
+ agent.status = "stopped";
223
+ // Don't unregister - keep in registry so it can be retried
224
+ this.tui.requestRender();
225
+ }
226
+ } else if (data === "c" || data === "C") {
227
+ // Cancel — kill, send failure to parent with "canceled by user" message
228
+ const agent = this.agents[this.selectedIndex];
229
+ if (agent) {
230
+ agent.canceledByUser = true;
231
+ // If agent is stopped, we need to send the cancel message directly
232
+ if (agent.status === "stopped") {
233
+ // Send cancel message to parent LLM
234
+ const summary = `Research failed for: ${agent.goal}\nStatus: Canceled by user`;
235
+ this.pi.sendMessage(
236
+ {
237
+ customType: "search-result",
238
+ content: summary,
239
+ display: true,
240
+ details: {
241
+ mode: "widget",
242
+ task: agent.task,
243
+ goal: agent.goal,
244
+ },
245
+ },
246
+ {
247
+ deliverAs: "followUp",
248
+ triggerTurn: true,
249
+ },
250
+ );
251
+ // Remove widget if it exists
252
+ if (agent.widgetId) {
253
+ this.ui.setWidget(agent.widgetId, undefined);
254
+ }
255
+ agentRegistry.unregister(agent.id);
256
+ } else {
257
+ agent.abort();
258
+ agent.status = "failed";
259
+ agentRegistry.unregister(agent.id);
260
+ }
261
+ this.tui.requestRender();
262
+ }
263
+ } else if (data === "r" || data === "R") {
264
+ // Retry — kill + respawn fresh, no parent feedback
265
+ const agent = this.agents[this.selectedIndex];
266
+ if (agent) {
267
+ agent.retry();
268
+ this.tui.requestRender();
269
+ }
270
+ } else if (data === "a" || data === "A") {
271
+ // Stop all
272
+ agentRegistry.stopAll();
273
+ this.tui.requestRender();
274
+ }
275
+ }
276
+
277
+ render(width: number): string[] {
278
+ const theme = this.theme;
279
+ const lines: string[] = [];
280
+
281
+ // Header
282
+ lines.push(theme.fg("accent", theme.bold("┌─ Research Agents ───────────────────────────────────────┐")));
283
+ lines.push("");
284
+
285
+ if (this.agents.length === 0) {
286
+ lines.push(theme.fg("muted", " No active agents"));
287
+ lines.push("");
288
+ } else {
289
+ for (let i = 0; i < this.agents.length; i++) {
290
+ const agent = this.agents[i];
291
+ const selected = i === this.selectedIndex;
292
+ const prefix = selected ? theme.fg("accent", "▸ ") : " ";
293
+ const num = `${i + 1}.`;
294
+
295
+ // Status icon
296
+ let icon: string;
297
+ let iconColor: string;
298
+ switch (agent.status) {
299
+ case "running":
300
+ icon = "⟳";
301
+ iconColor = "warning";
302
+ break;
303
+ case "stopped":
304
+ icon = "⏸";
305
+ iconColor = "muted";
306
+ break;
307
+ case "completed":
308
+ icon = "✓";
309
+ iconColor = "success";
310
+ break;
311
+ case "failed":
312
+ icon = "✗";
313
+ iconColor = "error";
314
+ break;
315
+ }
316
+
317
+ const duration = formatDuration(Date.now() - agent.startTime);
318
+ const modelStr = agent.model ? theme.fg("dim", ` (${agent.model})`) : "";
319
+ const goal = agent.goal.length > 45 ? agent.goal.slice(0, 45) + "..." : agent.goal;
320
+
321
+ // Agent line
322
+ lines.push(
323
+ `${prefix}${theme.fg("dim", num)} ${theme.fg(iconColor, icon)} ${theme.fg("text", goal)}${modelStr} ${theme.fg("dim", duration)}`
324
+ );
325
+
326
+ // Actions (only for selected)
327
+ if (selected) {
328
+ const actions: string[] = [];
329
+ if (agent.status === "running") {
330
+ actions.push(theme.fg("accent", "[S]top") + theme.fg("dim", " pause"));
331
+ actions.push(theme.fg("error", "[C]ancel") + theme.fg("dim", " kill"));
332
+ actions.push(theme.fg("warning", "[R]etry") + theme.fg("dim", " restart"));
333
+ } else if (agent.status === "stopped") {
334
+ actions.push(theme.fg("warning", "[R]etry") + theme.fg("dim", " restart"));
335
+ }
336
+ if (actions.length > 0) {
337
+ lines.push(` ${actions.join(" ")}`);
338
+ }
339
+ }
340
+
341
+ lines.push("");
342
+ }
343
+ }
344
+
345
+ // Footer
346
+ lines.push(theme.fg("accent", "──────────────────────────────────────────────────────────"));
347
+ if (this.agents.length > 0) {
348
+ lines.push(theme.fg("dim", " ↑↓ navigate ") + theme.fg("accent", "[A]stop all") + theme.fg("dim", " ") + theme.fg("muted", "[Esc]close"));
349
+ } else {
350
+ lines.push(theme.fg("dim", " [Esc] close"));
351
+ }
352
+ lines.push(theme.fg("accent", "└────────────────────────────────────────────────────────┘"));
353
+
354
+ return lines;
355
+ }
356
+
357
+ invalidate(): void {
358
+ // Clear any cached state if needed
359
+ }
360
+ }
361
+
362
+ // ── Throttle ──────────────────────────────────────────────────────────
363
+
364
+ function throttle<T extends (...args: any[]) => void>(fn: T, ms: number): T {
365
+ let lastCall = 0;
366
+ let timer: ReturnType<typeof setTimeout> | undefined;
367
+ return ((...args: any[]) => {
368
+ const now = Date.now();
369
+ const remaining = ms - (now - lastCall);
370
+ if (remaining <= 0) {
371
+ lastCall = Date.now();
372
+ if (timer) {
373
+ clearTimeout(timer);
374
+ timer = undefined;
375
+ }
376
+ fn(...args);
377
+ } else if (!timer) {
378
+ timer = setTimeout(() => {
379
+ lastCall = Date.now();
380
+ timer = undefined;
381
+ fn(...args);
382
+ }, remaining);
383
+ }
384
+ }) as T;
385
+ }
386
+
387
+ // ── Helper Functions ──────────────────────────────────────────────────
388
+
389
+ function formatTokens(count: number): string {
390
+ if (count < 1000) return count.toString();
391
+ if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
392
+ if (count < 1000000) return `${Math.round(count / 1000)}k`;
393
+ return `${(count / 1000000).toFixed(1)}M`;
394
+ }
395
+
396
+ function formatDuration(ms: number): string {
397
+ if (ms < 1000) return `${ms}ms`;
398
+ if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
399
+ return `${Math.floor(ms / 60000)}m${Math.floor((ms % 60000) / 1000)}s`;
400
+ }
401
+
402
+ function formatContextUsage(tokens: number, contextWindow: number | undefined): string {
403
+ if (!contextWindow) return `${formatTokens(tokens)} ctx`;
404
+ const pct = (tokens / contextWindow) * 100;
405
+ const maxStr =
406
+ contextWindow >= 1_000_000
407
+ ? `${(contextWindow / 1_000_000).toFixed(1)}M`
408
+ : `${Math.round(contextWindow / 1000)}k`;
409
+ return `${pct.toFixed(1)}%/${maxStr}`;
410
+ }
411
+
412
+ function formatUsageStats(
413
+ usage: {
414
+ input: number;
415
+ output: number;
416
+ cacheRead: number;
417
+ cacheWrite: number;
418
+ cost: number;
419
+ contextTokens?: number;
420
+ contextWindow?: number;
421
+ turns?: number;
422
+ },
423
+ model?: string,
424
+ ): string {
425
+ const parts: string[] = [];
426
+ if (usage.turns) parts.push(`${usage.turns} turn${usage.turns > 1 ? "s" : ""}`);
427
+ if (usage.input) parts.push(`↑${formatTokens(usage.input)}`);
428
+ if (usage.output) parts.push(`↓${formatTokens(usage.output)}`);
429
+ if (usage.cacheRead) parts.push(`R${formatTokens(usage.cacheRead)}`);
430
+ if (usage.cacheWrite) parts.push(`W${formatTokens(usage.cacheWrite)}`);
431
+ if (usage.cost) parts.push(`$${usage.cost.toFixed(4)}`);
432
+ if (usage.contextTokens && usage.contextTokens > 0) {
433
+ const ctxStr = formatContextUsage(usage.contextTokens, usage.contextWindow);
434
+ parts.push(`ctx:${ctxStr}`);
435
+ }
436
+ if (model) parts.push(model);
437
+ return parts.join(" ");
438
+ }
439
+
440
+ function formatToolPreview(name: string, args: Record<string, any>): string {
441
+ switch (name) {
442
+ case "search":
443
+ return `search: ${((args.query as string) || "").slice(0, 80)}`;
444
+ case "fetch":
445
+ return `fetch: ${((args.url as string) || "").slice(0, 80)}`;
446
+ default: {
447
+ const s = JSON.stringify(args);
448
+ return `${name} ${s.slice(0, 60)}`;
449
+ }
450
+ }
451
+ }
452
+
453
+ function isTransientError(result: SearchResult): boolean {
454
+ const msg = (
455
+ result.errorMessage ||
456
+ result.progress.error ||
457
+ result.stderr ||
458
+ ""
459
+ ).toLowerCase();
460
+
461
+ // 429 rate limit
462
+ if (
463
+ msg.includes("429") ||
464
+ msg.includes("too many requests") ||
465
+ msg.includes("rate limit")
466
+ )
467
+ return true;
468
+
469
+ // 5xx server errors
470
+ if (
471
+ /(?:^|\s)5[0-9]{2}(?:\s|$)/.test(msg) ||
472
+ msg.includes("500 internal server") ||
473
+ msg.includes("502 bad gateway") ||
474
+ msg.includes("503 service unavailable") ||
475
+ msg.includes("504 gateway timeout")
476
+ )
477
+ return true;
478
+
479
+ // Network errors
480
+ if (
481
+ msg.includes("econnreset") ||
482
+ msg.includes("etimedout") ||
483
+ msg.includes("econnrefused") ||
484
+ msg.includes("socket hang up") ||
485
+ msg.includes("network error") ||
486
+ msg.includes("fetch failed")
487
+ )
488
+ return true;
489
+
490
+ return false;
491
+ }
492
+
493
+ // ── Config ─────────────────────────────────────────────────────────────
494
+
495
+ function getConfigDir(): string {
496
+ const localConfig = path.join(process.cwd(), ".pi", "config", "pi-agentic-search");
497
+ if (fs.existsSync(localConfig)) {
498
+ return localConfig;
499
+ }
500
+ return path.join(os.homedir(), ".pi", "config", "pi-agentic-search");
501
+ }
502
+
503
+ function getSourceDir(): string {
504
+ const dir =
505
+ typeof __dirname !== "undefined"
506
+ ? __dirname
507
+ : path.dirname(new URL(import.meta.url).pathname);
508
+ return dir;
509
+ }
510
+
511
+ function loadSettings(): SearchSettings {
512
+ const configDir = getConfigDir();
513
+ const settingsPath = path.join(configDir, "settings.json");
514
+
515
+ try {
516
+ if (fs.existsSync(settingsPath)) {
517
+ const content = fs.readFileSync(settingsPath, "utf-8").trim();
518
+ if (content) {
519
+ return JSON.parse(content);
520
+ }
521
+ }
522
+ } catch {
523
+ // ignore parse errors
524
+ }
525
+
526
+ return {};
527
+ }
528
+
529
+ function readNonCommentContent(filePath: string): string | null {
530
+ try {
531
+ if (!fs.existsSync(filePath)) return null;
532
+ const content = fs.readFileSync(filePath, "utf-8").trim();
533
+ const nonCommentLines = content
534
+ .split("\n")
535
+ .filter((line) => !line.startsWith("#") && line.trim());
536
+ return nonCommentLines.length > 0 ? content : null;
537
+ } catch {
538
+ return null;
539
+ }
540
+ }
541
+
542
+ function loadSystemPrompt(): string {
543
+ const configDir = getConfigDir();
544
+ const sourceDir = getSourceDir();
545
+
546
+ const replaceContent = readNonCommentContent(path.join(configDir, "replace-system-prompt.md"));
547
+ if (replaceContent) {
548
+ return replaceContent;
549
+ }
550
+
551
+ const defaultPath = path.join(sourceDir, "default-system-prompt.md");
552
+ let defaultPrompt: string;
553
+ try {
554
+ defaultPrompt = fs.readFileSync(defaultPath, "utf-8").trim();
555
+ } catch {
556
+ throw new Error(`Failed to load default system prompt from ${defaultPath}`);
557
+ }
558
+
559
+ const prependContent = readNonCommentContent(path.join(configDir, "prepend-system-prompt.md"));
560
+ const appendContent = readNonCommentContent(path.join(configDir, "append-system-prompt.md"));
561
+
562
+ const parts: string[] = [];
563
+ if (prependContent) parts.push(prependContent);
564
+ parts.push(defaultPrompt);
565
+ if (appendContent) parts.push(appendContent);
566
+
567
+ return parts.join("\n\n");
568
+ }
569
+
570
+ // ── Message Extraction ────────────────────────────────────────────────
571
+
572
+ function getFinalOutput(messages: any[]): string {
573
+ for (let i = messages.length - 1; i >= 0; i--) {
574
+ const msg = messages[i];
575
+ if (msg.role === "assistant") {
576
+ for (const part of msg.content) {
577
+ if (part.type === "text") return part.text;
578
+ }
579
+ }
580
+ }
581
+ return "";
582
+ }
583
+
584
+ function extractTextFromContent(content: any): string {
585
+ if (!content) return "";
586
+ if (typeof content === "string") return content;
587
+ if (Array.isArray(content)) {
588
+ return content
589
+ .filter((c: any) => c.type === "text")
590
+ .map((c: any) => c.text)
591
+ .join("\n");
592
+ }
593
+ return "";
594
+ }
595
+
596
+ function getDisplayItems(
597
+ messages: any[],
598
+ ): Array<
599
+ { type: "text"; text: string } | { type: "toolCall"; name: string; args: Record<string, any> }
600
+ > {
601
+ const items: Array<
602
+ { type: "text"; text: string } | { type: "toolCall"; name: string; args: Record<string, any> }
603
+ > = [];
604
+ for (const msg of messages) {
605
+ if (msg.role === "assistant") {
606
+ for (const part of msg.content) {
607
+ if (part.type === "text") items.push({ type: "text", text: part.text });
608
+ else if (part.type === "toolCall")
609
+ items.push({ type: "toolCall", name: part.name, args: part.arguments });
610
+ }
611
+ }
612
+ }
613
+ return items;
614
+ }
615
+
616
+ // ── Pi Binary Resolution ─────────────────────────────────────────────
617
+
618
+ function resolvePiBinary(): { command: string; baseArgs: string[] } {
619
+ const entry = process.argv[1];
620
+ if (entry) {
621
+ try {
622
+ const realEntry = fs.realpathSync(entry);
623
+ if (/\.(?:mjs|cjs|js)$/i.test(realEntry)) {
624
+ return { command: process.execPath, baseArgs: [realEntry] };
625
+ }
626
+ } catch {
627
+ // ignore
628
+ }
629
+ }
630
+ return { command: "pi", baseArgs: [] };
631
+ }
632
+
633
+ // ── Build Pi Args ────────────────────────────────────────────────────
634
+
635
+ async function buildPiArgs(
636
+ systemPrompt: string,
637
+ model: string | undefined,
638
+ task: string,
639
+ cwd: string,
640
+ ): Promise<{ args: string[]; tempDir: string }> {
641
+ const piBin = resolvePiBinary();
642
+ const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "pi-search-"));
643
+
644
+ // Write system prompt to temp file
645
+ const promptPath = path.join(tempDir, "search-prompt.md");
646
+ await fs.promises.writeFile(promptPath, systemPrompt, { encoding: "utf-8", mode: 0o600 });
647
+
648
+ const args = [
649
+ ...piBin.baseArgs,
650
+ "--mode",
651
+ "json",
652
+ "-p",
653
+ "--no-session",
654
+ "--no-skills",
655
+ "--no-extensions",
656
+ "-e",
657
+ "npm:pi-search-tool",
658
+ "--tools",
659
+ "search,fetch",
660
+ "--append-system-prompt",
661
+ promptPath,
662
+ ];
663
+
664
+ // Add model flag if configured
665
+ if (model) {
666
+ args.push("--model", model);
667
+ }
668
+
669
+ // Handle long tasks by writing to file
670
+ if (task.length > TASK_LIMIT) {
671
+ const taskPath = path.join(tempDir, "task.md");
672
+ await fs.promises.writeFile(taskPath, `Research Task: ${task}`, { encoding: "utf-8", mode: 0o600 });
673
+ args.push(`@${taskPath}`);
674
+ } else {
675
+ args.push(`Research Task: ${task}`);
676
+ }
677
+
678
+ return { args: [piBin.command, ...args], tempDir };
679
+ }
680
+
681
+ // ── Run Search (Background) ───────────────────────────────────────────
682
+
683
+ async function runSearchInBackground(
684
+ task: string,
685
+ goal: string,
686
+ systemPrompt: string,
687
+ model: string | undefined,
688
+ signal: AbortSignal | undefined,
689
+ onUpdate: ((partial: any) => void) | undefined,
690
+ cwd: string,
691
+ startTime: number,
692
+ ui: any,
693
+ ): Promise<{ result: SearchResult; widgetId: string }> {
694
+
695
+ const result: SearchResult = {
696
+ task,
697
+ exitCode: 0,
698
+ messages: [],
699
+ stderr: "",
700
+ usage: {
701
+ input: 0,
702
+ output: 0,
703
+ cacheRead: 0,
704
+ cacheWrite: 0,
705
+ cost: 0,
706
+ contextTokens: 0,
707
+ turns: 0,
708
+ },
709
+ progress: {
710
+ status: "running",
711
+ recentTools: [],
712
+ toolCount: 0,
713
+ tokens: 0,
714
+ durationMs: 0,
715
+ lastMessage: "",
716
+ },
717
+ };
718
+
719
+ const progress = result.progress;
720
+ const WIDGET_TOOL_LIMIT = 5;
721
+
722
+ // Activity tracking for staleness detection
723
+ let lastActivityTime = Date.now();
724
+ let activityCheckTimer: ReturnType<typeof setInterval> | undefined;
725
+
726
+ // Register widget with unique ID per spawn (supports Box rendering)
727
+ // The render() closure reads mutable progress/result, so it's always up-to-date
728
+ const widgetId = `search-progress-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
729
+
730
+ // Register widget ONCE with callback form (supports Box rendering)
731
+ let tuiRef: any = null;
732
+ ui.setWidget(widgetId, (tui: any, theme: any) => {
733
+ tuiRef = tui;
734
+ return {
735
+ render: () => {
736
+ const duration = formatDuration(Date.now() - startTime);
737
+ const isRunning = progress.status === "running";
738
+ const isFailed = progress.status === "failed";
739
+ const staleMs = Date.now() - lastActivityTime;
740
+ const isStale = isRunning && staleMs > ACTIVITY_TIMEOUT_MS;
741
+ const icon = isStale ? "⚠" : isRunning ? "⟳" : isFailed ? "✗" : "✓";
742
+ const iconColor = isStale ? "error" : isRunning ? "warning" : isFailed ? "error" : "success";
743
+ const modelStr = result.model ? theme.fg("dim", ` (${result.model})`) : "";
744
+ const stats = isStale
745
+ ? `${progress.toolCount} searches · ${duration} · probably failed`
746
+ : `${progress.toolCount} searches · ${duration}`;
747
+ const box = new Box(1, 0, (t: string) => theme.bg("customMessageBg", t));
748
+
749
+ // Header: icon + label + stats
750
+ box.addChild(new Text(
751
+ `${theme.fg(iconColor, icon)} ${theme.fg("toolTitle", theme.bold("research"))}${modelStr} — ${theme.fg(isStale ? "error" : "dim", stats)}`,
752
+ 0, 0,
753
+ ));
754
+
755
+ // Tool log — last N tools
756
+ const tools = progress.recentTools;
757
+ const toShow = tools.slice(-WIDGET_TOOL_LIMIT);
758
+ const skipped = tools.length - toShow.length;
759
+ if (skipped > 0) {
760
+ box.addChild(new Text(theme.fg("muted", ` … ${skipped} earlier`), 0, 0));
761
+ }
762
+ for (const t of toShow) {
763
+ if (t.status === "running") {
764
+ box.addChild(new Text(
765
+ `${theme.fg("warning", "▸")} ${theme.fg("muted", t.tool)}: ${theme.fg("dim", t.args)}`,
766
+ 0, 0,
767
+ ));
768
+ } else {
769
+ box.addChild(new Text(` ${theme.fg("muted", t.tool)}: ${theme.fg("dim", t.args)}`, 0, 0));
770
+ }
771
+ }
772
+
773
+ // Latest "thinking" message
774
+ if (progress.lastMessage) {
775
+ const preview =
776
+ progress.lastMessage.length > 100
777
+ ? progress.lastMessage.slice(0, 100) + "…"
778
+ : progress.lastMessage;
779
+ box.addChild(new Text(theme.fg("text", preview), 0, 0));
780
+ }
781
+
782
+ // Usage line
783
+ const usageParts: string[] = [];
784
+ if (result.usage.turns)
785
+ usageParts.push(theme.fg("dim", `${result.usage.turns} turn${result.usage.turns !== 1 ? "s" : ""}`));
786
+ if (result.usage.input) usageParts.push(theme.fg("dim", `↑${formatTokens(result.usage.input)}`));
787
+ if (result.usage.output) usageParts.push(theme.fg("dim", `↓${formatTokens(result.usage.output)}`));
788
+ if (result.usage.cacheRead) usageParts.push(theme.fg("dim", `R${formatTokens(result.usage.cacheRead)}`));
789
+ if (result.usage.cacheWrite) usageParts.push(theme.fg("dim", `W${formatTokens(result.usage.cacheWrite)}`));
790
+ if (result.usage.cost) usageParts.push(theme.fg("dim", `$${result.usage.cost.toFixed(4)}`));
791
+ if (progress.tokens > 0) {
792
+ const ctxStr = formatContextUsage(progress.tokens, progress.contextWindow);
793
+ const pct = progress.contextWindow ? (progress.tokens / progress.contextWindow) * 100 : 0;
794
+ const ctxColor = pct > 90 ? "error" : pct > 70 ? "warning" : "dim";
795
+ usageParts.push(theme.fg(ctxColor, ctxStr));
796
+ }
797
+ if (usageParts.length) {
798
+ box.addChild(new Text(usageParts.join(" "), 0, 0));
799
+ }
800
+
801
+ // Error
802
+ if (progress.error) {
803
+ box.addChild(new Text(theme.fg("error", `Error: ${progress.error}`), 0, 0));
804
+ }
805
+
806
+ const width = process.stdout.columns || 80;
807
+ const lines = box.render(width);
808
+ // Add separator line between widgets
809
+ lines.push("");
810
+ return lines;
811
+ },
812
+ invalidate: () => {},
813
+ };
814
+ });
815
+ ui.setStatus("search", ui.theme.fg("warning", "⟳ research"));
816
+
817
+ const fireUpdate = throttle(() => {
818
+ lastActivityTime = Date.now();
819
+ progress.durationMs = Date.now() - startTime;
820
+ if (tuiRef?.requestRender) tuiRef.requestRender();
821
+ if (onUpdate) {
822
+ onUpdate({
823
+ content: [{ type: "text", text: progress.lastMessage || "(searching...)" }],
824
+ details: {
825
+ mode: "widget",
826
+ task,
827
+ goal,
828
+ result,
829
+ },
830
+ });
831
+ }
832
+ }, UPDATE_THROTTLE_MS);
833
+
834
+ const { args, tempDir } = await buildPiArgs(systemPrompt, model, task, cwd);
835
+ const command = args[0];
836
+ const spawnArgs = args.slice(1);
837
+
838
+ try {
839
+ const exitCode = await new Promise<number>((resolve) => {
840
+ const proc = spawn(command, spawnArgs, {
841
+ cwd,
842
+ shell: false,
843
+ stdio: ["ignore", "pipe", "pipe"],
844
+ });
845
+
846
+ // Activity timeout — kill process if stuck
847
+ activityCheckTimer = setInterval(() => {
848
+ const elapsed = Date.now() - lastActivityTime;
849
+ if (elapsed > ACTIVITY_TIMEOUT_MS) {
850
+ progress.status = "failed";
851
+ progress.error = `No activity for ${Math.round(elapsed / 1000)}s — process appears stuck`;
852
+ proc.kill("SIGTERM");
853
+ setTimeout(() => !proc.killed && proc.kill("SIGKILL"), 3000);
854
+ }
855
+ }, 30_000); // Check every 30s
856
+
857
+ let buf = "";
858
+ let stderrBuf = "";
859
+
860
+ const processLine = (line: string) => {
861
+ if (!line.trim()) return;
862
+ try {
863
+ const evt = JSON.parse(line) as any;
864
+ progress.durationMs = Date.now() - startTime;
865
+
866
+ // Track tool execution start
867
+ if (evt.type === "tool_execution_start") {
868
+ progress.toolCount++;
869
+ progress.recentTools.push({
870
+ tool: evt.toolName,
871
+ args: formatToolPreview(evt.toolName, (evt.args || {}) as Record<string, any>),
872
+ toolCallId: evt.toolCallId,
873
+ status: "running",
874
+ });
875
+ if (progress.recentTools.length > 20) {
876
+ progress.recentTools = progress.recentTools.slice(-20);
877
+ }
878
+ fireUpdate();
879
+ }
880
+
881
+ // Track tool execution updates (for progress)
882
+ if (evt.type === "tool_execution_update") {
883
+ const hit = evt.toolCallId
884
+ ? progress.recentTools.find((t) => t.toolCallId === evt.toolCallId)
885
+ : undefined;
886
+ if (hit) {
887
+ if (evt.args) {
888
+ hit.args = formatToolPreview(evt.toolName, evt.args);
889
+ }
890
+ }
891
+ fireUpdate();
892
+ }
893
+
894
+ // Track tool execution end
895
+ if (evt.type === "tool_execution_end") {
896
+ const hit = evt.toolCallId
897
+ ? progress.recentTools.find((t) => t.toolCallId === evt.toolCallId)
898
+ : undefined;
899
+ if (hit) {
900
+ hit.status = "done";
901
+ }
902
+ fireUpdate();
903
+ }
904
+
905
+ // Track tool results
906
+ if (evt.type === "tool_result_end") {
907
+ fireUpdate();
908
+ }
909
+
910
+ // Track messages
911
+ if (evt.type === "message_end" && evt.message) {
912
+ const msg = evt.message;
913
+ result.messages.push(msg);
914
+
915
+ if (msg.role === "assistant") {
916
+ result.usage.turns++;
917
+ const u = msg.usage;
918
+ if (u) {
919
+ result.usage.input += u.input || 0;
920
+ result.usage.output += u.output || 0;
921
+ result.usage.cacheRead += u.cacheRead || 0;
922
+ result.usage.cacheWrite += u.cacheWrite || 0;
923
+ result.usage.cost += u.cost?.total || 0;
924
+ progress.tokens =
925
+ (u as any).totalTokens ||
926
+ (u.input || 0) + (u.output || 0) + (u.cacheRead || 0) + (u.cacheWrite || 0);
927
+ result.usage.contextTokens = progress.tokens;
928
+ }
929
+ if (!result.model && msg.model) result.model = msg.model;
930
+ if (msg.stopReason) result.stopReason = msg.stopReason;
931
+ if (msg.errorMessage) {
932
+ result.errorMessage = msg.errorMessage;
933
+ progress.error = msg.errorMessage;
934
+ }
935
+
936
+ // Extract latest prose for progress display
937
+ const text = extractTextFromContent(msg.content);
938
+ if (text) {
939
+ const proseLines: string[] = [];
940
+ let inCodeBlock = false;
941
+ for (const line of text.split("\n")) {
942
+ if (line.trimStart().startsWith("```")) {
943
+ inCodeBlock = !inCodeBlock;
944
+ continue;
945
+ }
946
+ if (!inCodeBlock && line.trim()) {
947
+ proseLines.push(line.trim());
948
+ }
949
+ }
950
+ if (proseLines.length > 0) {
951
+ progress.lastMessage = proseLines.slice(0, 3).join(" ");
952
+ }
953
+ }
954
+ }
955
+
956
+ fireUpdate();
957
+ }
958
+ } catch {
959
+ // Non-JSON lines are expected
960
+ }
961
+ };
962
+
963
+ proc.stdout.on("data", (d: Buffer) => {
964
+ buf += d.toString();
965
+ const lines = buf.split("\n");
966
+ buf = lines.pop() || "";
967
+ lines.forEach(processLine);
968
+ });
969
+
970
+ proc.stderr.on("data", (d: Buffer) => {
971
+ stderrBuf += d.toString();
972
+ });
973
+
974
+ proc.on("close", (code) => {
975
+ if (activityCheckTimer) {
976
+ clearInterval(activityCheckTimer);
977
+ activityCheckTimer = undefined;
978
+ }
979
+ if (buf.trim()) processLine(buf);
980
+ if (code !== 0 && stderrBuf.trim() && !progress.error) {
981
+ // Filter out pi's internal dashboard errors (expected when process is killed)
982
+ const filteredStderr = stderrBuf.trim()
983
+ .split('\n')
984
+ .filter((line: string) => !line.includes('[dashboard]'))
985
+ .join('\n')
986
+ .trim();
987
+ if (filteredStderr) {
988
+ progress.error = filteredStderr;
989
+ result.stderr = filteredStderr;
990
+ }
991
+ }
992
+ resolve(code ?? 1);
993
+ });
994
+
995
+ proc.on("error", () => {
996
+ if (activityCheckTimer) {
997
+ clearInterval(activityCheckTimer);
998
+ activityCheckTimer = undefined;
999
+ }
1000
+ resolve(1);
1001
+ });
1002
+
1003
+ if (signal) {
1004
+ const kill = () => {
1005
+ proc.kill("SIGTERM");
1006
+ setTimeout(() => !proc.killed && proc.kill("SIGKILL"), 3000);
1007
+ };
1008
+ if (signal.aborted) kill();
1009
+ else signal.addEventListener("abort", kill, { once: true });
1010
+ }
1011
+ });
1012
+
1013
+ result.exitCode = exitCode;
1014
+ progress.status = exitCode === 0 && !progress.error ? "completed" : "failed";
1015
+ progress.durationMs = Date.now() - startTime;
1016
+
1017
+ // Truncate output if very large
1018
+ if (getFinalOutput(result.messages).length > DEFAULT_MAX_BYTES) {
1019
+ truncateHead(getFinalOutput(result.messages), {
1020
+ maxLines: DEFAULT_MAX_LINES,
1021
+ maxBytes: DEFAULT_MAX_BYTES,
1022
+ });
1023
+ }
1024
+
1025
+ return { result, widgetId };
1026
+ } finally {
1027
+ if (activityCheckTimer) {
1028
+ clearInterval(activityCheckTimer);
1029
+ activityCheckTimer = undefined;
1030
+ }
1031
+ try {
1032
+ fs.rmSync(tempDir, { recursive: true, force: true });
1033
+ } catch {
1034
+ // ignore cleanup errors
1035
+ }
1036
+ }
1037
+ }
1038
+
1039
+ // ── Build Search Summary ──────────────────────────────────────────────
1040
+
1041
+ function buildSearchSummary(result: SearchResult, goal: string, canceledByUser = false): string {
1042
+ const finalOutput = getFinalOutput(result.messages);
1043
+ const isError = result.exitCode !== 0;
1044
+
1045
+ const parts: string[] = [];
1046
+
1047
+ if (isError) {
1048
+ parts.push(`Research failed for: ${goal}`);
1049
+ if (canceledByUser) {
1050
+ parts.push(`Status: Canceled by user`);
1051
+ }
1052
+ const error = result.errorMessage || result.progress.error || result.stderr;
1053
+ if (error) parts.push(`Error: ${error}`);
1054
+ parts.push("");
1055
+ if (finalOutput) {
1056
+ parts.push("Partial results:");
1057
+ parts.push("");
1058
+ parts.push(finalOutput);
1059
+ }
1060
+ } else {
1061
+ parts.push(`Research completed for: ${goal}`);
1062
+ if (result.usage.turns > 0) {
1063
+ const usageStr = formatUsageStats(result.usage, result.model);
1064
+ if (usageStr) parts.push(`Stats: ${usageStr}`);
1065
+ }
1066
+ if (result.progress.durationMs > 0) {
1067
+ parts.push(`Duration: ${formatDuration(result.progress.durationMs)}`);
1068
+ }
1069
+ parts.push("");
1070
+ if (finalOutput) {
1071
+ parts.push("## Research Summary");
1072
+ parts.push("");
1073
+ parts.push(finalOutput);
1074
+ } else {
1075
+ parts.push("(no findings)");
1076
+ }
1077
+ }
1078
+
1079
+ return parts.join("\n");
1080
+ }
1081
+
1082
+ // ── Main Extension ───────────────────────────────────────────────────
1083
+
1084
+ export default function (pi: ExtensionAPI) {
1085
+ // Ensure config files exist on session start
1086
+ pi.on("session_start", async (_event: any, ctx: any) => {
1087
+ const configDir = getConfigDir();
1088
+ try {
1089
+ await fs.promises.mkdir(configDir, { recursive: true });
1090
+
1091
+ const prependPath = path.join(configDir, "prepend-system-prompt.md");
1092
+ if (!fs.existsSync(prependPath)) {
1093
+ await fs.promises.writeFile(
1094
+ prependPath,
1095
+ "# Prepend instructions before the default research prompt\n" +
1096
+ "# Content here is added before the default prompt\n" +
1097
+ "# This file survives updates\n\n",
1098
+ "utf-8",
1099
+ );
1100
+ }
1101
+
1102
+ const appendPath = path.join(configDir, "append-system-prompt.md");
1103
+ if (!fs.existsSync(appendPath)) {
1104
+ await fs.promises.writeFile(
1105
+ appendPath,
1106
+ "# Append instructions after the default research prompt\n" +
1107
+ "# Content here is added after the default prompt\n" +
1108
+ "# This file survives updates\n\n",
1109
+ "utf-8",
1110
+ );
1111
+ }
1112
+
1113
+ const replacePath = path.join(configDir, "replace-system-prompt.md");
1114
+ if (!fs.existsSync(replacePath)) {
1115
+ await fs.promises.writeFile(
1116
+ replacePath,
1117
+ "# Replace the default research prompt entirely\n" +
1118
+ "# If this file has non-comment content, it will be used instead of the default\n" +
1119
+ "# This file survives updates\n\n",
1120
+ "utf-8",
1121
+ );
1122
+ }
1123
+
1124
+ const settingsPath = path.join(configDir, "settings.json");
1125
+ if (!fs.existsSync(settingsPath)) {
1126
+ await fs.promises.writeFile(
1127
+ settingsPath,
1128
+ JSON.stringify({ model: "", keybinding: 2 }, null, 2) + "\n",
1129
+ "utf-8",
1130
+ );
1131
+ }
1132
+ } catch {
1133
+ // ignore errors
1134
+ }
1135
+
1136
+ // Register keybinding for agent control panel
1137
+ const settings = loadSettings();
1138
+ const keyNum = settings.keybinding ?? 2;
1139
+ pi.registerShortcut(`ctrl+shift+${keyNum}` as any, {
1140
+ description: "Open research agent control panel",
1141
+ handler: async () => {
1142
+ ctx.ui.custom((tui: any, theme: any, _kb: any, done: any) => {
1143
+ return new AgentControlPanel(tui, theme, done, pi, ctx.ui);
1144
+ }, { overlay: true });
1145
+ },
1146
+ });
1147
+ });
1148
+
1149
+ // Register message renderer for research results
1150
+ pi.registerMessageRenderer("search-result", (message: any, options: any, theme: any) => {
1151
+ const { expanded } = options;
1152
+ const details = message.details as SearchDetails | undefined;
1153
+
1154
+ const mdTheme = getMarkdownTheme();
1155
+ const isError = details?.result?.exitCode !== 0;
1156
+ const icon = isError ? theme.fg("error", "✗") : theme.fg("success", "✓");
1157
+ let text = `${icon} ${theme.fg("toolTitle", theme.bold("research"))} ${theme.fg("accent", isError ? "failed" : "completed")}`;
1158
+
1159
+ if (details?.result?.usage) {
1160
+ const usageStr = formatUsageStats(details.result.usage, details.result.model);
1161
+ if (usageStr) text += `\n${theme.fg("dim", usageStr)}`;
1162
+ }
1163
+
1164
+ if (expanded && message.content) {
1165
+ const box = new Box(1, 1, (t: string) => theme.bg("customMessageBg", t));
1166
+ box.addChild(new Text(text, 0, 0));
1167
+ box.addChild(new Spacer(1));
1168
+ box.addChild(new Markdown(message.content, 0, 0, mdTheme));
1169
+ return box;
1170
+ }
1171
+
1172
+ // Collapsed: show first few lines
1173
+ const preview = message.content?.split("\n").slice(0, 5).join("\n") || "(no content)";
1174
+ text += `\n${theme.fg("text", preview)}`;
1175
+
1176
+ const box = new Box(1, 1, (t: string) => theme.bg("customMessageBg", t));
1177
+ box.addChild(new Text(text, 0, 0));
1178
+ return box;
1179
+ });
1180
+
1181
+ // Register the agentic search tool
1182
+ pi.registerTool({
1183
+ name: "agentic_search",
1184
+ label: "Agentic Search",
1185
+ description:
1186
+ "Spawn a dedicated research agent that autonomously searches, fetches, and synthesizes information on a given topic. Unlike the primitive search and fetch tools — which return raw results — this agent reasons across multiple sources, follows leads, resolves conflicts, and returns a structured Research Summary. Use when the topic requires depth, cross-referencing, or synthesis beyond a single query.",
1187
+ promptSnippet: "Spawn an autonomous research agent for deep, multi-source investigation",
1188
+ promptGuidelines: [
1189
+ "Use agentic_search for complex or broad topics requiring synthesis across multiple sources — not for simple factual lookups where search + fetch suffices.",
1190
+ "Use agentic_search when the answer requires resolving conflicting sources, following chains of references, or producing a structured summary rather than raw results.",
1191
+ "The research agent is read-only — it can only search the web and fetch pages. It cannot modify anything or execute code.",
1192
+ "Good topics for agentic_search: comparing technologies, understanding trends, researching best practices, investigating controversies, gathering evidence for decisions.",
1193
+ "Simple factual questions (e.g., 'What is the capital of France?') should use the search tool directly, not agentic_search.",
1194
+ ],
1195
+ parameters: Type.Object({
1196
+ goal: Type.String({
1197
+ description:
1198
+ "The research question or topic to investigate. Be specific and outcome-oriented: what should the agent find out, compare, or explain? E.g. 'What are the current best practices for rate limiting in distributed APIs?' rather than 'rate limiting'.",
1199
+ }),
1200
+ context: Type.String({
1201
+ description:
1202
+ "Background that helps the agent focus its research: why this is being investigated, what is already known, what angle matters most, or what sources to prioritize or avoid. E.g. 'We are building a Node.js API gateway. We already use Redis. Interested in token bucket vs sliding window approaches.'",
1203
+ }),
1204
+ }),
1205
+ async execute(toolCallId: any, params: any, signal: any, onUpdate: any, ctx: any) {
1206
+ const settings = loadSettings();
1207
+ const systemPrompt = loadSystemPrompt();
1208
+ const startTime = Date.now();
1209
+ const task = `Research the following:\n\nGoal: ${params.goal}\n\nContext: ${params.context}`;
1210
+
1211
+ // Create an abort controller for this agent
1212
+ let agentAbortController = new AbortController();
1213
+ let agentId = `search-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
1214
+ let activeWidgetId: string | undefined;
1215
+
1216
+ // Fire and forget — run search in background with auto-retry on transient errors
1217
+ const backgroundTask = async () => {
1218
+ // Clear widget and status when done
1219
+ const clearWidget = () => {
1220
+ if (activeWidgetId) {
1221
+ ctx.ui.setWidget(activeWidgetId, undefined);
1222
+ }
1223
+ ctx.ui.setStatus("search", undefined);
1224
+ agentRegistry.unregister(agentId);
1225
+ };
1226
+
1227
+ let attempt = 0;
1228
+ let localCanceledByUser = false;
1229
+ // Initial empty result for the widget (before first run)
1230
+ let result: SearchResult = {
1231
+ task,
1232
+ exitCode: 0,
1233
+ messages: [],
1234
+ stderr: "",
1235
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
1236
+ progress: { status: "running", recentTools: [], toolCount: 0, tokens: 0, durationMs: 0, lastMessage: "" },
1237
+ };
1238
+
1239
+ // Retry function for the registry
1240
+ const retryAgent = () => {
1241
+ // Remove old widget before creating new one
1242
+ if (activeWidgetId) {
1243
+ ctx.ui.setWidget(activeWidgetId, undefined);
1244
+ }
1245
+ agentAbortController.abort();
1246
+ agentAbortController = new AbortController();
1247
+ attempt = 0;
1248
+ localCanceledByUser = false;
1249
+ result = {
1250
+ task,
1251
+ exitCode: 0,
1252
+ messages: [],
1253
+ stderr: "",
1254
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
1255
+ progress: { status: "running", recentTools: [], toolCount: 0, tokens: 0, durationMs: 0, lastMessage: "" },
1256
+ };
1257
+ // Update existing agent in registry (keep same ID and widget)
1258
+ const existingAgent = agentRegistry.get(agentId);
1259
+ if (existingAgent) {
1260
+ existingAgent.status = "running";
1261
+ existingAgent.stoppedByUser = false;
1262
+ existingAgent.canceledByUser = false;
1263
+ existingAgent.startTime = Date.now();
1264
+ existingAgent.progress = result.progress;
1265
+ existingAgent.abort = () => {
1266
+ localCanceledByUser = true;
1267
+ agentAbortController.abort();
1268
+ };
1269
+ } else {
1270
+ // Fallback: re-register if not found
1271
+ agentRegistry.register({
1272
+ id: agentId,
1273
+ widgetId: activeWidgetId || "",
1274
+ task,
1275
+ goal: params.goal,
1276
+ status: "running",
1277
+ startTime: Date.now(),
1278
+ abort: () => {
1279
+ agentAbortController.abort();
1280
+ },
1281
+ retry: retryAgent,
1282
+ progress: result.progress,
1283
+ model: settings.model,
1284
+ });
1285
+ }
1286
+ runBackground();
1287
+ };
1288
+
1289
+ // Register the agent
1290
+ agentRegistry.register({
1291
+ id: agentId,
1292
+ widgetId: "", // Will be updated when widget is created
1293
+ task,
1294
+ goal: params.goal,
1295
+ status: "running",
1296
+ startTime,
1297
+ abort: () => {
1298
+ localCanceledByUser = true;
1299
+ agentAbortController.abort();
1300
+ },
1301
+ retry: retryAgent,
1302
+ progress: result.progress,
1303
+ model: settings.model,
1304
+ });
1305
+
1306
+ const runBackground = async () => {
1307
+ attempt = 0;
1308
+
1309
+ while (attempt <= MAX_RETRIES) {
1310
+ try {
1311
+ const bgResult = await runSearchInBackground(
1312
+ task,
1313
+ params.goal,
1314
+ systemPrompt,
1315
+ settings.model,
1316
+ agentAbortController.signal,
1317
+ undefined,
1318
+ ctx.cwd,
1319
+ startTime,
1320
+ ctx.ui,
1321
+ );
1322
+ result = bgResult.result;
1323
+ activeWidgetId = bgResult.widgetId;
1324
+
1325
+ // Update agent registry with widgetId
1326
+ const agent = agentRegistry.get(agentId);
1327
+ if (agent) {
1328
+ agent.widgetId = activeWidgetId;
1329
+ agent.progress = result.progress;
1330
+ agent.model = result.model;
1331
+ }
1332
+
1333
+ // Check if agent was stopped by user — no feedback to LLM, keep widget visible
1334
+ const currentAgent = agentRegistry.get(agentId);
1335
+ if (currentAgent?.stoppedByUser) {
1336
+ // Agent was stopped, keep in registry with stopped status
1337
+ // Update widget to show stopped status
1338
+ if (activeWidgetId) {
1339
+ ctx.ui.setWidget(activeWidgetId, (tui: any, theme: any) => {
1340
+ return {
1341
+ render: () => {
1342
+ const box = new Box(1, 0, (t: string) => theme.bg("customMessageBg", t));
1343
+ box.addChild(new Text(
1344
+ `${theme.fg("muted", "⏸")} ${theme.fg("toolTitle", theme.bold("research"))} — ${theme.fg("muted", "stopped")}`,
1345
+ 0, 0,
1346
+ ));
1347
+ box.addChild(new Text(
1348
+ theme.fg("dim", ` ${params.goal.length > 50 ? params.goal.slice(0, 50) + "..." : params.goal}`),
1349
+ 0, 0,
1350
+ ));
1351
+ const width = process.stdout.columns || 80;
1352
+ const lines = box.render(width);
1353
+ lines.push("");
1354
+ return lines;
1355
+ },
1356
+ invalidate: () => {},
1357
+ };
1358
+ });
1359
+ }
1360
+ return;
1361
+ }
1362
+
1363
+ // If agent was canceled, send failure message with canceled status
1364
+ if (localCanceledByUser) {
1365
+ const summary = buildSearchSummary(result, params.goal, true);
1366
+ pi.sendMessage(
1367
+ {
1368
+ customType: "search-result",
1369
+ content: summary,
1370
+ display: true,
1371
+ details: {
1372
+ mode: "widget",
1373
+ task,
1374
+ goal: params.goal,
1375
+ result,
1376
+ },
1377
+ },
1378
+ {
1379
+ deliverAs: "followUp",
1380
+ triggerTurn: true,
1381
+ },
1382
+ );
1383
+ if (activeWidgetId) {
1384
+ ctx.ui.setWidget(activeWidgetId, undefined);
1385
+ }
1386
+ ctx.ui.setStatus("search", undefined);
1387
+ return;
1388
+ }
1389
+
1390
+ // Success — send results and return
1391
+ if (result.progress.status === "completed") {
1392
+ const summary = buildSearchSummary(result, params.goal, localCanceledByUser);
1393
+ pi.sendMessage(
1394
+ {
1395
+ customType: "search-result",
1396
+ content: summary,
1397
+ display: true,
1398
+ details: {
1399
+ mode: "widget",
1400
+ task,
1401
+ goal: params.goal,
1402
+ result,
1403
+ },
1404
+ },
1405
+ {
1406
+ deliverAs: "followUp",
1407
+ triggerTurn: true,
1408
+ },
1409
+ );
1410
+ clearWidget();
1411
+ return;
1412
+ }
1413
+
1414
+ // Failed — check if transient
1415
+ if (!isTransientError(result) || attempt >= MAX_RETRIES) {
1416
+ const summary = buildSearchSummary(result, params.goal, localCanceledByUser);
1417
+ pi.sendMessage(
1418
+ {
1419
+ customType: "search-result",
1420
+ content: summary,
1421
+ display: true,
1422
+ details: {
1423
+ mode: "widget",
1424
+ task,
1425
+ goal: params.goal,
1426
+ result,
1427
+ },
1428
+ },
1429
+ {
1430
+ deliverAs: "followUp",
1431
+ triggerTurn: true,
1432
+ },
1433
+ );
1434
+ clearWidget();
1435
+ return;
1436
+ }
1437
+
1438
+ // Transient error — retry with backoff
1439
+ attempt++;
1440
+ const delay = INITIAL_RETRY_DELAY_MS * Math.pow(2, attempt - 1);
1441
+ const errorMsg =
1442
+ result.errorMessage || result.progress.error || result.stderr || "unknown error";
1443
+
1444
+ // Show retry status in widget
1445
+ if (activeWidgetId) {
1446
+ ctx.ui.setWidget(activeWidgetId, (_tui: any, th: any) => {
1447
+ return {
1448
+ render: () => {
1449
+ const box = new Box(1, 0, (t: string) => th.bg("customMessageBg", t));
1450
+ box.addChild(new Text(
1451
+ `${th.fg("warning", "⟳")} ${th.fg("toolTitle", th.bold("research"))} — ${th.fg("warning", `retrying (${attempt}/${MAX_RETRIES})`)}`,
1452
+ 0, 0,
1453
+ ));
1454
+ box.addChild(new Text(
1455
+ th.fg("dim", params.goal.length > 60 ? params.goal.slice(0, 60) + "..." : params.goal),
1456
+ 0, 0,
1457
+ ));
1458
+ box.addChild(new Text(
1459
+ th.fg("error", `Error: ${errorMsg.slice(0, 80)}`),
1460
+ 0, 0,
1461
+ ));
1462
+ box.addChild(new Text(
1463
+ th.fg("muted", `Waiting ${Math.round(delay / 1000)}s before retry`),
1464
+ 0, 0,
1465
+ ));
1466
+ const width = process.stdout.columns || 80;
1467
+ return box.render(width);
1468
+ },
1469
+ invalidate: () => {},
1470
+ };
1471
+ });
1472
+ }
1473
+ ctx.ui.setStatus("search", ctx.ui.theme.fg("warning", `⟳ research retry ${attempt}/${MAX_RETRIES}`));
1474
+
1475
+ await new Promise((r) => setTimeout(r, delay));
1476
+
1477
+ // Check if aborted during wait
1478
+ if (agentAbortController.signal.aborted) return;
1479
+ } catch (error) {
1480
+ // Check if agent was stopped by user — no feedback to LLM
1481
+ const currentAgent = agentRegistry.get(agentId);
1482
+ if (currentAgent?.stoppedByUser) {
1483
+ // Clean up widget
1484
+ if (activeWidgetId) {
1485
+ ctx.ui.setWidget(activeWidgetId, undefined);
1486
+ }
1487
+ ctx.ui.setStatus("search", undefined);
1488
+ return;
1489
+ }
1490
+ // Build summary with canceledByUser flag if applicable
1491
+ const summary = buildSearchSummary(result, params.goal, localCanceledByUser);
1492
+ pi.sendMessage(
1493
+ {
1494
+ customType: "search-result",
1495
+ content: summary,
1496
+ display: true,
1497
+ details: {
1498
+ mode: "widget",
1499
+ task,
1500
+ goal: params.goal,
1501
+ result,
1502
+ },
1503
+ },
1504
+ {
1505
+ deliverAs: "followUp",
1506
+ triggerTurn: true,
1507
+ },
1508
+ );
1509
+ clearWidget();
1510
+ return;
1511
+ }
1512
+ }
1513
+ };
1514
+
1515
+ // Start the background run
1516
+ await runBackground();
1517
+ };
1518
+
1519
+ // Start background task (don't await) with error catching
1520
+ backgroundTask().catch((error) => {
1521
+ // Check if agent was stopped by user — no feedback to LLM
1522
+ const currentAgent = agentRegistry.get(agentId);
1523
+ if (currentAgent?.stoppedByUser) {
1524
+ // Clean up status
1525
+ ctx.ui.setStatus("search", undefined);
1526
+ return;
1527
+ }
1528
+ // Unexpected error — try to send failure message and clean up
1529
+ try {
1530
+ pi.sendMessage(
1531
+ {
1532
+ customType: "search-result",
1533
+ content: `Research failed unexpectedly: ${error instanceof Error ? error.message : String(error)}`,
1534
+ display: true,
1535
+ details: {
1536
+ error: error instanceof Error ? error.message : String(error),
1537
+ },
1538
+ },
1539
+ {
1540
+ deliverAs: "followUp",
1541
+ triggerTurn: true,
1542
+ },
1543
+ );
1544
+ ctx.ui.setStatus("search", undefined);
1545
+ } catch {
1546
+ // Last resort — nothing more we can do
1547
+ }
1548
+ });
1549
+
1550
+ // Return immediately
1551
+ return {
1552
+ content: [
1553
+ {
1554
+ type: "text",
1555
+ text: [
1556
+ `Research started in background for: ${params.goal}`,
1557
+ "",
1558
+ "IMPORTANT: The research agent is running in the background and will deliver a comprehensive summary automatically when done. Wait for the results before proceeding with your own searching or fetching — the agent is doing that work for you. You may spawn additional research agents for unrelated topics in parallel.",
1559
+ ].join("\n"),
1560
+ },
1561
+ ],
1562
+ details: {
1563
+ mode: "widget",
1564
+ task,
1565
+ goal: params.goal,
1566
+ status: "started",
1567
+ },
1568
+ };
1569
+ },
1570
+
1571
+ renderCall(args: any, theme: any, _context: any) {
1572
+ const goal = args.goal || "...";
1573
+ const preview = goal.length > 50 ? `${goal.slice(0, 50)}...` : goal;
1574
+
1575
+ let text = theme.fg("toolTitle", theme.bold("research "));
1576
+ text += theme.fg("accent", preview);
1577
+
1578
+ if (args.context) {
1579
+ const contextPreview =
1580
+ args.context.length > 50 ? `${args.context.slice(0, 50)}...` : args.context;
1581
+ text += `\n ${theme.fg("dim", contextPreview)}`;
1582
+ }
1583
+
1584
+ return new Text(text, 0, 0);
1585
+ },
1586
+
1587
+ renderResult(result: any, { expanded }: any, theme: any, _context: any) {
1588
+ const details = result.details as SearchDetails | undefined;
1589
+
1590
+ if (!details) {
1591
+ const text = result.content?.[0];
1592
+ return new Text(text?.type === "text" ? text.text : "(no output)", 0, 0);
1593
+ }
1594
+
1595
+ // Handle "started" status (background mode)
1596
+ if (details.status === "started") {
1597
+ const goal = details.task?.match(/Goal: (.+)/)?.[1] || "";
1598
+ const c = new Container();
1599
+
1600
+ c.addChild(
1601
+ new Text(
1602
+ theme.fg("warning", "⟳ ") +
1603
+ theme.fg("toolTitle", theme.bold("research")) +
1604
+ theme.fg("accent", " — searching in background"),
1605
+ 0,
1606
+ 0,
1607
+ ),
1608
+ );
1609
+
1610
+ if (goal)
1611
+ c.addChild(
1612
+ new Text(theme.fg("dim", " Goal: ") + theme.fg("text", goal), 0, 0),
1613
+ );
1614
+
1615
+ c.addChild(new Spacer(1));
1616
+ c.addChild(
1617
+ new Text(
1618
+ theme.fg(
1619
+ "warning",
1620
+ "Waiting for research results — delegate searching, do not search/fetch yourself.",
1621
+ ),
1622
+ 0,
1623
+ 0,
1624
+ ),
1625
+ );
1626
+
1627
+ return c;
1628
+ }
1629
+
1630
+ const mdTheme = getMarkdownTheme();
1631
+
1632
+ if (!details.result) {
1633
+ const text = result.content?.[0];
1634
+ return new Text(text?.type === "text" ? text.text : "(no output)", 0, 0);
1635
+ }
1636
+
1637
+ const r = details.result;
1638
+ const prog = r.progress;
1639
+ const isError = r.exitCode !== 0;
1640
+ const isRunning = prog.status === "running";
1641
+ const icon = isRunning
1642
+ ? theme.fg("warning", "⟳")
1643
+ : isError
1644
+ ? theme.fg("error", "✗")
1645
+ : theme.fg("success", "✓");
1646
+ const displayItems = getDisplayItems(r.messages);
1647
+ const finalOutput = getFinalOutput(r.messages);
1648
+
1649
+ if (expanded) {
1650
+ const container = new Container();
1651
+
1652
+ // Header: icon + research + stats
1653
+ const modelStr = r.model ? ` (${r.model})` : "";
1654
+ const stats = `${prog.toolCount} searches · ${formatDuration(prog.durationMs)}`;
1655
+ let header = `${icon} ${theme.fg("toolTitle", theme.bold("research"))}${theme.fg("dim", modelStr)} — ${theme.fg("dim", stats)}`;
1656
+ if (isError && r.stopReason) header += ` ${theme.fg("error", `[${r.stopReason}]`)}`;
1657
+ container.addChild(new Text(header, 0, 0));
1658
+
1659
+ if (isError && (r.errorMessage || prog.error)) {
1660
+ container.addChild(
1661
+ new Text(theme.fg("error", `Error: ${r.errorMessage || prog.error}`), 0, 0),
1662
+ );
1663
+ }
1664
+
1665
+ // Tool log
1666
+ if (prog.recentTools.length > 0) {
1667
+ container.addChild(new Spacer(1));
1668
+ for (const t of prog.recentTools.slice(-10)) {
1669
+ const statusIcon =
1670
+ t.status === "running" ? theme.fg("warning", "▸") : theme.fg("muted", " ");
1671
+ container.addChild(
1672
+ new Text(
1673
+ `${statusIcon} ${theme.fg("muted", t.tool)}: ${theme.fg("dim", t.args)}`,
1674
+ 0,
1675
+ 0,
1676
+ ),
1677
+ );
1678
+ }
1679
+ }
1680
+
1681
+ // Latest "thinking" message
1682
+ if (prog.lastMessage) {
1683
+ container.addChild(new Spacer(1));
1684
+ container.addChild(new Text(theme.fg("text", prog.lastMessage), 0, 0));
1685
+ }
1686
+
1687
+ // Final output
1688
+ if (finalOutput) {
1689
+ container.addChild(new Spacer(1));
1690
+ container.addChild(new Markdown(finalOutput.trim(), 0, 0, mdTheme));
1691
+ } else if (displayItems.length === 0) {
1692
+ container.addChild(new Spacer(1));
1693
+ container.addChild(new Text(theme.fg("muted", "(no output)"), 0, 0));
1694
+ }
1695
+
1696
+ // Usage line
1697
+ container.addChild(new Spacer(1));
1698
+ const usageParts: string[] = [];
1699
+ if (r.usage.turns)
1700
+ usageParts.push(
1701
+ theme.fg("dim", `${r.usage.turns} turn${r.usage.turns > 1 ? "s" : ""}`),
1702
+ );
1703
+ if (r.usage.input)
1704
+ usageParts.push(theme.fg("dim", `↑${formatTokens(r.usage.input)}`));
1705
+ if (r.usage.output)
1706
+ usageParts.push(theme.fg("dim", `↓${formatTokens(r.usage.output)}`));
1707
+ if (r.usage.cacheRead)
1708
+ usageParts.push(theme.fg("dim", `R${formatTokens(r.usage.cacheRead)}`));
1709
+ if (r.usage.cacheWrite)
1710
+ usageParts.push(theme.fg("dim", `W${formatTokens(r.usage.cacheWrite)}`));
1711
+ if (r.usage.cost) usageParts.push(theme.fg("dim", `$${r.usage.cost.toFixed(4)}`));
1712
+ if (prog.tokens > 0 && prog.contextWindow) {
1713
+ const ctxStr = formatContextUsage(prog.tokens, prog.contextWindow);
1714
+ const pct = (prog.tokens / prog.contextWindow) * 100;
1715
+ const coloredCtx =
1716
+ pct > 90
1717
+ ? theme.fg("error", ctxStr)
1718
+ : pct > 70
1719
+ ? theme.fg("warning", ctxStr)
1720
+ : theme.fg("dim", ctxStr);
1721
+ usageParts.push(coloredCtx);
1722
+ }
1723
+ if (usageParts.length) {
1724
+ container.addChild(new Text(usageParts.join(" "), 0, 0));
1725
+ }
1726
+
1727
+ return container;
1728
+ }
1729
+
1730
+ // ── Collapsed view ──
1731
+ const modelStr = r.model ? theme.fg("dim", ` (${r.model})`) : "";
1732
+ const stats = `${prog.toolCount} searches · ${formatDuration(prog.durationMs)}`;
1733
+ let text = `${icon} ${theme.fg("toolTitle", theme.bold("research"))}${modelStr} — ${theme.fg("dim", stats)}`;
1734
+ if (isError && r.stopReason) text += ` ${theme.fg("error", `[${r.stopReason}]`)}`;
1735
+
1736
+ // Tool log — last 5
1737
+ const toolSlice = prog.recentTools.slice(-5);
1738
+ const toolSkip = prog.recentTools.length - toolSlice.length;
1739
+ if (toolSkip > 0) text += `\n${theme.fg("muted", ` … ${toolSkip} earlier`)}`;
1740
+ for (const t of toolSlice) {
1741
+ if (t.status === "running") {
1742
+ text += `\n${theme.fg("warning", "▸")} ${theme.fg("muted", t.tool)}: ${theme.fg("dim", t.args)}`;
1743
+ } else {
1744
+ text += `\n${theme.fg("muted", ` ${t.tool}:`)} ${theme.fg("dim", t.args)}`;
1745
+ }
1746
+ }
1747
+
1748
+ // Last message or error
1749
+ if (isError && (r.errorMessage || prog.error)) {
1750
+ text += `\n${theme.fg("error", `Error: ${r.errorMessage || prog.error}`)}`;
1751
+ } else if (prog.lastMessage) {
1752
+ const preview =
1753
+ prog.lastMessage.length > 100
1754
+ ? `${prog.lastMessage.slice(0, 100)}…`
1755
+ : prog.lastMessage;
1756
+ text += `\n${theme.fg("text", preview)}`;
1757
+ }
1758
+
1759
+ // Usage line
1760
+ const usageParts: string[] = [];
1761
+ if (r.usage.turns)
1762
+ usageParts.push(`${r.usage.turns} turn${r.usage.turns > 1 ? "s" : ""}`);
1763
+ if (r.usage.input) usageParts.push(`↑${formatTokens(r.usage.input)}`);
1764
+ if (r.usage.output) usageParts.push(`↓${formatTokens(r.usage.output)}`);
1765
+ if (r.usage.cacheRead) usageParts.push(`R${formatTokens(r.usage.cacheRead)}`);
1766
+ if (r.usage.cacheWrite) usageParts.push(`W${formatTokens(r.usage.cacheWrite)}`);
1767
+ if (r.usage.cost) usageParts.push(`$${r.usage.cost.toFixed(4)}`);
1768
+ if (prog.tokens > 0) {
1769
+ usageParts.push(formatContextUsage(prog.tokens, prog.contextWindow));
1770
+ }
1771
+ if (usageParts.length) {
1772
+ text += `\n${theme.fg("dim", usageParts.join(" "))}`;
1773
+ }
1774
+
1775
+ return new Text(text, 0, 0);
1776
+ },
1777
+ });
1778
+ }