From 45fdf5d54db152f0caa9d0da1c3b1b02e8971efd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Facu=5FVi=C3=B1as?= Date: Wed, 11 Mar 2026 11:28:23 -0300 Subject: [PATCH 01/13] feat: remote user questions via Slack/Discord for headless auto-mode When ask_user_questions is called in non-interactive mode (ctx.hasUI = false), transparently route questions to a configured Slack or Discord channel and poll for the user's response. Same tool interface, automatic routing. - Add adapter pattern for Slack (Bot Token API) and Discord (HTTP API) - Add /gsd remote command for interactive setup wizard - Add SLACK_BOT_TOKEN / DISCORD_BOT_TOKEN to wizard and env hydration - Add remote_questions config to GSD preferences with merge support - Fix parseScalar to preserve large numeric IDs (Discord channel IDs) - Show remote channel status on session_start Co-Authored-By: Claude Opus 4.6 --- .../extensions/ask-user-questions.ts | 43 +- src/resources/extensions/gsd/commands.ts | 19 +- src/resources/extensions/gsd/index.ts | 13 +- src/resources/extensions/gsd/preferences.ts | 18 +- .../extensions/remote-questions/channels.ts | 36 ++ .../extensions/remote-questions/config.ts | 78 +++ .../remote-questions/discord-adapter.ts | 188 +++++++ .../extensions/remote-questions/format.ts | 216 ++++++++ .../extensions/remote-questions/index.ts | 213 ++++++++ .../remote-questions/remote-command.ts | 461 ++++++++++++++++++ .../remote-questions/slack-adapter.ts | 176 +++++++ src/wizard.ts | 16 + 12 files changed, 1469 insertions(+), 8 deletions(-) create mode 100644 src/resources/extensions/remote-questions/channels.ts create mode 100644 src/resources/extensions/remote-questions/config.ts create mode 100644 src/resources/extensions/remote-questions/discord-adapter.ts create mode 100644 src/resources/extensions/remote-questions/format.ts create mode 100644 src/resources/extensions/remote-questions/index.ts create mode 100644 src/resources/extensions/remote-questions/remote-command.ts create mode 100644 src/resources/extensions/remote-questions/slack-adapter.ts diff --git a/src/resources/extensions/ask-user-questions.ts b/src/resources/extensions/ask-user-questions.ts index 4446e676c..23f97decb 100644 --- a/src/resources/extensions/ask-user-questions.ts +++ b/src/resources/extensions/ask-user-questions.ts @@ -104,7 +104,7 @@ export default function AskUserQuestions(pi: ExtensionAPI) { ], parameters: AskUserQuestionsParams, - async execute(_toolCallId, params, _signal, _onUpdate, ctx) { + async execute(_toolCallId, params, signal, _onUpdate, ctx) { // Validation if (params.questions.length === 0 || params.questions.length > 3) { return errorResult("Error: questions must contain 1-3 items", params.questions); @@ -120,6 +120,9 @@ export default function AskUserQuestions(pi: ExtensionAPI) { } if (!ctx.hasUI) { + const { tryRemoteQuestions } = await import("./remote-questions/index.js"); + const remoteResult = await tryRemoteQuestions(params.questions, signal); + if (remoteResult) return remoteResult; return errorResult("Error: UI not available (non-interactive mode)", params.questions); } @@ -165,19 +168,53 @@ export default function AskUserQuestions(pi: ExtensionAPI) { }, renderResult(result, _options, theme) { - const details = result.details as AskUserQuestionsDetails | undefined; + const details = result.details as (AskUserQuestionsDetails & { remote?: boolean; channel?: string; timed_out?: boolean; threadUrl?: string }) | undefined; if (!details) { const text = result.content[0]; return new Text(text?.type === "text" ? text.text : "", 0, 0); } + // Remote channel result + if (details.remote) { + if (details.timed_out) { + const channelLabel = details.channel ?? "remote"; + return new Text( + `${theme.fg("warning", `${channelLabel} — timed out`)}${details.threadUrl ? theme.fg("dim", ` ${details.threadUrl}`) : ""}`, + 0, + 0, + ); + } + + const remoteResponse = details.response as import("./remote-questions/channels.js").RemoteAnswer | undefined; + const questions = (details.questions ?? []) as Question[]; + const lines: string[] = []; + const channelLabel = details.channel ?? "remote"; + lines.push(theme.fg("dim", channelLabel)); + if (remoteResponse) { + for (const q of questions) { + const answer = remoteResponse.answers[q.id]; + if (!answer) { + lines.push(`${theme.fg("accent", q.header)}: ${theme.fg("dim", "(no answer)")}`); + continue; + } + const answerText = answer.answers.length > 0 ? answer.answers.join(", ") : "(custom)"; + let line = `${theme.fg("success", "✓ ")}${theme.fg("accent", q.header)}: ${answerText}`; + if (answer.user_note) { + line += ` ${theme.fg("muted", `[note: ${answer.user_note}]`)}`; + } + lines.push(line); + } + } + return new Text(lines.join("\n"), 0, 0); + } + if (details.cancelled || !details.response) { return new Text(theme.fg("warning", "Cancelled"), 0, 0); } const lines: string[] = []; for (const q of details.questions) { - const answer = details.response.answers[q.id]; + const answer = (details.response as RoundResult).answers[q.id]; if (!answer) { lines.push(`${theme.fg("accent", q.header)}: ${theme.fg("dim", "(no answer)")}`); continue; diff --git a/src/resources/extensions/gsd/commands.ts b/src/resources/extensions/gsd/commands.ts index 65ac405a2..d3f8f30d3 100644 --- a/src/resources/extensions/gsd/commands.ts +++ b/src/resources/extensions/gsd/commands.ts @@ -31,6 +31,7 @@ import { } from "./doctor.js"; import { loadPrompt } from "./prompt-loader.js"; import { handleMigrate } from "./migrate/command.js"; +import { handleRemote } from "../remote-questions/remote-command.js"; function dispatchDoctorHeal(pi: ExtensionAPI, scope: string | undefined, reportText: string, structuredIssues: string): void { const workflowPath = process.env.GSD_WORKFLOW_PATH ?? join(process.env.HOME ?? "~", ".pi", "GSD-WORKFLOW.md"); @@ -52,10 +53,10 @@ function dispatchDoctorHeal(pi: ExtensionAPI, scope: string | undefined, reportT export function registerGSDCommand(pi: ExtensionAPI): void { pi.registerCommand("gsd", { - description: "GSD — Get Shit Done: /gsd auto|stop|status|queue|prefs|doctor|migrate", + description: "GSD — Get Shit Done: /gsd auto|stop|status|queue|prefs|doctor|migrate|remote", getArgumentCompletions: (prefix: string) => { - const subcommands = ["auto", "stop", "status", "queue", "discuss", "prefs", "doctor", "migrate"]; + const subcommands = ["auto", "stop", "status", "queue", "discuss", "prefs", "doctor", "migrate", "remote"]; const parts = prefix.trim().split(/\s+/); if (parts.length <= 1) { @@ -78,6 +79,13 @@ export function registerGSDCommand(pi: ExtensionAPI): void { .map((cmd) => ({ value: `prefs ${cmd}`, label: cmd })); } + if (parts[0] === "remote" && parts.length <= 2) { + const subPrefix = parts[1] ?? ""; + return ["slack", "discord", "status", "disconnect"] + .filter((cmd) => cmd.startsWith(subPrefix)) + .map((cmd) => ({ value: `remote ${cmd}`, label: cmd })); + } + if (parts[0] === "doctor") { const modePrefix = parts[1] ?? ""; const modes = ["fix", "heal", "audit"]; @@ -142,13 +150,18 @@ export function registerGSDCommand(pi: ExtensionAPI): void { return; } + if (trimmed === "remote" || trimmed.startsWith("remote ")) { + await handleRemote(trimmed.replace(/^remote\s*/, "").trim(), ctx, pi); + return; + } + if (trimmed === "") { await showSmartEntry(ctx, pi, process.cwd()); return; } ctx.ui.notify( - `Unknown: /gsd ${trimmed}. Use /gsd, /gsd auto, /gsd stop, /gsd status, /gsd queue, /gsd discuss, /gsd prefs [global|project|status], /gsd doctor [audit|fix|heal] [M###/S##], or /gsd migrate .`, + `Unknown: /gsd ${trimmed}. Use /gsd, /gsd auto, /gsd stop, /gsd status, /gsd queue, /gsd discuss, /gsd prefs [global|project|status], /gsd doctor [audit|fix|heal] [M###/S##], /gsd migrate , or /gsd remote [slack|discord|status|disconnect].`, "warning", ); }, diff --git a/src/resources/extensions/gsd/index.ts b/src/resources/extensions/gsd/index.ts index 018843df1..9d6376b5f 100644 --- a/src/resources/extensions/gsd/index.ts +++ b/src/resources/extensions/gsd/index.ts @@ -72,7 +72,7 @@ export default function (pi: ExtensionAPI) { }); pi.registerTool(dynamicBash as any); - // ── session_start: render branded GSD header ─────────────────────────── + // ── session_start: render branded GSD header + remote channel status ── pi.on("session_start", async (_event, ctx) => { const theme = ctx.ui.theme; const version = process.env.GSD_VERSION || "0.0.0"; @@ -82,6 +82,17 @@ export default function (pi: ExtensionAPI) { const headerContent = `${logoText}\n${titleLine}`; ctx.ui.setHeader((_ui, _theme) => new Text(headerContent, 1, 0)); + + // Notify remote questions status if configured + try { + const { getRemoteConfigStatus } = await import("../remote-questions/config.js"); + const status = getRemoteConfigStatus(); + if (!status.includes("not configured")) { + ctx.ui.notify(status, status.includes("disabled") ? "warning" : "info"); + } + } catch { + // Remote questions module not available — ignore + } }); // ── Ctrl+Alt+G shortcut — GSD dashboard overlay ──────────────────────── diff --git a/src/resources/extensions/gsd/preferences.ts b/src/resources/extensions/gsd/preferences.ts index 222fa3d03..a84fbceb7 100644 --- a/src/resources/extensions/gsd/preferences.ts +++ b/src/resources/extensions/gsd/preferences.ts @@ -31,6 +31,13 @@ export interface AutoSupervisorConfig { hard_timeout_minutes?: number; } +export interface RemoteQuestionsConfig { + channel: "slack" | "discord"; + channel_id: string; + timeout_minutes?: number; // Default: 5 + poll_interval_seconds?: number; // Default: 5 +} + export interface GSDPreferences { version?: number; always_use_skills?: string[]; @@ -43,6 +50,7 @@ export interface GSDPreferences { auto_supervisor?: AutoSupervisorConfig; uat_dispatch?: boolean; budget_ceiling?: number; + remote_questions?: RemoteQuestionsConfig; } export interface LoadedGSDPreferences { @@ -430,7 +438,12 @@ function parseFrontmatterBlock(frontmatter: string): GSDPreferences { function parseScalar(value: string): string | number | boolean { if (value === "true") return true; if (value === "false") return false; - if (/^-?\d+$/.test(value)) return Number(value); + if (/^-?\d+$/.test(value)) { + const n = Number(value); + // Keep large integers (e.g. Discord channel IDs) as strings to avoid precision loss + if (Number.isSafeInteger(n)) return n; + return value; + } return value.replace(/^['\"]|['\"]$/g, ""); } @@ -495,6 +508,9 @@ function mergePreferences(base: GSDPreferences, override: GSDPreferences): GSDPr auto_supervisor: { ...(base.auto_supervisor ?? {}), ...(override.auto_supervisor ?? {}) }, uat_dispatch: override.uat_dispatch ?? base.uat_dispatch, budget_ceiling: override.budget_ceiling ?? base.budget_ceiling, + remote_questions: override.remote_questions + ? { ...(base.remote_questions ?? {}), ...override.remote_questions } + : base.remote_questions, }; } diff --git a/src/resources/extensions/remote-questions/channels.ts b/src/resources/extensions/remote-questions/channels.ts new file mode 100644 index 000000000..7360c00a3 --- /dev/null +++ b/src/resources/extensions/remote-questions/channels.ts @@ -0,0 +1,36 @@ +/** + * Remote Questions — Adapter pattern interfaces + * + * Defines the contract for Slack/Discord (or any future) channel adapters. + */ + +export interface ChannelAdapter { + readonly name: string; + sendQuestions(questions: FormattedQuestion[]): Promise; + pollResponse(ref: PollReference): Promise; + validate(): Promise; +} + +export interface FormattedQuestion { + id: string; + header: string; + question: string; + options: Array<{ label: string; description: string }>; + allowMultiple: boolean; +} + +export interface SendResult { + ref: PollReference; + threadUrl?: string; +} + +export interface PollReference { + channelType: "slack" | "discord"; + messageId: string; + threadTs?: string; + channelId: string; +} + +export interface RemoteAnswer { + answers: Record; +} diff --git a/src/resources/extensions/remote-questions/config.ts b/src/resources/extensions/remote-questions/config.ts new file mode 100644 index 000000000..9c92f0fba --- /dev/null +++ b/src/resources/extensions/remote-questions/config.ts @@ -0,0 +1,78 @@ +/** + * Remote Questions — Configuration resolution + * + * Reads remote_questions config from GSD preferences and verifies + * the corresponding token exists in process.env. + */ + +import { loadEffectiveGSDPreferences, type RemoteQuestionsConfig } from "../gsd/preferences.js"; + +export interface ResolvedConfig { + channel: "slack" | "discord"; + channelId: string; + timeoutMs: number; + pollIntervalMs: number; + token: string; +} + +const ENV_KEYS: Record = { + slack: "SLACK_BOT_TOKEN", + discord: "DISCORD_BOT_TOKEN", +}; + +const DEFAULT_TIMEOUT_MINUTES = 5; +const DEFAULT_POLL_INTERVAL_SECONDS = 5; + +/** + * Resolve remote questions configuration from preferences + env. + * Returns null if not configured or token is missing. + */ +export function resolveRemoteConfig(): ResolvedConfig | null { + const prefs = loadEffectiveGSDPreferences(); + const rq: RemoteQuestionsConfig | undefined = prefs?.preferences.remote_questions; + if (!rq || !rq.channel || !rq.channel_id) return null; + + const envVar = ENV_KEYS[rq.channel]; + if (!envVar) return null; + + const token = process.env[envVar]; + if (!token) return null; + + const timeoutMinutes = rq.timeout_minutes ?? DEFAULT_TIMEOUT_MINUTES; + const pollIntervalSeconds = rq.poll_interval_seconds ?? DEFAULT_POLL_INTERVAL_SECONDS; + + // Always coerce channel_id to string — parseScalar may convert large numeric + // Discord IDs to a lossy Number (exceeds Number.MAX_SAFE_INTEGER). + const channelId = String(rq.channel_id); + + return { + channel: rq.channel, + channelId, + timeoutMs: timeoutMinutes * 60 * 1000, + pollIntervalMs: pollIntervalSeconds * 1000, + token, + }; +} + +/** + * Return a human-readable status string for the remote questions config. + * Used by session_start notification and /gsd remote status. + */ +export function getRemoteConfigStatus(): string { + const prefs = loadEffectiveGSDPreferences(); + const rq: RemoteQuestionsConfig | undefined = prefs?.preferences.remote_questions; + + if (!rq || !rq.channel || !rq.channel_id) { + return "Remote questions: not configured"; + } + + const envVar = ENV_KEYS[rq.channel]; + if (!envVar) return `Remote questions: unknown channel type "${rq.channel}"`; + + const token = process.env[envVar]; + if (!token) { + return `Remote questions: ${envVar} not set — remote questions disabled`; + } + + return `Remote questions: ${rq.channel} (channel ${rq.channel_id}) configured`; +} diff --git a/src/resources/extensions/remote-questions/discord-adapter.ts b/src/resources/extensions/remote-questions/discord-adapter.ts new file mode 100644 index 000000000..df54ef6bd --- /dev/null +++ b/src/resources/extensions/remote-questions/discord-adapter.ts @@ -0,0 +1,188 @@ +/** + * Remote Questions — Discord adapter + * + * Uses Discord Bot HTTP API (no gateway/websocket): + * - Send: POST /channels/{id}/messages with embed + * - Poll: GET reactions + GET messages after the sent message + */ + +import type { + ChannelAdapter, + FormattedQuestion, + PollReference, + RemoteAnswer, + SendResult, +} from "./channels.js"; +import { formatForDiscord, parseDiscordResponse } from "./format.js"; + +const DISCORD_API = "https://discord.com/api/v10"; + +export class DiscordAdapter implements ChannelAdapter { + readonly name = "discord"; + private botUserId: string | null = null; + + constructor( + private readonly token: string, + private readonly channelId: string, + ) {} + + async validate(): Promise { + const res = await this.discordApi("GET", "/users/@me"); + if (!res.id) { + throw new Error("Discord auth failed: invalid token"); + } + this.botUserId = res.id as string; + } + + async sendQuestions(questions: FormattedQuestion[]): Promise { + const { embeds, reactionEmojis } = formatForDiscord(questions); + + const res = await this.discordApi("POST", `/channels/${this.channelId}/messages`, { + content: "**GSD needs your input** — reply to this message or react with your choice", + embeds, + }); + + if (!res.id) { + throw new Error(`Discord send failed: ${JSON.stringify(res)}`); + } + + const messageId = res.id as string; + + // Add reaction emojis as templates (best-effort, don't block on failure) + for (const emoji of reactionEmojis) { + try { + await this.discordApi( + "PUT", + `/channels/${this.channelId}/messages/${messageId}/reactions/${encodeURIComponent(emoji)}/@me`, + ); + } catch { + // Non-critical — continue + } + } + + return { + ref: { + channelType: "discord", + messageId, + channelId: this.channelId, + }, + }; + } + + async pollResponse(ref: PollReference): Promise { + return this.pollResponseWithQuestions(ref, []); + } + + /** + * Poll with full question context for proper parsing. + */ + async pollResponseWithQuestions( + ref: PollReference, + questions: FormattedQuestion[], + ): Promise { + if (!this.botUserId) { + const me = await this.discordApi("GET", "/users/@me"); + if (me.id) this.botUserId = me.id as string; + } + + // Strategy 1: Check reactions on the original message + const reactionAnswer = await this.checkReactions(ref, questions); + if (reactionAnswer) return reactionAnswer; + + // Strategy 2: Check for text replies after the message + const replyAnswer = await this.checkReplies(ref, questions); + if (replyAnswer) return replyAnswer; + + return null; + } + + private async checkReactions( + ref: PollReference, + questions: FormattedQuestion[], + ): Promise { + const numberEmojis = ["1\ufe0f\u20e3", "2\ufe0f\u20e3", "3\ufe0f\u20e3", "4\ufe0f\u20e3", "5\ufe0f\u20e3"]; + const reactions: Array<{ emoji: string; count: number }> = []; + + for (const emoji of numberEmojis) { + try { + const users = await this.discordApi( + "GET", + `/channels/${ref.channelId}/messages/${ref.messageId}/reactions/${encodeURIComponent(emoji)}`, + ); + + if (Array.isArray(users)) { + // Filter out bot's own reactions + const humanUsers = users.filter( + (u: { id: string }) => u.id !== this.botUserId, + ); + if (humanUsers.length > 0) { + reactions.push({ emoji, count: humanUsers.length }); + } + } + } catch { + // Reaction not present or no access + } + } + + if (reactions.length === 0) return null; + + return parseDiscordResponse(reactions, null, questions); + } + + private async checkReplies( + ref: PollReference, + questions: FormattedQuestion[], + ): Promise { + const messages = await this.discordApi( + "GET", + `/channels/${ref.channelId}/messages?after=${ref.messageId}&limit=10`, + ); + + if (!Array.isArray(messages)) return null; + + // Only accept replies that explicitly reference our message via Discord's reply feature + const replies = messages.filter( + (m: { author: { id: string }; message_reference?: { message_id: string }; content: string }) => + m.author.id !== this.botUserId && + m.message_reference?.message_id === ref.messageId, + ); + + if (replies.length === 0) return null; + + const firstReply = replies[0] as { content: string }; + return parseDiscordResponse([], firstReply.content, questions); + } + + // ─── Internal ────────────────────────────────────────────────────────────── + + private async discordApi( + method: string, + path: string, + body?: unknown, + ): Promise> { + const url = `${DISCORD_API}${path}`; + + const headers: Record = { + Authorization: `Bot ${this.token}`, + }; + + const init: RequestInit = { method, headers }; + + if (body) { + headers["Content-Type"] = "application/json"; + init.body = JSON.stringify(body); + } + + const response = await fetch(url, init); + + // For reaction PUT, 204 No Content is success + if (response.status === 204) return {}; + + if (!response.ok) { + const text = await response.text().catch(() => ""); + throw new Error(`Discord API HTTP ${response.status}: ${text}`); + } + + return (await response.json()) as Record; + } +} diff --git a/src/resources/extensions/remote-questions/format.ts b/src/resources/extensions/remote-questions/format.ts new file mode 100644 index 000000000..348992c1d --- /dev/null +++ b/src/resources/extensions/remote-questions/format.ts @@ -0,0 +1,216 @@ +/** + * Remote Questions — Payload formatting for Slack and Discord + * + * Converts Question[] to channel-specific payloads and parses replies + * back into RemoteAnswer objects. + */ + +import type { FormattedQuestion, RemoteAnswer } from "./channels.js"; + +// ─── Slack Block Kit ───────────────────────────────────────────────────────── + +export interface SlackBlock { + type: string; + text?: { type: string; text: string }; + elements?: Array<{ type: string; text: string }>; +} + +/** + * Format questions as Slack Block Kit blocks for chat.postMessage. + */ +export function formatForSlack(questions: FormattedQuestion[]): SlackBlock[] { + const blocks: SlackBlock[] = [ + { + type: "header", + text: { type: "plain_text", text: "GSD needs your input" }, + }, + ]; + + for (const q of questions) { + // Question header + text + blocks.push({ + type: "section", + text: { + type: "mrkdwn", + text: `*${q.header}*\n${q.question}`, + }, + }); + + // Numbered options + const optionLines = q.options.map( + (opt, i) => `${i + 1}. *${opt.label}* — ${opt.description}`, + ); + blocks.push({ + type: "section", + text: { + type: "mrkdwn", + text: optionLines.join("\n"), + }, + }); + + // Instructions + const instruction = q.allowMultiple + ? `Reply in this thread with numbers separated by comma (e.g. \`1,3\`) or type a custom answer.` + : `Reply in this thread with the number of your choice (e.g. \`1\`) or type a custom answer.`; + + blocks.push({ + type: "context", + elements: [{ type: "mrkdwn", text: instruction }], + }); + + blocks.push({ type: "divider" }); + } + + return blocks; +} + +// ─── Discord Embed ─────────────────────────────────────────────────────────── + +export interface DiscordEmbed { + title: string; + description: string; + color: number; + fields: Array<{ name: string; value: string; inline?: boolean }>; + footer?: { text: string }; +} + +const NUMBER_EMOJIS = ["1\ufe0f\u20e3", "2\ufe0f\u20e3", "3\ufe0f\u20e3", "4\ufe0f\u20e3", "5\ufe0f\u20e3"]; + +/** + * Format questions as a Discord embed for channel message. + */ +export function formatForDiscord(questions: FormattedQuestion[]): { embeds: DiscordEmbed[]; reactionEmojis: string[] } { + const allEmojis: string[] = []; + const embeds: DiscordEmbed[] = []; + + for (const q of questions) { + const optionLines = q.options.map((opt, i) => { + const emoji = NUMBER_EMOJIS[i] ?? `${i + 1}.`; + allEmojis.push(NUMBER_EMOJIS[i] ?? ""); + return `${emoji} **${opt.label}** — ${opt.description}`; + }); + + const instruction = q.allowMultiple + ? "React with numbers or reply with comma-separated choices (e.g. `1,3`)" + : "React with a number or reply with your choice"; + + embeds.push({ + title: `${q.header}`, + description: q.question, + color: 0x7c3aed, // Purple accent + fields: [ + { name: "Options", value: optionLines.join("\n") }, + ], + footer: { text: instruction }, + }); + } + + return { embeds, reactionEmojis: allEmojis.filter(Boolean) }; +} + +// ─── Reply Parsing ─────────────────────────────────────────────────────────── + +/** + * Parse a Slack thread reply into a RemoteAnswer. + * Supports: single number, comma-separated numbers, or free text. + */ +export function parseSlackReply(text: string, questions: FormattedQuestion[]): RemoteAnswer { + const answers: RemoteAnswer["answers"] = {}; + const trimmed = text.trim(); + + // For single-question scenarios, map the reply directly + if (questions.length === 1) { + const q = questions[0]; + answers[q.id] = parseAnswerForQuestion(trimmed, q); + return { answers }; + } + + // Multi-question: try to split by lines or semicolons + const parts = trimmed.includes(";") + ? trimmed.split(";").map((s) => s.trim()) + : trimmed.split("\n").map((s) => s.trim()).filter(Boolean); + + for (let i = 0; i < questions.length; i++) { + const q = questions[i]; + const part = parts[i] ?? ""; + answers[q.id] = parseAnswerForQuestion(part, q); + } + + return { answers }; +} + +/** + * Parse a Discord reaction or reply into a RemoteAnswer. + */ +export function parseDiscordResponse( + reactions: Array<{ emoji: string; count: number }>, + replyText: string | null, + questions: FormattedQuestion[], +): RemoteAnswer { + // Prefer text reply if present + if (replyText) { + return parseSlackReply(replyText, questions); + } + + // Fall back to reactions + const answers: RemoteAnswer["answers"] = {}; + + if (questions.length === 1) { + const q = questions[0]; + const picked = reactions + .filter((r) => NUMBER_EMOJIS.includes(r.emoji) && r.count > 0) + .map((r) => { + const idx = NUMBER_EMOJIS.indexOf(r.emoji); + return q.options[idx]?.label; + }) + .filter(Boolean) as string[]; + + if (picked.length > 0) { + answers[q.id] = { answers: picked }; + } else { + answers[q.id] = { answers: [], user_note: "No clear response via reactions" }; + } + return { answers }; + } + + // Multi-question with reactions: map first N emojis to first question + for (const q of questions) { + answers[q.id] = { answers: [], user_note: "Reaction-based multi-question not supported — use text reply" }; + } + + return { answers }; +} + +// ─── Internal helpers ──────────────────────────────────────────────────────── + +function parseAnswerForQuestion( + text: string, + q: FormattedQuestion, +): { answers: string[]; user_note?: string } { + if (!text) { + return { answers: [], user_note: "No response provided" }; + } + + // Check for comma-separated numbers: "1,3" or "1, 3" + const numberPattern = /^[\d,\s]+$/; + if (numberPattern.test(text)) { + const nums = text + .split(",") + .map((s) => parseInt(s.trim(), 10)) + .filter((n) => !isNaN(n) && n >= 1 && n <= q.options.length); + + if (nums.length > 0) { + const selected = nums.map((n) => q.options[n - 1].label); + return { answers: q.allowMultiple ? selected : [selected[0]] }; + } + } + + // Single number + const singleNum = parseInt(text, 10); + if (!isNaN(singleNum) && singleNum >= 1 && singleNum <= q.options.length) { + return { answers: [q.options[singleNum - 1].label] }; + } + + // Free text response + return { answers: [], user_note: text }; +} diff --git a/src/resources/extensions/remote-questions/index.ts b/src/resources/extensions/remote-questions/index.ts new file mode 100644 index 000000000..90d6e293b --- /dev/null +++ b/src/resources/extensions/remote-questions/index.ts @@ -0,0 +1,213 @@ +/** + * Remote Questions — Entry point + * + * Transparent routing: when ctx.hasUI is false and a remote channel is + * configured, sends questions via Slack/Discord and polls for the response. + * + * The LLM keeps calling `ask_user_questions` as normal — this module + * intercepts the non-interactive branch. + */ + +import type { FormattedQuestion, ChannelAdapter, RemoteAnswer } from "./channels.js"; +import { resolveRemoteConfig, type ResolvedConfig } from "./config.js"; +import { SlackAdapter } from "./slack-adapter.js"; +import { DiscordAdapter } from "./discord-adapter.js"; + +// ─── Types ─────────────────────────────────────────────────────────────────── + +interface Question { + id: string; + header: string; + question: string; + options: Array<{ label: string; description: string }>; + allowMultiple?: boolean; +} + +interface ToolResult { + content: Array<{ type: "text"; text: string }>; + details?: Record; +} + +// ─── Public API ────────────────────────────────────────────────────────────── + +/** + * Try to send questions via a remote channel (Slack/Discord). + * Returns a formatted ToolResult if successful, or null if no remote + * channel is configured (caller falls back to the original error). + */ +export async function tryRemoteQuestions( + questions: Question[], + signal?: AbortSignal, +): Promise { + const config = resolveRemoteConfig(); + if (!config) return null; + + const adapter = createAdapter(config); + const formatted = questionsToFormatted(questions); + + try { + await adapter.validate(); + } catch (err) { + return errorToolResult(`Remote auth failed (${config.channel}): ${(err as Error).message}`); + } + + let sendResult; + try { + sendResult = await adapter.sendQuestions(formatted); + } catch (err) { + return errorToolResult(`Failed to send questions via ${config.channel}: ${(err as Error).message}`); + } + + const threadInfo = sendResult.threadUrl + ? ` Thread: ${sendResult.threadUrl}` + : ""; + + // Poll for response + const answer = await pollWithTimeout(adapter, sendResult.ref, formatted, signal, config); + + if (!answer) { + // Timeout — return structured result so the LLM knows + return { + content: [ + { + type: "text", + text: JSON.stringify({ + timed_out: true, + channel: config.channel, + timeout_minutes: config.timeoutMs / 60000, + thread_url: sendResult.threadUrl ?? null, + message: `User did not respond within ${config.timeoutMs / 60000} minutes.${threadInfo}`, + }), + }, + ], + details: { + remote: true, + channel: config.channel, + timed_out: true, + threadUrl: sendResult.threadUrl, + }, + }; + } + + // Format the answer in the same structure as formatForLLM + const formattedAnswer = formatRemoteAnswerForLLM(answer); + + return { + content: [{ type: "text", text: formattedAnswer }], + details: { + remote: true, + channel: config.channel, + timed_out: false, + threadUrl: sendResult.threadUrl, + questions, + response: answer, + }, + }; +} + +// ─── Internal ──────────────────────────────────────────────────────────────── + +function createAdapter(config: ResolvedConfig): ChannelAdapter & { + pollResponseWithQuestions?: ( + ref: import("./channels.js").PollReference, + questions: FormattedQuestion[], + ) => Promise; +} { + switch (config.channel) { + case "slack": + return new SlackAdapter(config.token, config.channelId); + case "discord": + return new DiscordAdapter(config.token, config.channelId); + default: + throw new Error(`Unknown channel type: ${config.channel}`); + } +} + +async function pollWithTimeout( + adapter: ReturnType, + ref: import("./channels.js").PollReference, + questions: FormattedQuestion[], + signal: AbortSignal | undefined, + config: ResolvedConfig, +): Promise { + const deadline = Date.now() + config.timeoutMs; + let retries = 0; + const maxNetworkRetries = 1; + + while (Date.now() < deadline && !signal?.aborted) { + try { + // Use the question-aware poll if available + const answer = adapter.pollResponseWithQuestions + ? await adapter.pollResponseWithQuestions(ref, questions) + : await adapter.pollResponse(ref); + + if (answer) return answer; + retries = 0; // Reset on successful poll + } catch { + retries++; + if (retries > maxNetworkRetries) return null; + } + + await sleep(config.pollIntervalMs, signal); + } + + return null; +} + +function sleep(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve) => { + if (signal?.aborted) { + resolve(); + return; + } + + let settled = false; + const settle = () => { + if (settled) return; + settled = true; + clearTimeout(timer); + if (signal) signal.removeEventListener("abort", onAbort); + resolve(); + }; + + const onAbort = () => settle(); + const timer = setTimeout(() => settle(), ms); + + if (signal) { + signal.addEventListener("abort", onAbort, { once: true }); + } + }); +} + +function questionsToFormatted(questions: Question[]): FormattedQuestion[] { + return questions.map((q) => ({ + id: q.id, + header: q.header, + question: q.question, + options: q.options, + allowMultiple: q.allowMultiple ?? false, + })); +} + +/** + * Format RemoteAnswer into the same JSON structure as the local formatForLLM. + * Structure: { answers: { [id]: { answers: string[] } } } + */ +function formatRemoteAnswerForLLM(answer: RemoteAnswer): string { + const formatted: Record = {}; + for (const [id, data] of Object.entries(answer.answers)) { + const list = [...data.answers]; + if (data.user_note) { + list.push(`user_note: ${data.user_note}`); + } + formatted[id] = { answers: list }; + } + return JSON.stringify({ answers: formatted }); +} + +function errorToolResult(message: string): ToolResult { + return { + content: [{ type: "text", text: message }], + details: { remote: true, error: true }, + }; +} diff --git a/src/resources/extensions/remote-questions/remote-command.ts b/src/resources/extensions/remote-questions/remote-command.ts new file mode 100644 index 000000000..a43b7bfca --- /dev/null +++ b/src/resources/extensions/remote-questions/remote-command.ts @@ -0,0 +1,461 @@ +/** + * Remote Questions — /gsd remote command + * + * Interactive wizard for configuring Slack/Discord as a remote question channel. + * Follows the patterns from wizard.ts and gsd/commands.ts. + */ + +import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-coding-agent"; +import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { homedir } from "node:os"; +import { resolveRemoteConfig, getRemoteConfigStatus } from "./config.js"; +import { loadEffectiveGSDPreferences, getGlobalGSDPreferencesPath } from "../gsd/preferences.js"; + +// ─── Public ────────────────────────────────────────────────────────────────── + +export async function handleRemote( + subcommand: string, + ctx: ExtensionCommandContext, + _pi: ExtensionAPI, +): Promise { + const trimmed = subcommand.trim(); + + if (trimmed === "slack") { + await handleSetupSlack(ctx); + return; + } + + if (trimmed === "discord") { + await handleSetupDiscord(ctx); + return; + } + + if (trimmed === "status") { + await handleRemoteStatus(ctx); + return; + } + + if (trimmed === "disconnect") { + await handleDisconnect(ctx); + return; + } + + // Default: show current status and guide + await handleRemoteMenu(ctx); +} + +// ─── Setup Slack ───────────────────────────────────────────────────────────── + +async function handleSetupSlack(ctx: ExtensionCommandContext): Promise { + // Step 1: Collect token + const token = await promptMaskedInput(ctx, "Slack Bot Token", "Paste your xoxb-... token"); + if (!token) { + ctx.ui.notify("Slack setup cancelled.", "info"); + return; + } + + if (!token.startsWith("xoxb-")) { + ctx.ui.notify("Invalid token format — Slack bot tokens start with xoxb-. Setup cancelled.", "warning"); + return; + } + + // Step 2: Validate token + ctx.ui.notify("Validating token...", "info"); + let botInfo: { ok: boolean; user?: string; team?: string; user_id?: string }; + try { + const res = await fetch("https://slack.com/api/auth.test", { + method: "GET", + headers: { Authorization: `Bearer ${token}` }, + }); + botInfo = (await res.json()) as typeof botInfo; + } catch (err) { + ctx.ui.notify(`Network error validating token: ${(err as Error).message}`, "error"); + return; + } + + if (!botInfo.ok) { + ctx.ui.notify("Token validation failed — check that the token is correct and the app is installed.", "error"); + return; + } + + ctx.ui.notify(`Token valid — bot: ${botInfo.user}, workspace: ${botInfo.team}`, "info"); + + // Step 3: Collect channel ID + const channelId = await promptInput(ctx, "Channel ID", "Paste the Slack channel ID (e.g. C0123456789)"); + if (!channelId) { + ctx.ui.notify("Slack setup cancelled.", "info"); + return; + } + + // Step 4: Send test message + ctx.ui.notify("Sending test message...", "info"); + try { + const res = await fetch("https://slack.com/api/chat.postMessage", { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json; charset=utf-8", + }, + body: JSON.stringify({ + channel: channelId, + text: "GSD remote questions connected! This channel will receive questions during auto-mode.", + }), + }); + const result = (await res.json()) as { ok: boolean; error?: string }; + if (!result.ok) { + ctx.ui.notify(`Could not send to channel: ${result.error}. Make sure the bot is invited to the channel.`, "error"); + return; + } + } catch (err) { + ctx.ui.notify(`Network error sending test message: ${(err as Error).message}`, "error"); + return; + } + + // Step 5: Save configuration + saveTokenToAuth("slack_bot", token); + process.env.SLACK_BOT_TOKEN = token; + saveRemoteQuestionsConfig("slack", channelId); + + ctx.ui.notify(`Slack connected — questions will arrive in channel ${channelId} during /gsd auto`, "info"); +} + +// ─── Setup Discord ─────────────────────────────────────────────────────────── + +async function handleSetupDiscord(ctx: ExtensionCommandContext): Promise { + // Step 1: Collect token + const token = await promptMaskedInput(ctx, "Discord Bot Token", "Paste your bot token"); + if (!token) { + ctx.ui.notify("Discord setup cancelled.", "info"); + return; + } + + // Step 2: Validate token + ctx.ui.notify("Validating token...", "info"); + let botInfo: { id?: string; username?: string }; + try { + const res = await fetch("https://discord.com/api/v10/users/@me", { + headers: { Authorization: `Bot ${token}` }, + }); + if (!res.ok) { + ctx.ui.notify(`Token validation failed (HTTP ${res.status}) — check that the token is correct.`, "error"); + return; + } + botInfo = (await res.json()) as typeof botInfo; + } catch (err) { + ctx.ui.notify(`Network error validating token: ${(err as Error).message}`, "error"); + return; + } + + ctx.ui.notify(`Token valid — bot: ${botInfo.username}`, "info"); + + // Step 3: Collect channel ID + const channelId = await promptInput(ctx, "Channel ID", "Paste the Discord channel ID (e.g. 1234567890123456789)"); + if (!channelId) { + ctx.ui.notify("Discord setup cancelled.", "info"); + return; + } + + // Step 4: Send test message + ctx.ui.notify("Sending test message...", "info"); + try { + const res = await fetch(`https://discord.com/api/v10/channels/${channelId}/messages`, { + method: "POST", + headers: { + Authorization: `Bot ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + content: "GSD remote questions connected! This channel will receive questions during auto-mode.", + }), + }); + if (!res.ok) { + const body = await res.text().catch(() => ""); + ctx.ui.notify(`Could not send to channel (HTTP ${res.status}): ${body}. Make sure the bot has access.`, "error"); + return; + } + } catch (err) { + ctx.ui.notify(`Network error sending test message: ${(err as Error).message}`, "error"); + return; + } + + // Step 5: Save configuration + saveTokenToAuth("discord_bot", token); + process.env.DISCORD_BOT_TOKEN = token; + saveRemoteQuestionsConfig("discord", channelId); + + ctx.ui.notify(`Discord connected — questions will arrive in channel ${channelId} during /gsd auto`, "info"); +} + +// ─── Status ────────────────────────────────────────────────────────────────── + +async function handleRemoteStatus(ctx: ExtensionCommandContext): Promise { + const config = resolveRemoteConfig(); + + if (!config) { + ctx.ui.notify(getRemoteConfigStatus(), "info"); + return; + } + + // Test the connection + ctx.ui.notify("Checking connection...", "info"); + + try { + if (config.channel === "slack") { + const res = await fetch("https://slack.com/api/auth.test", { + headers: { Authorization: `Bearer ${config.token}` }, + }); + const data = (await res.json()) as { ok: boolean; user?: string; team?: string }; + if (data.ok) { + ctx.ui.notify( + `Remote questions: Slack connected\n Bot: ${data.user}\n Workspace: ${data.team}\n Channel: ${config.channelId}\n Timeout: ${config.timeoutMs / 60000}m, poll: ${config.pollIntervalMs / 1000}s`, + "info", + ); + } else { + ctx.ui.notify("Remote questions: Slack token invalid — run /gsd remote slack to reconfigure", "warning"); + } + } else if (config.channel === "discord") { + const res = await fetch("https://discord.com/api/v10/users/@me", { + headers: { Authorization: `Bot ${config.token}` }, + }); + if (res.ok) { + const data = (await res.json()) as { username?: string }; + ctx.ui.notify( + `Remote questions: Discord connected\n Bot: ${data.username}\n Channel: ${config.channelId}\n Timeout: ${config.timeoutMs / 60000}m, poll: ${config.pollIntervalMs / 1000}s`, + "info", + ); + } else { + ctx.ui.notify("Remote questions: Discord token invalid — run /gsd remote discord to reconfigure", "warning"); + } + } + } catch (err) { + ctx.ui.notify(`Remote questions: connection check failed — ${(err as Error).message}`, "error"); + } +} + +// ─── Disconnect ────────────────────────────────────────────────────────────── + +async function handleDisconnect(ctx: ExtensionCommandContext): Promise { + const prefs = loadEffectiveGSDPreferences(); + if (!prefs?.preferences.remote_questions) { + ctx.ui.notify("No remote channel configured — nothing to disconnect.", "info"); + return; + } + + const channel = prefs.preferences.remote_questions.channel; + + // Remove from preferences file + removeRemoteQuestionsConfig(); + + // Remove token from auth storage + const provider = channel === "slack" ? "slack_bot" : "discord_bot"; + removeTokenFromAuth(provider); + + // Clear env + if (channel === "slack") delete process.env.SLACK_BOT_TOKEN; + if (channel === "discord") delete process.env.DISCORD_BOT_TOKEN; + + ctx.ui.notify(`Remote questions disconnected (${channel}).`, "info"); +} + +// ─── Menu ──────────────────────────────────────────────────────────────────── + +async function handleRemoteMenu(ctx: ExtensionCommandContext): Promise { + const config = resolveRemoteConfig(); + + if (config) { + ctx.ui.notify( + `Remote questions: ${config.channel} (channel ${config.channelId})\n` + + ` Timeout: ${config.timeoutMs / 60000}m, poll interval: ${config.pollIntervalMs / 1000}s\n\n` + + `Commands:\n` + + ` /gsd remote status — test connection\n` + + ` /gsd remote disconnect — remove configuration\n` + + ` /gsd remote slack — reconfigure with Slack\n` + + ` /gsd remote discord — reconfigure with Discord`, + "info", + ); + } else { + ctx.ui.notify( + `No remote question channel configured.\n\n` + + `Commands:\n` + + ` /gsd remote slack — set up Slack bot\n` + + ` /gsd remote discord — set up Discord bot\n` + + ` /gsd remote status — check configuration`, + "info", + ); + } +} + +// ─── Input helpers ─────────────────────────────────────────────────────────── + +async function promptMaskedInput( + ctx: ExtensionCommandContext, + label: string, + hint: string, +): Promise { + if (!ctx.hasUI) return null; + + return ctx.ui.custom((tui, theme, _kb, done) => { + let value = ""; + + function render(width: number): string[] { + const lines: string[] = []; + lines.push(theme.fg("accent", ` ${label}`)); + lines.push(theme.fg("dim", ` ${hint}`)); + lines.push(""); + lines.push(` ${theme.fg("text", "*".repeat(Math.min(value.length, width - 4)))}`); + lines.push(""); + lines.push(theme.fg("dim", " Enter to confirm, Esc to cancel")); + return lines; + } + + function handleInput(data: string): void { + if (data === "\r" || data === "\n") { + done(value.trim() || null); + } else if (data === "\x1b" || data === "\x03") { + done(null); + } else if (data === "\x7f") { + value = value.slice(0, -1); + tui.invalidate(); + } else if (data.length === 1 && data >= " ") { + value += data; + tui.invalidate(); + } + } + + return { render, handleInput, invalidate: () => tui.invalidate() }; + }); +} + +async function promptInput( + ctx: ExtensionCommandContext, + label: string, + hint: string, +): Promise { + if (!ctx.hasUI) return null; + + return ctx.ui.custom((tui, theme, _kb, done) => { + let value = ""; + + function render(_width: number): string[] { + const lines: string[] = []; + lines.push(theme.fg("accent", ` ${label}`)); + lines.push(theme.fg("dim", ` ${hint}`)); + lines.push(""); + lines.push(` ${theme.fg("text", value)}`); + lines.push(""); + lines.push(theme.fg("dim", " Enter to confirm, Esc to cancel")); + return lines; + } + + function handleInput(data: string): void { + if (data === "\r" || data === "\n") { + done(value.trim() || null); + } else if (data === "\x1b" || data === "\x03") { + done(null); + } else if (data === "\x7f") { + value = value.slice(0, -1); + tui.invalidate(); + } else if (data.length === 1 && data >= " ") { + value += data; + tui.invalidate(); + } + } + + return { render, handleInput, invalidate: () => tui.invalidate() }; + }); +} + +// ─── Persistence helpers ───────────────────────────────────────────────────── + +function getAuthFilePath(): string { + return join(homedir(), ".gsd", "agent", "auth.json"); +} + +function loadAuthJson(): Record { + const path = getAuthFilePath(); + if (!existsSync(path)) return {}; + try { + return JSON.parse(readFileSync(path, "utf-8")) as Record; + } catch { + return {}; + } +} + +function saveAuthJson(data: Record): void { + const path = getAuthFilePath(); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, JSON.stringify(data, null, 2), "utf-8"); +} + +function saveTokenToAuth(provider: string, token: string): void { + const auth = loadAuthJson(); + auth[provider] = { type: "api_key", key: token }; + saveAuthJson(auth); +} + +function removeTokenFromAuth(provider: string): void { + const auth = loadAuthJson(); + delete auth[provider]; + saveAuthJson(auth); +} + +function saveRemoteQuestionsConfig(channel: "slack" | "discord", channelId: string): void { + const prefsPath = getGlobalGSDPreferencesPath(); + let content = ""; + + if (existsSync(prefsPath)) { + content = readFileSync(prefsPath, "utf-8"); + } + + // Check if frontmatter exists + const fmMatch = content.match(/^---\n([\s\S]*?)\n---/); + + const remoteBlock = [ + `remote_questions:`, + ` channel: ${channel}`, + ` channel_id: "${channelId}"`, + ` timeout_minutes: 5`, + ` poll_interval_seconds: 5`, + ].join("\n"); + + if (fmMatch) { + // Replace existing remote_questions or append to frontmatter + let fm = fmMatch[1]; + const remoteRegex = /remote_questions:[\s\S]*?(?=\n[a-zA-Z_]|\n---|$)/; + if (remoteRegex.test(fm)) { + fm = fm.replace(remoteRegex, remoteBlock); + } else { + fm = fm.trimEnd() + "\n" + remoteBlock; + } + content = `---\n${fm}\n---` + content.slice(fmMatch[0].length); + } else { + // Create new frontmatter + content = `---\n${remoteBlock}\n---\n\n${content}`; + } + + mkdirSync(dirname(prefsPath), { recursive: true }); + writeFileSync(prefsPath, content, "utf-8"); +} + +function removeRemoteQuestionsConfig(): void { + const prefsPath = getGlobalGSDPreferencesPath(); + if (!existsSync(prefsPath)) return; + + let content = readFileSync(prefsPath, "utf-8"); + const fmMatch = content.match(/^---\n([\s\S]*?)\n---/); + if (!fmMatch) return; + + let fm = fmMatch[1]; + // Remove remote_questions block from frontmatter + fm = fm.replace(/remote_questions:[\s\S]*?(?=\n[a-zA-Z_]|\n---|$)/, "").trim(); + + if (fm) { + content = `---\n${fm}\n---` + content.slice(fmMatch[0].length); + } else { + // Frontmatter is now empty, remove it + content = content.slice(fmMatch[0].length).replace(/^\n+/, ""); + } + + writeFileSync(prefsPath, content, "utf-8"); +} diff --git a/src/resources/extensions/remote-questions/slack-adapter.ts b/src/resources/extensions/remote-questions/slack-adapter.ts new file mode 100644 index 000000000..8b48b328e --- /dev/null +++ b/src/resources/extensions/remote-questions/slack-adapter.ts @@ -0,0 +1,176 @@ +/** + * Remote Questions — Slack adapter + * + * Uses Slack Bot Token API (xoxb-*) for bidirectional messaging: + * - Send: POST chat.postMessage with Block Kit + * - Poll: GET conversations.replies to read thread responses + */ + +import type { + ChannelAdapter, + FormattedQuestion, + PollReference, + RemoteAnswer, + SendResult, +} from "./channels.js"; +import { formatForSlack, parseSlackReply } from "./format.js"; + +const SLACK_API = "https://slack.com/api"; + +export class SlackAdapter implements ChannelAdapter { + readonly name = "slack"; + private botUserId: string | null = null; + + constructor( + private readonly token: string, + private readonly channelId: string, + ) {} + + async validate(): Promise { + const res = await this.slackApi("auth.test", {}); + if (!res.ok) { + throw new Error(`Slack auth failed: ${res.error ?? "invalid token"}`); + } + this.botUserId = res.user_id as string; + } + + async sendQuestions(questions: FormattedQuestion[]): Promise { + const blocks = formatForSlack(questions); + + const res = await this.slackApi("chat.postMessage", { + channel: this.channelId, + text: "GSD needs your input", + blocks, + }); + + if (!res.ok) { + throw new Error(`Slack postMessage failed: ${res.error ?? "unknown"}`); + } + + const ts = res.ts as string; + const channel = res.channel as string; + + return { + ref: { + channelType: "slack", + messageId: ts, + threadTs: ts, + channelId: channel, + }, + threadUrl: `https://slack.com/archives/${channel}/p${ts.replace(".", "")}`, + }; + } + + async pollResponse(ref: PollReference): Promise { + // Ensure we know our bot user ID + if (!this.botUserId) { + const authRes = await this.slackApi("auth.test", {}); + if (authRes.ok) this.botUserId = authRes.user_id as string; + } + + const res = await this.slackApi("conversations.replies", { + channel: ref.channelId, + ts: ref.threadTs!, + limit: "20", + }); + + if (!res.ok) { + // Channel not found or no access — don't throw, just return null + return null; + } + + const messages = (res.messages ?? []) as Array<{ + user: string; + text: string; + ts: string; + }>; + + // Filter out the bot's own messages — only user replies count + const userReplies = messages.filter( + (m) => m.ts !== ref.threadTs && m.user !== this.botUserId, + ); + + if (userReplies.length === 0) return null; + + // Use the first user reply + const reply = userReplies[0]; + // We need the questions for parsing — store them on the ref isn't ideal, + // so the caller will need to pass them. For now, return raw text wrapped. + return { answers: { _raw: { answers: [reply.text] } } }; + } + + /** + * Poll with full question context for proper parsing. + */ + async pollResponseWithQuestions( + ref: PollReference, + questions: FormattedQuestion[], + ): Promise { + if (!this.botUserId) { + const authRes = await this.slackApi("auth.test", {}); + if (authRes.ok) this.botUserId = authRes.user_id as string; + } + + const res = await this.slackApi("conversations.replies", { + channel: ref.channelId, + ts: ref.threadTs!, + limit: "20", + }); + + if (!res.ok) return null; + + const messages = (res.messages ?? []) as Array<{ + user: string; + text: string; + ts: string; + }>; + + const userReplies = messages.filter( + (m) => m.ts !== ref.threadTs && m.user !== this.botUserId, + ); + + if (userReplies.length === 0) return null; + + return parseSlackReply(userReplies[0].text, questions); + } + + // ─── Internal ────────────────────────────────────────────────────────────── + + private async slackApi( + method: string, + params: Record, + ): Promise> { + const url = `${SLACK_API}/${method}`; + + const isGet = method === "conversations.replies" || method === "auth.test"; + + let response: Response; + if (isGet) { + // GET params must be strings for URLSearchParams + const stringParams: Record = {}; + for (const [k, v] of Object.entries(params)) { + stringParams[k] = String(v); + } + const qs = new URLSearchParams(stringParams).toString(); + response = await fetch(`${url}?${qs}`, { + method: "GET", + headers: { Authorization: `Bearer ${this.token}` }, + }); + } else { + response = await fetch(url, { + method: "POST", + headers: { + Authorization: `Bearer ${this.token}`, + "Content-Type": "application/json; charset=utf-8", + }, + body: JSON.stringify(params), + }); + } + + if (!response.ok) { + throw new Error(`Slack API HTTP ${response.status}: ${response.statusText}`); + } + + return (await response.json()) as Record; + } +} diff --git a/src/wizard.ts b/src/wizard.ts index 3706f5cae..fef191e5d 100644 --- a/src/wizard.ts +++ b/src/wizard.ts @@ -83,6 +83,8 @@ export function loadStoredEnvKeys(authStorage: AuthStorage): void { ['brave_answers', 'BRAVE_ANSWERS_KEY'], ['context7', 'CONTEXT7_API_KEY'], ['jina', 'JINA_API_KEY'], + ['slack_bot', 'SLACK_BOT_TOKEN'], + ['discord_bot', 'DISCORD_BOT_TOKEN'], ] for (const [provider, envVar] of providers) { if (!process.env[envVar]) { @@ -133,6 +135,20 @@ const API_KEYS: ApiKeyConfig[] = [ hint: '(clean page extraction)', description: 'High-quality web page content extraction', }, + { + provider: 'slack_bot', + envVar: 'SLACK_BOT_TOKEN', + label: 'Slack Bot', + hint: '(remote questions in auto-mode)', + description: 'Bot token for remote questions via Slack', + }, + { + provider: 'discord_bot', + envVar: 'DISCORD_BOT_TOKEN', + label: 'Discord Bot', + hint: '(remote questions in auto-mode)', + description: 'Bot token for remote questions via Discord', + }, ] /** From c9cb8dd1eb2493467220f2966d639e7445b981e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Facu=5FVi=C3=B1as?= Date: Wed, 11 Mar 2026 11:32:40 -0300 Subject: [PATCH 02/13] fix: rename remote-questions/index.ts to send.ts to avoid extension loader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pi's DefaultResourceLoader auto-discovers extensions/*/index.ts and expects a default export factory. remote-questions is an internal library (consumed via dynamic import), not an extension — having an index.ts caused the loader to try loading it as one and fail. Co-Authored-By: Claude Opus 4.6 --- src/resources/extensions/ask-user-questions.ts | 2 +- src/resources/extensions/remote-questions/{index.ts => send.ts} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename src/resources/extensions/remote-questions/{index.ts => send.ts} (100%) diff --git a/src/resources/extensions/ask-user-questions.ts b/src/resources/extensions/ask-user-questions.ts index 23f97decb..71e09704b 100644 --- a/src/resources/extensions/ask-user-questions.ts +++ b/src/resources/extensions/ask-user-questions.ts @@ -120,7 +120,7 @@ export default function AskUserQuestions(pi: ExtensionAPI) { } if (!ctx.hasUI) { - const { tryRemoteQuestions } = await import("./remote-questions/index.js"); + const { tryRemoteQuestions } = await import("./remote-questions/send.js"); const remoteResult = await tryRemoteQuestions(params.questions, signal); if (remoteResult) return remoteResult; return errorResult("Error: UI not available (non-interactive mode)", params.questions); diff --git a/src/resources/extensions/remote-questions/index.ts b/src/resources/extensions/remote-questions/send.ts similarity index 100% rename from src/resources/extensions/remote-questions/index.ts rename to src/resources/extensions/remote-questions/send.ts From c39388b2e30f149020986c43f2373db07951acc1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Facu=5FVi=C3=B1as?= Date: Wed, 11 Mar 2026 11:52:16 -0300 Subject: [PATCH 03/13] fix: use Editor from pi-tui for remote command input prompts The custom input handlers called tui.invalidate() which caused infinite recursion. Rewrite promptMaskedInput and promptInput to use the Editor component (same pattern as get-secrets-from-user.ts) with proper tui.requestRender() and cache invalidation. Co-Authored-By: Claude Opus 4.6 --- .../remote-questions/remote-command.ts | 159 +++++++++++++----- 1 file changed, 115 insertions(+), 44 deletions(-) diff --git a/src/resources/extensions/remote-questions/remote-command.ts b/src/resources/extensions/remote-questions/remote-command.ts index a43b7bfca..30fbd3702 100644 --- a/src/resources/extensions/remote-questions/remote-command.ts +++ b/src/resources/extensions/remote-questions/remote-command.ts @@ -6,6 +6,7 @@ */ import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-coding-agent"; +import { CURSOR_MARKER, Editor, type EditorTheme, Key, matchesKey, truncateToWidth } from "@mariozechner/pi-tui"; import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; import { dirname, join } from "node:path"; import { homedir } from "node:os"; @@ -288,6 +289,28 @@ async function handleRemoteMenu(ctx: ExtensionCommandContext): Promise { // ─── Input helpers ─────────────────────────────────────────────────────────── +function maskEditorLine(line: string): string { + let output = ""; + let i = 0; + while (i < line.length) { + if (line.startsWith(CURSOR_MARKER, i)) { + output += CURSOR_MARKER; + i += CURSOR_MARKER.length; + continue; + } + const ansiMatch = /^\x1b\[[0-9;]*m/.exec(line.slice(i)); + if (ansiMatch) { + output += ansiMatch[0]; + i += ansiMatch[0].length; + continue; + } + const ch = line[i] as string; + output += ch === " " ? " " : "*"; + i += 1; + } + return output; +} + async function promptMaskedInput( ctx: ExtensionCommandContext, label: string, @@ -295,35 +318,59 @@ async function promptMaskedInput( ): Promise { if (!ctx.hasUI) return null; - return ctx.ui.custom((tui, theme, _kb, done) => { - let value = ""; + return ctx.ui.custom((tui: any, theme: any, _kb: any, done: (r: string | null) => void) => { + let cachedLines: string[] | undefined; + const editorTheme: EditorTheme = { + borderColor: (s: string) => theme.fg("accent", s), + selectList: { + selectedPrefix: (t: string) => theme.fg("accent", t), + selectedText: (t: string) => theme.fg("accent", t), + description: (t: string) => theme.fg("muted", t), + scrollInfo: (t: string) => theme.fg("dim", t), + noMatch: (t: string) => theme.fg("warning", t), + }, + }; + const editor = new Editor(tui, editorTheme, { paddingX: 1 }); - function render(width: number): string[] { - const lines: string[] = []; - lines.push(theme.fg("accent", ` ${label}`)); - lines.push(theme.fg("dim", ` ${hint}`)); - lines.push(""); - lines.push(` ${theme.fg("text", "*".repeat(Math.min(value.length, width - 4)))}`); - lines.push(""); - lines.push(theme.fg("dim", " Enter to confirm, Esc to cancel")); - return lines; + function refresh() { + cachedLines = undefined; + tui.requestRender(); } function handleInput(data: string): void { - if (data === "\r" || data === "\n") { - done(value.trim() || null); - } else if (data === "\x1b" || data === "\x03") { - done(null); - } else if (data === "\x7f") { - value = value.slice(0, -1); - tui.invalidate(); - } else if (data.length === 1 && data >= " ") { - value += data; - tui.invalidate(); + if (matchesKey(data, Key.enter)) { + const value = editor.getText().trim(); + done(value.length > 0 ? value : null); + return; } + if (matchesKey(data, Key.escape)) { + done(null); + return; + } + editor.handleInput(data); + refresh(); } - return { render, handleInput, invalidate: () => tui.invalidate() }; + function render(width: number): string[] { + if (cachedLines) return cachedLines; + const lines: string[] = []; + const add = (s: string) => lines.push(truncateToWidth(s, width)); + add(theme.fg("accent", "\u2500".repeat(width))); + add(theme.fg("accent", theme.bold(` ${label}`))); + add(theme.fg("muted", ` ${hint}`)); + lines.push(""); + add(theme.fg("muted", " Enter value:")); + for (const line of editor.render(width - 2)) { + add(theme.fg("text", maskEditorLine(line))); + } + lines.push(""); + add(theme.fg("dim", ` enter to confirm | esc to cancel`)); + add(theme.fg("accent", "\u2500".repeat(width))); + cachedLines = lines; + return lines; + } + + return { render, handleInput, invalidate: () => { cachedLines = undefined; } }; }); } @@ -334,35 +381,59 @@ async function promptInput( ): Promise { if (!ctx.hasUI) return null; - return ctx.ui.custom((tui, theme, _kb, done) => { - let value = ""; + return ctx.ui.custom((tui: any, theme: any, _kb: any, done: (r: string | null) => void) => { + let cachedLines: string[] | undefined; + const editorTheme: EditorTheme = { + borderColor: (s: string) => theme.fg("accent", s), + selectList: { + selectedPrefix: (t: string) => theme.fg("accent", t), + selectedText: (t: string) => theme.fg("accent", t), + description: (t: string) => theme.fg("muted", t), + scrollInfo: (t: string) => theme.fg("dim", t), + noMatch: (t: string) => theme.fg("warning", t), + }, + }; + const editor = new Editor(tui, editorTheme, { paddingX: 1 }); - function render(_width: number): string[] { - const lines: string[] = []; - lines.push(theme.fg("accent", ` ${label}`)); - lines.push(theme.fg("dim", ` ${hint}`)); - lines.push(""); - lines.push(` ${theme.fg("text", value)}`); - lines.push(""); - lines.push(theme.fg("dim", " Enter to confirm, Esc to cancel")); - return lines; + function refresh() { + cachedLines = undefined; + tui.requestRender(); } function handleInput(data: string): void { - if (data === "\r" || data === "\n") { - done(value.trim() || null); - } else if (data === "\x1b" || data === "\x03") { - done(null); - } else if (data === "\x7f") { - value = value.slice(0, -1); - tui.invalidate(); - } else if (data.length === 1 && data >= " ") { - value += data; - tui.invalidate(); + if (matchesKey(data, Key.enter)) { + const value = editor.getText().trim(); + done(value.length > 0 ? value : null); + return; } + if (matchesKey(data, Key.escape)) { + done(null); + return; + } + editor.handleInput(data); + refresh(); } - return { render, handleInput, invalidate: () => tui.invalidate() }; + function render(width: number): string[] { + if (cachedLines) return cachedLines; + const lines: string[] = []; + const add = (s: string) => lines.push(truncateToWidth(s, width)); + add(theme.fg("accent", "\u2500".repeat(width))); + add(theme.fg("accent", theme.bold(` ${label}`))); + add(theme.fg("muted", ` ${hint}`)); + lines.push(""); + add(theme.fg("muted", " Enter value:")); + for (const line of editor.render(width - 2)) { + add(theme.fg("text", line)); + } + lines.push(""); + add(theme.fg("dim", ` enter to confirm | esc to cancel`)); + add(theme.fg("accent", "\u2500".repeat(width))); + cachedLines = lines; + return lines; + } + + return { render, handleInput, invalidate: () => { cachedLines = undefined; } }; }); } From 0643d6348039ad2db42d8c7f2878cd649588a6f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Facu=5FVi=C3=B1as?= Date: Wed, 11 Mar 2026 11:56:18 -0300 Subject: [PATCH 04/13] fix: desugar TypeScript parameter properties for strip-types compat Node's --experimental-strip-types doesn't support parameter properties (private readonly in constructor params). Convert to explicit field declarations + constructor assignments. Co-Authored-By: Claude Opus 4.6 --- .../extensions/remote-questions/discord-adapter.ts | 10 ++++++---- .../extensions/remote-questions/slack-adapter.ts | 10 ++++++---- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/src/resources/extensions/remote-questions/discord-adapter.ts b/src/resources/extensions/remote-questions/discord-adapter.ts index df54ef6bd..97e145a00 100644 --- a/src/resources/extensions/remote-questions/discord-adapter.ts +++ b/src/resources/extensions/remote-questions/discord-adapter.ts @@ -20,11 +20,13 @@ const DISCORD_API = "https://discord.com/api/v10"; export class DiscordAdapter implements ChannelAdapter { readonly name = "discord"; private botUserId: string | null = null; + private readonly token: string; + private readonly channelId: string; - constructor( - private readonly token: string, - private readonly channelId: string, - ) {} + constructor(token: string, channelId: string) { + this.token = token; + this.channelId = channelId; + } async validate(): Promise { const res = await this.discordApi("GET", "/users/@me"); diff --git a/src/resources/extensions/remote-questions/slack-adapter.ts b/src/resources/extensions/remote-questions/slack-adapter.ts index 8b48b328e..1f3beff17 100644 --- a/src/resources/extensions/remote-questions/slack-adapter.ts +++ b/src/resources/extensions/remote-questions/slack-adapter.ts @@ -20,11 +20,13 @@ const SLACK_API = "https://slack.com/api"; export class SlackAdapter implements ChannelAdapter { readonly name = "slack"; private botUserId: string | null = null; + private readonly token: string; + private readonly channelId: string; - constructor( - private readonly token: string, - private readonly channelId: string, - ) {} + constructor(token: string, channelId: string) { + this.token = token; + this.channelId = channelId; + } async validate(): Promise { const res = await this.slackApi("auth.test", {}); From a37ef56146af5bdc6c0998060fa6a309e73d3181 Mon Sep 17 00:00:00 2001 From: Lex Christopherson Date: Wed, 11 Mar 2026 10:35:59 -0600 Subject: [PATCH 05/13] feat: harden remote questions flow --- .../extensions/ask-user-questions.ts | 4 +- src/resources/extensions/gsd/index.ts | 9 +- src/resources/extensions/gsd/preferences.ts | 6 +- .../gsd/tests/remote-questions.test.ts | 107 ++++ .../gsd/tests/remote-status.test.ts | 44 ++ .../extensions/remote-questions/config.ts | 62 +- .../remote-questions/discord-adapter.ts | 157 ++--- .../extensions/remote-questions/format.ts | 224 +++---- .../extensions/remote-questions/manager.ts | 171 ++++++ .../remote-questions/remote-command.ts | 549 +++++------------- .../remote-questions/slack-adapter.ts | 130 +---- .../extensions/remote-questions/status.ts | 23 + .../extensions/remote-questions/store.ts | 77 +++ .../extensions/remote-questions/types.ts | 75 +++ 14 files changed, 841 insertions(+), 797 deletions(-) create mode 100644 src/resources/extensions/gsd/tests/remote-questions.test.ts create mode 100644 src/resources/extensions/gsd/tests/remote-status.test.ts create mode 100644 src/resources/extensions/remote-questions/manager.ts create mode 100644 src/resources/extensions/remote-questions/status.ts create mode 100644 src/resources/extensions/remote-questions/store.ts create mode 100644 src/resources/extensions/remote-questions/types.ts diff --git a/src/resources/extensions/ask-user-questions.ts b/src/resources/extensions/ask-user-questions.ts index 71e09704b..0f9d803e7 100644 --- a/src/resources/extensions/ask-user-questions.ts +++ b/src/resources/extensions/ask-user-questions.ts @@ -120,7 +120,7 @@ export default function AskUserQuestions(pi: ExtensionAPI) { } if (!ctx.hasUI) { - const { tryRemoteQuestions } = await import("./remote-questions/send.js"); + const { tryRemoteQuestions } = await import("./remote-questions/manager.js"); const remoteResult = await tryRemoteQuestions(params.questions, signal); if (remoteResult) return remoteResult; return errorResult("Error: UI not available (non-interactive mode)", params.questions); @@ -168,7 +168,7 @@ export default function AskUserQuestions(pi: ExtensionAPI) { }, renderResult(result, _options, theme) { - const details = result.details as (AskUserQuestionsDetails & { remote?: boolean; channel?: string; timed_out?: boolean; threadUrl?: string }) | undefined; + const details = result.details as (AskUserQuestionsDetails & { remote?: boolean; channel?: string; timed_out?: boolean; threadUrl?: string; promptId?: string; status?: string }) | undefined; if (!details) { const text = result.content[0]; return new Text(text?.type === "text" ? text.text : "", 0, 0); diff --git a/src/resources/extensions/gsd/index.ts b/src/resources/extensions/gsd/index.ts index 9d6376b5f..e0e491d17 100644 --- a/src/resources/extensions/gsd/index.ts +++ b/src/resources/extensions/gsd/index.ts @@ -85,10 +85,15 @@ export default function (pi: ExtensionAPI) { // Notify remote questions status if configured try { - const { getRemoteConfigStatus } = await import("../remote-questions/config.js"); + const [{ getRemoteConfigStatus }, { getLatestPromptSummary }] = await Promise.all([ + import("../remote-questions/config.js"), + import("../remote-questions/status.js"), + ]); const status = getRemoteConfigStatus(); + const latest = getLatestPromptSummary(); if (!status.includes("not configured")) { - ctx.ui.notify(status, status.includes("disabled") ? "warning" : "info"); + const suffix = latest ? `\nLast remote prompt: ${latest.id} (${latest.status})` : ""; + ctx.ui.notify(`${status}${suffix}`, status.includes("disabled") ? "warning" : "info"); } } catch { // Remote questions module not available — ignore diff --git a/src/resources/extensions/gsd/preferences.ts b/src/resources/extensions/gsd/preferences.ts index a84fbceb7..30b567e75 100644 --- a/src/resources/extensions/gsd/preferences.ts +++ b/src/resources/extensions/gsd/preferences.ts @@ -33,9 +33,9 @@ export interface AutoSupervisorConfig { export interface RemoteQuestionsConfig { channel: "slack" | "discord"; - channel_id: string; - timeout_minutes?: number; // Default: 5 - poll_interval_seconds?: number; // Default: 5 + channel_id: string | number; + timeout_minutes?: number; // clamped to 1-30 + poll_interval_seconds?: number; // clamped to 2-30 } export interface GSDPreferences { diff --git a/src/resources/extensions/gsd/tests/remote-questions.test.ts b/src/resources/extensions/gsd/tests/remote-questions.test.ts new file mode 100644 index 000000000..f409224ce --- /dev/null +++ b/src/resources/extensions/gsd/tests/remote-questions.test.ts @@ -0,0 +1,107 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { parseSlackReply, parseDiscordResponse } from "../../remote-questions/format.ts"; +import { resolveRemoteConfig } from "../../remote-questions/config.ts"; + +const originalEnv = { ...process.env }; + +test("parseSlackReply handles single-number single-question answers", () => { + const result = parseSlackReply("2", [{ + id: "choice", + header: "Choice", + question: "Pick one", + allowMultiple: false, + options: [ + { label: "Alpha", description: "A" }, + { label: "Beta", description: "B" }, + ], + }]); + + assert.deepEqual(result, { answers: { choice: { answers: ["Beta"] } } }); +}); + +test("parseSlackReply handles multiline multi-question answers", () => { + const result = parseSlackReply("1\ncustom note", [ + { + id: "first", + header: "First", + question: "Pick one", + allowMultiple: false, + options: [ + { label: "Alpha", description: "A" }, + { label: "Beta", description: "B" }, + ], + }, + { + id: "second", + header: "Second", + question: "Explain", + allowMultiple: false, + options: [ + { label: "Gamma", description: "G" }, + { label: "Delta", description: "D" }, + ], + }, + ]); + + assert.deepEqual(result, { + answers: { + first: { answers: ["Alpha"] }, + second: { answers: [], user_note: "custom note" }, + }, + }); +}); + +test("parseDiscordResponse handles single-question reactions", () => { + const result = parseDiscordResponse([{ emoji: "2️⃣", count: 1 }], null, [{ + id: "choice", + header: "Choice", + question: "Pick one", + allowMultiple: false, + options: [ + { label: "Alpha", description: "A" }, + { label: "Beta", description: "B" }, + ], + }]); + + assert.deepEqual(result, { answers: { choice: { answers: ["Beta"] } } }); +}); + +test("parseDiscordResponse rejects multi-question reaction parsing", () => { + const result = parseDiscordResponse([{ emoji: "1️⃣", count: 1 }], null, [ + { + id: "first", + header: "First", + question: "Pick one", + allowMultiple: false, + options: [{ label: "Alpha", description: "A" }], + }, + { + id: "second", + header: "Second", + question: "Pick one", + allowMultiple: false, + options: [{ label: "Beta", description: "B" }], + }, + ]); + + assert.match(String(result.answers.first.user_note), /single-question prompts/i); + assert.match(String(result.answers.second.user_note), /single-question prompts/i); +}); + +test("resolveRemoteConfig clamps invalid timeout and poll interval values", async () => { + process.env.SLACK_BOT_TOKEN = "token"; + const home = process.env.HOME!; + const fs = await import("node:fs"); + const path = await import("node:path"); + const prefsPath = path.join(home, ".gsd", "preferences.md"); + fs.mkdirSync(path.dirname(prefsPath), { recursive: true }); + fs.writeFileSync(prefsPath, `---\nremote_questions:\n channel: slack\n channel_id: \"C123\"\n timeout_minutes: 999\n poll_interval_seconds: 0\n---\n`, "utf-8"); + + const config = resolveRemoteConfig(); + assert.ok(config); + assert.equal(config?.timeoutMs, 30 * 60 * 1000); + assert.equal(config?.pollIntervalMs, 2 * 1000); + + process.env = { ...originalEnv }; +}); diff --git a/src/resources/extensions/gsd/tests/remote-status.test.ts b/src/resources/extensions/gsd/tests/remote-status.test.ts new file mode 100644 index 000000000..4ca3ff0ce --- /dev/null +++ b/src/resources/extensions/gsd/tests/remote-status.test.ts @@ -0,0 +1,44 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdirSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { createPromptRecord, writePromptRecord } from "../../remote-questions/store.ts"; +import { getLatestPromptSummary } from "../../remote-questions/status.ts"; + +test("getLatestPromptSummary returns latest stored prompt", async () => { + const home = process.env.HOME!; + const tempHome = join(tmpdir(), `gsd-remote-status-${Date.now()}`); + mkdirSync(join(tempHome, ".gsd", "runtime", "remote-questions"), { recursive: true }); + process.env.HOME = tempHome; + + const recordA = createPromptRecord({ + id: "a-prompt", + channel: "slack", + createdAt: 1, + timeoutAt: 10, + pollIntervalMs: 5000, + questions: [], + }); + recordA.updatedAt = 1; + writePromptRecord(recordA); + + const recordB = createPromptRecord({ + id: "z-prompt", + channel: "discord", + createdAt: 2, + timeoutAt: 10, + pollIntervalMs: 5000, + questions: [], + }); + recordB.updatedAt = 2; + recordB.status = "answered"; + writePromptRecord(recordB); + + const latest = getLatestPromptSummary(); + assert.equal(latest?.id, "z-prompt"); + assert.equal(latest?.status, "answered"); + + process.env.HOME = home; + rmSync(tempHome, { recursive: true, force: true }); +}); diff --git a/src/resources/extensions/remote-questions/config.ts b/src/resources/extensions/remote-questions/config.ts index 9c92f0fba..7fe7b7d2c 100644 --- a/src/resources/extensions/remote-questions/config.ts +++ b/src/resources/extensions/remote-questions/config.ts @@ -1,78 +1,66 @@ /** - * Remote Questions — Configuration resolution - * - * Reads remote_questions config from GSD preferences and verifies - * the corresponding token exists in process.env. + * Remote Questions — configuration resolution and validation */ import { loadEffectiveGSDPreferences, type RemoteQuestionsConfig } from "../gsd/preferences.js"; +import type { RemoteChannel } from "./types.js"; export interface ResolvedConfig { - channel: "slack" | "discord"; + channel: RemoteChannel; channelId: string; timeoutMs: number; pollIntervalMs: number; token: string; } -const ENV_KEYS: Record = { +const ENV_KEYS: Record = { slack: "SLACK_BOT_TOKEN", discord: "DISCORD_BOT_TOKEN", }; const DEFAULT_TIMEOUT_MINUTES = 5; const DEFAULT_POLL_INTERVAL_SECONDS = 5; +const MIN_TIMEOUT_MINUTES = 1; +const MAX_TIMEOUT_MINUTES = 30; +const MIN_POLL_INTERVAL_SECONDS = 2; +const MAX_POLL_INTERVAL_SECONDS = 30; -/** - * Resolve remote questions configuration from preferences + env. - * Returns null if not configured or token is missing. - */ export function resolveRemoteConfig(): ResolvedConfig | null { const prefs = loadEffectiveGSDPreferences(); const rq: RemoteQuestionsConfig | undefined = prefs?.preferences.remote_questions; if (!rq || !rq.channel || !rq.channel_id) return null; + if (rq.channel !== "slack" && rq.channel !== "discord") return null; - const envVar = ENV_KEYS[rq.channel]; - if (!envVar) return null; - - const token = process.env[envVar]; + const token = process.env[ENV_KEYS[rq.channel]]; if (!token) return null; - const timeoutMinutes = rq.timeout_minutes ?? DEFAULT_TIMEOUT_MINUTES; - const pollIntervalSeconds = rq.poll_interval_seconds ?? DEFAULT_POLL_INTERVAL_SECONDS; - - // Always coerce channel_id to string — parseScalar may convert large numeric - // Discord IDs to a lossy Number (exceeds Number.MAX_SAFE_INTEGER). - const channelId = String(rq.channel_id); + const timeoutMinutes = clampNumber(rq.timeout_minutes, DEFAULT_TIMEOUT_MINUTES, MIN_TIMEOUT_MINUTES, MAX_TIMEOUT_MINUTES); + const pollIntervalSeconds = clampNumber(rq.poll_interval_seconds, DEFAULT_POLL_INTERVAL_SECONDS, MIN_POLL_INTERVAL_SECONDS, MAX_POLL_INTERVAL_SECONDS); return { channel: rq.channel, - channelId, + channelId: String(rq.channel_id), timeoutMs: timeoutMinutes * 60 * 1000, pollIntervalMs: pollIntervalSeconds * 1000, token, }; } -/** - * Return a human-readable status string for the remote questions config. - * Used by session_start notification and /gsd remote status. - */ export function getRemoteConfigStatus(): string { const prefs = loadEffectiveGSDPreferences(); const rq: RemoteQuestionsConfig | undefined = prefs?.preferences.remote_questions; - - if (!rq || !rq.channel || !rq.channel_id) { - return "Remote questions: not configured"; - } - + if (!rq || !rq.channel || !rq.channel_id) return "Remote questions: not configured"; + if (rq.channel !== "slack" && rq.channel !== "discord") return `Remote questions: unknown channel type \"${rq.channel}\"`; const envVar = ENV_KEYS[rq.channel]; - if (!envVar) return `Remote questions: unknown channel type "${rq.channel}"`; + if (!process.env[envVar]) return `Remote questions: ${envVar} not set — remote questions disabled`; - const token = process.env[envVar]; - if (!token) { - return `Remote questions: ${envVar} not set — remote questions disabled`; - } - - return `Remote questions: ${rq.channel} (channel ${rq.channel_id}) configured`; + const timeoutMinutes = clampNumber(rq.timeout_minutes, DEFAULT_TIMEOUT_MINUTES, MIN_TIMEOUT_MINUTES, MAX_TIMEOUT_MINUTES); + const pollIntervalSeconds = clampNumber(rq.poll_interval_seconds, DEFAULT_POLL_INTERVAL_SECONDS, MIN_POLL_INTERVAL_SECONDS, MAX_POLL_INTERVAL_SECONDS); + return `Remote questions: ${rq.channel} configured (timeout ${timeoutMinutes}m, poll ${pollIntervalSeconds}s)`; +} + +function clampNumber(value: unknown, fallback: number, min: number, max: number): number { + const n = typeof value === "number" ? value : Number(value); + if (!Number.isFinite(n)) return fallback; + return Math.max(min, Math.min(max, n)); } diff --git a/src/resources/extensions/remote-questions/discord-adapter.ts b/src/resources/extensions/remote-questions/discord-adapter.ts index 97e145a00..e477af65a 100644 --- a/src/resources/extensions/remote-questions/discord-adapter.ts +++ b/src/resources/extensions/remote-questions/discord-adapter.ts @@ -1,24 +1,15 @@ /** * Remote Questions — Discord adapter - * - * Uses Discord Bot HTTP API (no gateway/websocket): - * - Send: POST /channels/{id}/messages with embed - * - Poll: GET reactions + GET messages after the sent message */ -import type { - ChannelAdapter, - FormattedQuestion, - PollReference, - RemoteAnswer, - SendResult, -} from "./channels.js"; +import type { ChannelAdapter, RemotePrompt, RemoteDispatchResult, RemoteAnswer, RemotePromptRef } from "./types.js"; import { formatForDiscord, parseDiscordResponse } from "./format.js"; const DISCORD_API = "https://discord.com/api/v10"; +const NUMBER_EMOJIS = ["1️⃣", "2️⃣", "3️⃣", "4️⃣", "5️⃣"]; export class DiscordAdapter implements ChannelAdapter { - readonly name = "discord"; + readonly name = "discord" as const; private botUserId: string | null = null; private readonly token: string; private readonly channelId: string; @@ -30,161 +21,99 @@ export class DiscordAdapter implements ChannelAdapter { async validate(): Promise { const res = await this.discordApi("GET", "/users/@me"); - if (!res.id) { - throw new Error("Discord auth failed: invalid token"); - } - this.botUserId = res.id as string; + if (!res.id) throw new Error("Discord auth failed: invalid token"); + this.botUserId = String(res.id); } - async sendQuestions(questions: FormattedQuestion[]): Promise { - const { embeds, reactionEmojis } = formatForDiscord(questions); - + async sendPrompt(prompt: RemotePrompt): Promise { + const { embeds, reactionEmojis } = formatForDiscord(prompt); const res = await this.discordApi("POST", `/channels/${this.channelId}/messages`, { - content: "**GSD needs your input** — reply to this message or react with your choice", + content: "**GSD needs your input** — reply to this message with your answer", embeds, }); - if (!res.id) { - throw new Error(`Discord send failed: ${JSON.stringify(res)}`); - } + if (!res.id) throw new Error(`Discord send failed: ${JSON.stringify(res)}`); - const messageId = res.id as string; - - // Add reaction emojis as templates (best-effort, don't block on failure) - for (const emoji of reactionEmojis) { - try { - await this.discordApi( - "PUT", - `/channels/${this.channelId}/messages/${messageId}/reactions/${encodeURIComponent(emoji)}/@me`, - ); - } catch { - // Non-critical — continue + const messageId = String(res.id); + if (prompt.questions.length === 1) { + for (const emoji of reactionEmojis) { + try { + await this.discordApi("PUT", `/channels/${this.channelId}/messages/${messageId}/reactions/${encodeURIComponent(emoji)}/@me`); + } catch { + // Best-effort only + } } } return { ref: { - channelType: "discord", + id: prompt.id, + channel: "discord", messageId, channelId: this.channelId, }, }; } - async pollResponse(ref: PollReference): Promise { - return this.pollResponseWithQuestions(ref, []); - } + async pollAnswer(prompt: RemotePrompt, ref: RemotePromptRef): Promise { + if (!this.botUserId) await this.validate(); - /** - * Poll with full question context for proper parsing. - */ - async pollResponseWithQuestions( - ref: PollReference, - questions: FormattedQuestion[], - ): Promise { - if (!this.botUserId) { - const me = await this.discordApi("GET", "/users/@me"); - if (me.id) this.botUserId = me.id as string; + if (prompt.questions.length === 1) { + const reactionAnswer = await this.checkReactions(prompt, ref); + if (reactionAnswer) return reactionAnswer; } - // Strategy 1: Check reactions on the original message - const reactionAnswer = await this.checkReactions(ref, questions); - if (reactionAnswer) return reactionAnswer; - - // Strategy 2: Check for text replies after the message - const replyAnswer = await this.checkReplies(ref, questions); - if (replyAnswer) return replyAnswer; - - return null; + return this.checkReplies(prompt, ref); } - private async checkReactions( - ref: PollReference, - questions: FormattedQuestion[], - ): Promise { - const numberEmojis = ["1\ufe0f\u20e3", "2\ufe0f\u20e3", "3\ufe0f\u20e3", "4\ufe0f\u20e3", "5\ufe0f\u20e3"]; + private async checkReactions(prompt: RemotePrompt, ref: RemotePromptRef): Promise { const reactions: Array<{ emoji: string; count: number }> = []; - - for (const emoji of numberEmojis) { + for (const emoji of NUMBER_EMOJIS) { try { - const users = await this.discordApi( - "GET", - `/channels/${ref.channelId}/messages/${ref.messageId}/reactions/${encodeURIComponent(emoji)}`, - ); - + const users = await this.discordApi("GET", `/channels/${ref.channelId}/messages/${ref.messageId}/reactions/${encodeURIComponent(emoji)}`); if (Array.isArray(users)) { - // Filter out bot's own reactions - const humanUsers = users.filter( - (u: { id: string }) => u.id !== this.botUserId, - ); - if (humanUsers.length > 0) { - reactions.push({ emoji, count: humanUsers.length }); - } + const humanUsers = users.filter((u: { id: string }) => u.id !== this.botUserId); + if (humanUsers.length > 0) reactions.push({ emoji, count: humanUsers.length }); } } catch { - // Reaction not present or no access + // ignore missing reaction } } if (reactions.length === 0) return null; - - return parseDiscordResponse(reactions, null, questions); + return parseDiscordResponse(reactions, null, prompt.questions); } - private async checkReplies( - ref: PollReference, - questions: FormattedQuestion[], - ): Promise { - const messages = await this.discordApi( - "GET", - `/channels/${ref.channelId}/messages?after=${ref.messageId}&limit=10`, - ); - + private async checkReplies(prompt: RemotePrompt, ref: RemotePromptRef): Promise { + const messages = await this.discordApi("GET", `/channels/${ref.channelId}/messages?after=${ref.messageId}&limit=10`); if (!Array.isArray(messages)) return null; - // Only accept replies that explicitly reference our message via Discord's reply feature const replies = messages.filter( - (m: { author: { id: string }; message_reference?: { message_id: string }; content: string }) => + (m: { author?: { id?: string }; message_reference?: { message_id?: string }; content?: string }) => + m.author?.id && m.author.id !== this.botUserId && - m.message_reference?.message_id === ref.messageId, + m.message_reference?.message_id === ref.messageId && + m.content, ); if (replies.length === 0) return null; - - const firstReply = replies[0] as { content: string }; - return parseDiscordResponse([], firstReply.content, questions); + return parseDiscordResponse([], String(replies[0].content), prompt.questions); } - // ─── Internal ────────────────────────────────────────────────────────────── - - private async discordApi( - method: string, - path: string, - body?: unknown, - ): Promise> { - const url = `${DISCORD_API}${path}`; - - const headers: Record = { - Authorization: `Bot ${this.token}`, - }; - + private async discordApi(method: string, path: string, body?: unknown): Promise { + const headers: Record = { Authorization: `Bot ${this.token}` }; const init: RequestInit = { method, headers }; - if (body) { headers["Content-Type"] = "application/json"; init.body = JSON.stringify(body); } - const response = await fetch(url, init); - - // For reaction PUT, 204 No Content is success + const response = await fetch(`${DISCORD_API}${path}`, init); if (response.status === 204) return {}; - if (!response.ok) { const text = await response.text().catch(() => ""); throw new Error(`Discord API HTTP ${response.status}: ${text}`); } - - return (await response.json()) as Record; + return response.json(); } } diff --git a/src/resources/extensions/remote-questions/format.ts b/src/resources/extensions/remote-questions/format.ts index 348992c1d..dd01039b8 100644 --- a/src/resources/extensions/remote-questions/format.ts +++ b/src/resources/extensions/remote-questions/format.ts @@ -1,13 +1,8 @@ /** - * Remote Questions — Payload formatting for Slack and Discord - * - * Converts Question[] to channel-specific payloads and parses replies - * back into RemoteAnswer objects. + * Remote Questions — payload formatting and parsing helpers */ -import type { FormattedQuestion, RemoteAnswer } from "./channels.js"; - -// ─── Slack Block Kit ───────────────────────────────────────────────────────── +import type { RemotePrompt, RemoteQuestion, RemoteAnswer } from "./types.js"; export interface SlackBlock { type: string; @@ -15,57 +10,6 @@ export interface SlackBlock { elements?: Array<{ type: string; text: string }>; } -/** - * Format questions as Slack Block Kit blocks for chat.postMessage. - */ -export function formatForSlack(questions: FormattedQuestion[]): SlackBlock[] { - const blocks: SlackBlock[] = [ - { - type: "header", - text: { type: "plain_text", text: "GSD needs your input" }, - }, - ]; - - for (const q of questions) { - // Question header + text - blocks.push({ - type: "section", - text: { - type: "mrkdwn", - text: `*${q.header}*\n${q.question}`, - }, - }); - - // Numbered options - const optionLines = q.options.map( - (opt, i) => `${i + 1}. *${opt.label}* — ${opt.description}`, - ); - blocks.push({ - type: "section", - text: { - type: "mrkdwn", - text: optionLines.join("\n"), - }, - }); - - // Instructions - const instruction = q.allowMultiple - ? `Reply in this thread with numbers separated by comma (e.g. \`1,3\`) or type a custom answer.` - : `Reply in this thread with the number of your choice (e.g. \`1\`) or type a custom answer.`; - - blocks.push({ - type: "context", - elements: [{ type: "mrkdwn", text: instruction }], - }); - - blocks.push({ type: "divider" }); - } - - return blocks; -} - -// ─── Discord Embed ─────────────────────────────────────────────────────────── - export interface DiscordEmbed { title: string; description: string; @@ -74,130 +18,130 @@ export interface DiscordEmbed { footer?: { text: string }; } -const NUMBER_EMOJIS = ["1\ufe0f\u20e3", "2\ufe0f\u20e3", "3\ufe0f\u20e3", "4\ufe0f\u20e3", "5\ufe0f\u20e3"]; +const NUMBER_EMOJIS = ["1️⃣", "2️⃣", "3️⃣", "4️⃣", "5️⃣"]; -/** - * Format questions as a Discord embed for channel message. - */ -export function formatForDiscord(questions: FormattedQuestion[]): { embeds: DiscordEmbed[]; reactionEmojis: string[] } { - const allEmojis: string[] = []; - const embeds: DiscordEmbed[] = []; +export function formatForSlack(prompt: RemotePrompt): SlackBlock[] { + const blocks: SlackBlock[] = [ + { + type: "header", + text: { type: "plain_text", text: "GSD needs your input" }, + }, + ]; - for (const q of questions) { + for (const q of prompt.questions) { + blocks.push({ + type: "section", + text: { type: "mrkdwn", text: `*${q.header}*\n${q.question}` }, + }); + + blocks.push({ + type: "section", + text: { + type: "mrkdwn", + text: q.options.map((opt, i) => `${i + 1}. *${opt.label}* — ${opt.description}`).join("\n"), + }, + }); + + blocks.push({ + type: "context", + elements: [{ + type: "mrkdwn", + text: q.allowMultiple + ? "Reply in thread with comma-separated numbers (`1,3`) or free text." + : "Reply in thread with a number (`1`) or free text.", + }], + }); + + blocks.push({ type: "divider" }); + } + + return blocks; +} + +export function formatForDiscord(prompt: RemotePrompt): { embeds: DiscordEmbed[]; reactionEmojis: string[] } { + const reactionEmojis: string[] = []; + const embeds: DiscordEmbed[] = prompt.questions.map((q, questionIndex) => { + const supportsReactions = prompt.questions.length === 1; const optionLines = q.options.map((opt, i) => { const emoji = NUMBER_EMOJIS[i] ?? `${i + 1}.`; - allEmojis.push(NUMBER_EMOJIS[i] ?? ""); + if (supportsReactions && NUMBER_EMOJIS[i]) reactionEmojis.push(NUMBER_EMOJIS[i]); return `${emoji} **${opt.label}** — ${opt.description}`; }); - const instruction = q.allowMultiple - ? "React with numbers or reply with comma-separated choices (e.g. `1,3`)" - : "React with a number or reply with your choice"; + const footerText = supportsReactions + ? (q.allowMultiple + ? "Reply with comma-separated choices (`1,3`) or react with matching numbers" + : "Reply with a number or react with the matching number") + : `Question ${questionIndex + 1}/${prompt.questions.length} — reply with one line per question or use semicolons`; - embeds.push({ - title: `${q.header}`, + return { + title: q.header, description: q.question, - color: 0x7c3aed, // Purple accent - fields: [ - { name: "Options", value: optionLines.join("\n") }, - ], - footer: { text: instruction }, - }); - } + color: 0x7c3aed, + fields: [{ name: "Options", value: optionLines.join("\n") }], + footer: { text: footerText }, + }; + }); - return { embeds, reactionEmojis: allEmojis.filter(Boolean) }; + return { embeds, reactionEmojis }; } -// ─── Reply Parsing ─────────────────────────────────────────────────────────── - -/** - * Parse a Slack thread reply into a RemoteAnswer. - * Supports: single number, comma-separated numbers, or free text. - */ -export function parseSlackReply(text: string, questions: FormattedQuestion[]): RemoteAnswer { +export function parseSlackReply(text: string, questions: RemoteQuestion[]): RemoteAnswer { const answers: RemoteAnswer["answers"] = {}; const trimmed = text.trim(); - // For single-question scenarios, map the reply directly if (questions.length === 1) { - const q = questions[0]; - answers[q.id] = parseAnswerForQuestion(trimmed, q); + answers[questions[0].id] = parseAnswerForQuestion(trimmed, questions[0]); return { answers }; } - // Multi-question: try to split by lines or semicolons const parts = trimmed.includes(";") - ? trimmed.split(";").map((s) => s.trim()) + ? trimmed.split(";").map((s) => s.trim()).filter(Boolean) : trimmed.split("\n").map((s) => s.trim()).filter(Boolean); for (let i = 0; i < questions.length; i++) { - const q = questions[i]; - const part = parts[i] ?? ""; - answers[q.id] = parseAnswerForQuestion(part, q); + answers[questions[i].id] = parseAnswerForQuestion(parts[i] ?? "", questions[i]); } return { answers }; } -/** - * Parse a Discord reaction or reply into a RemoteAnswer. - */ export function parseDiscordResponse( reactions: Array<{ emoji: string; count: number }>, replyText: string | null, - questions: FormattedQuestion[], + questions: RemoteQuestion[], ): RemoteAnswer { - // Prefer text reply if present - if (replyText) { - return parseSlackReply(replyText, questions); - } + if (replyText) return parseSlackReply(replyText, questions); - // Fall back to reactions const answers: RemoteAnswer["answers"] = {}; - - if (questions.length === 1) { - const q = questions[0]; - const picked = reactions - .filter((r) => NUMBER_EMOJIS.includes(r.emoji) && r.count > 0) - .map((r) => { - const idx = NUMBER_EMOJIS.indexOf(r.emoji); - return q.options[idx]?.label; - }) - .filter(Boolean) as string[]; - - if (picked.length > 0) { - answers[q.id] = { answers: picked }; - } else { - answers[q.id] = { answers: [], user_note: "No clear response via reactions" }; + if (questions.length !== 1) { + for (const q of questions) { + answers[q.id] = { answers: [], user_note: "Discord reactions are only supported for single-question prompts" }; } return { answers }; } - // Multi-question with reactions: map first N emojis to first question - for (const q of questions) { - answers[q.id] = { answers: [], user_note: "Reaction-based multi-question not supported — use text reply" }; - } + const q = questions[0]; + const picked = reactions + .filter((r) => NUMBER_EMOJIS.includes(r.emoji) && r.count > 0) + .map((r) => q.options[NUMBER_EMOJIS.indexOf(r.emoji)]?.label) + .filter(Boolean) as string[]; + + answers[q.id] = picked.length > 0 + ? { answers: q.allowMultiple ? picked : [picked[0]] } + : { answers: [], user_note: "No clear response via reactions" }; return { answers }; } -// ─── Internal helpers ──────────────────────────────────────────────────────── +function parseAnswerForQuestion(text: string, q: RemoteQuestion): { answers: string[]; user_note?: string } { + if (!text) return { answers: [], user_note: "No response provided" }; -function parseAnswerForQuestion( - text: string, - q: FormattedQuestion, -): { answers: string[]; user_note?: string } { - if (!text) { - return { answers: [], user_note: "No response provided" }; - } - - // Check for comma-separated numbers: "1,3" or "1, 3" - const numberPattern = /^[\d,\s]+$/; - if (numberPattern.test(text)) { + if (/^[\d,\s]+$/.test(text)) { const nums = text .split(",") .map((s) => parseInt(s.trim(), 10)) - .filter((n) => !isNaN(n) && n >= 1 && n <= q.options.length); + .filter((n) => !Number.isNaN(n) && n >= 1 && n <= q.options.length); if (nums.length > 0) { const selected = nums.map((n) => q.options[n - 1].label); @@ -205,12 +149,10 @@ function parseAnswerForQuestion( } } - // Single number - const singleNum = parseInt(text, 10); - if (!isNaN(singleNum) && singleNum >= 1 && singleNum <= q.options.length) { - return { answers: [q.options[singleNum - 1].label] }; + const single = parseInt(text, 10); + if (!Number.isNaN(single) && single >= 1 && single <= q.options.length) { + return { answers: [q.options[single - 1].label] }; } - // Free text response return { answers: [], user_note: text }; } diff --git a/src/resources/extensions/remote-questions/manager.ts b/src/resources/extensions/remote-questions/manager.ts new file mode 100644 index 000000000..9baabbd58 --- /dev/null +++ b/src/resources/extensions/remote-questions/manager.ts @@ -0,0 +1,171 @@ +/** + * Remote Questions — orchestration manager + */ + +import { randomUUID } from "node:crypto"; +import type { ChannelAdapter, RemotePrompt, RemoteQuestion, RemoteAnswer } from "./types.js"; +import { resolveRemoteConfig, type ResolvedConfig } from "./config.js"; +import { SlackAdapter } from "./slack-adapter.js"; +import { DiscordAdapter } from "./discord-adapter.js"; +import { createPromptRecord, writePromptRecord, markPromptAnswered, markPromptDispatched, markPromptStatus, updatePromptRecord } from "./store.js"; + +interface ToolResult { + content: Array<{ type: "text"; text: string }>; + details?: Record; +} + +interface QuestionInput { + id: string; + header: string; + question: string; + options: Array<{ label: string; description: string }>; + allowMultiple?: boolean; +} + +export async function tryRemoteQuestions( + questions: QuestionInput[], + signal?: AbortSignal, +): Promise { + const config = resolveRemoteConfig(); + if (!config) return null; + + const prompt = createPrompt(questions, config); + writePromptRecord(createPromptRecord(prompt)); + + const adapter = createAdapter(config); + try { + await adapter.validate(); + } catch (err) { + markPromptStatus(prompt.id, "failed", String((err as Error).message)); + return errorResult(`Remote auth failed (${config.channel}): ${(err as Error).message}`, config.channel); + } + + let dispatch; + try { + dispatch = await adapter.sendPrompt(prompt); + markPromptDispatched(prompt.id, dispatch.ref); + } catch (err) { + markPromptStatus(prompt.id, "failed", String((err as Error).message)); + return errorResult(`Failed to send questions via ${config.channel}: ${(err as Error).message}`, config.channel); + } + + const answer = await pollUntilDone(adapter, prompt, dispatch.ref, signal); + if (!answer) { + markPromptStatus(prompt.id, signal?.aborted ? "cancelled" : "timed_out"); + return { + content: [{ + type: "text", + text: JSON.stringify({ + timed_out: true, + channel: config.channel, + prompt_id: prompt.id, + timeout_minutes: config.timeoutMs / 60000, + thread_url: dispatch.ref.threadUrl ?? null, + message: `User did not respond within ${config.timeoutMs / 60000} minutes.`, + }), + }], + details: { + remote: true, + channel: config.channel, + timed_out: true, + promptId: prompt.id, + threadUrl: dispatch.ref.threadUrl, + status: signal?.aborted ? "cancelled" : "timed_out", + }, + }; + } + + markPromptAnswered(prompt.id, answer); + return { + content: [{ type: "text", text: JSON.stringify({ answers: formatForTool(answer) }) }], + details: { + remote: true, + channel: config.channel, + timed_out: false, + promptId: prompt.id, + threadUrl: dispatch.ref.threadUrl, + questions, + response: answer, + status: "answered", + }, + }; +} + +function createPrompt(questions: QuestionInput[], config: ResolvedConfig): RemotePrompt { + const createdAt = Date.now(); + return { + id: randomUUID(), + channel: config.channel, + createdAt, + timeoutAt: createdAt + config.timeoutMs, + pollIntervalMs: config.pollIntervalMs, + context: { source: "ask_user_questions" }, + questions: questions.map((q): RemoteQuestion => ({ + id: q.id, + header: q.header, + question: q.question, + options: q.options, + allowMultiple: q.allowMultiple ?? false, + })), + }; +} + +function createAdapter(config: ResolvedConfig): ChannelAdapter { + return config.channel === "slack" + ? new SlackAdapter(config.token, config.channelId) + : new DiscordAdapter(config.token, config.channelId); +} + +async function pollUntilDone( + adapter: ChannelAdapter, + prompt: RemotePrompt, + ref: import("./types.js").RemotePromptRef, + signal?: AbortSignal, +): Promise { + while (Date.now() < prompt.timeoutAt && !signal?.aborted) { + try { + const answer = await adapter.pollAnswer(prompt, ref); + updatePromptRecord(prompt.id, { lastPollAt: Date.now() }); + if (answer) return answer; + } catch (err) { + markPromptStatus(prompt.id, "failed", String((err as Error).message)); + return null; + } + + await sleep(prompt.pollIntervalMs, signal); + } + + return null; +} + +function sleep(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve) => { + if (signal?.aborted) return resolve(); + const timer = setTimeout(() => { + if (signal) signal.removeEventListener("abort", onAbort); + resolve(); + }, ms); + const onAbort = () => { + clearTimeout(timer); + resolve(); + }; + signal?.addEventListener("abort", onAbort, { once: true }); + }); +} + +function formatForTool(answer: RemoteAnswer): Record { + const out: Record = {}; + for (const [id, data] of Object.entries(answer.answers)) { + const list = [...data.answers]; + if (data.user_note) list.push(`user_note: ${data.user_note}`); + out[id] = { answers: list }; + } + return out; +} + +function errorResult(message: string, channel: string): ToolResult { + return { + content: [{ type: "text", text: message }], + details: { remote: true, channel, error: true, status: "failed" }, + }; +} diff --git a/src/resources/extensions/remote-questions/remote-command.ts b/src/resources/extensions/remote-questions/remote-command.ts index 30fbd3702..be5796ff2 100644 --- a/src/resources/extensions/remote-questions/remote-command.ts +++ b/src/resources/extensions/remote-questions/remote-command.ts @@ -1,19 +1,15 @@ /** * Remote Questions — /gsd remote command - * - * Interactive wizard for configuring Slack/Discord as a remote question channel. - * Follows the patterns from wizard.ts and gsd/commands.ts. */ import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-coding-agent"; +import { AuthStorage } from "@mariozechner/pi-coding-agent"; import { CURSOR_MARKER, Editor, type EditorTheme, Key, matchesKey, truncateToWidth } from "@mariozechner/pi-tui"; import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; import { dirname, join } from "node:path"; -import { homedir } from "node:os"; -import { resolveRemoteConfig, getRemoteConfigStatus } from "./config.js"; -import { loadEffectiveGSDPreferences, getGlobalGSDPreferencesPath } from "../gsd/preferences.js"; - -// ─── Public ────────────────────────────────────────────────────────────────── +import { getGlobalGSDPreferencesPath, loadEffectiveGSDPreferences } from "../gsd/preferences.js"; +import { getRemoteConfigStatus, resolveRemoteConfig } from "./config.js"; +import { getLatestPromptSummary } from "./status.js"; export async function handleRemote( subcommand: string, @@ -22,272 +18,186 @@ export async function handleRemote( ): Promise { const trimmed = subcommand.trim(); - if (trimmed === "slack") { - await handleSetupSlack(ctx); - return; - } + if (trimmed === "slack") return handleSetupSlack(ctx); + if (trimmed === "discord") return handleSetupDiscord(ctx); + if (trimmed === "status") return handleRemoteStatus(ctx); + if (trimmed === "disconnect") return handleDisconnect(ctx); - if (trimmed === "discord") { - await handleSetupDiscord(ctx); - return; - } - - if (trimmed === "status") { - await handleRemoteStatus(ctx); - return; - } - - if (trimmed === "disconnect") { - await handleDisconnect(ctx); - return; - } - - // Default: show current status and guide - await handleRemoteMenu(ctx); + return handleRemoteMenu(ctx); } -// ─── Setup Slack ───────────────────────────────────────────────────────────── - async function handleSetupSlack(ctx: ExtensionCommandContext): Promise { - // Step 1: Collect token const token = await promptMaskedInput(ctx, "Slack Bot Token", "Paste your xoxb-... token"); - if (!token) { - ctx.ui.notify("Slack setup cancelled.", "info"); - return; - } + if (!token) return void ctx.ui.notify("Slack setup cancelled.", "info"); + if (!token.startsWith("xoxb-")) return void ctx.ui.notify("Invalid token format — Slack bot tokens start with xoxb-.", "warning"); - if (!token.startsWith("xoxb-")) { - ctx.ui.notify("Invalid token format — Slack bot tokens start with xoxb-. Setup cancelled.", "warning"); - return; - } - - // Step 2: Validate token ctx.ui.notify("Validating token...", "info"); - let botInfo: { ok: boolean; user?: string; team?: string; user_id?: string }; - try { - const res = await fetch("https://slack.com/api/auth.test", { - method: "GET", - headers: { Authorization: `Bearer ${token}` }, - }); - botInfo = (await res.json()) as typeof botInfo; - } catch (err) { - ctx.ui.notify(`Network error validating token: ${(err as Error).message}`, "error"); - return; - } + const auth = await fetchJson("https://slack.com/api/auth.test", { headers: { Authorization: `Bearer ${token}` } }); + if (!auth?.ok) return void ctx.ui.notify("Token validation failed — check the token and app install.", "error"); - if (!botInfo.ok) { - ctx.ui.notify("Token validation failed — check that the token is correct and the app is installed.", "error"); - return; - } - - ctx.ui.notify(`Token valid — bot: ${botInfo.user}, workspace: ${botInfo.team}`, "info"); - - // Step 3: Collect channel ID const channelId = await promptInput(ctx, "Channel ID", "Paste the Slack channel ID (e.g. C0123456789)"); - if (!channelId) { - ctx.ui.notify("Slack setup cancelled.", "info"); - return; - } + if (!channelId) return void ctx.ui.notify("Slack setup cancelled.", "info"); - // Step 4: Send test message - ctx.ui.notify("Sending test message...", "info"); - try { - const res = await fetch("https://slack.com/api/chat.postMessage", { - method: "POST", - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json; charset=utf-8", - }, - body: JSON.stringify({ - channel: channelId, - text: "GSD remote questions connected! This channel will receive questions during auto-mode.", - }), - }); - const result = (await res.json()) as { ok: boolean; error?: string }; - if (!result.ok) { - ctx.ui.notify(`Could not send to channel: ${result.error}. Make sure the bot is invited to the channel.`, "error"); - return; - } - } catch (err) { - ctx.ui.notify(`Network error sending test message: ${(err as Error).message}`, "error"); - return; - } + const send = await fetchJson("https://slack.com/api/chat.postMessage", { + method: "POST", + headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json; charset=utf-8" }, + body: JSON.stringify({ channel: channelId, text: "GSD remote questions connected." }), + }); + if (!send?.ok) return void ctx.ui.notify(`Could not send to channel: ${send?.error ?? "unknown error"}`, "error"); - // Step 5: Save configuration - saveTokenToAuth("slack_bot", token); + saveProviderToken("slack_bot", token); process.env.SLACK_BOT_TOKEN = token; saveRemoteQuestionsConfig("slack", channelId); - - ctx.ui.notify(`Slack connected — questions will arrive in channel ${channelId} during /gsd auto`, "info"); + ctx.ui.notify(`Slack connected — remote questions enabled for channel ${channelId}.`, "info"); } -// ─── Setup Discord ─────────────────────────────────────────────────────────── - async function handleSetupDiscord(ctx: ExtensionCommandContext): Promise { - // Step 1: Collect token const token = await promptMaskedInput(ctx, "Discord Bot Token", "Paste your bot token"); - if (!token) { - ctx.ui.notify("Discord setup cancelled.", "info"); - return; - } + if (!token) return void ctx.ui.notify("Discord setup cancelled.", "info"); - // Step 2: Validate token ctx.ui.notify("Validating token...", "info"); - let botInfo: { id?: string; username?: string }; - try { - const res = await fetch("https://discord.com/api/v10/users/@me", { - headers: { Authorization: `Bot ${token}` }, - }); - if (!res.ok) { - ctx.ui.notify(`Token validation failed (HTTP ${res.status}) — check that the token is correct.`, "error"); - return; - } - botInfo = (await res.json()) as typeof botInfo; - } catch (err) { - ctx.ui.notify(`Network error validating token: ${(err as Error).message}`, "error"); - return; - } + const auth = await fetchJson("https://discord.com/api/v10/users/@me", { headers: { Authorization: `Bot ${token}` } }); + if (!auth?.id) return void ctx.ui.notify("Token validation failed — check the bot token.", "error"); - ctx.ui.notify(`Token valid — bot: ${botInfo.username}`, "info"); - - // Step 3: Collect channel ID const channelId = await promptInput(ctx, "Channel ID", "Paste the Discord channel ID (e.g. 1234567890123456789)"); - if (!channelId) { - ctx.ui.notify("Discord setup cancelled.", "info"); - return; + if (!channelId) return void ctx.ui.notify("Discord setup cancelled.", "info"); + + const sendResponse = await fetch(`https://discord.com/api/v10/channels/${channelId}/messages`, { + method: "POST", + headers: { Authorization: `Bot ${token}`, "Content-Type": "application/json" }, + body: JSON.stringify({ content: "GSD remote questions connected." }), + }); + if (!sendResponse.ok) { + const body = await sendResponse.text().catch(() => ""); + return void ctx.ui.notify(`Could not send to channel (HTTP ${sendResponse.status}): ${body}`, "error"); } - // Step 4: Send test message - ctx.ui.notify("Sending test message...", "info"); - try { - const res = await fetch(`https://discord.com/api/v10/channels/${channelId}/messages`, { - method: "POST", - headers: { - Authorization: `Bot ${token}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - content: "GSD remote questions connected! This channel will receive questions during auto-mode.", - }), - }); - if (!res.ok) { - const body = await res.text().catch(() => ""); - ctx.ui.notify(`Could not send to channel (HTTP ${res.status}): ${body}. Make sure the bot has access.`, "error"); - return; - } - } catch (err) { - ctx.ui.notify(`Network error sending test message: ${(err as Error).message}`, "error"); - return; - } - - // Step 5: Save configuration - saveTokenToAuth("discord_bot", token); + saveProviderToken("discord_bot", token); process.env.DISCORD_BOT_TOKEN = token; saveRemoteQuestionsConfig("discord", channelId); - - ctx.ui.notify(`Discord connected — questions will arrive in channel ${channelId} during /gsd auto`, "info"); + ctx.ui.notify(`Discord connected — remote questions enabled for channel ${channelId}.`, "info"); } -// ─── Status ────────────────────────────────────────────────────────────────── - async function handleRemoteStatus(ctx: ExtensionCommandContext): Promise { + const status = getRemoteConfigStatus(); const config = resolveRemoteConfig(); - if (!config) { - ctx.ui.notify(getRemoteConfigStatus(), "info"); + ctx.ui.notify(status, status.includes("disabled") ? "warning" : "info"); return; } - // Test the connection - ctx.ui.notify("Checking connection...", "info"); - - try { - if (config.channel === "slack") { - const res = await fetch("https://slack.com/api/auth.test", { - headers: { Authorization: `Bearer ${config.token}` }, - }); - const data = (await res.json()) as { ok: boolean; user?: string; team?: string }; - if (data.ok) { - ctx.ui.notify( - `Remote questions: Slack connected\n Bot: ${data.user}\n Workspace: ${data.team}\n Channel: ${config.channelId}\n Timeout: ${config.timeoutMs / 60000}m, poll: ${config.pollIntervalMs / 1000}s`, - "info", - ); - } else { - ctx.ui.notify("Remote questions: Slack token invalid — run /gsd remote slack to reconfigure", "warning"); - } - } else if (config.channel === "discord") { - const res = await fetch("https://discord.com/api/v10/users/@me", { - headers: { Authorization: `Bot ${config.token}` }, - }); - if (res.ok) { - const data = (await res.json()) as { username?: string }; - ctx.ui.notify( - `Remote questions: Discord connected\n Bot: ${data.username}\n Channel: ${config.channelId}\n Timeout: ${config.timeoutMs / 60000}m, poll: ${config.pollIntervalMs / 1000}s`, - "info", - ); - } else { - ctx.ui.notify("Remote questions: Discord token invalid — run /gsd remote discord to reconfigure", "warning"); - } - } - } catch (err) { - ctx.ui.notify(`Remote questions: connection check failed — ${(err as Error).message}`, "error"); + const latestPrompt = getLatestPromptSummary(); + const lines = [status]; + if (latestPrompt) { + lines.push(`Last prompt: ${latestPrompt.id}`); + lines.push(` status: ${latestPrompt.status}`); + if (latestPrompt.updatedAt) lines.push(` updated: ${new Date(latestPrompt.updatedAt).toLocaleString()}`); } -} -// ─── Disconnect ────────────────────────────────────────────────────────────── + ctx.ui.notify(lines.join("\n"), "info"); +} async function handleDisconnect(ctx: ExtensionCommandContext): Promise { const prefs = loadEffectiveGSDPreferences(); - if (!prefs?.preferences.remote_questions) { - ctx.ui.notify("No remote channel configured — nothing to disconnect.", "info"); - return; - } + const channel = prefs?.preferences.remote_questions?.channel; + if (!channel) return void ctx.ui.notify("No remote channel configured — nothing to disconnect.", "info"); - const channel = prefs.preferences.remote_questions.channel; - - // Remove from preferences file removeRemoteQuestionsConfig(); - - // Remove token from auth storage - const provider = channel === "slack" ? "slack_bot" : "discord_bot"; - removeTokenFromAuth(provider); - - // Clear env + removeProviderToken(channel === "slack" ? "slack_bot" : "discord_bot"); if (channel === "slack") delete process.env.SLACK_BOT_TOKEN; if (channel === "discord") delete process.env.DISCORD_BOT_TOKEN; - ctx.ui.notify(`Remote questions disconnected (${channel}).`, "info"); } -// ─── Menu ──────────────────────────────────────────────────────────────────── - async function handleRemoteMenu(ctx: ExtensionCommandContext): Promise { const config = resolveRemoteConfig(); + const latestPrompt = getLatestPromptSummary(); + const lines = config + ? [ + `Remote questions: ${config.channel} configured`, + ` Timeout: ${config.timeoutMs / 60000}m, poll: ${config.pollIntervalMs / 1000}s`, + latestPrompt ? ` Last prompt: ${latestPrompt.id} (${latestPrompt.status})` : " No remote prompts recorded yet", + "", + "Commands:", + " /gsd remote status", + " /gsd remote disconnect", + " /gsd remote slack", + " /gsd remote discord", + ] + : [ + "No remote question channel configured.", + "", + "Commands:", + " /gsd remote slack", + " /gsd remote discord", + " /gsd remote status", + ]; - if (config) { - ctx.ui.notify( - `Remote questions: ${config.channel} (channel ${config.channelId})\n` + - ` Timeout: ${config.timeoutMs / 60000}m, poll interval: ${config.pollIntervalMs / 1000}s\n\n` + - `Commands:\n` + - ` /gsd remote status — test connection\n` + - ` /gsd remote disconnect — remove configuration\n` + - ` /gsd remote slack — reconfigure with Slack\n` + - ` /gsd remote discord — reconfigure with Discord`, - "info", - ); - } else { - ctx.ui.notify( - `No remote question channel configured.\n\n` + - `Commands:\n` + - ` /gsd remote slack — set up Slack bot\n` + - ` /gsd remote discord — set up Discord bot\n` + - ` /gsd remote status — check configuration`, - "info", - ); + ctx.ui.notify(lines.join("\n"), "info"); +} + +async function fetchJson(url: string, init?: RequestInit): Promise { + try { + const response = await fetch(url, init); + return await response.json(); + } catch { + return null; } } -// ─── Input helpers ─────────────────────────────────────────────────────────── +function getAuthStorage(): AuthStorage { + const authPath = join(process.env.HOME ?? "", ".gsd", "agent", "auth.json"); + mkdirSync(dirname(authPath), { recursive: true }); + return AuthStorage.create(authPath); +} + +function saveProviderToken(provider: string, token: string): void { + const auth = getAuthStorage(); + auth.set(provider, { type: "api_key", key: token }); +} + +function removeProviderToken(provider: string): void { + const auth = getAuthStorage(); + auth.set(provider, { type: "api_key", key: "" }); +} + +function saveRemoteQuestionsConfig(channel: "slack" | "discord", channelId: string): void { + const prefsPath = getGlobalGSDPreferencesPath(); + const block = [ + "remote_questions:", + ` channel: ${channel}`, + ` channel_id: \"${channelId}\"`, + " timeout_minutes: 5", + " poll_interval_seconds: 5", + ].join("\n"); + + const content = existsSync(prefsPath) ? readFileSync(prefsPath, "utf-8") : ""; + const fmMatch = content.match(/^---\n([\s\S]*?)\n---/); + let next = content; + + if (fmMatch) { + let frontmatter = fmMatch[1]; + const regex = /remote_questions:[\s\S]*?(?=\n[a-zA-Z_]|\n---|$)/; + frontmatter = regex.test(frontmatter) ? frontmatter.replace(regex, block) : `${frontmatter.trimEnd()}\n${block}`; + next = `---\n${frontmatter}\n---${content.slice(fmMatch[0].length)}`; + } else { + next = `---\n${block}\n---\n\n${content}`; + } + + mkdirSync(dirname(prefsPath), { recursive: true }); + writeFileSync(prefsPath, next, "utf-8"); +} + +function removeRemoteQuestionsConfig(): void { + const prefsPath = getGlobalGSDPreferencesPath(); + if (!existsSync(prefsPath)) return; + const content = readFileSync(prefsPath, "utf-8"); + const fmMatch = content.match(/^---\n([\s\S]*?)\n---/); + if (!fmMatch) return; + const frontmatter = fmMatch[1].replace(/remote_questions:[\s\S]*?(?=\n[a-zA-Z_]|\n---|$)/, "").trim(); + const next = frontmatter ? `---\n${frontmatter}\n---${content.slice(fmMatch[0].length)}` : content.slice(fmMatch[0].length).replace(/^\n+/, ""); + writeFileSync(prefsPath, next, "utf-8"); +} function maskEditorLine(line: string): string { let output = ""; @@ -304,20 +214,14 @@ function maskEditorLine(line: string): string { i += ansiMatch[0].length; continue; } - const ch = line[i] as string; - output += ch === " " ? " " : "*"; + output += line[i] === " " ? " " : "*"; i += 1; } return output; } -async function promptMaskedInput( - ctx: ExtensionCommandContext, - label: string, - hint: string, -): Promise { +async function promptMaskedInput(ctx: ExtensionCommandContext, label: string, hint: string): Promise { if (!ctx.hasUI) return null; - return ctx.ui.custom((tui: any, theme: any, _kb: any, done: (r: string | null) => void) => { let cachedLines: string[] | undefined; const editorTheme: EditorTheme = { @@ -331,56 +235,34 @@ async function promptMaskedInput( }, }; const editor = new Editor(tui, editorTheme, { paddingX: 1 }); - - function refresh() { - cachedLines = undefined; - tui.requestRender(); - } - - function handleInput(data: string): void { - if (matchesKey(data, Key.enter)) { - const value = editor.getText().trim(); - done(value.length > 0 ? value : null); - return; - } - if (matchesKey(data, Key.escape)) { - done(null); - return; - } - editor.handleInput(data); - refresh(); - } - - function render(width: number): string[] { + const refresh = () => { cachedLines = undefined; tui.requestRender(); }; + const handleInput = (data: string) => { + if (matchesKey(data, Key.enter)) return done(editor.getText().trim() || null); + if (matchesKey(data, Key.escape)) return done(null); + editor.handleInput(data); refresh(); + }; + const render = (width: number) => { if (cachedLines) return cachedLines; const lines: string[] = []; const add = (s: string) => lines.push(truncateToWidth(s, width)); - add(theme.fg("accent", "\u2500".repeat(width))); + add(theme.fg("accent", "─".repeat(width))); add(theme.fg("accent", theme.bold(` ${label}`))); add(theme.fg("muted", ` ${hint}`)); lines.push(""); add(theme.fg("muted", " Enter value:")); - for (const line of editor.render(width - 2)) { - add(theme.fg("text", maskEditorLine(line))); - } + for (const line of editor.render(width - 2)) add(theme.fg("text", maskEditorLine(line))); lines.push(""); - add(theme.fg("dim", ` enter to confirm | esc to cancel`)); - add(theme.fg("accent", "\u2500".repeat(width))); + add(theme.fg("dim", " enter to confirm | esc to cancel")); + add(theme.fg("accent", "─".repeat(width))); cachedLines = lines; return lines; - } - + }; return { render, handleInput, invalidate: () => { cachedLines = undefined; } }; }); } -async function promptInput( - ctx: ExtensionCommandContext, - label: string, - hint: string, -): Promise { +async function promptInput(ctx: ExtensionCommandContext, label: string, hint: string): Promise { if (!ctx.hasUI) return null; - return ctx.ui.custom((tui: any, theme: any, _kb: any, done: (r: string | null) => void) => { let cachedLines: string[] | undefined; const editorTheme: EditorTheme = { @@ -394,139 +276,28 @@ async function promptInput( }, }; const editor = new Editor(tui, editorTheme, { paddingX: 1 }); - - function refresh() { - cachedLines = undefined; - tui.requestRender(); - } - - function handleInput(data: string): void { - if (matchesKey(data, Key.enter)) { - const value = editor.getText().trim(); - done(value.length > 0 ? value : null); - return; - } - if (matchesKey(data, Key.escape)) { - done(null); - return; - } - editor.handleInput(data); - refresh(); - } - - function render(width: number): string[] { + const refresh = () => { cachedLines = undefined; tui.requestRender(); }; + const handleInput = (data: string) => { + if (matchesKey(data, Key.enter)) return done(editor.getText().trim() || null); + if (matchesKey(data, Key.escape)) return done(null); + editor.handleInput(data); refresh(); + }; + const render = (width: number) => { if (cachedLines) return cachedLines; const lines: string[] = []; const add = (s: string) => lines.push(truncateToWidth(s, width)); - add(theme.fg("accent", "\u2500".repeat(width))); + add(theme.fg("accent", "─".repeat(width))); add(theme.fg("accent", theme.bold(` ${label}`))); add(theme.fg("muted", ` ${hint}`)); lines.push(""); add(theme.fg("muted", " Enter value:")); - for (const line of editor.render(width - 2)) { - add(theme.fg("text", line)); - } + for (const line of editor.render(width - 2)) add(theme.fg("text", line)); lines.push(""); - add(theme.fg("dim", ` enter to confirm | esc to cancel`)); - add(theme.fg("accent", "\u2500".repeat(width))); + add(theme.fg("dim", " enter to confirm | esc to cancel")); + add(theme.fg("accent", "─".repeat(width))); cachedLines = lines; return lines; - } - + }; return { render, handleInput, invalidate: () => { cachedLines = undefined; } }; }); } - -// ─── Persistence helpers ───────────────────────────────────────────────────── - -function getAuthFilePath(): string { - return join(homedir(), ".gsd", "agent", "auth.json"); -} - -function loadAuthJson(): Record { - const path = getAuthFilePath(); - if (!existsSync(path)) return {}; - try { - return JSON.parse(readFileSync(path, "utf-8")) as Record; - } catch { - return {}; - } -} - -function saveAuthJson(data: Record): void { - const path = getAuthFilePath(); - mkdirSync(dirname(path), { recursive: true }); - writeFileSync(path, JSON.stringify(data, null, 2), "utf-8"); -} - -function saveTokenToAuth(provider: string, token: string): void { - const auth = loadAuthJson(); - auth[provider] = { type: "api_key", key: token }; - saveAuthJson(auth); -} - -function removeTokenFromAuth(provider: string): void { - const auth = loadAuthJson(); - delete auth[provider]; - saveAuthJson(auth); -} - -function saveRemoteQuestionsConfig(channel: "slack" | "discord", channelId: string): void { - const prefsPath = getGlobalGSDPreferencesPath(); - let content = ""; - - if (existsSync(prefsPath)) { - content = readFileSync(prefsPath, "utf-8"); - } - - // Check if frontmatter exists - const fmMatch = content.match(/^---\n([\s\S]*?)\n---/); - - const remoteBlock = [ - `remote_questions:`, - ` channel: ${channel}`, - ` channel_id: "${channelId}"`, - ` timeout_minutes: 5`, - ` poll_interval_seconds: 5`, - ].join("\n"); - - if (fmMatch) { - // Replace existing remote_questions or append to frontmatter - let fm = fmMatch[1]; - const remoteRegex = /remote_questions:[\s\S]*?(?=\n[a-zA-Z_]|\n---|$)/; - if (remoteRegex.test(fm)) { - fm = fm.replace(remoteRegex, remoteBlock); - } else { - fm = fm.trimEnd() + "\n" + remoteBlock; - } - content = `---\n${fm}\n---` + content.slice(fmMatch[0].length); - } else { - // Create new frontmatter - content = `---\n${remoteBlock}\n---\n\n${content}`; - } - - mkdirSync(dirname(prefsPath), { recursive: true }); - writeFileSync(prefsPath, content, "utf-8"); -} - -function removeRemoteQuestionsConfig(): void { - const prefsPath = getGlobalGSDPreferencesPath(); - if (!existsSync(prefsPath)) return; - - let content = readFileSync(prefsPath, "utf-8"); - const fmMatch = content.match(/^---\n([\s\S]*?)\n---/); - if (!fmMatch) return; - - let fm = fmMatch[1]; - // Remove remote_questions block from frontmatter - fm = fm.replace(/remote_questions:[\s\S]*?(?=\n[a-zA-Z_]|\n---|$)/, "").trim(); - - if (fm) { - content = `---\n${fm}\n---` + content.slice(fmMatch[0].length); - } else { - // Frontmatter is now empty, remove it - content = content.slice(fmMatch[0].length).replace(/^\n+/, ""); - } - - writeFileSync(prefsPath, content, "utf-8"); -} diff --git a/src/resources/extensions/remote-questions/slack-adapter.ts b/src/resources/extensions/remote-questions/slack-adapter.ts index 1f3beff17..6bc69b036 100644 --- a/src/resources/extensions/remote-questions/slack-adapter.ts +++ b/src/resources/extensions/remote-questions/slack-adapter.ts @@ -1,24 +1,14 @@ /** * Remote Questions — Slack adapter - * - * Uses Slack Bot Token API (xoxb-*) for bidirectional messaging: - * - Send: POST chat.postMessage with Block Kit - * - Poll: GET conversations.replies to read thread responses */ -import type { - ChannelAdapter, - FormattedQuestion, - PollReference, - RemoteAnswer, - SendResult, -} from "./channels.js"; +import type { ChannelAdapter, RemotePrompt, RemoteDispatchResult, RemoteAnswer, RemotePromptRef } from "./types.js"; import { formatForSlack, parseSlackReply } from "./format.js"; const SLACK_API = "https://slack.com/api"; export class SlackAdapter implements ChannelAdapter { - readonly name = "slack"; + readonly name = "slack" as const; private botUserId: string | null = null; private readonly token: string; private readonly channelId: string; @@ -30,88 +20,35 @@ export class SlackAdapter implements ChannelAdapter { async validate(): Promise { const res = await this.slackApi("auth.test", {}); - if (!res.ok) { - throw new Error(`Slack auth failed: ${res.error ?? "invalid token"}`); - } - this.botUserId = res.user_id as string; + if (!res.ok) throw new Error(`Slack auth failed: ${res.error ?? "invalid token"}`); + this.botUserId = String(res.user_id ?? ""); } - async sendQuestions(questions: FormattedQuestion[]): Promise { - const blocks = formatForSlack(questions); - + async sendPrompt(prompt: RemotePrompt): Promise { const res = await this.slackApi("chat.postMessage", { channel: this.channelId, text: "GSD needs your input", - blocks, + blocks: formatForSlack(prompt), }); - if (!res.ok) { - throw new Error(`Slack postMessage failed: ${res.error ?? "unknown"}`); - } - - const ts = res.ts as string; - const channel = res.channel as string; + if (!res.ok) throw new Error(`Slack postMessage failed: ${res.error ?? "unknown"}`); + const ts = String(res.ts); + const channel = String(res.channel); return { ref: { - channelType: "slack", + id: prompt.id, + channel: "slack", messageId: ts, threadTs: ts, channelId: channel, + threadUrl: `https://slack.com/archives/${channel}/p${ts.replace(".", "")}`, }, - threadUrl: `https://slack.com/archives/${channel}/p${ts.replace(".", "")}`, }; } - async pollResponse(ref: PollReference): Promise { - // Ensure we know our bot user ID - if (!this.botUserId) { - const authRes = await this.slackApi("auth.test", {}); - if (authRes.ok) this.botUserId = authRes.user_id as string; - } - - const res = await this.slackApi("conversations.replies", { - channel: ref.channelId, - ts: ref.threadTs!, - limit: "20", - }); - - if (!res.ok) { - // Channel not found or no access — don't throw, just return null - return null; - } - - const messages = (res.messages ?? []) as Array<{ - user: string; - text: string; - ts: string; - }>; - - // Filter out the bot's own messages — only user replies count - const userReplies = messages.filter( - (m) => m.ts !== ref.threadTs && m.user !== this.botUserId, - ); - - if (userReplies.length === 0) return null; - - // Use the first user reply - const reply = userReplies[0]; - // We need the questions for parsing — store them on the ref isn't ideal, - // so the caller will need to pass them. For now, return raw text wrapped. - return { answers: { _raw: { answers: [reply.text] } } }; - } - - /** - * Poll with full question context for proper parsing. - */ - async pollResponseWithQuestions( - ref: PollReference, - questions: FormattedQuestion[], - ): Promise { - if (!this.botUserId) { - const authRes = await this.slackApi("auth.test", {}); - if (authRes.ok) this.botUserId = authRes.user_id as string; - } + async pollAnswer(prompt: RemotePrompt, ref: RemotePromptRef): Promise { + if (!this.botUserId) await this.validate(); const res = await this.slackApi("conversations.replies", { channel: ref.channelId, @@ -121,43 +58,21 @@ export class SlackAdapter implements ChannelAdapter { if (!res.ok) return null; - const messages = (res.messages ?? []) as Array<{ - user: string; - text: string; - ts: string; - }>; - - const userReplies = messages.filter( - (m) => m.ts !== ref.threadTs && m.user !== this.botUserId, - ); - + const messages = (res.messages ?? []) as Array<{ user?: string; text?: string; ts: string }>; + const userReplies = messages.filter((m) => m.ts !== ref.threadTs && m.user && m.user !== this.botUserId && m.text); if (userReplies.length === 0) return null; - return parseSlackReply(userReplies[0].text, questions); + return parseSlackReply(String(userReplies[0].text), prompt.questions); } - // ─── Internal ────────────────────────────────────────────────────────────── - - private async slackApi( - method: string, - params: Record, - ): Promise> { + private async slackApi(method: string, params: Record): Promise> { const url = `${SLACK_API}/${method}`; - const isGet = method === "conversations.replies" || method === "auth.test"; let response: Response; if (isGet) { - // GET params must be strings for URLSearchParams - const stringParams: Record = {}; - for (const [k, v] of Object.entries(params)) { - stringParams[k] = String(v); - } - const qs = new URLSearchParams(stringParams).toString(); - response = await fetch(`${url}?${qs}`, { - method: "GET", - headers: { Authorization: `Bearer ${this.token}` }, - }); + const qs = new URLSearchParams(Object.fromEntries(Object.entries(params).map(([k, v]) => [k, String(v)]))).toString(); + response = await fetch(`${url}?${qs}`, { method: "GET", headers: { Authorization: `Bearer ${this.token}` } }); } else { response = await fetch(url, { method: "POST", @@ -169,10 +84,7 @@ export class SlackAdapter implements ChannelAdapter { }); } - if (!response.ok) { - throw new Error(`Slack API HTTP ${response.status}: ${response.statusText}`); - } - + if (!response.ok) throw new Error(`Slack API HTTP ${response.status}: ${response.statusText}`); return (await response.json()) as Record; } } diff --git a/src/resources/extensions/remote-questions/status.ts b/src/resources/extensions/remote-questions/status.ts new file mode 100644 index 000000000..e322aeaf8 --- /dev/null +++ b/src/resources/extensions/remote-questions/status.ts @@ -0,0 +1,23 @@ +/** + * Remote Questions — status helpers + */ + +import { existsSync, readdirSync } from "node:fs"; +import { join } from "node:path"; +import { homedir } from "node:os"; +import { readPromptRecord } from "./store.js"; + +export interface LatestPromptSummary { + id: string; + status: string; + updatedAt: number; +} + +export function getLatestPromptSummary(): LatestPromptSummary | null { + const runtimeDir = join(homedir(), ".gsd", "runtime", "remote-questions"); + if (!existsSync(runtimeDir)) return null; + const files = readdirSync(runtimeDir).filter((f) => f.endsWith(".json")).sort().reverse(); + if (files.length === 0) return null; + const record = readPromptRecord(files[0].replace(/\.json$/, "")); + return record ? { id: record.id, status: record.status, updatedAt: record.updatedAt } : null; +} diff --git a/src/resources/extensions/remote-questions/store.ts b/src/resources/extensions/remote-questions/store.ts new file mode 100644 index 000000000..226ac8996 --- /dev/null +++ b/src/resources/extensions/remote-questions/store.ts @@ -0,0 +1,77 @@ +/** + * Remote Questions — durable prompt store + */ + +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { homedir } from "node:os"; +import type { RemotePrompt, RemotePromptRecord, RemotePromptRef, RemoteAnswer, RemotePromptStatus } from "./types.js"; + +function runtimeDir(): string { + return join(homedir(), ".gsd", "runtime", "remote-questions"); +} + +function recordPath(id: string): string { + return join(runtimeDir(), `${id}.json`); +} + +export function createPromptRecord(prompt: RemotePrompt): RemotePromptRecord { + return { + version: 1, + id: prompt.id, + createdAt: prompt.createdAt, + updatedAt: Date.now(), + status: "pending", + channel: prompt.channel, + timeoutAt: prompt.timeoutAt, + pollIntervalMs: prompt.pollIntervalMs, + questions: prompt.questions, + context: prompt.context, + }; +} + +export function writePromptRecord(record: RemotePromptRecord): void { + mkdirSync(runtimeDir(), { recursive: true }); + writeFileSync(recordPath(record.id), JSON.stringify(record, null, 2) + "\n", "utf-8"); +} + +export function readPromptRecord(id: string): RemotePromptRecord | null { + const path = recordPath(id); + if (!existsSync(path)) return null; + try { + return JSON.parse(readFileSync(path, "utf-8")) as RemotePromptRecord; + } catch { + return null; + } +} + +export function updatePromptRecord( + id: string, + updates: Partial, +): RemotePromptRecord | null { + const current = readPromptRecord(id); + if (!current) return null; + const next: RemotePromptRecord = { + ...current, + ...updates, + updatedAt: Date.now(), + }; + writePromptRecord(next); + return next; +} + +export function markPromptDispatched(id: string, ref: RemotePromptRef): RemotePromptRecord | null { + return updatePromptRecord(id, { ref, status: "pending" }); +} + +export function markPromptAnswered(id: string, response: RemoteAnswer): RemotePromptRecord | null { + return updatePromptRecord(id, { response, status: "answered", lastPollAt: Date.now() }); +} + +export function markPromptStatus(id: string, status: RemotePromptStatus, lastError?: string): RemotePromptRecord | null { + return updatePromptRecord(id, { + status, + lastPollAt: Date.now(), + ...(lastError ? { lastError } : {}), + }); +} diff --git a/src/resources/extensions/remote-questions/types.ts b/src/resources/extensions/remote-questions/types.ts new file mode 100644 index 000000000..b1237fdf7 --- /dev/null +++ b/src/resources/extensions/remote-questions/types.ts @@ -0,0 +1,75 @@ +/** + * Remote Questions — shared types + */ + +export type RemoteChannel = "slack" | "discord"; + +export interface RemoteQuestionOption { + label: string; + description: string; +} + +export interface RemoteQuestion { + id: string; + header: string; + question: string; + options: RemoteQuestionOption[]; + allowMultiple: boolean; +} + +export interface RemotePrompt { + id: string; + channel: RemoteChannel; + createdAt: number; + timeoutAt: number; + pollIntervalMs: number; + questions: RemoteQuestion[]; + context?: { + source: string; + }; +} + +export interface RemotePromptRef { + id: string; + channel: RemoteChannel; + messageId: string; + channelId: string; + threadTs?: string; + threadUrl?: string; +} + +export interface RemoteAnswer { + answers: Record; +} + +export type RemotePromptStatus = "pending" | "answered" | "timed_out" | "failed" | "cancelled"; + +export interface RemotePromptRecord { + version: 1; + id: string; + createdAt: number; + updatedAt: number; + status: RemotePromptStatus; + channel: RemoteChannel; + timeoutAt: number; + pollIntervalMs: number; + questions: RemoteQuestion[]; + ref?: RemotePromptRef; + response?: RemoteAnswer; + lastPollAt?: number; + lastError?: string; + context?: { + source: string; + }; +} + +export interface RemoteDispatchResult { + ref: RemotePromptRef; +} + +export interface ChannelAdapter { + readonly name: RemoteChannel; + validate(): Promise; + sendPrompt(prompt: RemotePrompt): Promise; + pollAnswer(prompt: RemotePrompt, ref: RemotePromptRef): Promise; +} From 8a00605e51146c16b18393efb263fbd8667d99e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Facu=5FVi=C3=B1as?= Date: Wed, 11 Mar 2026 14:44:42 -0300 Subject: [PATCH 06/13] fix: sort prompt store by updatedAt instead of filename getLatestPromptSummary() sorted JSON filenames alphabetically to find the most recent prompt. Since filenames are UUIDs (random, not temporal), this returned arbitrary results. Now reads updatedAt from each record and picks the highest. Also fixes test isolation on Windows (USERPROFILE) and adds a regression test that fails with the old alphabetical sort. Co-Authored-By: Claude Opus 4.6 --- .../gsd/tests/remote-status.test.ts | 71 ++++++++++++++++--- .../extensions/remote-questions/status.ts | 14 +++- 2 files changed, 74 insertions(+), 11 deletions(-) diff --git a/src/resources/extensions/gsd/tests/remote-status.test.ts b/src/resources/extensions/gsd/tests/remote-status.test.ts index 4ca3ff0ce..507c9cf35 100644 --- a/src/resources/extensions/gsd/tests/remote-status.test.ts +++ b/src/resources/extensions/gsd/tests/remote-status.test.ts @@ -6,12 +6,25 @@ import { tmpdir } from "node:os"; import { createPromptRecord, writePromptRecord } from "../../remote-questions/store.ts"; import { getLatestPromptSummary } from "../../remote-questions/status.ts"; -test("getLatestPromptSummary returns latest stored prompt", async () => { - const home = process.env.HOME!; - const tempHome = join(tmpdir(), `gsd-remote-status-${Date.now()}`); - mkdirSync(join(tempHome, ".gsd", "runtime", "remote-questions"), { recursive: true }); - process.env.HOME = tempHome; +function withTempHome(fn: (tempHome: string) => void | Promise) { + return async () => { + const savedHome = process.env.HOME; + const savedUserProfile = process.env.USERPROFILE; + const tempHome = join(tmpdir(), `gsd-remote-status-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(join(tempHome, ".gsd", "runtime", "remote-questions"), { recursive: true }); + process.env.HOME = tempHome; + process.env.USERPROFILE = tempHome; + try { + await fn(tempHome); + } finally { + process.env.HOME = savedHome; + process.env.USERPROFILE = savedUserProfile; + rmSync(tempHome, { recursive: true, force: true }); + } + }; +} +test("getLatestPromptSummary returns latest stored prompt", withTempHome(() => { const recordA = createPromptRecord({ id: "a-prompt", channel: "slack", @@ -38,7 +51,49 @@ test("getLatestPromptSummary returns latest stored prompt", async () => { const latest = getLatestPromptSummary(); assert.equal(latest?.id, "z-prompt"); assert.equal(latest?.status, "answered"); +})); - process.env.HOME = home; - rmSync(tempHome, { recursive: true, force: true }); -}); +test("getLatestPromptSummary sorts by updatedAt, not filename", withTempHome(() => { + // Record with alphabetically-LAST id but OLDEST timestamp + const old = createPromptRecord({ + id: "zzz-oldest", + channel: "slack", + createdAt: 1000, + timeoutAt: 9999, + pollIntervalMs: 5000, + questions: [], + }); + old.updatedAt = 1000; + writePromptRecord(old); + + // Record with alphabetically-FIRST id but NEWEST timestamp + const newest = createPromptRecord({ + id: "aaa-newest", + channel: "discord", + createdAt: 3000, + timeoutAt: 9999, + pollIntervalMs: 5000, + questions: [], + }); + newest.updatedAt = 3000; + newest.status = "answered"; + writePromptRecord(newest); + + // Record in between + const middle = createPromptRecord({ + id: "mmm-middle", + channel: "slack", + createdAt: 2000, + timeoutAt: 9999, + pollIntervalMs: 5000, + questions: [], + }); + middle.updatedAt = 2000; + writePromptRecord(middle); + + const latest = getLatestPromptSummary(); + // Should return "aaa-newest" (updatedAt=3000), NOT "zzz-oldest" (alphabetically last) + assert.equal(latest?.id, "aaa-newest", "should pick the most recently updated prompt, not the alphabetically last filename"); + assert.equal(latest?.status, "answered"); + assert.equal(latest?.updatedAt, 3000); +})); diff --git a/src/resources/extensions/remote-questions/status.ts b/src/resources/extensions/remote-questions/status.ts index e322aeaf8..dd4593488 100644 --- a/src/resources/extensions/remote-questions/status.ts +++ b/src/resources/extensions/remote-questions/status.ts @@ -16,8 +16,16 @@ export interface LatestPromptSummary { export function getLatestPromptSummary(): LatestPromptSummary | null { const runtimeDir = join(homedir(), ".gsd", "runtime", "remote-questions"); if (!existsSync(runtimeDir)) return null; - const files = readdirSync(runtimeDir).filter((f) => f.endsWith(".json")).sort().reverse(); + const files = readdirSync(runtimeDir).filter((f) => f.endsWith(".json")); if (files.length === 0) return null; - const record = readPromptRecord(files[0].replace(/\.json$/, "")); - return record ? { id: record.id, status: record.status, updatedAt: record.updatedAt } : null; + + let latest: LatestPromptSummary | null = null; + for (const file of files) { + const record = readPromptRecord(file.replace(/\.json$/, "")); + if (!record) continue; + if (!latest || record.updatedAt > latest.updatedAt) { + latest = { id: record.id, status: record.status, updatedAt: record.updatedAt }; + } + } + return latest; } From 9b80c221ce10b87d8c6e230a9d6c399490fbb01f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Facu=5FVi=C3=B1as?= Date: Wed, 11 Mar 2026 15:46:44 -0300 Subject: [PATCH 07/13] fix: isolate remote-questions config test for Windows compatibility resolveRemoteConfig test used process.env.HOME which is undefined on Windows (Node uses USERPROFILE). Use a temp directory with both HOME and USERPROFILE set, and clean up in a finally block. Co-Authored-By: Claude Opus 4.6 --- .../gsd/tests/remote-questions.test.ts | 34 ++++++++++++------- 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/src/resources/extensions/gsd/tests/remote-questions.test.ts b/src/resources/extensions/gsd/tests/remote-questions.test.ts index f409224ce..5480b15e0 100644 --- a/src/resources/extensions/gsd/tests/remote-questions.test.ts +++ b/src/resources/extensions/gsd/tests/remote-questions.test.ts @@ -3,8 +3,6 @@ import assert from "node:assert/strict"; import { parseSlackReply, parseDiscordResponse } from "../../remote-questions/format.ts"; import { resolveRemoteConfig } from "../../remote-questions/config.ts"; -const originalEnv = { ...process.env }; - test("parseSlackReply handles single-number single-question answers", () => { const result = parseSlackReply("2", [{ id: "choice", @@ -90,18 +88,30 @@ test("parseDiscordResponse rejects multi-question reaction parsing", () => { }); test("resolveRemoteConfig clamps invalid timeout and poll interval values", async () => { - process.env.SLACK_BOT_TOKEN = "token"; - const home = process.env.HOME!; + const os = await import("node:os"); const fs = await import("node:fs"); const path = await import("node:path"); - const prefsPath = path.join(home, ".gsd", "preferences.md"); - fs.mkdirSync(path.dirname(prefsPath), { recursive: true }); - fs.writeFileSync(prefsPath, `---\nremote_questions:\n channel: slack\n channel_id: \"C123\"\n timeout_minutes: 999\n poll_interval_seconds: 0\n---\n`, "utf-8"); - const config = resolveRemoteConfig(); - assert.ok(config); - assert.equal(config?.timeoutMs, 30 * 60 * 1000); - assert.equal(config?.pollIntervalMs, 2 * 1000); + const savedHome = process.env.HOME; + const savedUserProfile = process.env.USERPROFILE; + const tempHome = path.join(os.tmpdir(), `gsd-remote-config-${Date.now()}-${Math.random().toString(36).slice(2)}`); + fs.mkdirSync(path.join(tempHome, ".gsd"), { recursive: true }); + process.env.HOME = tempHome; + process.env.USERPROFILE = tempHome; + process.env.SLACK_BOT_TOKEN = "token"; - process.env = { ...originalEnv }; + try { + const prefsPath = path.join(tempHome, ".gsd", "preferences.md"); + fs.writeFileSync(prefsPath, `---\nremote_questions:\n channel: slack\n channel_id: \"C123\"\n timeout_minutes: 999\n poll_interval_seconds: 0\n---\n`, "utf-8"); + + const config = resolveRemoteConfig(); + assert.ok(config); + assert.equal(config?.timeoutMs, 30 * 60 * 1000); + assert.equal(config?.pollIntervalMs, 2 * 1000); + } finally { + process.env.HOME = savedHome; + process.env.USERPROFILE = savedUserProfile; + delete process.env.SLACK_BOT_TOKEN; + fs.rmSync(tempHome, { recursive: true, force: true }); + } }); From 41f362841ef11372770eaec8c3a52da9a457f820 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Facu=5FVi=C3=B1as?= Date: Wed, 11 Mar 2026 17:58:47 -0300 Subject: [PATCH 08/13] fix(security): validate channel ID format to prevent SSRF MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slack IDs must match ^[A-Z0-9]{9,12}$, Discord snowflakes must match ^\d{17,20}$. resolveRemoteConfig() and getRemoteConfigStatus() now reject malformed IDs before they reach any URL interpolation. Also fixes pre-existing false-positive in config tests (env overrides couldn't affect module-level homedir() cache) — replaced with direct isValidChannelId() unit tests. Co-Authored-By: Claude Opus 4.6 --- .../gsd/tests/remote-questions.test.ts | 57 ++++++++++--------- .../extensions/remote-questions/config.ts | 17 +++++- 2 files changed, 45 insertions(+), 29 deletions(-) diff --git a/src/resources/extensions/gsd/tests/remote-questions.test.ts b/src/resources/extensions/gsd/tests/remote-questions.test.ts index 5480b15e0..bf37c2771 100644 --- a/src/resources/extensions/gsd/tests/remote-questions.test.ts +++ b/src/resources/extensions/gsd/tests/remote-questions.test.ts @@ -1,7 +1,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import { parseSlackReply, parseDiscordResponse } from "../../remote-questions/format.ts"; -import { resolveRemoteConfig } from "../../remote-questions/config.ts"; +import { resolveRemoteConfig, isValidChannelId } from "../../remote-questions/config.ts"; test("parseSlackReply handles single-number single-question answers", () => { const result = parseSlackReply("2", [{ @@ -87,31 +87,32 @@ test("parseDiscordResponse rejects multi-question reaction parsing", () => { assert.match(String(result.answers.second.user_note), /single-question prompts/i); }); -test("resolveRemoteConfig clamps invalid timeout and poll interval values", async () => { - const os = await import("node:os"); - const fs = await import("node:fs"); - const path = await import("node:path"); - - const savedHome = process.env.HOME; - const savedUserProfile = process.env.USERPROFILE; - const tempHome = path.join(os.tmpdir(), `gsd-remote-config-${Date.now()}-${Math.random().toString(36).slice(2)}`); - fs.mkdirSync(path.join(tempHome, ".gsd"), { recursive: true }); - process.env.HOME = tempHome; - process.env.USERPROFILE = tempHome; - process.env.SLACK_BOT_TOKEN = "token"; - - try { - const prefsPath = path.join(tempHome, ".gsd", "preferences.md"); - fs.writeFileSync(prefsPath, `---\nremote_questions:\n channel: slack\n channel_id: \"C123\"\n timeout_minutes: 999\n poll_interval_seconds: 0\n---\n`, "utf-8"); - - const config = resolveRemoteConfig(); - assert.ok(config); - assert.equal(config?.timeoutMs, 30 * 60 * 1000); - assert.equal(config?.pollIntervalMs, 2 * 1000); - } finally { - process.env.HOME = savedHome; - process.env.USERPROFILE = savedUserProfile; - delete process.env.SLACK_BOT_TOKEN; - fs.rmSync(tempHome, { recursive: true, force: true }); - } +test("isValidChannelId rejects invalid Slack channel IDs", () => { + // Too short + assert.equal(isValidChannelId("slack", "C123"), false); + // Contains invalid chars (URL injection) + assert.equal(isValidChannelId("slack", "https://evil.com"), false); + // Lowercase + assert.equal(isValidChannelId("slack", "c12345678"), false); + // Too long + assert.equal(isValidChannelId("slack", "C1234567890AB"), false); + // Valid: 9-12 uppercase alphanumeric + assert.equal(isValidChannelId("slack", "C12345678"), true); + assert.equal(isValidChannelId("slack", "C12345678AB"), true); + assert.equal(isValidChannelId("slack", "C1234567890A"), true); }); + +test("isValidChannelId rejects invalid Discord channel IDs", () => { + // Too short + assert.equal(isValidChannelId("discord", "12345"), false); + // Contains letters (not a snowflake) + assert.equal(isValidChannelId("discord", "abc12345678901234"), false); + // URL injection + assert.equal(isValidChannelId("discord", "https://evil.com"), false); + // Too long (21 digits) + assert.equal(isValidChannelId("discord", "123456789012345678901"), false); + // Valid: 17-20 digit snowflake + assert.equal(isValidChannelId("discord", "12345678901234567"), true); + assert.equal(isValidChannelId("discord", "11234567890123456789"), true); +}); + diff --git a/src/resources/extensions/remote-questions/config.ts b/src/resources/extensions/remote-questions/config.ts index 7fe7b7d2c..0b962c2e4 100644 --- a/src/resources/extensions/remote-questions/config.ts +++ b/src/resources/extensions/remote-questions/config.ts @@ -18,6 +18,12 @@ const ENV_KEYS: Record = { discord: "DISCORD_BOT_TOKEN", }; +// Channel ID format validation — prevents SSRF if preferences are attacker-controlled +const CHANNEL_ID_PATTERNS: Record = { + slack: /^[A-Z0-9]{9,12}$/, + discord: /^\d{17,20}$/, +}; + const DEFAULT_TIMEOUT_MINUTES = 5; const DEFAULT_POLL_INTERVAL_SECONDS = 5; const MIN_TIMEOUT_MINUTES = 1; @@ -31,6 +37,9 @@ export function resolveRemoteConfig(): ResolvedConfig | null { if (!rq || !rq.channel || !rq.channel_id) return null; if (rq.channel !== "slack" && rq.channel !== "discord") return null; + const channelId = String(rq.channel_id); + if (!CHANNEL_ID_PATTERNS[rq.channel].test(channelId)) return null; + const token = process.env[ENV_KEYS[rq.channel]]; if (!token) return null; @@ -39,7 +48,7 @@ export function resolveRemoteConfig(): ResolvedConfig | null { return { channel: rq.channel, - channelId: String(rq.channel_id), + channelId, timeoutMs: timeoutMinutes * 60 * 1000, pollIntervalMs: pollIntervalSeconds * 1000, token, @@ -51,6 +60,8 @@ export function getRemoteConfigStatus(): string { const rq: RemoteQuestionsConfig | undefined = prefs?.preferences.remote_questions; if (!rq || !rq.channel || !rq.channel_id) return "Remote questions: not configured"; if (rq.channel !== "slack" && rq.channel !== "discord") return `Remote questions: unknown channel type \"${rq.channel}\"`; + const channelId = String(rq.channel_id); + if (!CHANNEL_ID_PATTERNS[rq.channel].test(channelId)) return `Remote questions: invalid ${rq.channel} channel ID format`; const envVar = ENV_KEYS[rq.channel]; if (!process.env[envVar]) return `Remote questions: ${envVar} not set — remote questions disabled`; @@ -59,6 +70,10 @@ export function getRemoteConfigStatus(): string { return `Remote questions: ${rq.channel} configured (timeout ${timeoutMinutes}m, poll ${pollIntervalSeconds}s)`; } +export function isValidChannelId(channel: RemoteChannel, id: string): boolean { + return CHANNEL_ID_PATTERNS[channel].test(id); +} + function clampNumber(value: unknown, fallback: number, min: number, max: number): number { const n = typeof value === "number" ? value : Number(value); if (!Number.isFinite(n)) return fallback; From c67151bef3ea0cdc47bd58111ccb6a759604f1e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Facu=5FVi=C3=B1as?= Date: Wed, 11 Mar 2026 17:59:32 -0300 Subject: [PATCH 09/13] fix(security): cap user_note at 500 chars to prevent LLM context DoS Arbitrary-length free-text replies from remote channels were passed directly into the LLM context. Now truncated to 500 chars with trailing ellipsis. Co-Authored-By: Claude Opus 4.6 --- .../extensions/gsd/tests/remote-questions.test.ts | 15 +++++++++++++++ .../extensions/remote-questions/format.ts | 7 ++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/resources/extensions/gsd/tests/remote-questions.test.ts b/src/resources/extensions/gsd/tests/remote-questions.test.ts index bf37c2771..4175f1d38 100644 --- a/src/resources/extensions/gsd/tests/remote-questions.test.ts +++ b/src/resources/extensions/gsd/tests/remote-questions.test.ts @@ -87,6 +87,21 @@ test("parseDiscordResponse rejects multi-question reaction parsing", () => { assert.match(String(result.answers.second.user_note), /single-question prompts/i); }); +test("parseSlackReply truncates user_note longer than 500 chars", () => { + const longText = "x".repeat(600); + const result = parseSlackReply(longText, [{ + id: "q1", + header: "Q1", + question: "Pick", + allowMultiple: false, + options: [{ label: "A", description: "a" }], + }]); + + const note = result.answers.q1.user_note!; + assert.ok(note.length <= 502, `note should be truncated, got ${note.length} chars`); + assert.ok(note.endsWith("…"), "truncated note should end with ellipsis"); +}); + test("isValidChannelId rejects invalid Slack channel IDs", () => { // Too short assert.equal(isValidChannelId("slack", "C123"), false); diff --git a/src/resources/extensions/remote-questions/format.ts b/src/resources/extensions/remote-questions/format.ts index dd01039b8..1e03c637b 100644 --- a/src/resources/extensions/remote-questions/format.ts +++ b/src/resources/extensions/remote-questions/format.ts @@ -19,6 +19,7 @@ export interface DiscordEmbed { } const NUMBER_EMOJIS = ["1️⃣", "2️⃣", "3️⃣", "4️⃣", "5️⃣"]; +const MAX_USER_NOTE_LENGTH = 500; export function formatForSlack(prompt: RemotePrompt): SlackBlock[] { const blocks: SlackBlock[] = [ @@ -154,5 +155,9 @@ function parseAnswerForQuestion(text: string, q: RemoteQuestion): { answers: str return { answers: [q.options[single - 1].label] }; } - return { answers: [], user_note: text }; + return { answers: [], user_note: truncateNote(text) }; +} + +function truncateNote(text: string): string { + return text.length > MAX_USER_NOTE_LENGTH ? text.slice(0, MAX_USER_NOTE_LENGTH) + "…" : text; } From 492daaf709b10274e73a49575c7a9d7dbfba7b07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Facu=5FVi=C3=B1as?= Date: Wed, 11 Mar 2026 18:00:19 -0300 Subject: [PATCH 10/13] fix(reliability): add 15s per-request fetch timeout to adapters Individual HTTP calls to Slack/Discord APIs could hang indefinitely if the network stalls. The overall poll deadline only bounds the loop, not each request. Now each fetch() gets AbortSignal.timeout(15_000). Co-Authored-By: Claude Opus 4.6 --- src/resources/extensions/remote-questions/discord-adapter.ts | 2 ++ src/resources/extensions/remote-questions/slack-adapter.ts | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/resources/extensions/remote-questions/discord-adapter.ts b/src/resources/extensions/remote-questions/discord-adapter.ts index e477af65a..544675a1c 100644 --- a/src/resources/extensions/remote-questions/discord-adapter.ts +++ b/src/resources/extensions/remote-questions/discord-adapter.ts @@ -6,6 +6,7 @@ import type { ChannelAdapter, RemotePrompt, RemoteDispatchResult, RemoteAnswer, import { formatForDiscord, parseDiscordResponse } from "./format.js"; const DISCORD_API = "https://discord.com/api/v10"; +const PER_REQUEST_TIMEOUT_MS = 15_000; const NUMBER_EMOJIS = ["1️⃣", "2️⃣", "3️⃣", "4️⃣", "5️⃣"]; export class DiscordAdapter implements ChannelAdapter { @@ -108,6 +109,7 @@ export class DiscordAdapter implements ChannelAdapter { init.body = JSON.stringify(body); } + init.signal = AbortSignal.timeout(PER_REQUEST_TIMEOUT_MS); const response = await fetch(`${DISCORD_API}${path}`, init); if (response.status === 204) return {}; if (!response.ok) { diff --git a/src/resources/extensions/remote-questions/slack-adapter.ts b/src/resources/extensions/remote-questions/slack-adapter.ts index 6bc69b036..42b9fcc07 100644 --- a/src/resources/extensions/remote-questions/slack-adapter.ts +++ b/src/resources/extensions/remote-questions/slack-adapter.ts @@ -6,6 +6,7 @@ import type { ChannelAdapter, RemotePrompt, RemoteDispatchResult, RemoteAnswer, import { formatForSlack, parseSlackReply } from "./format.js"; const SLACK_API = "https://slack.com/api"; +const PER_REQUEST_TIMEOUT_MS = 15_000; export class SlackAdapter implements ChannelAdapter { readonly name = "slack" as const; @@ -72,7 +73,7 @@ export class SlackAdapter implements ChannelAdapter { let response: Response; if (isGet) { const qs = new URLSearchParams(Object.fromEntries(Object.entries(params).map(([k, v]) => [k, String(v)]))).toString(); - response = await fetch(`${url}?${qs}`, { method: "GET", headers: { Authorization: `Bearer ${this.token}` } }); + response = await fetch(`${url}?${qs}`, { method: "GET", headers: { Authorization: `Bearer ${this.token}` }, signal: AbortSignal.timeout(PER_REQUEST_TIMEOUT_MS) }); } else { response = await fetch(url, { method: "POST", @@ -81,6 +82,7 @@ export class SlackAdapter implements ChannelAdapter { "Content-Type": "application/json; charset=utf-8", }, body: JSON.stringify(params), + signal: AbortSignal.timeout(PER_REQUEST_TIMEOUT_MS), }); } From 003cb44007006698d6fbc06c8c90d70cdac6cdc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Facu=5FVi=C3=B1as?= Date: Wed, 11 Mar 2026 18:01:42 -0300 Subject: [PATCH 11/13] fix(security): sanitize error messages to prevent token leakage Error messages from adapter auth/send failures may contain token fragments. Added sanitizeError() that strips Slack token patterns (xoxb-, xoxp-, xoxa-) and long opaque secrets (20+ alphanumeric chars). Also truncates verbose Discord API error responses to 200 chars. Applied to all error paths in manager.ts and discord-adapter.ts. Co-Authored-By: Claude Opus 4.6 --- .../gsd/tests/remote-questions.test.ts | 22 +++++++++++++++++ .../remote-questions/discord-adapter.ts | 4 +++- .../extensions/remote-questions/manager.ts | 24 +++++++++++++++---- 3 files changed, 45 insertions(+), 5 deletions(-) diff --git a/src/resources/extensions/gsd/tests/remote-questions.test.ts b/src/resources/extensions/gsd/tests/remote-questions.test.ts index 4175f1d38..40dbe551c 100644 --- a/src/resources/extensions/gsd/tests/remote-questions.test.ts +++ b/src/resources/extensions/gsd/tests/remote-questions.test.ts @@ -2,6 +2,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import { parseSlackReply, parseDiscordResponse } from "../../remote-questions/format.ts"; import { resolveRemoteConfig, isValidChannelId } from "../../remote-questions/config.ts"; +import { sanitizeError } from "../../remote-questions/manager.ts"; test("parseSlackReply handles single-number single-question answers", () => { const result = parseSlackReply("2", [{ @@ -131,3 +132,24 @@ test("isValidChannelId rejects invalid Discord channel IDs", () => { assert.equal(isValidChannelId("discord", "11234567890123456789"), true); }); +test("sanitizeError strips Slack token patterns from error messages", () => { + assert.equal( + sanitizeError("Auth failed: xoxb-1234-5678-abcdef"), + "Auth failed: [REDACTED]", + ); + assert.equal( + sanitizeError("Bad token xoxp-abc-def-ghi in request"), + "Bad token [REDACTED] in request", + ); +}); + +test("sanitizeError strips long opaque secrets", () => { + const fakeDiscordToken = "MTIzNDU2Nzg5MDEyMzQ1Njc4OQ.G1x2y3.abcdefghijklmnop"; + assert.ok(!sanitizeError(`Token: ${fakeDiscordToken}`).includes(fakeDiscordToken)); +}); + +test("sanitizeError preserves short safe messages", () => { + assert.equal(sanitizeError("HTTP 401: Unauthorized"), "HTTP 401: Unauthorized"); + assert.equal(sanitizeError("Connection refused"), "Connection refused"); +}); + diff --git a/src/resources/extensions/remote-questions/discord-adapter.ts b/src/resources/extensions/remote-questions/discord-adapter.ts index 544675a1c..a3f84e0f0 100644 --- a/src/resources/extensions/remote-questions/discord-adapter.ts +++ b/src/resources/extensions/remote-questions/discord-adapter.ts @@ -114,7 +114,9 @@ export class DiscordAdapter implements ChannelAdapter { if (response.status === 204) return {}; if (!response.ok) { const text = await response.text().catch(() => ""); - throw new Error(`Discord API HTTP ${response.status}: ${text}`); + // Limit error body length to avoid leaking verbose Discord error responses + const safeText = text.length > 200 ? text.slice(0, 200) + "…" : text; + throw new Error(`Discord API HTTP ${response.status}: ${safeText}`); } return response.json(); } diff --git a/src/resources/extensions/remote-questions/manager.ts b/src/resources/extensions/remote-questions/manager.ts index 9baabbd58..511668deb 100644 --- a/src/resources/extensions/remote-questions/manager.ts +++ b/src/resources/extensions/remote-questions/manager.ts @@ -36,7 +36,7 @@ export async function tryRemoteQuestions( try { await adapter.validate(); } catch (err) { - markPromptStatus(prompt.id, "failed", String((err as Error).message)); + markPromptStatus(prompt.id, "failed", sanitizeError(String((err as Error).message))); return errorResult(`Remote auth failed (${config.channel}): ${(err as Error).message}`, config.channel); } @@ -45,7 +45,7 @@ export async function tryRemoteQuestions( dispatch = await adapter.sendPrompt(prompt); markPromptDispatched(prompt.id, dispatch.ref); } catch (err) { - markPromptStatus(prompt.id, "failed", String((err as Error).message)); + markPromptStatus(prompt.id, "failed", sanitizeError(String((err as Error).message))); return errorResult(`Failed to send questions via ${config.channel}: ${(err as Error).message}`, config.channel); } @@ -128,7 +128,7 @@ async function pollUntilDone( updatePromptRecord(prompt.id, { lastPollAt: Date.now() }); if (answer) return answer; } catch (err) { - markPromptStatus(prompt.id, "failed", String((err as Error).message)); + markPromptStatus(prompt.id, "failed", sanitizeError(String((err as Error).message))); return null; } @@ -163,9 +163,25 @@ function formatForTool(answer: RemoteAnswer): Record Date: Wed, 11 Mar 2026 18:02:20 -0300 Subject: [PATCH 12/13] fix(reliability): distinguish Discord 404 from auth errors in reactions The catch-all in checkReactions() silently swallowed auth failures (401/403), making them indistinguishable from "no reaction yet". Now: - 404: expected (no reactions for this emoji), continue - 401/403: re-thrown so the poll loop surfaces the auth failure - Other errors: best-effort skip (rate limits, network) Co-Authored-By: Claude Opus 4.6 --- .../extensions/remote-questions/discord-adapter.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/resources/extensions/remote-questions/discord-adapter.ts b/src/resources/extensions/remote-questions/discord-adapter.ts index a3f84e0f0..4c9a4960e 100644 --- a/src/resources/extensions/remote-questions/discord-adapter.ts +++ b/src/resources/extensions/remote-questions/discord-adapter.ts @@ -76,8 +76,13 @@ export class DiscordAdapter implements ChannelAdapter { const humanUsers = users.filter((u: { id: string }) => u.id !== this.botUserId); if (humanUsers.length > 0) reactions.push({ emoji, count: humanUsers.length }); } - } catch { - // ignore missing reaction + } catch (err) { + const msg = String((err as Error).message ?? ""); + // 404 = no reactions for this emoji — expected, continue + if (msg.includes("HTTP 404")) continue; + // 401/403 = auth failure — surface to caller so it can fail the poll + if (msg.includes("HTTP 401") || msg.includes("HTTP 403")) throw err; + // Other errors (rate limit, network) — skip this emoji, best-effort } } From 090554373c5f1b612b2e022c30c477283d63de53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Facu=5FVi=C3=B1as?= Date: Wed, 11 Mar 2026 18:03:31 -0300 Subject: [PATCH 13/13] refactor: use discriminated union for remote vs local result details Replace the inline union cast in renderResult with a proper discriminated union (LocalResultDetails | RemoteResultDetails) keyed on the `remote` field. Improves type safety and makes the rendering logic self-documenting. Co-Authored-By: Claude Opus 4.6 --- .../extensions/ask-user-questions.ts | 36 ++++++++++++------- 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/src/resources/extensions/ask-user-questions.ts b/src/resources/extensions/ask-user-questions.ts index 0f9d803e7..d33efea4a 100644 --- a/src/resources/extensions/ask-user-questions.ts +++ b/src/resources/extensions/ask-user-questions.ts @@ -21,12 +21,27 @@ import { // ─── Types ──────────────────────────────────────────────────────────────────── -interface AskUserQuestionsDetails { +interface LocalResultDetails { + remote?: false; questions: Question[]; response: RoundResult | null; cancelled: boolean; } +interface RemoteResultDetails { + remote: true; + channel: string; + timed_out: boolean; + promptId?: string; + threadUrl?: string; + status?: string; + questions?: Question[]; + response?: import("./remote-questions/types.js").RemoteAnswer; + error?: boolean; +} + +type AskUserQuestionsDetails = LocalResultDetails | RemoteResultDetails; + // ─── Schema ─────────────────────────────────────────────────────────────────── const OptionSchema = Type.Object({ @@ -134,13 +149,13 @@ export default function AskUserQuestions(pi: ExtensionAPI) { if (!hasAnswers) { return { content: [{ type: "text", text: "ask_user_questions was cancelled before receiving a response" }], - details: { questions: params.questions, response: null, cancelled: true } as AskUserQuestionsDetails, + details: { questions: params.questions, response: null, cancelled: true } satisfies LocalResultDetails, }; } return { content: [{ type: "text", text: formatForLLM(result) }], - details: { questions: params.questions, response: result, cancelled: false } as AskUserQuestionsDetails, + details: { questions: params.questions, response: result, cancelled: false } satisfies LocalResultDetails, }; }, @@ -168,31 +183,28 @@ export default function AskUserQuestions(pi: ExtensionAPI) { }, renderResult(result, _options, theme) { - const details = result.details as (AskUserQuestionsDetails & { remote?: boolean; channel?: string; timed_out?: boolean; threadUrl?: string; promptId?: string; status?: string }) | undefined; + const details = result.details as AskUserQuestionsDetails | undefined; if (!details) { const text = result.content[0]; return new Text(text?.type === "text" ? text.text : "", 0, 0); } - // Remote channel result + // Remote channel result (discriminated on details.remote === true) if (details.remote) { if (details.timed_out) { - const channelLabel = details.channel ?? "remote"; return new Text( - `${theme.fg("warning", `${channelLabel} — timed out`)}${details.threadUrl ? theme.fg("dim", ` ${details.threadUrl}`) : ""}`, + `${theme.fg("warning", `${details.channel} — timed out`)}${details.threadUrl ? theme.fg("dim", ` ${details.threadUrl}`) : ""}`, 0, 0, ); } - const remoteResponse = details.response as import("./remote-questions/channels.js").RemoteAnswer | undefined; const questions = (details.questions ?? []) as Question[]; const lines: string[] = []; - const channelLabel = details.channel ?? "remote"; - lines.push(theme.fg("dim", channelLabel)); - if (remoteResponse) { + lines.push(theme.fg("dim", details.channel)); + if (details.response) { for (const q of questions) { - const answer = remoteResponse.answers[q.id]; + const answer = details.response.answers[q.id]; if (!answer) { lines.push(`${theme.fg("accent", q.header)}: ${theme.fg("dim", "(no answer)")}`); continue;