feat(sf): Tier 3 — /rubber-duck, /delegate, /share, /ask, /resume, /sidekicks

handlers/core.js:
- /ask <question> — ephemeral side question via ctx.fork (graceful
  fallback if fork unavailable)
- /resume [id] — session listing via ctx.listSessions; falls back to
  ~/.sf/sessions/ file listing with upgrade hint for BACKGROUND_SESSIONS

handlers/ops.js:
- /rubber-duck [topic] — constructive review subagent gated on
  RUBBER_DUCK experimental flag; routes via ctx.sendMessage
- /delegate [title] — GitHub PR creation via gh pr create --web;
  shows recent commits for context
- /share [md] — export session transcript to ~/sf-session-<ts>.md;
  copies path to clipboard (pbcopy / xclip / xsel)

catalog.js:
- Add /rubber-duck, /delegate, /share, /ask, /resume to TOP_LEVEL_SUBCOMMANDS

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Mikael Hugo 2026-05-09 05:35:54 +02:00
parent c1c3195f75
commit b34f5997eb
3 changed files with 265 additions and 0 deletions

View file

@ -285,6 +285,26 @@ export const TOP_LEVEL_SUBCOMMANDS = [
cmd: "keep-alive",
desc: "Prevent system sleep during long runs (caffeinate / systemd-inhibit)",
},
{
cmd: "rubber-duck",
desc: "Request constructive code/design review from a rubber-duck subagent (RUBBER_DUCK flag)",
},
{
cmd: "delegate",
desc: "Create a GitHub PR from the current branch via gh pr create",
},
{
cmd: "share",
desc: "Export session transcript to a Markdown file",
},
{
cmd: "ask",
desc: "Ask a side question without it entering the main conversation history",
},
{
cmd: "resume",
desc: "List or switch to another session (BACKGROUND_SESSIONS flag for live switching)",
},
];
export const DIRECT_SF_COMMANDS = TOP_LEVEL_SUBCOMMANDS.filter(

View file

@ -853,6 +853,14 @@ export async function handleCoreCommand(trimmed, ctx, pi) {
await handleSidekicksCommand(ctx);
return true;
}
if (trimmed === "ask" || trimmed.startsWith("ask ")) {
await handleAskCommand(trimmed.replace(/^ask\s*/, "").trim(), ctx);
return true;
}
if (trimmed === "resume" || trimmed.startsWith("resume ")) {
await handleResumeCommand(trimmed.replace(/^resume\s*/, "").trim(), ctx);
return true;
}
return false;
}
@ -1337,3 +1345,92 @@ async function handleSidekicksCommand(ctx) {
ctx.ui.notify(`Sidekicks error: ${e.message}`, "error");
}
}
// ─── /ask ────────────────────────────────────────────────────────────────────
async function handleAskCommand(question, ctx) {
if (!question) {
ctx.ui.notify(
"Usage: /ask <question>\nAsk a side question without it becoming part of the main conversation history.\n\nTip: The response appears inline but does not affect the agent's working context.",
"info",
);
return;
}
// Use ctx.fork if available (ephemeral branch), otherwise fall back to a
// labelled notify so the UX is still useful.
try {
if (ctx.fork) {
const ephemeral = await ctx.fork();
await ephemeral.sendMessage?.(question);
ctx.ui.notify("(/ask response in ephemeral branch above)", "info");
} else {
// Graceful fallback: route as a tagged message
ctx.ui.notify(
`/ask question: ${question}\n\n(Note: ctx.fork is unavailable in this build — /ask behaves like a regular message. The response will be in the main conversation.)`,
"info",
);
await ctx.sendMessage?.(question);
}
} catch (e) {
ctx.ui.notify(`/ask failed: ${e.message}`, "error");
}
}
// ─── /resume ─────────────────────────────────────────────────────────────────
async function handleResumeCommand(args, ctx) {
try {
// Try the native session switcher if available
if (ctx.switchSession) {
if (args) {
await ctx.switchSession(args);
return;
}
// No arg — show picker
const sessions = await ctx.listSessions?.();
if (sessions?.length) {
const lines = ["Available sessions — /resume <id> to switch\n"];
for (const s of sessions.slice(0, 20)) {
lines.push(
` ${s.id ?? s.name} ${s.summary ?? s.description ?? ""}`.trim(),
);
}
ctx.ui.notify(lines.join("\n"), "info");
} else {
ctx.ui.notify("No other sessions found.", "info");
}
return;
}
// Fallback: show recent SF headless session files
const { readdirSync, existsSync } = await import("node:fs");
const { join: pathJoin } = await import("node:path");
const { homedir } = await import("node:os");
const sfDir = pathJoin(homedir(), ".sf");
const sessDir = pathJoin(sfDir, "sessions");
if (!existsSync(sessDir)) {
ctx.ui.notify(
"No session history found. Sessions are tracked in ~/.sf/sessions/",
"info",
);
return;
}
const files = readdirSync(sessDir)
.filter((f) => f.endsWith(".json") || f.endsWith(".jsonl"))
.slice(-10)
.reverse();
if (!files.length) {
ctx.ui.notify("No session files found in ~/.sf/sessions/", "info");
return;
}
const lines = ["Recent sessions (read-only view)\n"];
for (const f of files) {
lines.push(` ${f}`);
}
lines.push(
"\nBACKGROUND_SESSIONS flag enables live switching (/experimental on background_sessions).",
);
ctx.ui.notify(lines.join("\n"), "info");
} catch (e) {
ctx.ui.notify(`Resume failed: ${e.message}`, "error");
}
}

View file

@ -29,6 +29,7 @@ import { handleRate } from "../../commands-rate.js";
import { handleSessionReport } from "../../commands-session-report.js";
import { handleShip } from "../../commands-ship.js";
import { handleCost } from "../../cost-command.js";
import { getExperimentalFlag } from "../../experimental.js";
import { handleExport } from "../../export.js";
import { handleHistory } from "../../history.js";
import { handleUndo } from "../../undo.js";
@ -499,6 +500,27 @@ Examples:
await handleKeepAlive(trimmed.replace(/^keep-alive\s*/, "").trim(), ctx);
return true;
}
if (
trimmed === "rubber-duck" ||
trimmed.startsWith("rubber-duck ") ||
trimmed === "review-code" ||
trimmed.startsWith("review-code ")
) {
const input = trimmed.replace(/^(?:rubber-duck|review-code)\s*/, "").trim();
await handleRubberDuckCommand(input, ctx, pi);
return true;
}
if (trimmed === "delegate" || trimmed.startsWith("delegate ")) {
await handleDelegateCommand(
trimmed.replace(/^delegate\s*/, "").trim(),
ctx,
);
return true;
}
if (trimmed === "share" || trimmed.startsWith("share ")) {
await handleShareCommand(trimmed.replace(/^share\s*/, "").trim(), ctx);
return true;
}
return false;
}
@ -570,3 +592,129 @@ async function handleKeepAlive(args, ctx) {
);
}
}
// ─── /rubber-duck ────────────────────────────────────────────────────────────
async function handleRubberDuckCommand(topic, ctx, _pi) {
if (!getExperimentalFlag("rubber_duck")) {
ctx.ui.notify(
"RUBBER_DUCK is not enabled. Run /experimental on rubber_duck to enable.",
"warning",
);
return;
}
const prompt = topic
? `Rubber-duck review requested: ${topic}\n\nPlease review this as a constructive critic: identify risks, edge cases, missing tests, and improvements. Be direct and concise.`
: "Please give constructive feedback on the current code changes or design. Identify risks, edge cases, missing tests, and improvements.";
ctx.ui.notify(
"Starting rubber-duck review… (RUBBER_DUCK agent is constructive, not adversarial)",
"info",
);
try {
await ctx.sendMessage?.(prompt);
} catch {
ctx.ui.notify(
"Could not start rubber-duck session. Try typing your review request directly.",
"warning",
);
}
}
// ─── /delegate ───────────────────────────────────────────────────────────────
async function handleDelegateCommand(args, ctx) {
const { execSync } = await import("node:child_process");
const root = projectRoot();
const title = args || undefined;
try {
// Check if gh is available
execSync("gh --version", { stdio: "pipe" });
} catch {
ctx.ui.notify(
"GitHub CLI (gh) is not installed. Install it from https://cli.github.com",
"warning",
);
return;
}
try {
const branch = execSync("git rev-parse --abbrev-ref HEAD", {
cwd: root,
encoding: "utf-8",
}).trim();
const summary = execSync(
"git log --oneline -5 2>/dev/null || echo '(no commits)'",
{ cwd: root, encoding: "utf-8" },
).trim();
ctx.ui.notify(
[
`Branch: ${branch}`,
"",
"Recent commits:",
summary,
"",
title
? `Creating PR with title: "${title}"`
: 'Run: gh pr create --web (or: /delegate "My PR title")',
].join("\n"),
"info",
);
if (title) {
const prUrl = execSync(
`gh pr create --title "${title.replace(/"/g, '\\"')}" --body "Created from SF session" --web 2>&1 || true`,
{ cwd: root, encoding: "utf-8" },
).trim();
if (prUrl) ctx.ui.notify(`PR: ${prUrl}`, "info");
}
} catch (e) {
ctx.ui.notify(`Delegate failed: ${e.message}`, "error");
}
}
// ─── /share ──────────────────────────────────────────────────────────────────
async function handleShareCommand(format, ctx) {
const { writeFileSync } = await import("node:fs");
const { join: pathJoin } = await import("node:path");
const { homedir } = await import("node:os");
const { execSync } = await import("node:child_process");
const fmt = format || "md";
const entries = ctx.sessionManager?.getEntries?.() ?? [];
const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
const outPath = pathJoin(
homedir(),
`sf-session-${ts}.${fmt === "md" ? "md" : "txt"}`,
);
const lines = [`# SF Session — ${new Date().toLocaleString()}`, ""];
for (const entry of entries) {
const role =
entry.type === "user"
? "**User**"
: entry.type === "assistant"
? "**SF**"
: `*${entry.type ?? "system"}*`;
const content =
typeof entry.content === "string"
? entry.content
: JSON.stringify(entry.content ?? "");
lines.push(`${role}:\n${content}\n`);
}
const md = lines.join("\n");
writeFileSync(outPath, md, "utf-8");
ctx.ui.notify(`Session exported to: ${outPath}`, "info");
// Try to copy to clipboard (non-fatal)
try {
const platform = process.platform;
if (platform === "darwin") {
execSync(`echo "${outPath}" | pbcopy`, { stdio: "pipe" });
} else if (platform === "linux") {
execSync(
`echo "${outPath}" | xclip -selection clipboard 2>/dev/null || echo "${outPath}" | xsel --clipboard --input 2>/dev/null || true`,
{ stdio: "pipe" },
);
}
} catch {
/* non-fatal */
}
}