From b34f5997eb133ccd1f0164d35b485b69a0b45d03 Mon Sep 17 00:00:00 2001 From: Mikael Hugo Date: Sat, 9 May 2026 05:35:54 +0200 Subject: [PATCH] =?UTF-8?q?feat(sf):=20Tier=203=20=E2=80=94=20/rubber-duck?= =?UTF-8?q?,=20/delegate,=20/share,=20/ask,=20/resume,=20/sidekicks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit handlers/core.js: - /ask — 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-.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> --- .../extensions/sf/commands/catalog.js | 20 +++ .../extensions/sf/commands/handlers/core.js | 97 ++++++++++++ .../extensions/sf/commands/handlers/ops.js | 148 ++++++++++++++++++ 3 files changed, 265 insertions(+) diff --git a/src/resources/extensions/sf/commands/catalog.js b/src/resources/extensions/sf/commands/catalog.js index 6f9dfb019..8265c0b54 100644 --- a/src/resources/extensions/sf/commands/catalog.js +++ b/src/resources/extensions/sf/commands/catalog.js @@ -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( diff --git a/src/resources/extensions/sf/commands/handlers/core.js b/src/resources/extensions/sf/commands/handlers/core.js index e70f2bf69..66a556b3c 100644 --- a/src/resources/extensions/sf/commands/handlers/core.js +++ b/src/resources/extensions/sf/commands/handlers/core.js @@ -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 \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 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"); + } +} diff --git a/src/resources/extensions/sf/commands/handlers/ops.js b/src/resources/extensions/sf/commands/handlers/ops.js index 8053239c0..bbecb1486 100644 --- a/src/resources/extensions/sf/commands/handlers/ops.js +++ b/src/resources/extensions/sf/commands/handlers/ops.js @@ -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 */ + } +}