@bacnh85/pi-subagent 0.14.0 → 0.15.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/CHANGELOG.md +71 -0
- package/README.md +34 -1
- package/extensions/background.ts +351 -0
- package/extensions/history.ts +117 -0
- package/extensions/index.ts +267 -12
- package/extensions/render.ts +62 -31
- package/extensions/result.ts +109 -0
- package/extensions/runner.ts +3 -0
- package/extensions/security.ts +24 -1
- package/extensions/widget.ts +338 -0
- package/package.json +13 -9
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structured result extraction (parent-side, no child XML contract).
|
|
3
|
+
*
|
|
4
|
+
* Best-effort parsing of a child's final assistant message into sections.
|
|
5
|
+
* Detects markdown headers (## Findings, ## Files, etc.) when present, but
|
|
6
|
+
* degrades gracefully to plain-text summary when they're absent.
|
|
7
|
+
*
|
|
8
|
+
* Deliberately rejects pi-task's <task_result> XML envelope injection
|
|
9
|
+
* (brittle prompt contract). We structure the output ourselves.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export interface StructuredResult {
|
|
13
|
+
/** One-line summary (first non-empty sentence/line, capped). */
|
|
14
|
+
summary: string;
|
|
15
|
+
/** Full raw output, unmodified. */
|
|
16
|
+
fullOutput: string;
|
|
17
|
+
/** Detected "## Findings" / "## Evidence" section, if present. */
|
|
18
|
+
findings?: string;
|
|
19
|
+
/** Detected "## Files" / "## Changed files" section, if present. */
|
|
20
|
+
files?: string;
|
|
21
|
+
/** Detected "## Caveats" / "## Risks" section, if present. */
|
|
22
|
+
caveats?: string;
|
|
23
|
+
/** Detected "## Next steps" / "## Recommendations" section, if present. */
|
|
24
|
+
nextSteps?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const SUMMARY_MAX_CHARS = 200;
|
|
28
|
+
|
|
29
|
+
// Header aliases — case-insensitive, match the heading text after "## ".
|
|
30
|
+
const FINDINGS_HEADERS = ["findings", "evidence", "results", "analysis", "details"];
|
|
31
|
+
const FILES_HEADERS = ["files", "changed files", "modified files", "changes"];
|
|
32
|
+
const CAVEATS_HEADERS = ["caveats", "risks", "limitations", "warnings"];
|
|
33
|
+
const NEXT_STEPS_HEADERS = ["next steps", "recommendations", "follow-up", "follow up", "action items"];
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Parse a raw output string into a StructuredResult.
|
|
37
|
+
* If no markdown headers are found, returns summary + fullOutput only.
|
|
38
|
+
*/
|
|
39
|
+
export function parseStructuredResult(rawOutput: string): StructuredResult {
|
|
40
|
+
const fullOutput = rawOutput.trim();
|
|
41
|
+
const summary = extractSummary(fullOutput);
|
|
42
|
+
|
|
43
|
+
// Split into sections by "## Header" lines.
|
|
44
|
+
const sections = splitByMarkdownHeaders(fullOutput);
|
|
45
|
+
if (Object.keys(sections).length === 0) {
|
|
46
|
+
return { summary, fullOutput };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const result: StructuredResult = { summary, fullOutput };
|
|
50
|
+
const find = (aliases: string[]): string | undefined => {
|
|
51
|
+
for (const [header, body] of Object.entries(sections)) {
|
|
52
|
+
if (aliases.some((a) => header === a || header.includes(a))) {
|
|
53
|
+
return body.trim() || undefined;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return undefined;
|
|
57
|
+
};
|
|
58
|
+
const findings = find(FINDINGS_HEADERS);
|
|
59
|
+
const files = find(FILES_HEADERS);
|
|
60
|
+
const caveats = find(CAVEATS_HEADERS);
|
|
61
|
+
const nextSteps = find(NEXT_STEPS_HEADERS);
|
|
62
|
+
if (findings) result.findings = findings;
|
|
63
|
+
if (files) result.files = files;
|
|
64
|
+
if (caveats) result.caveats = caveats;
|
|
65
|
+
if (nextSteps) result.nextSteps = nextSteps;
|
|
66
|
+
return result;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Extract a one-line summary: first non-empty line that isn't a header,
|
|
71
|
+
* capped to SUMMARY_MAX_CHARS.
|
|
72
|
+
*/
|
|
73
|
+
export function extractSummary(text: string): string {
|
|
74
|
+
for (const line of text.split("\n")) {
|
|
75
|
+
const trimmed = line.trim();
|
|
76
|
+
if (!trimmed) continue;
|
|
77
|
+
// Skip markdown headers.
|
|
78
|
+
if (/^#{1,6}\s/.test(trimmed)) continue;
|
|
79
|
+
// Take the first sentence if it ends with punctuation, else the whole line.
|
|
80
|
+
const sentenceMatch = trimmed.match(/^.+?[.!?](?:\s|$)/);
|
|
81
|
+
const summary = (sentenceMatch ? sentenceMatch[0] : trimmed).trim();
|
|
82
|
+
return summary.length > SUMMARY_MAX_CHARS ? `${summary.slice(0, SUMMARY_MAX_CHARS - 1)}…` : summary;
|
|
83
|
+
}
|
|
84
|
+
return text.slice(0, SUMMARY_MAX_CHARS);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Split markdown into { header: body } sections by "## Header" lines.
|
|
89
|
+
* Text before any header is ignored for section extraction.
|
|
90
|
+
* Returns {} if no headers found.
|
|
91
|
+
*/
|
|
92
|
+
function splitByMarkdownHeaders(text: string): Record<string, string> {
|
|
93
|
+
const sections: Record<string, string> = {};
|
|
94
|
+
const lines = text.split("\n");
|
|
95
|
+
let currentHeader: string | null = null;
|
|
96
|
+
let currentBody: string[] = [];
|
|
97
|
+
for (const line of lines) {
|
|
98
|
+
const headerMatch = line.match(/^#{1,6}\s+(.+?)\s*$/);
|
|
99
|
+
if (headerMatch) {
|
|
100
|
+
if (currentHeader) sections[currentHeader] = currentBody.join("\n");
|
|
101
|
+
currentHeader = headerMatch[1]!.toLowerCase().replace(/[:*_-]/g, "").trim();
|
|
102
|
+
currentBody = [];
|
|
103
|
+
} else if (currentHeader) {
|
|
104
|
+
currentBody.push(line);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (currentHeader) sections[currentHeader] = currentBody.join("\n");
|
|
108
|
+
return sections;
|
|
109
|
+
}
|
package/extensions/runner.ts
CHANGED
|
@@ -120,6 +120,8 @@ export interface SubAgentResult {
|
|
|
120
120
|
patch?: string;
|
|
121
121
|
/** Canonical result status (added in 0.6.0). */
|
|
122
122
|
status?: SubagentStatus;
|
|
123
|
+
/** Wall-clock duration of the run, set by runSubAgent (Date.now() - startedAt). */
|
|
124
|
+
durationMs?: number;
|
|
123
125
|
}
|
|
124
126
|
|
|
125
127
|
// ---------------------------------------------------------------------------
|
|
@@ -308,6 +310,7 @@ export async function runSubAgent(options: {
|
|
|
308
310
|
return result;
|
|
309
311
|
} finally {
|
|
310
312
|
clearTimers(); cleanupCombined?.();
|
|
313
|
+
result.durationMs = Date.now() - startedAt;
|
|
311
314
|
}
|
|
312
315
|
}
|
|
313
316
|
|
package/extensions/security.ts
CHANGED
|
@@ -37,7 +37,30 @@ export const ALLOWED_CHILD_TOOLS = BUILTIN_TOOLS;
|
|
|
37
37
|
*/
|
|
38
38
|
export const DENIED_CHILD_TOOLS = new Set(["subagent"]);
|
|
39
39
|
|
|
40
|
-
|
|
40
|
+
// ponytail: keep in sync with pi-plan/extensions/lib/plan-tools.ts READ_ONLY_TOOLS
|
|
41
|
+
// and pi-review/extensions/index.ts SAFE_REVIEW_TOOLS (additions must be mirrored).
|
|
42
|
+
// Used by sandbox: read-only agents to inherit the full read/research toolset
|
|
43
|
+
// (serena/web/munin/fff), not just the 4 built-ins — otherwise read-only research
|
|
44
|
+
// subagents (scout/planner/reviewer) can't use the tools plan mode tells them to prefer.
|
|
45
|
+
export const READ_ONLY_TOOLS: readonly string[] = [
|
|
46
|
+
// Built-in reads
|
|
47
|
+
"read", "grep", "find", "ls",
|
|
48
|
+
// FFF tools
|
|
49
|
+
"ffgrep", "fffind", "resolve_file", "fff_multi_grep", "related_files",
|
|
50
|
+
// Windows tools (read-only: detect, audit, path-convert, classify, doctor)
|
|
51
|
+
"windows_shell_detect", "windows_audit_log",
|
|
52
|
+
"windows_path_to_windows", "windows_path_to_wsl", "windows_path_to_gitbash", "windows_path_quote",
|
|
53
|
+
"windows_safety_classify", "windows_doctor", "windows_tool_discover", "windows_wsl_list_distros",
|
|
54
|
+
// Web tools
|
|
55
|
+
"web_search", "web_extract", "web_map", "web_crawl", "web_screenshot", "web_pdf", "web_status",
|
|
56
|
+
// Serena read-only
|
|
57
|
+
"serena_status", "serena_list_tools", "serena_get_current_config",
|
|
58
|
+
"serena_check_onboarding_performed", "serena_get_symbols_overview", "serena_find_symbol", "serena_find_declaration",
|
|
59
|
+
"serena_find_implementations", "serena_find_referencing_symbols",
|
|
60
|
+
"serena_search_for_pattern", "serena_get_diagnostics_for_file",
|
|
61
|
+
// Munin read-only
|
|
62
|
+
"munin_search", "munin_get", "munin_list", "munin_recent", "munin_capabilities",
|
|
63
|
+
];
|
|
41
64
|
export const MUTATION_TOOLS: readonly string[] = ["edit", "write"];
|
|
42
65
|
export const EXECUTION_TOOLS: readonly string[] = ["bash"];
|
|
43
66
|
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live progress widget for pi-subagent.
|
|
3
|
+
*
|
|
4
|
+
* A persistent above-editor widget that shows what each running subagent is
|
|
5
|
+
* doing right now — spinner, agent, elapsed time, tool-call count, and the
|
|
6
|
+
* latest tool call with done/error/in-progress status. Fed by the live
|
|
7
|
+
* threadStore subscription (per SDK session event), NOT by JSONL polling.
|
|
8
|
+
*
|
|
9
|
+
* Mirrors pi-task's widget UX but cheaper: we have in-process live events.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
13
|
+
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
14
|
+
import type { Message } from "@earendil-works/pi-ai";
|
|
15
|
+
import type { SubagentThread } from "./threads.ts";
|
|
16
|
+
import { formatToolCall } from "./render.ts";
|
|
17
|
+
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Spinner
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
23
|
+
const SPINNER_MS = 80;
|
|
24
|
+
const TREE_LAST = "└─"; // pi-task uses └─; keep consistent
|
|
25
|
+
const MAX_WIDTH = 120;
|
|
26
|
+
const MAX_THREADS = 8;
|
|
27
|
+
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
// Theme shim
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
export interface WidgetTheme {
|
|
33
|
+
fg(color: string, text: string): string;
|
|
34
|
+
bold?(text: string): string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function color(theme: WidgetTheme | null | undefined, token: string, text: string): string {
|
|
38
|
+
return theme?.fg ? theme.fg(token, text) : text;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function bold(theme: WidgetTheme | null | undefined, text: string): string {
|
|
42
|
+
return theme?.bold ? theme.bold(text) : text;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// Elapsed formatting
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
export function formatMs(ms: number): string {
|
|
50
|
+
if (ms >= 60_000) return `${Math.floor(ms / 60_000)}m ${Math.floor((ms % 60_000) / 1_000)}s`;
|
|
51
|
+
if (ms >= 1_000) return `${(ms / 1_000).toFixed(1)}s`;
|
|
52
|
+
return `${ms}ms`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
// Tool-call status derivation from the message stream
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
export type ToolStatus = "in_progress" | "done" | "error";
|
|
60
|
+
|
|
61
|
+
export interface RecentToolCall {
|
|
62
|
+
name: string;
|
|
63
|
+
detail: string;
|
|
64
|
+
status: ToolStatus;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Derive recent tool calls with status by pairing assistant toolCall parts
|
|
69
|
+
* (carrying .id) against later toolResult messages (carrying toolCallId + isError).
|
|
70
|
+
* Returns most-recent-last. Capped at `cap` entries.
|
|
71
|
+
*/
|
|
72
|
+
export function deriveRecentToolCalls(messages: Message[], cap = 5): RecentToolCall[] {
|
|
73
|
+
// Map toolCallId -> isError for completed results.
|
|
74
|
+
const resultsById = new Map<string, boolean>();
|
|
75
|
+
for (const msg of messages) {
|
|
76
|
+
if (msg.role === "toolResult") {
|
|
77
|
+
resultsById.set(msg.toolCallId, msg.isError);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
// Walk assistant messages, collect toolCall parts in order.
|
|
81
|
+
const calls: RecentToolCall[] = [];
|
|
82
|
+
for (const msg of messages) {
|
|
83
|
+
if (msg.role !== "assistant") continue;
|
|
84
|
+
for (const part of msg.content) {
|
|
85
|
+
if (part.type !== "toolCall") continue;
|
|
86
|
+
const isError = resultsById.get(part.id);
|
|
87
|
+
const status: ToolStatus = isError === undefined ? "in_progress" : isError ? "error" : "done";
|
|
88
|
+
calls.push({
|
|
89
|
+
name: part.name,
|
|
90
|
+
detail: formatToolCall(part.name, part.arguments ?? {}, (token, text) => text),
|
|
91
|
+
status,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return calls.slice(-cap);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function countToolProgress(messages: Message[]): { toolCount: number; inFlight: number } {
|
|
99
|
+
const resultIds = new Set(messages.filter((m) => m.role === "toolResult").map((m) => m.toolCallId));
|
|
100
|
+
let toolCount = 0;
|
|
101
|
+
let inFlight = 0;
|
|
102
|
+
for (const m of messages) {
|
|
103
|
+
if (m.role !== "assistant") continue;
|
|
104
|
+
for (const p of m.content) {
|
|
105
|
+
if (p.type !== "toolCall") continue;
|
|
106
|
+
if (resultIds.has(p.id)) toolCount++;
|
|
107
|
+
else inFlight++;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return { toolCount, inFlight };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
// Rendering
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
function statusMark(theme: WidgetTheme | null | undefined, status: ToolStatus, spinner: string): string {
|
|
118
|
+
switch (status) {
|
|
119
|
+
case "done": return color(theme, "success", "✓");
|
|
120
|
+
case "error": return color(theme, "error", "✗");
|
|
121
|
+
case "in_progress":
|
|
122
|
+
default: return color(theme, "accent", spinner);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* One-line live status for a running thread: spinner · Agent · elapsed ·
|
|
128
|
+
* tools · latest tool call. Used by both the widget and the tool-call row.
|
|
129
|
+
*/
|
|
130
|
+
export function renderLiveThreadLine(
|
|
131
|
+
thread: SubagentThread,
|
|
132
|
+
theme: WidgetTheme | null | undefined,
|
|
133
|
+
now: number,
|
|
134
|
+
agentColor: string,
|
|
135
|
+
maxCalls = 5,
|
|
136
|
+
): string {
|
|
137
|
+
const agentName = thread.agentName.charAt(0).toUpperCase() + thread.agentName.slice(1);
|
|
138
|
+
const elapsed = formatMs(now - thread.createdAt);
|
|
139
|
+
const messages = thread.result?.messages ?? [];
|
|
140
|
+
const { toolCount, inFlight } = countToolProgress(messages);
|
|
141
|
+
const spinner = SPINNER_FRAMES[Math.floor(now / SPINNER_MS) % SPINNER_FRAMES.length]!;
|
|
142
|
+
|
|
143
|
+
let line =
|
|
144
|
+
color(theme, "accent", spinner) + " " +
|
|
145
|
+
color(theme, agentColor, bold(theme, agentName)) +
|
|
146
|
+
color(theme, "dim", " · ") +
|
|
147
|
+
color(theme, "warning", elapsed);
|
|
148
|
+
if (toolCount > 0 || inFlight > 0) {
|
|
149
|
+
const parts: string[] = [];
|
|
150
|
+
if (toolCount > 0) parts.push(`${toolCount} tool${toolCount > 1 ? "s" : ""}`);
|
|
151
|
+
if (inFlight > 0) parts.push(`${inFlight} running`);
|
|
152
|
+
line += color(theme, "dim", " · ") + color(theme, "muted", parts.join(", "));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Recent tool calls (up to maxCalls) — list, most-recent-last.
|
|
156
|
+
const recent = deriveRecentToolCalls(messages, maxCalls);
|
|
157
|
+
const hidden = Math.max(0, toolCount + inFlight - recent.length);
|
|
158
|
+
if (hidden > 0 && recent.length >= maxCalls) {
|
|
159
|
+
line += "\n " + color(theme, "dim", `+${hidden} earlier`);
|
|
160
|
+
}
|
|
161
|
+
for (const call of recent) {
|
|
162
|
+
line += "\n " +
|
|
163
|
+
color(theme, "dim", TREE_LAST) + " " +
|
|
164
|
+
statusMark(theme, call.status, spinner) + " " +
|
|
165
|
+
call.detail;
|
|
166
|
+
}
|
|
167
|
+
return line;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function renderThread(
|
|
171
|
+
thread: SubagentThread,
|
|
172
|
+
now: number,
|
|
173
|
+
maxWidth: number,
|
|
174
|
+
theme: WidgetTheme | null | undefined,
|
|
175
|
+
): string[] {
|
|
176
|
+
const lines: string[] = [];
|
|
177
|
+
const agentColor = thread.color ?? "accent";
|
|
178
|
+
|
|
179
|
+
// Header: spinner · Agent · elapsed · tools — then task preview.
|
|
180
|
+
const base = renderLiveThreadLine(thread, theme, now, agentColor).split("\n")[0] ?? "";
|
|
181
|
+
const taskPreview = thread.task.length > 40 ? `${thread.task.slice(0, 37)}...` : thread.task;
|
|
182
|
+
const header = base + (thread.task ? color(theme, "dim", ` — ${taskPreview}`) : "");
|
|
183
|
+
lines.push(truncateToWidth(header, maxWidth));
|
|
184
|
+
|
|
185
|
+
// Latest tool call line (from the shared live-line renderer).
|
|
186
|
+
const full = renderLiveThreadLine(thread, theme, now, agentColor).split("\n");
|
|
187
|
+
if (full.length > 1) {
|
|
188
|
+
lines.push(truncateToWidth(full[1]!, maxWidth));
|
|
189
|
+
}
|
|
190
|
+
return lines;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Render the full widget for a set of threads.
|
|
195
|
+
* Pure function — takes state, returns lines. No side effects.
|
|
196
|
+
*/
|
|
197
|
+
export function renderTaskWidget(params: {
|
|
198
|
+
threads: SubagentThread[];
|
|
199
|
+
width: number;
|
|
200
|
+
theme?: WidgetTheme | null;
|
|
201
|
+
now?: number;
|
|
202
|
+
}): string[] {
|
|
203
|
+
const { threads, width, theme } = params;
|
|
204
|
+
// Only running threads appear in the live widget.
|
|
205
|
+
const running = threads.filter((t) => t.status === "running");
|
|
206
|
+
if (running.length === 0) return [];
|
|
207
|
+
|
|
208
|
+
const now = params.now ?? Date.now();
|
|
209
|
+
const maxWidth = Math.min(width, MAX_WIDTH);
|
|
210
|
+
const spinner = SPINNER_FRAMES[Math.floor(now / SPINNER_MS) % SPINNER_FRAMES.length]!;
|
|
211
|
+
|
|
212
|
+
const lines: string[] = [];
|
|
213
|
+
const shown = running.slice(0, MAX_THREADS);
|
|
214
|
+
for (const thread of shown) {
|
|
215
|
+
lines.push(...renderThread(thread, now, maxWidth, theme));
|
|
216
|
+
lines.push(""); // breathing room between threads
|
|
217
|
+
}
|
|
218
|
+
const hidden = running.length - shown.length;
|
|
219
|
+
if (hidden > 0) {
|
|
220
|
+
lines.push(truncateToWidth(color(theme, "dim", `+ ${hidden} more running`), maxWidth));
|
|
221
|
+
lines.push("");
|
|
222
|
+
}
|
|
223
|
+
return lines;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ---------------------------------------------------------------------------
|
|
227
|
+
// Controller — owns the setWidget handle + threadStore subscription
|
|
228
|
+
// ---------------------------------------------------------------------------
|
|
229
|
+
|
|
230
|
+
export interface TaskWidgetController {
|
|
231
|
+
ensureWidget(ctx: ExtensionContext): void;
|
|
232
|
+
requestRender(): void;
|
|
233
|
+
clearWidgetIfIdle(): void;
|
|
234
|
+
dispose(): void;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function createTaskWidgetController(
|
|
238
|
+
getThreads: () => SubagentThread[],
|
|
239
|
+
subscribe?: (listener: () => void) => () => void,
|
|
240
|
+
): TaskWidgetController {
|
|
241
|
+
let widgetCtx: ExtensionContext | null = null;
|
|
242
|
+
let requestWidgetRender: (() => void) | null = null;
|
|
243
|
+
let widgetTheme: WidgetTheme | null = null;
|
|
244
|
+
let unsubscribe: (() => void) | null = null;
|
|
245
|
+
|
|
246
|
+
// On every threadStore change: re-render if running threads exist,
|
|
247
|
+
// else clear the widget. This is the live-data path — no polling.
|
|
248
|
+
const onStoreChange = (): void => {
|
|
249
|
+
const running = getThreads().some((t) => t.status === "running");
|
|
250
|
+
if (running) {
|
|
251
|
+
if (widgetCtx) requestRender();
|
|
252
|
+
} else {
|
|
253
|
+
clearWidgetIfIdle();
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
function renderWidget(width: number): string[] {
|
|
258
|
+
return renderTaskWidget({ threads: getThreads(), width, theme: widgetTheme });
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function requestRender(): void {
|
|
262
|
+
requestWidgetRender?.();
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Lazily install the widget + subscription on the first running thread.
|
|
267
|
+
* Idempotent — safe to call on every thread creation.
|
|
268
|
+
*/
|
|
269
|
+
function ensureWidget(ctx: ExtensionContext): void {
|
|
270
|
+
if (ctx.mode !== "tui") return;
|
|
271
|
+
// Subscribe once so future threadStore changes drive renders + idle-clear.
|
|
272
|
+
if (!unsubscribe && subscribe) {
|
|
273
|
+
unsubscribe = subscribe(onStoreChange);
|
|
274
|
+
}
|
|
275
|
+
if (widgetCtx) {
|
|
276
|
+
requestRender();
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
widgetCtx = ctx;
|
|
280
|
+
ignoreStaleExtensionCtx(() =>
|
|
281
|
+
ctx.ui.setWidget("pi-subagent", (tui, theme) => {
|
|
282
|
+
widgetTheme = theme ?? null;
|
|
283
|
+
requestWidgetRender = () => tui.requestRender();
|
|
284
|
+
return {
|
|
285
|
+
render: (width: number) => renderWidget(width),
|
|
286
|
+
invalidate: requestWidgetRender,
|
|
287
|
+
dispose: () => {
|
|
288
|
+
widgetTheme = null;
|
|
289
|
+
requestWidgetRender = null;
|
|
290
|
+
},
|
|
291
|
+
};
|
|
292
|
+
}),
|
|
293
|
+
);
|
|
294
|
+
requestRender();
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Clear the widget when no threads are running (called after task completion). */
|
|
298
|
+
function clearWidgetIfIdle(): void {
|
|
299
|
+
const running = getThreads().filter((t) => t.status === "running").length;
|
|
300
|
+
if (running > 0) {
|
|
301
|
+
requestRender();
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
if (widgetCtx) {
|
|
305
|
+
const ctx = widgetCtx;
|
|
306
|
+
ignoreStaleExtensionCtx(() => ctx.ui.setWidget("pi-subagent", undefined));
|
|
307
|
+
widgetCtx = null;
|
|
308
|
+
}
|
|
309
|
+
requestWidgetRender = null;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function dispose(): void {
|
|
313
|
+
unsubscribe?.();
|
|
314
|
+
unsubscribe = null;
|
|
315
|
+
if (widgetCtx) {
|
|
316
|
+
const ctx = widgetCtx;
|
|
317
|
+
ignoreStaleExtensionCtx(() => ctx.ui.setWidget("pi-subagent", undefined));
|
|
318
|
+
widgetCtx = null;
|
|
319
|
+
}
|
|
320
|
+
widgetTheme = null;
|
|
321
|
+
requestWidgetRender = null;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
return { ensureWidget, requestRender, clearWidgetIfIdle, dispose };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Wrap a ctx operation so a stale (post-replacement) ExtensionContext
|
|
329
|
+
* doesn't crash. Mirrors pi-task's ignoreStaleExtensionCtx.
|
|
330
|
+
* ponytail: minimal try/catch — the only failure mode is a replaced session.
|
|
331
|
+
*/
|
|
332
|
+
function ignoreStaleExtensionCtx<T>(fn: () => T): T | undefined {
|
|
333
|
+
try {
|
|
334
|
+
return fn();
|
|
335
|
+
} catch {
|
|
336
|
+
return undefined;
|
|
337
|
+
}
|
|
338
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bacnh85/pi-subagent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"description": "In-process subagents for Pi with isolated SDK sessions, parallel and chained delegation, and inspectable threads.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -40,6 +40,10 @@
|
|
|
40
40
|
"extensions/threads.ts",
|
|
41
41
|
"extensions/thread-viewer.ts",
|
|
42
42
|
"extensions/security.ts",
|
|
43
|
+
"extensions/background.ts",
|
|
44
|
+
"extensions/history.ts",
|
|
45
|
+
"extensions/result.ts",
|
|
46
|
+
"extensions/widget.ts",
|
|
43
47
|
"extensions/package.json"
|
|
44
48
|
],
|
|
45
49
|
"pi": {
|
|
@@ -56,17 +60,17 @@
|
|
|
56
60
|
"check": "npm run typecheck && npm test"
|
|
57
61
|
},
|
|
58
62
|
"peerDependencies": {
|
|
59
|
-
"@earendil-works/pi-coding-agent": ">=0.80.0 <0.
|
|
60
|
-
"@earendil-works/pi-ai": ">=0.80.0 <0.
|
|
61
|
-
"@earendil-works/pi-agent-core": ">=0.80.0 <0.
|
|
62
|
-
"@earendil-works/pi-tui": ">=0.80.0 <0.
|
|
63
|
+
"@earendil-works/pi-coding-agent": ">=0.80.0 <0.85.0",
|
|
64
|
+
"@earendil-works/pi-ai": ">=0.80.0 <0.85.0",
|
|
65
|
+
"@earendil-works/pi-agent-core": ">=0.80.0 <0.85.0",
|
|
66
|
+
"@earendil-works/pi-tui": ">=0.80.0 <0.85.0",
|
|
63
67
|
"typebox": ">=1.3.0 <2.0.0"
|
|
64
68
|
},
|
|
65
69
|
"devDependencies": {
|
|
66
|
-
"@earendil-works/pi-agent-core": "^0.
|
|
67
|
-
"@earendil-works/pi-ai": "^0.
|
|
68
|
-
"@earendil-works/pi-coding-agent": "^0.
|
|
69
|
-
"@earendil-works/pi-tui": "^0.
|
|
70
|
+
"@earendil-works/pi-agent-core": "^0.84.0",
|
|
71
|
+
"@earendil-works/pi-ai": "^0.84.0",
|
|
72
|
+
"@earendil-works/pi-coding-agent": "^0.84.0",
|
|
73
|
+
"@earendil-works/pi-tui": "^0.84.0",
|
|
70
74
|
"@types/mocha": "^10.0.10",
|
|
71
75
|
"@types/node": "^20.19.43",
|
|
72
76
|
"mocha": "^10.8.2",
|