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:
parent
c1c3195f75
commit
b34f5997eb
3 changed files with 265 additions and 0 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue