feat(sf): Tier 4 — ASK_USER_ELICITATION, CONFIGURE_COPILOT_AGENT, BACKGROUND_SESSIONS, MULTI_TURN_AGENTS, marketplace Enter install

- ask_user_elicitation tool: structured select/input form when flag is on
- spawn_agent tool: persistent named sub-agent via file-backed .sf/agents/<name>/history.jsonl
- /configure-agent command: list/add/remove MCP servers in .mcp.json (CONFIGURE_COPILOT_AGENT flag)
- Ctrl+Alt+B: opens bg session switcher overlay from .sf/sessions-queue.json (BACKGROUND_SESSIONS flag)
- openBgSessionSwitcher(): TUI ctx.ui.select picker for session switching
- marketplace.js: Enter key triggers installExtensionNpm (EXTENSIONS flag); footer hint updated
- Fix require() → ESM-safe imports in sf-tui/index.js (spawn, execSync, platform from static imports)
- catalog.js: /configure-agent entry added

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Mikael Hugo 2026-05-09 07:30:33 +02:00
parent 3017663a69
commit 9450b4a11d
4 changed files with 444 additions and 13 deletions

View file

@ -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/<name>/.",
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 <sessionId> 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 <description> 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}`);
}
}

View file

@ -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`,
);
});
});
}

View file

@ -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(

View file

@ -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 <name> <command> [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 <name> <cmd> — add a server");
lines.push("/configure-agent remove-mcp <name> — remove a server");
ctx.ui.notify(lines.join("\n"), "info");
return;
}
// add-mcp <name> <command> [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 <name> <command> [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 <name>
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 <name> <cmd> — Add MCP server",
" /configure-agent remove-mcp <name> — Remove MCP server",
].join("\n"),
"info",
);
}