Add sssf skill, installable via the skills CLI
Port the sssf skill from ~/.agents/skills/sssf into this repo so it can be distributed and installed with the skills CLI (skills add INDigitalStudio/skills --skill sssf). - Copy the skill (SKILL.md, cookbooks, references, scripts, templates, and the visualizer app source) into sssf/. - Gitignore build/runtime artifacts: the visualizer's node_modules/ and dist/, Python bytecode, and the machine-specific repos.json. - Make the skill location-independent: install.py now stamps the skill's real path into the stamped justfile's skill_dir (replacing the hardcoded ~/.agents/skills/sssf), so 'just obs' finds the visualizer wherever the CLI installed the skill. - Update cookbooks to use <skill>/scripts/... instead of the hardcoded path, and document the skills CLI install command. - Update the repo README with install instructions.
This commit is contained in:
parent
a42608f602
commit
2cc766aabe
98 changed files with 12508 additions and 0 deletions
593
sssf/templates/harness_engineering/subagents.ts
Normal file
593
sssf/templates/harness_engineering/subagents.ts
Normal file
|
|
@ -0,0 +1,593 @@
|
|||
/**
|
||||
* Subagent Widget — /sub, /subclear, /subrm, /subcont commands with stacking live widgets
|
||||
*
|
||||
* Each /sub spawns a background Pi subagent with its own persistent session,
|
||||
* enabling conversation continuations via /subcont.
|
||||
*
|
||||
* Usage: pi -e extensions/subagent-widget.ts
|
||||
* Then:
|
||||
* /sub list files and summarize — spawn using the parent model/thinking
|
||||
* /sub --model openai/gpt-5 --thinking high review this code
|
||||
* /subcont 1 --thinking xhigh now write tests for it
|
||||
* /subrm 2 — remove subagent #2 widget
|
||||
* /subclear — clear all subagent widgets
|
||||
*/
|
||||
|
||||
import { StringEnum, type ThinkingLevel } from "@mariozechner/pi-ai";
|
||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
import { DynamicBorder } from "@mariozechner/pi-coding-agent";
|
||||
import { Container, Text } from "@mariozechner/pi-tui";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
const { spawn } = require("child_process") as any;
|
||||
import * as fs from "fs";
|
||||
import * as os from "os";
|
||||
import * as path from "path";
|
||||
import { applyExtensionDefaults } from "./themeMap.ts";
|
||||
|
||||
const FALLBACK_MODEL = "openrouter/google/gemini-3.5-flash";
|
||||
const THINKING_OVERRIDES = ["low", "medium", "high", "xhigh"] as const;
|
||||
type ThinkingOverride = (typeof THINKING_OVERRIDES)[number];
|
||||
|
||||
interface SpawnOptions {
|
||||
model?: string;
|
||||
thinking?: ThinkingOverride;
|
||||
}
|
||||
|
||||
interface SubState {
|
||||
id: number;
|
||||
status: "running" | "done" | "error";
|
||||
task: string;
|
||||
textChunks: string[];
|
||||
toolCount: number;
|
||||
elapsed: number;
|
||||
sessionFile: string; // persistent JSONL session path — used by /subcont to resume
|
||||
turnCount: number; // increments each time /subcont continues this agent
|
||||
model: string;
|
||||
thinking: ThinkingLevel;
|
||||
proc?: any; // active ChildProcess ref (for kill on /subrm)
|
||||
}
|
||||
|
||||
interface ParsedCommand {
|
||||
options: SpawnOptions;
|
||||
rest: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function readCommandValue(input: string): { value?: string; rest: string } {
|
||||
const trimmed = input.trimStart();
|
||||
if (!trimmed) return { rest: "" };
|
||||
|
||||
const quote = trimmed[0];
|
||||
if (quote === '"' || quote === "'") {
|
||||
const end = trimmed.indexOf(quote, 1);
|
||||
if (end === -1) return { rest: trimmed };
|
||||
return { value: trimmed.slice(1, end), rest: trimmed.slice(end + 1) };
|
||||
}
|
||||
|
||||
const end = trimmed.search(/\s/);
|
||||
return end === -1
|
||||
? { value: trimmed, rest: "" }
|
||||
: { value: trimmed.slice(0, end), rest: trimmed.slice(end) };
|
||||
}
|
||||
|
||||
function parseCommandOptions(input: string): ParsedCommand {
|
||||
const options: SpawnOptions = {};
|
||||
let rest = input.trimStart();
|
||||
|
||||
while (rest.startsWith("--")) {
|
||||
const flagMatch = rest.match(/^--(model|thinking)(?:=([^\s]+))?(?:\s+|$)/);
|
||||
if (!flagMatch) {
|
||||
const flag = rest.match(/^\S+/)?.[0] || rest;
|
||||
return { options, rest: "", error: `Unknown or malformed option: ${flag}` };
|
||||
}
|
||||
|
||||
const flag = flagMatch[1];
|
||||
let value = flagMatch[2];
|
||||
rest = rest.slice(flagMatch[0].length);
|
||||
if (!value) {
|
||||
const parsed = readCommandValue(rest);
|
||||
value = parsed.value;
|
||||
rest = parsed.rest;
|
||||
}
|
||||
if (!value) return { options, rest: "", error: `Missing value for --${flag}` };
|
||||
|
||||
if (flag === "model") {
|
||||
options.model = value;
|
||||
rest = rest.trimStart();
|
||||
continue;
|
||||
}
|
||||
|
||||
const thinking = value.toLowerCase();
|
||||
if (!THINKING_OVERRIDES.includes(thinking as ThinkingOverride)) {
|
||||
return {
|
||||
options,
|
||||
rest: "",
|
||||
error: "Thinking must be one of: low, medium, high, xhigh",
|
||||
};
|
||||
}
|
||||
options.thinking = thinking as ThinkingOverride;
|
||||
rest = rest.trimStart();
|
||||
}
|
||||
|
||||
return { options, rest: rest.trim() };
|
||||
}
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
const agents: Map<number, SubState> = new Map();
|
||||
let nextId = 1;
|
||||
let widgetCtx: any;
|
||||
|
||||
// ── Session file helpers ──────────────────────────────────────────────────
|
||||
|
||||
function makeSessionFile(id: number): string {
|
||||
const dir = path.join(os.homedir(), ".pi", "agent", "sessions", "subagents");
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
return path.join(dir, `subagent-${id}-${Date.now()}.jsonl`);
|
||||
}
|
||||
|
||||
// ── Widget rendering ──────────────────────────────────────────────────────
|
||||
|
||||
function updateWidgets() {
|
||||
if (!widgetCtx) return;
|
||||
|
||||
for (const [id, state] of Array.from(agents.entries())) {
|
||||
const key = `sub-${id}`;
|
||||
widgetCtx.ui.setWidget(key, (_tui: any, theme: any) => {
|
||||
const container = new Container();
|
||||
const borderFn = (s: string) => theme.fg("dim", s);
|
||||
|
||||
container.addChild(new Text("", 0, 0)); // top margin
|
||||
container.addChild(new DynamicBorder(borderFn));
|
||||
const content = new Text("", 1, 0);
|
||||
container.addChild(content);
|
||||
container.addChild(new DynamicBorder(borderFn));
|
||||
|
||||
return {
|
||||
render(width: number): string[] {
|
||||
const lines: string[] = [];
|
||||
const statusColor = state.status === "running" ? "accent"
|
||||
: state.status === "done" ? "success" : "error";
|
||||
const statusIcon = state.status === "running" ? "●"
|
||||
: state.status === "done" ? "✓" : "✗";
|
||||
|
||||
const taskPreview = state.task.length > 40
|
||||
? state.task.slice(0, 37) + "..."
|
||||
: state.task;
|
||||
|
||||
const turnLabel = state.turnCount > 1
|
||||
? theme.fg("dim", ` · Turn ${state.turnCount}`)
|
||||
: "";
|
||||
|
||||
lines.push(
|
||||
theme.fg(statusColor, `${statusIcon} Subagent #${state.id}`) +
|
||||
turnLabel +
|
||||
theme.fg("dim", ` ${taskPreview}`) +
|
||||
theme.fg("dim", ` (${Math.round(state.elapsed / 1000)}s)`) +
|
||||
theme.fg("dim", ` | Tools: ${state.toolCount}`)
|
||||
);
|
||||
|
||||
const fullText = state.textChunks.join("");
|
||||
const lastLine = fullText.split("\n").filter((l: string) => l.trim()).pop() || "";
|
||||
if (lastLine) {
|
||||
const trimmed = lastLine.length > width - 10
|
||||
? lastLine.slice(0, width - 13) + "..."
|
||||
: lastLine;
|
||||
lines.push(theme.fg("muted", ` ${trimmed}`));
|
||||
}
|
||||
|
||||
content.setText(lines.join("\n"));
|
||||
return container.render(width);
|
||||
},
|
||||
invalidate() {
|
||||
container.invalidate();
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Streaming helpers ─────────────────────────────────────────────────────
|
||||
|
||||
function processLine(state: SubState, line: string) {
|
||||
if (!line.trim()) return;
|
||||
try {
|
||||
const event = JSON.parse(line);
|
||||
const type = event.type;
|
||||
|
||||
if (type === "message_update") {
|
||||
const delta = event.assistantMessageEvent;
|
||||
if (delta?.type === "text_delta") {
|
||||
state.textChunks.push(delta.delta || "");
|
||||
updateWidgets();
|
||||
}
|
||||
} else if (type === "tool_execution_start") {
|
||||
state.toolCount++;
|
||||
updateWidgets();
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function spawnAgent(
|
||||
state: SubState,
|
||||
prompt: string,
|
||||
ctx: any,
|
||||
options: SpawnOptions = {},
|
||||
): Promise<void> {
|
||||
const parentProvider = ctx.model?.provider?.trim();
|
||||
const parentModelId = ctx.model?.id?.trim();
|
||||
const hasParentModel = parentProvider && parentModelId
|
||||
&& parentProvider !== "unknown" && parentModelId !== "unknown";
|
||||
const parentModel = hasParentModel
|
||||
? `${parentProvider}/${parentModelId}`
|
||||
: FALLBACK_MODEL;
|
||||
const model = options.model?.trim() || parentModel;
|
||||
const thinking = options.thinking || pi.getThinkingLevel();
|
||||
state.model = model;
|
||||
state.thinking = thinking;
|
||||
|
||||
return new Promise<void>((resolve) => {
|
||||
const proc = spawn("pi", [
|
||||
"--mode", "json",
|
||||
"-p",
|
||||
"--session", state.sessionFile, // persistent session for /subcont resumption
|
||||
"--no-extensions",
|
||||
"--model", model,
|
||||
"--tools", "read,bash,grep,find,ls",
|
||||
"--thinking", thinking,
|
||||
prompt,
|
||||
], {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env: { ...process.env },
|
||||
});
|
||||
|
||||
state.proc = proc;
|
||||
|
||||
const startTime = Date.now();
|
||||
const timer = setInterval(() => {
|
||||
state.elapsed = Date.now() - startTime;
|
||||
updateWidgets();
|
||||
}, 1000);
|
||||
|
||||
let buffer = "";
|
||||
|
||||
proc.stdout!.setEncoding("utf-8");
|
||||
proc.stdout!.on("data", (chunk: string) => {
|
||||
buffer += chunk;
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || "";
|
||||
for (const line of lines) processLine(state, line);
|
||||
});
|
||||
|
||||
proc.stderr!.setEncoding("utf-8");
|
||||
proc.stderr!.on("data", (chunk: string) => {
|
||||
if (chunk.trim()) {
|
||||
state.textChunks.push(chunk);
|
||||
updateWidgets();
|
||||
}
|
||||
});
|
||||
|
||||
proc.on("close", (code) => {
|
||||
if (buffer.trim()) processLine(state, buffer);
|
||||
clearInterval(timer);
|
||||
state.elapsed = Date.now() - startTime;
|
||||
state.status = code === 0 ? "done" : "error";
|
||||
state.proc = undefined;
|
||||
updateWidgets();
|
||||
|
||||
const result = state.textChunks.join("");
|
||||
ctx.ui.notify(
|
||||
`Subagent #${state.id} ${state.status} in ${Math.round(state.elapsed / 1000)}s`,
|
||||
state.status === "done" ? "success" : "error"
|
||||
);
|
||||
|
||||
pi.sendMessage({
|
||||
customType: "subagent-result",
|
||||
content: `Subagent #${state.id}${state.turnCount > 1 ? ` (Turn ${state.turnCount})` : ""} finished "${prompt}" in ${Math.round(state.elapsed / 1000)}s.\n\nResult:\n${result.slice(0, 8000)}${result.length > 8000 ? "\n\n... [truncated]" : ""}`,
|
||||
display: true,
|
||||
}, { deliverAs: "followUp", triggerTurn: true });
|
||||
|
||||
resolve();
|
||||
});
|
||||
|
||||
proc.on("error", (err) => {
|
||||
clearInterval(timer);
|
||||
state.status = "error";
|
||||
state.proc = undefined;
|
||||
state.textChunks.push(`Error: ${err.message}`);
|
||||
updateWidgets();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── Tools for the Main Agent ──────────────────────────────────────────────
|
||||
|
||||
pi.registerTool({
|
||||
name: "subagent_create",
|
||||
description: "Spawn a background subagent. Thinking level is required and is the primary way to match the subagent to task complexity: low for lightweight/simple tasks, medium for routine tasks needing moderate reasoning, high for complex multi-step work, and xhigh for the hardest tasks or when accuracy and performance are critical. Unless the user explicitly requests a specific model, omit model and use the default inherited parent model. Returns immediately and delivers results as a follow-up message.",
|
||||
parameters: Type.Object({
|
||||
task: Type.String({ description: "The complete task description for the subagent to perform" }),
|
||||
model: Type.Optional(Type.String({
|
||||
description: "Leave blank or omit unless the user explicitly requests a specific model. Do not choose a different model autonomously. When explicitly requested, provide the override in provider/model form. The default reuses the parent caller's current model and falls back to openrouter/google/gemini-3.5-flash only if the parent has no model.",
|
||||
})),
|
||||
thinking: StringEnum([...THINKING_OVERRIDES], {
|
||||
description: "Required thinking level. Use low for lightweight/simple tasks; medium for routine tasks needing moderate reasoning; high for complex, multi-step, or ambiguous work; and xhigh for the hardest tasks or when accuracy and performance are critical. Pi may clamp the value to the selected model's supported maximum.",
|
||||
}),
|
||||
}),
|
||||
execute: async (callId, args, _signal, _onUpdate, ctx) => {
|
||||
widgetCtx = ctx;
|
||||
const id = nextId++;
|
||||
const state: SubState = {
|
||||
id,
|
||||
status: "running",
|
||||
task: args.task,
|
||||
textChunks: [],
|
||||
toolCount: 0,
|
||||
elapsed: 0,
|
||||
sessionFile: makeSessionFile(id),
|
||||
turnCount: 1,
|
||||
model: "",
|
||||
thinking: pi.getThinkingLevel(),
|
||||
};
|
||||
agents.set(id, state);
|
||||
updateWidgets();
|
||||
|
||||
// Fire-and-forget
|
||||
spawnAgent(state, args.task, ctx, { model: args.model, thinking: args.thinking });
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: `Subagent #${id} spawned with ${state.model} (${state.thinking} thinking) and is running in background.` }],
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerTool({
|
||||
name: "subagent_continue",
|
||||
description: "Continue an existing subagent conversation. Thinking level is required and is the primary way to match this turn to task complexity: low for lightweight/simple tasks, medium for routine tasks needing moderate reasoning, high for complex multi-step work, and xhigh for the hardest tasks or when accuracy and performance are critical. Unless the user explicitly requests a specific model, omit model and use the default inherited parent model. Returns immediately while it runs in the background.",
|
||||
parameters: Type.Object({
|
||||
id: Type.Number({ description: "The ID of the subagent to continue" }),
|
||||
prompt: Type.String({ description: "The follow-up prompt or new instructions" }),
|
||||
model: Type.Optional(Type.String({
|
||||
description: "Leave blank or omit unless the user explicitly requests a specific model. Do not choose a different model autonomously. When explicitly requested, provide the override in provider/model form for this turn. The default reuses the parent caller's current model.",
|
||||
})),
|
||||
thinking: StringEnum([...THINKING_OVERRIDES], {
|
||||
description: "Required thinking level for this turn. Use low for lightweight/simple tasks; medium for routine tasks needing moderate reasoning; high for complex, multi-step, or ambiguous work; and xhigh for the hardest tasks or when accuracy and performance are critical. Pi may clamp the value to the selected model's supported maximum.",
|
||||
}),
|
||||
}),
|
||||
execute: async (callId, args, _signal, _onUpdate, ctx) => {
|
||||
widgetCtx = ctx;
|
||||
const state = agents.get(args.id);
|
||||
if (!state) {
|
||||
return { content: [{ type: "text", text: `Error: No subagent #${args.id} found.` }] };
|
||||
}
|
||||
if (state.status === "running") {
|
||||
return { content: [{ type: "text", text: `Error: Subagent #${args.id} is still running.` }] };
|
||||
}
|
||||
|
||||
state.status = "running";
|
||||
state.task = args.prompt;
|
||||
state.textChunks = [];
|
||||
state.elapsed = 0;
|
||||
state.turnCount++;
|
||||
updateWidgets();
|
||||
|
||||
ctx.ui.notify(`Continuing Subagent #${args.id} (Turn ${state.turnCount})…`, "info");
|
||||
spawnAgent(state, args.prompt, ctx, { model: args.model, thinking: args.thinking });
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: `Subagent #${args.id} continuing with ${state.model} (${state.thinking} thinking) in background.` }],
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerTool({
|
||||
name: "subagent_remove",
|
||||
description: "Remove a specific subagent. Kills it if it's currently running.",
|
||||
parameters: Type.Object({
|
||||
id: Type.Number({ description: "The ID of the subagent to remove" }),
|
||||
}),
|
||||
execute: async (callId, args, _signal, _onUpdate, ctx) => {
|
||||
widgetCtx = ctx;
|
||||
const state = agents.get(args.id);
|
||||
if (!state) {
|
||||
return { content: [{ type: "text", text: `Error: No subagent #${args.id} found.` }] };
|
||||
}
|
||||
|
||||
if (state.proc && state.status === "running") {
|
||||
state.proc.kill("SIGTERM");
|
||||
}
|
||||
ctx.ui.setWidget(`sub-${args.id}`, undefined);
|
||||
agents.delete(args.id);
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: `Subagent #${args.id} removed successfully.` }],
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerTool({
|
||||
name: "subagent_list",
|
||||
description: "List all active and finished subagents, showing their IDs, tasks, and status.",
|
||||
parameters: Type.Object({}),
|
||||
execute: async () => {
|
||||
if (agents.size === 0) {
|
||||
return { content: [{ type: "text", text: "No active subagents." }] };
|
||||
}
|
||||
|
||||
const list = Array.from(agents.values()).map(s =>
|
||||
`#${s.id} [${s.status.toUpperCase()}] (Turn ${s.turnCount}, ${s.model}, ${s.thinking}) - ${s.task}`
|
||||
).join("\n");
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: `Subagents:\n${list}` }],
|
||||
};
|
||||
},
|
||||
});
|
||||
// ── /sub [--model <model>] [--thinking <level>] <task> ────────────────────
|
||||
|
||||
pi.registerCommand("sub", {
|
||||
description: "Spawn a subagent: /sub [--model provider/model] [--thinking low|medium|high|xhigh] <task>",
|
||||
handler: async (args, ctx) => {
|
||||
widgetCtx = ctx;
|
||||
|
||||
const parsed = parseCommandOptions(args || "");
|
||||
if (parsed.error) {
|
||||
ctx.ui.notify(parsed.error, "error");
|
||||
return;
|
||||
}
|
||||
const task = parsed.rest;
|
||||
if (!task) {
|
||||
ctx.ui.notify("Usage: /sub [--model provider/model] [--thinking low|medium|high|xhigh] <task>", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const id = nextId++;
|
||||
const state: SubState = {
|
||||
id,
|
||||
status: "running",
|
||||
task,
|
||||
textChunks: [],
|
||||
toolCount: 0,
|
||||
elapsed: 0,
|
||||
sessionFile: makeSessionFile(id),
|
||||
turnCount: 1,
|
||||
model: "",
|
||||
thinking: pi.getThinkingLevel(),
|
||||
};
|
||||
agents.set(id, state);
|
||||
updateWidgets();
|
||||
|
||||
// Fire-and-forget
|
||||
spawnAgent(state, task, ctx, parsed.options);
|
||||
ctx.ui.notify(`Subagent #${id}: ${state.model} (${state.thinking} thinking)`, "info");
|
||||
},
|
||||
});
|
||||
|
||||
// ── /subcont <id> [--model <model>] [--thinking <level>] <prompt> ─────────
|
||||
|
||||
pi.registerCommand("subcont", {
|
||||
description: "Continue a subagent: /subcont <id> [--model provider/model] [--thinking low|medium|high|xhigh] <prompt>",
|
||||
handler: async (args, ctx) => {
|
||||
widgetCtx = ctx;
|
||||
|
||||
const trimmed = args?.trim() ?? "";
|
||||
const idMatch = trimmed.match(/^(\d+)(?:\s+|$)/);
|
||||
if (!idMatch) {
|
||||
ctx.ui.notify("Usage: /subcont <id> [--model provider/model] [--thinking low|medium|high|xhigh] <prompt>", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const num = parseInt(idMatch[1], 10);
|
||||
const parsed = parseCommandOptions(trimmed.slice(idMatch[0].length));
|
||||
if (parsed.error) {
|
||||
ctx.ui.notify(parsed.error, "error");
|
||||
return;
|
||||
}
|
||||
const prompt = parsed.rest;
|
||||
|
||||
if (!prompt) {
|
||||
ctx.ui.notify("Usage: /subcont <id> [--model provider/model] [--thinking low|medium|high|xhigh] <prompt>", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const state = agents.get(num);
|
||||
if (!state) {
|
||||
ctx.ui.notify(`No subagent #${num} found. Use /sub to create one.`, "error");
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.status === "running") {
|
||||
ctx.ui.notify(`Subagent #${num} is still running — wait for it to finish first.`, "warning");
|
||||
return;
|
||||
}
|
||||
|
||||
// Resume: update state for a new turn
|
||||
state.status = "running";
|
||||
state.task = prompt;
|
||||
state.textChunks = [];
|
||||
state.elapsed = 0;
|
||||
state.turnCount++;
|
||||
updateWidgets();
|
||||
|
||||
ctx.ui.notify(`Continuing Subagent #${num} (Turn ${state.turnCount})…`, "info");
|
||||
|
||||
// Fire-and-forget — reuses the same sessionFile for conversation history
|
||||
spawnAgent(state, prompt, ctx, parsed.options);
|
||||
ctx.ui.notify(`Subagent #${num}: ${state.model} (${state.thinking} thinking)`, "info");
|
||||
},
|
||||
});
|
||||
|
||||
// ── /subrm <number> ───────────────────────────────────────────────────────
|
||||
|
||||
pi.registerCommand("subrm", {
|
||||
description: "Remove a specific subagent widget: /subrm <number>",
|
||||
handler: async (args, ctx) => {
|
||||
widgetCtx = ctx;
|
||||
|
||||
const num = parseInt(args?.trim() ?? "", 10);
|
||||
if (isNaN(num)) {
|
||||
ctx.ui.notify("Usage: /subrm <number>", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const state = agents.get(num);
|
||||
if (!state) {
|
||||
ctx.ui.notify(`No subagent #${num} found.`, "error");
|
||||
return;
|
||||
}
|
||||
|
||||
// Kill the process if still running
|
||||
if (state.proc && state.status === "running") {
|
||||
state.proc.kill("SIGTERM");
|
||||
ctx.ui.notify(`Subagent #${num} killed and removed.`, "warning");
|
||||
} else {
|
||||
ctx.ui.notify(`Subagent #${num} removed.`, "info");
|
||||
}
|
||||
|
||||
ctx.ui.setWidget(`sub-${num}`, undefined);
|
||||
agents.delete(num);
|
||||
},
|
||||
});
|
||||
|
||||
// ── /subclear ─────────────────────────────────────────────────────────────
|
||||
|
||||
pi.registerCommand("subclear", {
|
||||
description: "Clear all subagent widgets",
|
||||
handler: async (_args, ctx) => {
|
||||
widgetCtx = ctx;
|
||||
|
||||
let killed = 0;
|
||||
for (const [id, state] of Array.from(agents.entries())) {
|
||||
if (state.proc && state.status === "running") {
|
||||
state.proc.kill("SIGTERM");
|
||||
killed++;
|
||||
}
|
||||
ctx.ui.setWidget(`sub-${id}`, undefined);
|
||||
}
|
||||
|
||||
const total = agents.size;
|
||||
agents.clear();
|
||||
nextId = 1;
|
||||
|
||||
const msg = total === 0
|
||||
? "No subagents to clear."
|
||||
: `Cleared ${total} subagent${total !== 1 ? "s" : ""}${killed > 0 ? ` (${killed} killed)` : ""}.`;
|
||||
ctx.ui.notify(msg, total === 0 ? "info" : "success");
|
||||
},
|
||||
});
|
||||
|
||||
// ── Session lifecycle ─────────────────────────────────────────────────────
|
||||
|
||||
pi.on("session_start", async (_event, ctx) => {
|
||||
applyExtensionDefaults(import.meta.url, ctx);
|
||||
for (const [id, state] of Array.from(agents.entries())) {
|
||||
if (state.proc && state.status === "running") {
|
||||
state.proc.kill("SIGTERM");
|
||||
}
|
||||
ctx.ui.setWidget(`sub-${id}`, undefined);
|
||||
}
|
||||
agents.clear();
|
||||
nextId = 1;
|
||||
widgetCtx = ctx;
|
||||
});
|
||||
}
|
||||
145
sssf/templates/harness_engineering/themeMap.ts
Normal file
145
sssf/templates/harness_engineering/themeMap.ts
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
/**
|
||||
* themeMap.ts — Per-extension default theme assignments
|
||||
*
|
||||
* Themes live in .pi/themes/ and are mapped by extension filename (no extension).
|
||||
* Each extension calls applyExtensionTheme(import.meta.url, ctx) in its session_start
|
||||
* hook to automatically load its designated theme on boot.
|
||||
*
|
||||
* Available themes (.pi/themes/):
|
||||
* catppuccin-mocha · cyberpunk · dracula · everforest · gruvbox
|
||||
* midnight-ocean · nord · ocean-breeze · rose-pine
|
||||
* synthwave · tokyo-night
|
||||
*/
|
||||
|
||||
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
|
||||
import { basename } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
// ── Theme assignments ──────────────────────────────────────────────────────
|
||||
//
|
||||
// Key = extension filename without extension (matches extensions/<key>.ts)
|
||||
// Value = theme name from .pi/themes/<value>.json
|
||||
//
|
||||
export const THEME_MAP: Record<string, string> = {
|
||||
"agent-chain": "midnight-ocean", // deep sequential pipeline
|
||||
"agent-team": "dracula", // rich orchestration palette
|
||||
"coms": "ocean-breeze", // peer-to-peer messaging, cross-boundary
|
||||
"coms-net": "ocean-breeze", // peer-to-peer messaging, cross-boundary
|
||||
"cross-agent": "ocean-breeze", // cross-boundary, connecting
|
||||
"damage-control": "gruvbox", // grounded, earthy safety
|
||||
"minimal": "synthwave", // synthwave by default now!
|
||||
"pi-pi": "rose-pine", // warm creative meta-agent
|
||||
"pure-focus": "everforest", // calm, distraction-free
|
||||
"purpose-gate": "tokyo-night", // intentional, sharp focus
|
||||
"session-replay": "catppuccin-mocha", // soft, reflective history
|
||||
"subagent-widget": "cyberpunk", // multi-agent futuristic
|
||||
"system-select": "catppuccin-mocha", // soft selection UI
|
||||
"theme-cycler": "synthwave", // neon, it's a theme tool
|
||||
"tilldone": "everforest", // task-focused calm
|
||||
"tool-counter": "synthwave", // techy metrics
|
||||
"tool-counter-widget":"synthwave", // same family
|
||||
};
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
/** Derive the extension name (e.g. "minimal") from its import.meta.url. */
|
||||
function extensionName(fileUrl: string): string {
|
||||
const filePath = fileUrl.startsWith("file://") ? fileURLToPath(fileUrl) : fileUrl;
|
||||
return basename(filePath).replace(/\.[^.]+$/, "");
|
||||
}
|
||||
|
||||
// ── Theme ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Apply the mapped theme for an extension on session boot.
|
||||
*
|
||||
* @param fileUrl Pass `import.meta.url` from the calling extension file.
|
||||
* @param ctx The ExtensionContext from the session_start handler.
|
||||
* @returns true if the theme was applied successfully, false otherwise.
|
||||
*/
|
||||
export function applyExtensionTheme(fileUrl: string, ctx: ExtensionContext): boolean {
|
||||
if (!ctx.hasUI) return false;
|
||||
|
||||
const name = extensionName(fileUrl);
|
||||
|
||||
// If there are multiple extensions stacked in 'ipi', they each fire session_start
|
||||
// and try to apply their own mapped theme. The LAST one to fire wins.
|
||||
// Since system-select is last in the ipi alias array, it was setting 'catppuccin-mocha'.
|
||||
|
||||
// We want to skip theme application for all secondary extensions if they are stacked,
|
||||
// so the primary extension (first in the array) dictates the theme.
|
||||
const primaryExt = primaryExtensionName();
|
||||
if (primaryExt && primaryExt !== name) {
|
||||
return true; // Pretend we succeeded, but don't overwrite the primary theme
|
||||
}
|
||||
|
||||
let themeName = THEME_MAP[name];
|
||||
|
||||
if (!themeName) {
|
||||
themeName = "synthwave";
|
||||
}
|
||||
|
||||
const result = ctx.ui.setTheme(themeName);
|
||||
|
||||
if (!result.success && themeName !== "synthwave") {
|
||||
return ctx.ui.setTheme("synthwave").success;
|
||||
}
|
||||
|
||||
return result.success;
|
||||
}
|
||||
// ── Title ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Read process.argv to find the first -e / --extension flag value.
|
||||
*
|
||||
* When Pi is launched as:
|
||||
* pi -e extensions/subagent-widget.ts -e extensions/pure-focus.ts
|
||||
*
|
||||
* process.argv contains those paths verbatim. Every stacked extension calls
|
||||
* this and gets the same answer ("subagent-widget"), so all setTitle calls
|
||||
* are idempotent — no shared state or deduplication needed.
|
||||
*
|
||||
* Returns null if no -e flag is present (e.g. plain `pi` with no extensions).
|
||||
*/
|
||||
function primaryExtensionName(): string | null {
|
||||
const argv = process.argv;
|
||||
for (let i = 0; i < argv.length - 1; i++) {
|
||||
if (argv[i] === "-e" || argv[i] === "--extension") {
|
||||
return basename(argv[i + 1]).replace(/\.[^.]+$/, "");
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the terminal title to "π - <first-extension-name>" on session boot.
|
||||
* Reads the title from process.argv so all stacked extensions agree on the
|
||||
* same value — no coordination or shared state required.
|
||||
*
|
||||
* Deferred 150 ms to fire after Pi's own startup title-set.
|
||||
*/
|
||||
function applyExtensionTitle(ctx: ExtensionContext): void {
|
||||
if (!ctx.hasUI) return;
|
||||
const name = primaryExtensionName();
|
||||
if (!name) return;
|
||||
setTimeout(() => ctx.ui.setTitle(`π - ${name}`), 150);
|
||||
}
|
||||
|
||||
// ── Combined default ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Apply both the mapped theme AND the terminal title for an extension.
|
||||
* Drop-in replacement for applyExtensionTheme — call this in every session_start.
|
||||
*
|
||||
* Usage:
|
||||
* import { applyExtensionDefaults } from "./themeMap.ts";
|
||||
*
|
||||
* pi.on("session_start", async (_event, ctx) => {
|
||||
* applyExtensionDefaults(import.meta.url, ctx);
|
||||
* // ... rest of handler
|
||||
* });
|
||||
*/
|
||||
export function applyExtensionDefaults(fileUrl: string, ctx: ExtensionContext): void {
|
||||
applyExtensionTheme(fileUrl, ctx);
|
||||
applyExtensionTitle(ctx);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue