747 lines
23 KiB
TypeScript
747 lines
23 KiB
TypeScript
/**
|
|
* Subagents — spawn background subagents on one of three backends
|
|
* (pi, Claude Code, Codex) unified behind a single Effect service interface.
|
|
*
|
|
* Tools (for the parent LLM):
|
|
* - subagent_spawn: fire-and-forget spawn (prompt, title, agent, working_dir,
|
|
* model, reasoning_effort). Max 4 running at once across all backends.
|
|
* - subagent_wait: block until the listed subagents settle, return results.
|
|
* - subagent_cancel: stop one or more running subagents.
|
|
* - subagent_check: peek at a subagent's status and recent activity.
|
|
* - subagent_list: list all subagents.
|
|
*
|
|
* Unawaited subagents queue their result as a follow-up message when they
|
|
* settle. `/subagents` opens a picker + full interactive takeover view.
|
|
*
|
|
* Architecture: Effect v4 generators throughout (backends -> manager ->
|
|
* runtime); this file is the async boundary where tool handlers run effects
|
|
* against one shared ManagedRuntime. All three backends are real: pi runs
|
|
* in-process SDK sessions, claude drives the Claude Agent SDK, codex speaks
|
|
* JSON-RPC to a scoped `codex app-server` process.
|
|
*/
|
|
|
|
import * as fs from "node:fs";
|
|
import * as path from "node:path";
|
|
import { StringEnum } from "@earendil-works/pi-ai";
|
|
import type {
|
|
ExtensionAPI,
|
|
ExtensionCommandContext,
|
|
ExtensionContext,
|
|
ExtensionUIContext,
|
|
} from "@earendil-works/pi-coding-agent";
|
|
import {
|
|
DEFAULT_MAX_BYTES,
|
|
DEFAULT_MAX_LINES,
|
|
formatSize,
|
|
getAgentDir,
|
|
getMarkdownTheme,
|
|
ProjectTrustStore,
|
|
truncateHead,
|
|
} from "@earendil-works/pi-coding-agent";
|
|
import { Markdown, Text } from "@earendil-works/pi-tui";
|
|
import { Type } from "typebox";
|
|
import { deriveBtwTitle, isModelVisible } from "./src/by-the-way.ts";
|
|
import {
|
|
BACKEND_NAMES,
|
|
formatElapsed,
|
|
latestText,
|
|
REASONING_EFFORTS,
|
|
type SubagentSnapshot,
|
|
} from "./src/domain.ts";
|
|
import {
|
|
formatActivityStatus,
|
|
formatContextUtilization,
|
|
} from "./src/format.ts";
|
|
import { SubagentManager, type SubagentManagerShape } from "./src/manager.ts";
|
|
import {
|
|
buildSubagentResultMessage,
|
|
buildSubagentSpawnResult,
|
|
SUBAGENT_CANCEL_PARAMETER_DESCRIPTIONS,
|
|
SUBAGENT_CANCEL_TOOL_DESCRIPTION,
|
|
SUBAGENT_CHECK_PARAMETER_DESCRIPTIONS,
|
|
SUBAGENT_CHECK_TOOL_DESCRIPTION,
|
|
SUBAGENT_LIST_TOOL_DESCRIPTION,
|
|
SUBAGENT_SPAWN_PARAMETER_DESCRIPTIONS,
|
|
SUBAGENT_SPAWN_PROMPT_GUIDELINES,
|
|
SUBAGENT_SPAWN_PROMPT_SNIPPET,
|
|
SUBAGENT_SPAWN_TOOL_DESCRIPTION,
|
|
SUBAGENT_WAIT_PARAMETER_DESCRIPTIONS,
|
|
SUBAGENT_WAIT_TOOL_DESCRIPTION,
|
|
} from "./src/prompt.ts";
|
|
import { createDeferredResultDelivery } from "./src/result-delivery.ts";
|
|
import {
|
|
createSubagentRuntime,
|
|
runTool,
|
|
type SubagentRuntime,
|
|
} from "./src/runtime.ts";
|
|
import { openSubagentPicker, openSubagentTakeover } from "./src/ui/takeover.ts";
|
|
|
|
const SUBAGENT_OUTPUT_MAX_BYTES = 24 * 1024;
|
|
const WAIT_OUTPUT_MAX_BYTES = 48 * 1024;
|
|
const WAIT_PER_AGENT_MAX_BYTES = 16 * 1024;
|
|
|
|
interface BtwResultData {
|
|
readonly id: string;
|
|
readonly title: string;
|
|
readonly status: SubagentSnapshot["status"];
|
|
readonly errorText?: string;
|
|
readonly prompt: string;
|
|
readonly answer: string;
|
|
readonly sessionFilePath?: string;
|
|
}
|
|
|
|
function describeSubagent(snap: SubagentSnapshot) {
|
|
const details = [
|
|
`${snap.backend}: ${snap.meta.modelLabel ?? "?"}`,
|
|
formatContextUtilization(snap.usage),
|
|
formatElapsed(snap),
|
|
snap.cwd,
|
|
].filter(Boolean);
|
|
return `${snap.id} [${snap.status}] "${snap.title}" (${details.join(", ")})`;
|
|
}
|
|
|
|
function truncatedOutput(
|
|
snap: SubagentSnapshot,
|
|
maxBytes = SUBAGENT_OUTPUT_MAX_BYTES,
|
|
): string {
|
|
const output = snap.finalText || "(no output)";
|
|
const truncation = truncateHead(output, {
|
|
maxBytes: Math.min(maxBytes, DEFAULT_MAX_BYTES),
|
|
maxLines: Math.min(600, DEFAULT_MAX_LINES),
|
|
});
|
|
let text = truncation.content;
|
|
if (truncation.truncated) {
|
|
text += `\n\n[Output truncated: ${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)} shown. Full transcript in session file: ${snap.meta.sessionFilePath ?? "?"}]`;
|
|
}
|
|
return text;
|
|
}
|
|
|
|
/**
|
|
* Same-directory children inherit the live parent decision. An alternate cwd
|
|
* is trusted only when pi's persisted trust store explicitly trusts it (or a
|
|
* containing directory); unreadable/invalid trust data fails closed.
|
|
*/
|
|
function resolveChildProjectTrust(options: {
|
|
parentCwd: string;
|
|
childCwd: string;
|
|
parentTrusted: boolean;
|
|
}) {
|
|
if (path.resolve(options.childCwd) === path.resolve(options.parentCwd)) {
|
|
return options.parentTrusted;
|
|
}
|
|
try {
|
|
const trustStore = new ProjectTrustStore(getAgentDir());
|
|
return trustStore.get(options.childCwd) === true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export default function (pi: ExtensionAPI) {
|
|
let runtime: SubagentRuntime | undefined;
|
|
let managerPromise: Promise<SubagentManagerShape> | undefined;
|
|
let sessionContext: ExtensionContext | undefined;
|
|
let ui: ExtensionUIContext | undefined;
|
|
let unsubStatus: (() => void) | undefined;
|
|
const resultDelivery = createDeferredResultDelivery<SubagentSnapshot>();
|
|
|
|
const getRuntime = () => (runtime ??= createSubagentRuntime());
|
|
|
|
/** Resolve the manager service once per runtime and wire the extension hooks. */
|
|
const getManager = () => {
|
|
managerPromise ??= getRuntime()
|
|
.runPromise(SubagentManager)
|
|
.then((manager) => {
|
|
manager.view.setOnSettled(onSettled);
|
|
unsubStatus?.();
|
|
unsubStatus = manager.view.subscribe(() => updateStatus(manager));
|
|
updateStatus(manager);
|
|
return manager;
|
|
});
|
|
return managerPromise;
|
|
};
|
|
|
|
const updateStatus = (manager: SubagentManagerShape) => {
|
|
if (!ui) return;
|
|
const subs = manager.view.list();
|
|
if (subs.length === 0) {
|
|
ui.setStatus("subagents", undefined);
|
|
return;
|
|
}
|
|
const running = subs.filter((snap) => snap.status === "running").length;
|
|
const failed = subs.filter((snap) => snap.status === "error").length;
|
|
const done = subs.length - running - failed;
|
|
ui.setStatus(
|
|
"subagents",
|
|
formatActivityStatus(ui.theme, { running, done, failed }),
|
|
);
|
|
};
|
|
|
|
const deliverResult = (snap: SubagentSnapshot) => {
|
|
pi.sendMessage(
|
|
{
|
|
customType: "subagent-result",
|
|
content: buildSubagentResultMessage({
|
|
id: snap.id,
|
|
title: snap.title,
|
|
status: snap.status,
|
|
errorText: snap.errorText,
|
|
output: truncatedOutput(snap),
|
|
}),
|
|
display: true,
|
|
details: { id: snap.id, title: snap.title, status: snap.status },
|
|
},
|
|
{ deliverAs: "followUp", triggerTurn: true },
|
|
);
|
|
};
|
|
|
|
const flushResults = () => {
|
|
for (const snap of resultDelivery.drain()) deliverResult(snap);
|
|
};
|
|
|
|
const deliverBtwResult = (snap: SubagentSnapshot) => {
|
|
// appendEntry is a synchronous SessionManager operation and emits an
|
|
// entry_appended event, so it is safe while the parent is streaming and
|
|
// never enters the model's context or follow-up queue.
|
|
pi.appendEntry<BtwResultData>("btw-result", {
|
|
id: snap.id,
|
|
title: snap.title,
|
|
status: snap.status,
|
|
errorText: snap.errorText,
|
|
prompt: snap.prompt,
|
|
answer: truncatedOutput(snap),
|
|
sessionFilePath: snap.meta.sessionFilePath,
|
|
});
|
|
ui?.notify(
|
|
snap.status === "error"
|
|
? `by the way “${snap.title}” failed — reopen it with /subagents`
|
|
: `by the way “${snap.title}” answered — reopen it with /subagents`,
|
|
snap.status === "error" ? "error" : "info",
|
|
);
|
|
};
|
|
|
|
const onSettled = (snap: SubagentSnapshot, consumed: boolean) => {
|
|
// A shutdown can settle children while disposing their scopes. Never
|
|
// append into a session whose extension runtime is already closing.
|
|
if (!sessionContext) return;
|
|
if (snap.origin === "btw") {
|
|
deliverBtwResult({ ...snap, meta: { ...snap.meta } });
|
|
return;
|
|
}
|
|
if (consumed) {
|
|
resultDelivery.consume([snap.id]);
|
|
return;
|
|
}
|
|
// Keep the result retractable while the parent is working. A later
|
|
// subagent_wait can consume it before agent_settled flushes follow-ups.
|
|
// Defer a copy: the live snapshot keeps mutating if the subagent is
|
|
// restarted before the deferred result flushes.
|
|
resultDelivery.defer({ ...snap, meta: { ...snap.meta } });
|
|
if (sessionContext?.isIdle()) flushResults();
|
|
};
|
|
|
|
pi.on("session_start", (_event, ctx) => {
|
|
sessionContext = ctx;
|
|
if (ctx.hasUI) ui = ctx.ui;
|
|
});
|
|
|
|
pi.on("agent_settled", flushResults);
|
|
|
|
pi.on("session_shutdown", async () => {
|
|
sessionContext = undefined;
|
|
resultDelivery.clear();
|
|
unsubStatus?.();
|
|
unsubStatus = undefined;
|
|
ui?.setStatus("subagents", undefined);
|
|
ui = undefined;
|
|
const closing = runtime;
|
|
runtime = undefined;
|
|
managerPromise = undefined;
|
|
// Disposing the runtime runs the manager finalizer, which tears down all
|
|
// subagent scopes (and, later, their real child processes).
|
|
await closing?.dispose();
|
|
});
|
|
|
|
// --- Tools -------------------------------------------------------------
|
|
|
|
pi.registerTool({
|
|
name: "subagent_spawn",
|
|
label: "Spawn Subagent",
|
|
description: SUBAGENT_SPAWN_TOOL_DESCRIPTION,
|
|
promptSnippet: SUBAGENT_SPAWN_PROMPT_SNIPPET,
|
|
promptGuidelines: SUBAGENT_SPAWN_PROMPT_GUIDELINES,
|
|
parameters: Type.Object({
|
|
prompt: Type.String({
|
|
description: SUBAGENT_SPAWN_PARAMETER_DESCRIPTIONS.prompt,
|
|
}),
|
|
name: Type.String({
|
|
description: SUBAGENT_SPAWN_PARAMETER_DESCRIPTIONS.name,
|
|
}),
|
|
harness: StringEnum(BACKEND_NAMES, {
|
|
description: SUBAGENT_SPAWN_PARAMETER_DESCRIPTIONS.harness,
|
|
}),
|
|
working_dir: Type.Optional(
|
|
Type.String({
|
|
description: SUBAGENT_SPAWN_PARAMETER_DESCRIPTIONS.workingDir,
|
|
}),
|
|
),
|
|
model: Type.Optional(
|
|
Type.String({
|
|
description: SUBAGENT_SPAWN_PARAMETER_DESCRIPTIONS.model,
|
|
}),
|
|
),
|
|
reasoning_effort: Type.Optional(
|
|
StringEnum(REASONING_EFFORTS, {
|
|
description: SUBAGENT_SPAWN_PARAMETER_DESCRIPTIONS.reasoningEffort,
|
|
}),
|
|
),
|
|
}),
|
|
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
const manager = await getManager();
|
|
const harness = params.harness;
|
|
|
|
const cwd = path.resolve(ctx.cwd, params.working_dir ?? ".");
|
|
if (!fs.existsSync(cwd) || !fs.statSync(cwd).isDirectory()) {
|
|
throw new Error(`working_dir is not a directory: ${cwd}`);
|
|
}
|
|
|
|
const title = params.name.trim().slice(0, 160) || "subagent";
|
|
const snap = await runTool(
|
|
getRuntime(),
|
|
manager.spawn(harness, {
|
|
prompt: params.prompt,
|
|
title,
|
|
cwd,
|
|
model: params.model,
|
|
reasoningEffort: params.reasoning_effort,
|
|
parent: {
|
|
parentCwd: ctx.cwd,
|
|
projectTrusted: resolveChildProjectTrust({
|
|
parentCwd: ctx.cwd,
|
|
childCwd: cwd,
|
|
parentTrusted: ctx.isProjectTrusted(),
|
|
}),
|
|
inheritedModel: ctx.model
|
|
? { provider: ctx.model.provider, id: ctx.model.id }
|
|
: undefined,
|
|
inheritedThinkingLevel: pi.getThinkingLevel(),
|
|
modelRegistry: ctx.modelRegistry,
|
|
},
|
|
}),
|
|
{ signal, interruptMessage: "Subagent spawn aborted." },
|
|
);
|
|
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: buildSubagentSpawnResult({
|
|
id: snap.id,
|
|
title: snap.title,
|
|
harness,
|
|
modelLabel: snap.meta.modelLabel ?? "?",
|
|
cwd,
|
|
}),
|
|
},
|
|
],
|
|
details: {
|
|
id: snap.id,
|
|
title: snap.title,
|
|
cwd,
|
|
harness,
|
|
model: snap.meta.modelLabel,
|
|
},
|
|
};
|
|
},
|
|
});
|
|
|
|
pi.registerTool({
|
|
name: "subagent_wait",
|
|
label: "Wait for Subagents",
|
|
description: SUBAGENT_WAIT_TOOL_DESCRIPTION,
|
|
parameters: Type.Object({
|
|
ids: Type.Array(Type.String(), {
|
|
maxItems: 64,
|
|
description: SUBAGENT_WAIT_PARAMETER_DESCRIPTIONS.ids,
|
|
}),
|
|
}),
|
|
async execute(_toolCallId, params, signal, onUpdate) {
|
|
const manager = await getManager();
|
|
const ids = [...new Set(params.ids)];
|
|
if (ids.length === 0)
|
|
throw new Error("Provide at least one subagent id.");
|
|
const known = manager.view
|
|
.list()
|
|
.filter(isModelVisible)
|
|
.map((snap) => snap.id);
|
|
const unknown = ids.filter((id) => {
|
|
const snap = manager.view.get(id);
|
|
return !snap || !isModelVisible(snap);
|
|
});
|
|
if (unknown.length > 0) {
|
|
throw new Error(
|
|
`Unknown subagent id(s): ${unknown.join(", ")}. Known: ${known.join(", ") || "none"}.`,
|
|
);
|
|
}
|
|
|
|
await runTool(
|
|
getRuntime(),
|
|
manager.waitFor(ids, (pending) => {
|
|
onUpdate?.({
|
|
content: [
|
|
{ type: "text", text: `Waiting for ${pending.join(", ")}...` },
|
|
],
|
|
details: { pending },
|
|
});
|
|
}),
|
|
{ signal, interruptMessage: "Wait aborted. Subagents keep running." },
|
|
);
|
|
|
|
// Settlement may have happened before this wait began. Remove any
|
|
// deferred automatic delivery now that the tool is returning the result.
|
|
resultDelivery.consume(ids);
|
|
|
|
const sections: string[] = [];
|
|
let remainingBytes = WAIT_OUTPUT_MAX_BYTES;
|
|
for (const id of ids) {
|
|
const snap = manager.view.get(id);
|
|
if (!snap) {
|
|
sections.push(`## ${id}\n\n(no longer tracked)`);
|
|
continue;
|
|
}
|
|
const verb = snap.status === "error" ? "failed" : "finished";
|
|
let section = `## ${snap.id} "${snap.title}" ${verb}`;
|
|
if (snap.errorText) section += `\nError: ${snap.errorText}`;
|
|
const headerBytes = Buffer.byteLength(section, "utf8") + 2;
|
|
const outputBudget = Math.max(
|
|
512,
|
|
Math.min(WAIT_PER_AGENT_MAX_BYTES, remainingBytes - headerBytes),
|
|
);
|
|
section += `\n\n${truncatedOutput(snap, outputBudget)}`;
|
|
const sectionBytes = Buffer.byteLength(section, "utf8");
|
|
if (sectionBytes > remainingBytes) {
|
|
sections.push(
|
|
`## ${snap.id} "${snap.title}"\n\n[omitted: total wait output limit reached]`,
|
|
);
|
|
break;
|
|
}
|
|
sections.push(section);
|
|
remainingBytes -= sectionBytes;
|
|
}
|
|
|
|
const combined = sections.join("\n\n---\n\n");
|
|
const bounded = truncateHead(combined, {
|
|
maxBytes: WAIT_OUTPUT_MAX_BYTES - 128,
|
|
maxLines: DEFAULT_MAX_LINES,
|
|
});
|
|
const text = bounded.truncated
|
|
? `${bounded.content}\n\n[wait output truncated at the total output limit]`
|
|
: bounded.content;
|
|
return {
|
|
content: [{ type: "text", text }],
|
|
details: {
|
|
results: ids.map((id) => {
|
|
const snap = manager.view.get(id);
|
|
return { id, title: snap?.title, status: snap?.status };
|
|
}),
|
|
},
|
|
};
|
|
},
|
|
});
|
|
|
|
pi.registerTool({
|
|
name: "subagent_cancel",
|
|
label: "Cancel Subagents",
|
|
description: SUBAGENT_CANCEL_TOOL_DESCRIPTION,
|
|
parameters: Type.Object({
|
|
ids: Type.Array(Type.String(), {
|
|
description: SUBAGENT_CANCEL_PARAMETER_DESCRIPTIONS.ids,
|
|
}),
|
|
}),
|
|
async execute(_toolCallId, params, signal) {
|
|
const manager = await getManager();
|
|
const ids = [...new Set(params.ids)];
|
|
if (ids.length === 0)
|
|
throw new Error("Provide at least one subagent id.");
|
|
|
|
const known = manager.view
|
|
.list()
|
|
.filter(isModelVisible)
|
|
.map((snap) => snap.id);
|
|
const unknown = ids.filter((id) => {
|
|
const snap = manager.view.get(id);
|
|
return !snap || !isModelVisible(snap);
|
|
});
|
|
if (unknown.length > 0) {
|
|
throw new Error(
|
|
`Unknown subagent id(s): ${unknown.join(", ")}. Known: ${known.join(", ") || "none"}.`,
|
|
);
|
|
}
|
|
|
|
const report = await runTool(getRuntime(), manager.cancel(ids), {
|
|
signal,
|
|
interruptMessage: "Subagent cancellation aborted.",
|
|
});
|
|
|
|
const lines = report.map((entry) =>
|
|
entry.cancelled
|
|
? `Cancelled ${entry.id} "${entry.title}".`
|
|
: `${entry.id} "${entry.title}" was already ${entry.status}.`,
|
|
);
|
|
|
|
return {
|
|
content: [{ type: "text", text: lines.join("\n") }],
|
|
details: {
|
|
results: report.map((entry) => ({
|
|
id: entry.id,
|
|
title: entry.title,
|
|
status: entry.status,
|
|
})),
|
|
},
|
|
};
|
|
},
|
|
});
|
|
|
|
pi.registerTool({
|
|
name: "subagent_check",
|
|
label: "Check Subagent",
|
|
description: SUBAGENT_CHECK_TOOL_DESCRIPTION,
|
|
parameters: Type.Object({
|
|
id: Type.String({
|
|
description: SUBAGENT_CHECK_PARAMETER_DESCRIPTIONS.id,
|
|
}),
|
|
}),
|
|
async execute(_toolCallId, params) {
|
|
const manager = await getManager();
|
|
const snap = manager.view.get(params.id);
|
|
if (!snap || !isModelVisible(snap)) {
|
|
const known = manager.view
|
|
.list()
|
|
.filter(isModelVisible)
|
|
.map((s) => s.id);
|
|
throw new Error(
|
|
`Unknown subagent id "${params.id}". Known: ${known.join(", ") || "none"}.`,
|
|
);
|
|
}
|
|
|
|
let text = `${describeSubagent(snap)}\nTurns: ${snap.turns}`;
|
|
if (snap.errorText) text += `\nError: ${snap.errorText}`;
|
|
|
|
const output = latestText(snap);
|
|
if (output) {
|
|
const preview = truncateHead(output, { maxBytes: 2048, maxLines: 20 });
|
|
text += `\n\nLatest output:\n${preview.content}`;
|
|
if (preview.truncated) text += "\n[...]";
|
|
} else if (snap.status === "running") {
|
|
text += "\n\n(no text output yet)";
|
|
}
|
|
|
|
return {
|
|
content: [{ type: "text", text }],
|
|
details: { id: snap.id, status: snap.status, turns: snap.turns },
|
|
};
|
|
},
|
|
});
|
|
|
|
pi.registerTool({
|
|
name: "subagent_list",
|
|
label: "List Subagents",
|
|
description: SUBAGENT_LIST_TOOL_DESCRIPTION,
|
|
parameters: Type.Object({}),
|
|
async execute() {
|
|
const manager = await getManager();
|
|
const subs = manager.view.list().filter(isModelVisible);
|
|
const text =
|
|
subs.length === 0
|
|
? "No subagents."
|
|
: subs.map((snap) => describeSubagent(snap)).join("\n");
|
|
return {
|
|
content: [{ type: "text", text }],
|
|
details: {
|
|
subagents: subs.map((snap) => ({
|
|
id: snap.id,
|
|
title: snap.title,
|
|
harness: snap.backend,
|
|
status: snap.status,
|
|
})),
|
|
},
|
|
};
|
|
},
|
|
});
|
|
|
|
// --- Result message rendering ------------------------------------------
|
|
|
|
pi.registerMessageRenderer(
|
|
"subagent-result",
|
|
(message, { expanded }, theme) => {
|
|
const details = (message.details ?? {}) as {
|
|
id?: string;
|
|
title?: string;
|
|
status?: string;
|
|
};
|
|
const failed = details.status === "error";
|
|
const icon = failed ? theme.fg("error", "x") : theme.fg("success", "■");
|
|
const header =
|
|
`${icon} ` +
|
|
theme.fg("accent", theme.bold(`subagent ${details.id ?? "?"}`)) +
|
|
theme.fg(
|
|
"muted",
|
|
` · ${details.title ?? ""} · ${failed ? "failed" : "finished"}`,
|
|
);
|
|
|
|
const content =
|
|
typeof message.content === "string" ? message.content : "";
|
|
// Remove only the summary line. The following Error line (when present)
|
|
// is part of the actual result and must remain visible.
|
|
const body = content.split("\n").slice(1).join("\n").trim();
|
|
|
|
if (expanded) {
|
|
const md = new Markdown(`${body}`, 0, 0, getMarkdownTheme());
|
|
const container = new Text(header, 0, 0);
|
|
return {
|
|
render: (width: number) => [
|
|
...container.render(width),
|
|
...md.render(width),
|
|
],
|
|
invalidate: () => {
|
|
container.invalidate();
|
|
md.invalidate();
|
|
},
|
|
};
|
|
}
|
|
|
|
const previewLines = body.split("\n").slice(0, 8);
|
|
let text = header;
|
|
for (const line of previewLines)
|
|
text += `\n${theme.fg("toolOutput", line)}`;
|
|
if (body.split("\n").length > 8)
|
|
text += `\n${theme.fg("dim", "... (ctrl+o to expand)")}`;
|
|
return new Text(text, 0, 0);
|
|
},
|
|
);
|
|
|
|
pi.registerEntryRenderer<BtwResultData>(
|
|
"btw-result",
|
|
(entry, { expanded }, theme) => {
|
|
const data = entry.data;
|
|
const failed = data?.status === "error";
|
|
const icon = failed ? theme.fg("error", "x") : theme.fg("success", "■");
|
|
const header =
|
|
`${icon} ` +
|
|
theme.fg("accent", theme.bold(`by the way · ${data?.title ?? "?"}`)) +
|
|
theme.fg(
|
|
"muted",
|
|
` · ${failed ? "failed" : "answered"} · ${data?.id ?? "?"}`,
|
|
);
|
|
const body = [
|
|
data?.errorText ? `Error: ${data.errorText}` : "",
|
|
data?.answer ?? "(no answer)",
|
|
]
|
|
.filter(Boolean)
|
|
.join("\n\n");
|
|
|
|
if (expanded) {
|
|
const md = new Markdown(body, 0, 0, getMarkdownTheme());
|
|
const container = new Text(header, 0, 0);
|
|
return {
|
|
render: (width: number) => [
|
|
...container.render(width),
|
|
...md.render(width),
|
|
],
|
|
invalidate: () => {
|
|
container.invalidate();
|
|
md.invalidate();
|
|
},
|
|
};
|
|
}
|
|
|
|
const lines = body.split("\n");
|
|
let text = header;
|
|
for (const line of lines.slice(0, 8))
|
|
text += `\n${theme.fg("toolOutput", line)}`;
|
|
if (lines.length > 8)
|
|
text += `\n${theme.fg("dim", "... (ctrl+o to expand)")}`;
|
|
return new Text(text, 0, 0);
|
|
},
|
|
);
|
|
|
|
// --- Commands -----------------------------------------------------------
|
|
|
|
const runByTheWay = async (rawArgs: string, ctx: ExtensionCommandContext) => {
|
|
if (ctx.mode !== "tui") {
|
|
if (ctx.hasUI)
|
|
ctx.ui.notify("by the way is only available in the TUI", "error");
|
|
return;
|
|
}
|
|
|
|
let prompt = rawArgs.trim();
|
|
if (!prompt) {
|
|
const input = await ctx.ui.input("by the way", "Ask a one-off question…");
|
|
prompt = input?.trim() ?? "";
|
|
if (!prompt) return;
|
|
}
|
|
|
|
const manager = await getManager();
|
|
let snap: SubagentSnapshot;
|
|
try {
|
|
snap = await runTool(
|
|
getRuntime(),
|
|
manager.spawn("pi", {
|
|
origin: "btw",
|
|
prompt,
|
|
title: deriveBtwTitle(prompt),
|
|
cwd: ctx.cwd,
|
|
parent: {
|
|
parentCwd: ctx.cwd,
|
|
projectTrusted: ctx.isProjectTrusted(),
|
|
inheritedModel: ctx.model
|
|
? { provider: ctx.model.provider, id: ctx.model.id }
|
|
: undefined,
|
|
inheritedThinkingLevel: pi.getThinkingLevel(),
|
|
modelRegistry: ctx.modelRegistry,
|
|
},
|
|
}),
|
|
);
|
|
} catch (error) {
|
|
ctx.ui.notify(
|
|
error instanceof Error ? error.message : String(error),
|
|
"error",
|
|
);
|
|
return;
|
|
}
|
|
|
|
await openSubagentTakeover(ctx, manager.view, snap.id, {
|
|
badge: "by the way",
|
|
});
|
|
};
|
|
|
|
pi.registerCommand("btw", {
|
|
description:
|
|
"Ask a one-off side question while the main agent keeps working",
|
|
handler: runByTheWay,
|
|
});
|
|
|
|
pi.registerCommand("subagents", {
|
|
description: "List, inspect, and take over subagents",
|
|
handler: async (_args, ctx) => {
|
|
if (ctx.mode !== "tui") {
|
|
if (ctx.hasUI)
|
|
ctx.ui.notify(
|
|
"Subagent takeover is only available in the TUI",
|
|
"error",
|
|
);
|
|
return;
|
|
}
|
|
const manager = await getManager();
|
|
if (manager.view.size() === 0) {
|
|
ctx.ui.notify(
|
|
"No subagents yet. The agent spawns them with subagent_spawn.",
|
|
"info",
|
|
);
|
|
return;
|
|
}
|
|
await openSubagentPicker(ctx, manager.view);
|
|
},
|
|
});
|
|
}
|