- TUI header now shows CPU cores, RAM, GPU, and Docker availability - System prompt uses resource info to recommend execution environments - Docker skill for running experiment code in isolated containers - Renamed docker-sandbox skill to docker (Feynman stays on host, code runs in containers) - Updated README and website to cite Docker alongside Agent Computer Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
380 lines
13 KiB
TypeScript
380 lines
13 KiB
TypeScript
import { readdir } from "node:fs/promises";
|
|
import { cpus, freemem, homedir, totalmem } from "node:os";
|
|
import { execSync } from "node:child_process";
|
|
import { resolve as resolvePath } from "node:path";
|
|
|
|
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
|
|
import {
|
|
APP_ROOT,
|
|
FEYNMAN_AGENT_LOGO,
|
|
FEYNMAN_VERSION,
|
|
} from "./shared.js";
|
|
|
|
const ANSI_RE = /\x1b\[[0-9;]*m/g;
|
|
|
|
function visibleLength(text: string): number {
|
|
return text.replace(ANSI_RE, "").length;
|
|
}
|
|
|
|
function formatHeaderPath(path: string): string {
|
|
const home = homedir();
|
|
return path.startsWith(home) ? `~${path.slice(home.length)}` : path;
|
|
}
|
|
|
|
function truncateVisible(text: string, maxVisible: number): string {
|
|
const raw = text.replace(ANSI_RE, "");
|
|
if (raw.length <= maxVisible) return text;
|
|
if (maxVisible <= 3) return ".".repeat(maxVisible);
|
|
return `${raw.slice(0, maxVisible - 3)}...`;
|
|
}
|
|
|
|
function wrapWords(text: string, maxW: number): string[] {
|
|
const words = text.split(" ");
|
|
const lines: string[] = [];
|
|
let cur = "";
|
|
for (let word of words) {
|
|
if (word.length > maxW) {
|
|
if (cur) { lines.push(cur); cur = ""; }
|
|
word = maxW > 3 ? `${word.slice(0, maxW - 1)}…` : word.slice(0, maxW);
|
|
}
|
|
const test = cur ? `${cur} ${word}` : word;
|
|
if (cur && test.length > maxW) {
|
|
lines.push(cur);
|
|
cur = word;
|
|
} else {
|
|
cur = test;
|
|
}
|
|
}
|
|
if (cur) lines.push(cur);
|
|
return lines.length ? lines : [""];
|
|
}
|
|
|
|
function padRight(text: string, width: number): string {
|
|
const gap = Math.max(0, width - visibleLength(text));
|
|
return `${text}${" ".repeat(gap)}`;
|
|
}
|
|
|
|
function centerText(text: string, width: number): string {
|
|
if (text.length >= width) return text.slice(0, width);
|
|
const left = Math.floor((width - text.length) / 2);
|
|
const right = width - text.length - left;
|
|
return `${" ".repeat(left)}${text}${" ".repeat(right)}`;
|
|
}
|
|
|
|
function getCurrentModelLabel(ctx: ExtensionContext): string {
|
|
if (ctx.model) return `${ctx.model.provider}/${ctx.model.id}`;
|
|
const branch = ctx.sessionManager.getBranch();
|
|
for (let index = branch.length - 1; index >= 0; index -= 1) {
|
|
const entry = branch[index]!;
|
|
if (entry.type === "model_change") return `${(entry as any).provider}/${(entry as any).modelId}`;
|
|
}
|
|
return "not set";
|
|
}
|
|
|
|
function extractMessageText(message: unknown): string {
|
|
if (!message || typeof message !== "object") return "";
|
|
const content = (message as { content?: unknown }).content;
|
|
if (typeof content === "string") return content;
|
|
if (!Array.isArray(content)) return "";
|
|
return content
|
|
.map((item) => {
|
|
if (!item || typeof item !== "object") return "";
|
|
const record = item as { type?: string; text?: unknown; name?: unknown };
|
|
if (record.type === "text" && typeof record.text === "string") return record.text;
|
|
if (record.type === "toolCall") return `[${typeof record.name === "string" ? record.name : "tool"}]`;
|
|
return "";
|
|
})
|
|
.filter(Boolean)
|
|
.join(" ");
|
|
}
|
|
|
|
function getRecentActivitySummary(ctx: ExtensionContext): string {
|
|
const branch = ctx.sessionManager.getBranch();
|
|
for (let index = branch.length - 1; index >= 0; index -= 1) {
|
|
const entry = branch[index]!;
|
|
if (entry.type !== "message") continue;
|
|
const msg = entry as any;
|
|
const text = extractMessageText(msg.message).replace(/\s+/g, " ").trim();
|
|
if (!text) continue;
|
|
const role = msg.message.role === "assistant" ? "agent" : msg.message.role === "user" ? "you" : msg.message.role;
|
|
return `${role}: ${text}`;
|
|
}
|
|
return "";
|
|
}
|
|
|
|
async function buildAgentCatalogSummary(): Promise<{ agents: string[]; chains: string[] }> {
|
|
const agents: string[] = [];
|
|
const chains: string[] = [];
|
|
try {
|
|
const entries = await readdir(resolvePath(APP_ROOT, ".feynman", "agents"), { withFileTypes: true });
|
|
for (const entry of entries) {
|
|
if (!entry.isFile() || !entry.name.endsWith(".md")) continue;
|
|
if (entry.name.endsWith(".chain.md")) {
|
|
chains.push(entry.name.replace(/\.chain\.md$/i, ""));
|
|
} else {
|
|
agents.push(entry.name.replace(/\.md$/i, ""));
|
|
}
|
|
}
|
|
} catch {
|
|
return { agents: [], chains: [] };
|
|
}
|
|
agents.sort();
|
|
chains.sort();
|
|
return { agents, chains };
|
|
}
|
|
|
|
type SystemResources = {
|
|
cpu: string;
|
|
cores: number;
|
|
ramTotal: string;
|
|
ramFree: string;
|
|
gpu: string | null;
|
|
docker: boolean;
|
|
};
|
|
|
|
function detectSystemResources(): SystemResources {
|
|
const cores = cpus().length;
|
|
const cpu = cpus()[0]?.model?.trim() ?? "unknown";
|
|
const totalBytes = totalmem();
|
|
const freeBytes = freemem();
|
|
const ramTotal = `${Math.round(totalBytes / (1024 ** 3))}GB`;
|
|
const ramFree = `${Math.round(freeBytes / (1024 ** 3))}GB`;
|
|
|
|
let gpu: string | null = null;
|
|
try {
|
|
if (process.platform === "darwin") {
|
|
const out = execSync("system_profiler SPDisplaysDataType 2>/dev/null | grep 'Chipset Model\\|Chip Model'", { encoding: "utf8", timeout: 3000 }).trim();
|
|
const match = out.match(/:\s*(.+)/);
|
|
if (match) gpu = match[1]!.trim();
|
|
} else {
|
|
const out = execSync("nvidia-smi --query-gpu=name --format=csv,noheader 2>/dev/null", { encoding: "utf8", timeout: 3000 }).trim();
|
|
if (out) gpu = out.split("\n")[0]!.trim();
|
|
}
|
|
} catch {}
|
|
|
|
let docker = false;
|
|
try {
|
|
execSync("docker info 2>/dev/null", { timeout: 3000 });
|
|
docker = true;
|
|
} catch {}
|
|
|
|
return { cpu, cores, ramTotal, ramFree, gpu, docker };
|
|
}
|
|
|
|
type WorkflowInfo = { name: string; description: string };
|
|
|
|
function getResearchWorkflows(pi: ExtensionAPI): WorkflowInfo[] {
|
|
return pi.getCommands()
|
|
.filter((cmd) => cmd.source === "prompt")
|
|
.map((cmd) => ({ name: `/${cmd.name}`, description: cmd.description ?? "" }))
|
|
.sort((a, b) => a.name.localeCompare(b.name));
|
|
}
|
|
|
|
function shortDescription(desc: string): string {
|
|
const lower = desc.toLowerCase();
|
|
for (const prefix of ["run a ", "run an ", "set up a ", "build a ", "build the ", "turn ", "design the ", "produce a ", "compare ", "simulate ", "inspect ", "write a ", "plan or execute a ", "prepare a "]) {
|
|
if (lower.startsWith(prefix)) return desc.slice(prefix.length);
|
|
}
|
|
return desc;
|
|
}
|
|
|
|
export function installFeynmanHeader(
|
|
pi: ExtensionAPI,
|
|
ctx: ExtensionContext,
|
|
cache: { agentSummaryPromise?: Promise<{ agents: string[]; chains: string[] }> },
|
|
): void | Promise<void> {
|
|
if (!ctx.hasUI) return;
|
|
|
|
cache.agentSummaryPromise ??= buildAgentCatalogSummary();
|
|
|
|
return cache.agentSummaryPromise.then((agentData) => {
|
|
const resources = detectSystemResources();
|
|
const workflows = getResearchWorkflows(pi);
|
|
const toolCount = pi.getAllTools().length;
|
|
const commandCount = pi.getCommands().length;
|
|
const agentCount = agentData.agents.length + agentData.chains.length;
|
|
|
|
ctx.ui.setHeader((_tui, theme) => ({
|
|
render(width: number): string[] {
|
|
const maxW = Math.max(width - 2, 1);
|
|
const cardW = Math.min(maxW, 120);
|
|
const innerW = cardW - 2;
|
|
const contentW = innerW - 2;
|
|
const outerPad = " ".repeat(Math.max(0, Math.floor((width - cardW) / 2)));
|
|
const lines: string[] = [];
|
|
|
|
const push = (line: string) => { lines.push(`${outerPad}${line}`); };
|
|
const border = (ch: string) => theme.fg("borderMuted", ch);
|
|
|
|
const row = (content: string): string =>
|
|
`${border("│")} ${padRight(content, contentW)} ${border("│")}`;
|
|
const emptyRow = (): string =>
|
|
`${border("│")}${" ".repeat(innerW)}${border("│")}`;
|
|
const sep = (): string =>
|
|
`${border("├")}${border("─".repeat(innerW))}${border("┤")}`;
|
|
|
|
const useWideLayout = contentW >= 70;
|
|
const leftW = useWideLayout ? Math.min(38, Math.floor(contentW * 0.35)) : 0;
|
|
const divColW = useWideLayout ? 3 : 0;
|
|
const rightW = useWideLayout ? contentW - leftW - divColW : contentW;
|
|
|
|
const twoCol = (left: string, right: string): string => {
|
|
if (!useWideLayout) return row(left || right);
|
|
return row(
|
|
`${padRight(left, leftW)}${border(" │ ")}${padRight(right, rightW)}`,
|
|
);
|
|
};
|
|
|
|
const modelLabel = getCurrentModelLabel(ctx);
|
|
const sessionId = ctx.sessionManager.getSessionName()?.trim() || ctx.sessionManager.getSessionId();
|
|
const dirLabel = formatHeaderPath(ctx.cwd);
|
|
const activity = getRecentActivitySummary(ctx);
|
|
|
|
push("");
|
|
if (cardW >= 70) {
|
|
for (const logoLine of FEYNMAN_AGENT_LOGO) {
|
|
push(theme.fg("accent", theme.bold(centerText(truncateVisible(logoLine, cardW), cardW))));
|
|
}
|
|
push("");
|
|
}
|
|
|
|
const versionTag = ` v${FEYNMAN_VERSION} `;
|
|
const gap = Math.max(0, innerW - versionTag.length);
|
|
const gapL = Math.floor(gap / 2);
|
|
push(
|
|
border(`╭${"─".repeat(gapL)}`) +
|
|
theme.fg("dim", versionTag) +
|
|
border(`${"─".repeat(gap - gapL)}╮`),
|
|
);
|
|
|
|
if (useWideLayout) {
|
|
const cmdNameW = 16;
|
|
const descW = Math.max(10, rightW - cmdNameW - 2);
|
|
|
|
const leftValueW = Math.max(1, leftW - 11);
|
|
const indent = " ".repeat(11);
|
|
const leftLines: string[] = [""];
|
|
|
|
const pushLabeled = (label: string, value: string, color: "text" | "dim") => {
|
|
const wrapped = wrapWords(value, leftValueW);
|
|
leftLines.push(`${theme.fg("dim", label.padEnd(10))} ${theme.fg(color, wrapped[0]!)}`);
|
|
for (let i = 1; i < wrapped.length; i++) {
|
|
leftLines.push(`${indent}${theme.fg(color, wrapped[i]!)}`);
|
|
}
|
|
};
|
|
|
|
pushLabeled("model", modelLabel, "text");
|
|
pushLabeled("directory", dirLabel, "text");
|
|
pushLabeled("session", sessionId, "dim");
|
|
leftLines.push("");
|
|
pushLabeled("cpu", `${resources.cores} cores`, "dim");
|
|
pushLabeled("ram", `${resources.ramFree} free / ${resources.ramTotal}`, "dim");
|
|
if (resources.gpu) pushLabeled("gpu", resources.gpu, "dim");
|
|
pushLabeled("docker", resources.docker ? "available" : "not found", "dim");
|
|
leftLines.push("");
|
|
leftLines.push(theme.fg("dim", `${toolCount} tools · ${agentCount} agents`));
|
|
|
|
const pushList = (heading: string, items: string[]) => {
|
|
if (items.length === 0) return;
|
|
leftLines.push("");
|
|
leftLines.push(theme.fg("accent", theme.bold(heading)));
|
|
for (const line of wrapWords(items.join(", "), leftW)) {
|
|
leftLines.push(theme.fg("dim", line));
|
|
}
|
|
};
|
|
|
|
pushList("Agents", agentData.agents);
|
|
pushList("Chains", agentData.chains);
|
|
|
|
if (activity) {
|
|
const maxActivityLen = leftW * 2;
|
|
const trimmed = activity.length > maxActivityLen
|
|
? `${activity.slice(0, maxActivityLen - 1)}…`
|
|
: activity;
|
|
leftLines.push("");
|
|
leftLines.push(theme.fg("accent", theme.bold("Last Activity")));
|
|
for (const line of wrapWords(trimmed, leftW)) {
|
|
leftLines.push(theme.fg("dim", line));
|
|
}
|
|
}
|
|
|
|
const rightLines: string[] = [
|
|
"",
|
|
theme.fg("accent", theme.bold("Research Workflows")),
|
|
];
|
|
|
|
for (const wf of workflows) {
|
|
if (wf.name === "/jobs" || wf.name === "/log") continue;
|
|
const desc = shortDescription(wf.description);
|
|
const descWords = desc.split(" ");
|
|
let line = "";
|
|
let first = true;
|
|
for (const word of descWords) {
|
|
const test = line ? `${line} ${word}` : word;
|
|
if (line && test.length > descW) {
|
|
rightLines.push(
|
|
first
|
|
? `${theme.fg("accent", wf.name.padEnd(cmdNameW))}${theme.fg("dim", line)}`
|
|
: `${" ".repeat(cmdNameW)}${theme.fg("dim", line)}`,
|
|
);
|
|
first = false;
|
|
line = word;
|
|
} else {
|
|
line = test;
|
|
}
|
|
}
|
|
if (line || first) {
|
|
rightLines.push(
|
|
first
|
|
? `${theme.fg("accent", wf.name.padEnd(cmdNameW))}${theme.fg("dim", line)}`
|
|
: `${" ".repeat(cmdNameW)}${theme.fg("dim", line)}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
const maxRows = Math.max(leftLines.length, rightLines.length);
|
|
for (let i = 0; i < maxRows; i++) {
|
|
push(twoCol(leftLines[i] ?? "", rightLines[i] ?? ""));
|
|
}
|
|
} else {
|
|
const narrowValW = Math.max(1, contentW - 11);
|
|
push(emptyRow());
|
|
push(row(`${theme.fg("dim", "model".padEnd(10))} ${theme.fg("text", truncateVisible(modelLabel, narrowValW))}`));
|
|
push(row(`${theme.fg("dim", "directory".padEnd(10))} ${theme.fg("text", truncateVisible(dirLabel, narrowValW))}`));
|
|
push(row(`${theme.fg("dim", "session".padEnd(10))} ${theme.fg("dim", truncateVisible(sessionId, narrowValW))}`));
|
|
const resourceLine = `${resources.cores} cores · ${resources.ramTotal} ram${resources.gpu ? ` · ${resources.gpu}` : ""}${resources.docker ? " · docker" : ""}`;
|
|
push(row(theme.fg("dim", truncateVisible(resourceLine, contentW))));
|
|
push(row(theme.fg("dim", truncateVisible(`${toolCount} tools · ${agentCount} agents · ${commandCount} commands`, contentW))));
|
|
push(emptyRow());
|
|
|
|
push(sep());
|
|
push(row(theme.fg("accent", theme.bold("Research Workflows"))));
|
|
const narrowDescW = Math.max(1, contentW - 17);
|
|
for (const wf of workflows) {
|
|
if (wf.name === "/jobs" || wf.name === "/log") continue;
|
|
const desc = shortDescription(wf.description);
|
|
push(row(`${theme.fg("accent", wf.name.padEnd(16))} ${theme.fg("dim", truncateVisible(desc, narrowDescW))}`));
|
|
}
|
|
|
|
if (agentData.agents.length > 0 || agentData.chains.length > 0) {
|
|
push(sep());
|
|
push(row(theme.fg("accent", theme.bold("Agents & Chains"))));
|
|
if (agentData.agents.length > 0) {
|
|
push(row(theme.fg("dim", truncateVisible(`agents ${agentData.agents.join(", ")}`, contentW))));
|
|
}
|
|
if (agentData.chains.length > 0) {
|
|
push(row(theme.fg("dim", truncateVisible(`chains ${agentData.chains.join(", ")}`, contentW))));
|
|
}
|
|
}
|
|
}
|
|
|
|
push(border(`╰${"─".repeat(innerW)}╯`));
|
|
push("");
|
|
return lines;
|
|
},
|
|
invalidate() {},
|
|
}));
|
|
});
|
|
}
|