diff --git a/src/resources/extensions/sf-tui/index.js b/src/resources/extensions/sf-tui/index.js index c18080589..5b87dad90 100644 --- a/src/resources/extensions/sf-tui/index.js +++ b/src/resources/extensions/sf-tui/index.js @@ -7,7 +7,10 @@ * - Prompt history: Ctrl+Alt+H overlay * - Mode cycling: Ctrl+Shift+M, Ctrl+Shift+R, Ctrl+Shift+A, Ctrl+Shift+S */ + +import { execSync, spawn } from "node:child_process"; import { randomUUID } from "node:crypto"; +import { platform } from "node:os"; import { Key } from "@singularity-forge/pi-tui"; import { getAutoSession } from "../sf/auto/session.js"; import { isAutoActive } from "../sf/auto.js"; @@ -185,7 +188,6 @@ export default function sfTui(pi) { ); return; } - const { spawn } = require("node:child_process"); spawn(editor, [projectRoot() ?? "."], { stdio: "ignore", detached: true, @@ -202,19 +204,21 @@ export default function sfTui(pi) { ctx.ui.notify(`Reasoning display ${current ? "OFF" : "ON"}`, "info"); }, }); - // Ctrl+X B — open background tasks surface (/tasks) + // Ctrl+X B — open background session switcher (BACKGROUND_SESSIONS flag) pi.registerShortcut(Key.ctrlAlt("b"), { - description: "Open background tasks surface", - handler: () => { - ctx.sendMessage?.("/tasks"); + description: "Open background session switcher", + handler: async () => { + if (!getExperimentalFlag("background_sessions")) { + ctx.sendMessage?.("/tasks"); + return; + } + await openBgSessionSwitcher(ctx); }, }); // Ctrl+X O — open URL from last agent message pi.registerShortcut(Key.ctrlAlt("o"), { description: "Open last URL from agent output in browser", handler: () => { - const { execSync } = require("node:child_process"); - const os = require("node:os"); const entries = ctx.sessionManager?.getEntries?.() ?? []; const lastText = [...entries].reverse().find((e) => e.type === "assistant")?.content ?? @@ -227,9 +231,9 @@ export default function sfTui(pi) { const url = urlMatch[0]; try { const openCmd = - os.platform() === "darwin" + platform() === "darwin" ? "open" - : os.platform() === "win32" + : platform() === "win32" ? "start" : "xdg-open"; execSync(`${openCmd} "${url}"`, { stdio: "ignore" }); @@ -325,6 +329,173 @@ export default function sfTui(pi) { }, renderResult: ({ output }) => output, }); + + // ASK_USER_ELICITATION — structured form-based ask_user replacement. + // When the flag is on and the agent calls this tool with choices, a TUI + // select overlay is shown instead of a plain text prompt. + pi.registerTool({ + name: "ask_user_elicitation", + description: + "Ask the user a question using a structured form with optional choices. When ASK_USER_ELICITATION is enabled this is preferred over plain ask_user for questions with known choices.", + parameters: { + type: "object", + properties: { + question: { + type: "string", + description: "The question to ask the user", + }, + choices: { + type: "array", + items: { type: "string" }, + description: "Optional list of choices to show as a select menu", + }, + allow_freeform: { + type: "boolean", + description: + "Whether to allow freeform text input in addition to choices", + }, + }, + required: ["question"], + }, + execute: async ({ question, choices, allow_freeform }, ctx) => { + if (!ctx?.hasUI) { + return { output: "No UI available for elicitation." }; + } + if (!getExperimentalFlag("ask_elicitation")) { + return { + output: + "ASK_USER_ELICITATION is not enabled. Run /experimental on ask_elicitation to enable.", + }; + } + if (choices?.length) { + const answer = await ctx.ui.select(question, choices); + if (!answer && allow_freeform) { + const freeform = await ctx.ui.input(question); + return { output: freeform ?? "" }; + } + return { output: answer ?? "" }; + } + const answer = await ctx.ui.input(question); + return { output: answer ?? "" }; + }, + renderResult: ({ output }) => (output ? `**Answer:** ${output}` : ""), + }); + + // MULTI_TURN_AGENTS — persistent named sub-agent sessions via file-backed state. + // Tool that spawns or resumes a named SF child process, relaying messages. + pi.registerTool({ + name: "spawn_agent", + description: + "Spawn or resume a named persistent sub-agent. Sends a message and waits for the response. The agent persists across calls using file-backed state in .sf/agents//.", + parameters: { + type: "object", + properties: { + name: { + type: "string", + description: + "Unique agent name (alphanumeric + hyphens, e.g. 'researcher')", + }, + message: { + type: "string", + description: "Message to send to the agent", + }, + reset: { + type: "boolean", + description: + "If true, clear the agent's state and start fresh (default: false)", + }, + }, + required: ["name", "message"], + }, + execute: async ({ name, message, reset }) => { + if (!getExperimentalFlag("multi_turn_agents")) { + return { + output: + "MULTI_TURN_AGENTS is not enabled. Run /experimental on multi_turn_agents to enable.", + }; + } + if (!/^[a-z0-9-]{1,32}$/i.test(name)) { + return { + output: "Agent name must be 1-32 alphanumeric/hyphen characters.", + }; + } + const { join: pathJoin } = await import("node:path"); + const { mkdirSync, writeFileSync, readFileSync, existsSync } = + await import("node:fs"); + const stateDir = pathJoin( + projectRoot() ?? process.cwd(), + ".sf", + "agents", + name, + ); + mkdirSync(stateDir, { recursive: true }); + const historyPath = pathJoin(stateDir, "history.jsonl"); + if (reset && existsSync(historyPath)) { + writeFileSync(historyPath, "", "utf-8"); + } + // Append user message to history + const entry = JSON.stringify({ + role: "user", + content: message, + ts: Date.now(), + }); + const { appendFileSync } = await import("node:fs"); + appendFileSync(historyPath, `${entry}\n`, "utf-8"); + // Dispatch to SF headless with the conversation history as context + const historyLines = existsSync(historyPath) + ? readFileSync(historyPath, "utf-8") + .trim() + .split("\n") + .filter(Boolean) + .map((l) => { + try { + return JSON.parse(l); + } catch { + return null; + } + }) + .filter(Boolean) + : []; + const contextMsg = historyLines + .slice(-10) // last 10 turns for context + .map((e) => `${e.role === "user" ? "User" : "Agent"}: ${e.content}`) + .join("\n"); + const fullPrompt = `[Agent: ${name}]\n\nConversation history:\n${contextMsg}\n\nRespond to the last user message only.`; + const { execFile } = await import("node:child_process"); + const { promisify } = await import("node:util"); + const execFileAsync = promisify(execFile); + try { + const { stdout } = await execFileAsync( + process.execPath, + [ + "-y", + "node@24", + process.env.SF_LOADER ?? "dist/loader.js", + "headless", + "--print", + fullPrompt, + ], + { + timeout: 60000, + encoding: "utf-8", + env: { ...process.env }, + }, + ); + const response = stdout.trim(); + appendFileSync( + historyPath, + `${JSON.stringify({ role: "assistant", content: response, ts: Date.now() })}\n`, + "utf-8", + ); + return { output: response }; + } catch (err) { + return { + output: `Agent dispatch failed: ${err instanceof Error ? err.message : String(err)}`, + }; + } + }, + renderResult: ({ output }) => output, + }); } /** Run the STATUS_LINE user script on a 5s interval, posting stdout to footer. */ @@ -357,3 +528,54 @@ function startStatusLineRunner(ctx) { _statusLineInterval = setInterval(tick, 5000); tick(); } + +// ─── Background Session Switcher ───────────────────────────────────────────── + +const BG_SESSIONS_FILE = ".sf/sessions-queue.json"; + +/** + * Open a TUI overlay listing background sessions from .sf/sessions-queue.json. + * Selecting one sends /resume to the chat. + * + * Purpose: BACKGROUND_SESSIONS parity — let users switch between concurrent + * sessions without leaving the TUI. + * Consumer: Ctrl+Alt+B keybinding (session_start hook). + */ +async function openBgSessionSwitcher(ctx) { + if (!ctx?.hasUI) return; + const { existsSync, readFileSync } = await import("node:fs"); + const { join: pathJoin } = await import("node:path"); + let root; + try { + root = projectRoot(); + } catch { + root = process.cwd(); + } + const queuePath = pathJoin(root ?? process.cwd(), BG_SESSIONS_FILE); + let sessions = []; + if (existsSync(queuePath)) { + try { + sessions = JSON.parse(readFileSync(queuePath, "utf-8")); + } catch { + /* malformed */ + } + } + if (!sessions.length) { + ctx.ui.notify( + "No background sessions queued.\nStart one with /resume or via BACKGROUND_SESSIONS controls.", + "info", + ); + return; + } + const labels = sessions.map( + (s, i) => + `${(i + 1).toString().padStart(2)}. ${s.id ?? "unknown"} — ${s.summary ?? "no summary"}`, + ); + const chosen = await ctx.ui.select("Switch to session:", labels); + if (!chosen) return; + const idx = labels.indexOf(chosen); + const session = sessions[idx]; + if (session?.id) { + ctx.sendMessage?.(`/resume ${session.id}`); + } +} diff --git a/src/resources/extensions/sf-tui/marketplace.js b/src/resources/extensions/sf-tui/marketplace.js index 7fa5f7549..4c2dfb0df 100644 --- a/src/resources/extensions/sf-tui/marketplace.js +++ b/src/resources/extensions/sf-tui/marketplace.js @@ -7,6 +7,7 @@ import { truncateToWidth, visibleWidth, } from "@singularity-forge/pi-tui"; +import { getExperimentalFlag } from "../sf/experimental.js"; const CATEGORIES = ["all", "extension", "skill", "theme"]; const FEATURED = [ @@ -167,10 +168,17 @@ class MarketplaceOverlay { if (matchesKey(data, Key.return) || matchesKey(data, Key.enter)) { const item = this.filtered[this.sel]; if (item) { - // In a full implementation this would trigger install/uninstall - // For now we just show info and close + if (item.source === "installed") { + // Already installed — close + this.onClose(); + } else { + // Trigger install via npm + this.onClose(); + installExtensionNpm(item.id, item.name); + } + } else { + this.onClose(); } - this.onClose(); } } invalidate() { @@ -203,7 +211,7 @@ class MarketplaceOverlay { lines.push( pad( box( - `${th.fg("dim", "filter:")} ${filterLabel} ${th.fg("dim", "↑/jk navigate • f filter • Esc close")}`, + `${th.fg("dim", "filter:")} ${filterLabel} ${th.fg("dim", "↑/jk navigate • f filter • Enter install • Esc close")}`, ), ), ); @@ -295,3 +303,44 @@ export async function openMarketplaceOverlay(ctx) { }, ); } + +/** + * Install a marketplace extension via npm into ~/.sf/agent/extensions/. + * + * Purpose: wire the EXTENSIONS experimental flag — press Enter on a featured + * package to trigger `npm install` into the user extensions directory. + * Consumer: MarketplaceOverlay.handleInput on Enter for non-installed items. + */ +export function installExtensionNpm(packageId, displayName) { + if (!getExperimentalFlag("extensions")) { + // Silently skip if EXTENSIONS flag is off; overlay already closed + return; + } + import("node:child_process").then(({ spawn }) => { + const target = join(homedir(), ".sf", "agent", "extensions"); + const proc = spawn("npm", ["install", "--prefix", target, packageId], { + stdio: ["ignore", "pipe", "pipe"], + detached: false, + }); + let stderr = ""; + proc.stderr?.on("data", (d) => { + stderr += d.toString(); + }); + proc.on("close", (code) => { + if (code === 0) { + process.stdout.write( + `\r\n\x1b[32m✓ Installed ${displayName || packageId}\x1b[0m\r\n`, + ); + } else { + process.stdout.write( + `\r\n\x1b[31m✗ Install failed for ${displayName || packageId}: ${stderr.slice(0, 200)}\x1b[0m\r\n`, + ); + } + }); + proc.on("error", () => { + process.stdout.write( + `\r\n\x1b[33m⚠ npm not found — install npm to enable extension installs\x1b[0m\r\n`, + ); + }); + }); +} diff --git a/src/resources/extensions/sf/commands/catalog.js b/src/resources/extensions/sf/commands/catalog.js index 8265c0b54..7a21a4e7c 100644 --- a/src/resources/extensions/sf/commands/catalog.js +++ b/src/resources/extensions/sf/commands/catalog.js @@ -305,6 +305,10 @@ export const TOP_LEVEL_SUBCOMMANDS = [ cmd: "resume", desc: "List or switch to another session (BACKGROUND_SESSIONS flag for live switching)", }, + { + cmd: "configure-agent", + desc: "Manage MCP servers and agent settings (CONFIGURE_COPILOT_AGENT flag)", + }, ]; export const DIRECT_SF_COMMANDS = TOP_LEVEL_SUBCOMMANDS.filter( diff --git a/src/resources/extensions/sf/commands/handlers/ops.js b/src/resources/extensions/sf/commands/handlers/ops.js index bbecb1486..620319edf 100644 --- a/src/resources/extensions/sf/commands/handlers/ops.js +++ b/src/resources/extensions/sf/commands/handlers/ops.js @@ -521,6 +521,13 @@ Examples: await handleShareCommand(trimmed.replace(/^share\s*/, "").trim(), ctx); return true; } + if (trimmed === "configure-agent" || trimmed.startsWith("configure-agent ")) { + await handleConfigureAgentCommand( + trimmed.replace(/^configure-agent\s*/, "").trim(), + ctx, + ); + return true; + } return false; } @@ -718,3 +725,152 @@ async function handleShareCommand(format, ctx) { /* non-fatal */ } } + +// ─── /configure-agent ──────────────────────────────────────────────────────── + +/** + * Interactive wizard for MCP servers and agent settings. + * + * Purpose: CONFIGURE_COPILOT_AGENT parity — let the user view and toggle + * MCP server entries without leaving SF. + * Consumer: /configure-agent command dispatch. + */ +async function handleConfigureAgentCommand(args, ctx) { + if (!getExperimentalFlag("configure_agent")) { + ctx.ui.notify( + "CONFIGURE_COPILOT_AGENT is not enabled.\nRun /experimental on configure_agent to enable.", + "warning", + ); + return; + } + + const { existsSync, readFileSync, writeFileSync, mkdirSync } = await import( + "node:fs" + ); + const { join: pathJoin } = await import("node:path"); + + const root = projectRoot(); + const mcpPath = pathJoin(root, ".mcp.json"); + const sfMcpPath = pathJoin(root, ".sf", "mcp.json"); + + // Sub-command routing + if (args === "mcp" || args === "servers" || !args) { + // Show interactive MCP server list + let config = {}; + const activePath = existsSync(mcpPath) + ? mcpPath + : existsSync(sfMcpPath) + ? sfMcpPath + : null; + if (activePath) { + try { + config = JSON.parse(readFileSync(activePath, "utf-8")); + } catch { + /* malformed */ + } + } + const servers = Object.entries(config.mcpServers ?? config.servers ?? {}); + if (!servers.length) { + const lines = [ + "No MCP servers configured.", + "", + "Add a server:", + " /configure-agent add-mcp [args...]", + "", + "Example:", + " /configure-agent add-mcp filesystem npx @modelcontextprotocol/server-filesystem /tmp", + "", + "Config file: .mcp.json (or .sf/mcp.json)", + ]; + ctx.ui.notify(lines.join("\n"), "info"); + return; + } + const lines = [`MCP Servers (${servers.length}) — config: ${activePath}\n`]; + for (const [name, cfg] of servers) { + const endpoint = cfg.command ?? cfg.url ?? "(unknown)"; + lines.push(` ● ${name.padEnd(18)} ${endpoint}`); + } + lines.push("\n/configure-agent add-mcp — add a server"); + lines.push("/configure-agent remove-mcp — remove a server"); + ctx.ui.notify(lines.join("\n"), "info"); + return; + } + + // add-mcp [args...] + if (args.startsWith("add-mcp ")) { + const parts = args.slice(8).trim().split(/\s+/); + const [name, command, ...cmdArgs] = parts; + if (!name || !command) { + ctx.ui.notify( + "Usage: /configure-agent add-mcp [args...]\nExample: /configure-agent add-mcp fs npx @modelcontextprotocol/server-filesystem /tmp", + "info", + ); + return; + } + const targetPath = existsSync(mcpPath) ? mcpPath : mcpPath; + let config = {}; + if (existsSync(targetPath)) { + try { + config = JSON.parse(readFileSync(targetPath, "utf-8")); + } catch { + /* malformed */ + } + } + config.mcpServers = config.mcpServers ?? {}; + config.mcpServers[name] = { + command, + ...(cmdArgs.length ? { args: cmdArgs } : {}), + }; + mkdirSync(projectRoot(), { recursive: true }); + writeFileSync(targetPath, JSON.stringify(config, null, 2), "utf-8"); + ctx.ui.notify( + `MCP server "${name}" added to ${targetPath}.\nRestart SF to load the new server.`, + "info", + ); + return; + } + + // remove-mcp + if (args.startsWith("remove-mcp ")) { + const name = args.slice(11).trim(); + const activePath = existsSync(mcpPath) + ? mcpPath + : existsSync(sfMcpPath) + ? sfMcpPath + : null; + if (!activePath || !existsSync(activePath)) { + ctx.ui.notify("No MCP config file found.", "warning"); + return; + } + let config = {}; + try { + config = JSON.parse(readFileSync(activePath, "utf-8")); + } catch { + ctx.ui.notify("Could not parse MCP config.", "error"); + return; + } + if (config.mcpServers?.[name]) { + delete config.mcpServers[name]; + writeFileSync(activePath, JSON.stringify(config, null, 2), "utf-8"); + ctx.ui.notify(`MCP server "${name}" removed.`, "info"); + } else if (config.servers?.[name]) { + delete config.servers[name]; + writeFileSync(activePath, JSON.stringify(config, null, 2), "utf-8"); + ctx.ui.notify(`MCP server "${name}" removed.`, "info"); + } else { + ctx.ui.notify(`No server named "${name}" found.`, "warning"); + } + return; + } + + ctx.ui.notify( + [ + "configure-agent subcommands:", + " /configure-agent — List MCP servers", + " /configure-agent mcp — Same as above", + " /configure-agent add-mcp — Add MCP server", + " /configure-agent remove-mcp — Remove MCP server", + ].join("\n"), + "info", + ); +}