diff --git a/src/resources/extensions/ask-user-questions.ts b/src/resources/extensions/ask-user-questions.ts index 4446e676c..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({ @@ -104,7 +119,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 +135,9 @@ export default function AskUserQuestions(pi: ExtensionAPI) { } if (!ctx.hasUI) { + 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); } @@ -131,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, }; }, @@ -171,13 +189,44 @@ export default function AskUserQuestions(pi: ExtensionAPI) { return new Text(text?.type === "text" ? text.text : "", 0, 0); } + // Remote channel result (discriminated on details.remote === true) + if (details.remote) { + if (details.timed_out) { + return new Text( + `${theme.fg("warning", `${details.channel} — timed out`)}${details.threadUrl ? theme.fg("dim", ` ${details.threadUrl}`) : ""}`, + 0, + 0, + ); + } + + const questions = (details.questions ?? []) as Question[]; + const lines: string[] = []; + lines.push(theme.fg("dim", details.channel)); + if (details.response) { + for (const q of questions) { + const answer = details.response.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 f682fe758..c1f3442f8 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 next|auto|stop|status|queue|prefs|doctor|migrate", + description: "GSD — Get Shit Done: /gsd next|auto|stop|status|queue|prefs|doctor|migrate|remote", getArgumentCompletions: (prefix: string) => { - const subcommands = ["next", "auto", "stop", "status", "queue", "discuss", "prefs", "doctor", "migrate"]; + const subcommands = ["next", "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"]; @@ -148,6 +156,11 @@ 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 === "") { // Bare /gsd defaults to step mode await startAuto(ctx, pi, process.cwd(), false, { step: true }); @@ -155,7 +168,7 @@ export function registerGSDCommand(pi: ExtensionAPI): void { } ctx.ui.notify( - `Unknown: /gsd ${trimmed}. Use /gsd, /gsd next, /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 next, /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 3cd6c8d95..bf52720b2 100644 --- a/src/resources/extensions/gsd/index.ts +++ b/src/resources/extensions/gsd/index.ts @@ -102,7 +102,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"; @@ -112,6 +112,22 @@ 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 }, { 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")) { + 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 + } }); // ── 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..30b567e75 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 | number; + timeout_minutes?: number; // clamped to 1-30 + poll_interval_seconds?: number; // clamped to 2-30 +} + 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/gsd/tests/remote-questions.test.ts b/src/resources/extensions/gsd/tests/remote-questions.test.ts new file mode 100644 index 000000000..40dbe551c --- /dev/null +++ b/src/resources/extensions/gsd/tests/remote-questions.test.ts @@ -0,0 +1,155 @@ +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", [{ + 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("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); + // 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); +}); + +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/gsd/tests/remote-status.test.ts b/src/resources/extensions/gsd/tests/remote-status.test.ts new file mode 100644 index 000000000..507c9cf35 --- /dev/null +++ b/src/resources/extensions/gsd/tests/remote-status.test.ts @@ -0,0 +1,99 @@ +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"; + +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", + 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"); +})); + +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/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..0b962c2e4 --- /dev/null +++ b/src/resources/extensions/remote-questions/config.ts @@ -0,0 +1,81 @@ +/** + * Remote Questions — configuration resolution and validation + */ + +import { loadEffectiveGSDPreferences, type RemoteQuestionsConfig } from "../gsd/preferences.js"; +import type { RemoteChannel } from "./types.js"; + +export interface ResolvedConfig { + channel: RemoteChannel; + channelId: string; + timeoutMs: number; + pollIntervalMs: number; + token: string; +} + +const ENV_KEYS: Record = { + slack: "SLACK_BOT_TOKEN", + 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; +const MAX_TIMEOUT_MINUTES = 30; +const MIN_POLL_INTERVAL_SECONDS = 2; +const MAX_POLL_INTERVAL_SECONDS = 30; + +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 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; + + 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, + timeoutMs: timeoutMinutes * 60 * 1000, + pollIntervalMs: pollIntervalSeconds * 1000, + token, + }; +} + +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.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`; + + 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)`; +} + +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; + 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 new file mode 100644 index 000000000..4c9a4960e --- /dev/null +++ b/src/resources/extensions/remote-questions/discord-adapter.ts @@ -0,0 +1,128 @@ +/** + * Remote Questions — Discord adapter + */ + +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 PER_REQUEST_TIMEOUT_MS = 15_000; +const NUMBER_EMOJIS = ["1️⃣", "2️⃣", "3️⃣", "4️⃣", "5️⃣"]; + +export class DiscordAdapter implements ChannelAdapter { + readonly name = "discord" as const; + private botUserId: string | null = null; + 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"); + if (!res.id) throw new Error("Discord auth failed: invalid token"); + this.botUserId = String(res.id); + } + + 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 with your answer", + embeds, + }); + + if (!res.id) throw new Error(`Discord send failed: ${JSON.stringify(res)}`); + + 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: { + id: prompt.id, + channel: "discord", + messageId, + channelId: this.channelId, + }, + }; + } + + async pollAnswer(prompt: RemotePrompt, ref: RemotePromptRef): Promise { + if (!this.botUserId) await this.validate(); + + if (prompt.questions.length === 1) { + const reactionAnswer = await this.checkReactions(prompt, ref); + if (reactionAnswer) return reactionAnswer; + } + + return this.checkReplies(prompt, ref); + } + + private async checkReactions(prompt: RemotePrompt, ref: RemotePromptRef): Promise { + const reactions: Array<{ emoji: string; count: number }> = []; + for (const emoji of NUMBER_EMOJIS) { + try { + const users = await this.discordApi("GET", `/channels/${ref.channelId}/messages/${ref.messageId}/reactions/${encodeURIComponent(emoji)}`); + if (Array.isArray(users)) { + const humanUsers = users.filter((u: { id: string }) => u.id !== this.botUserId); + if (humanUsers.length > 0) reactions.push({ emoji, count: humanUsers.length }); + } + } 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 + } + } + + if (reactions.length === 0) return null; + return parseDiscordResponse(reactions, null, prompt.questions); + } + + 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; + + const replies = messages.filter( + (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.content, + ); + + if (replies.length === 0) return null; + return parseDiscordResponse([], String(replies[0].content), prompt.questions); + } + + 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); + } + + init.signal = AbortSignal.timeout(PER_REQUEST_TIMEOUT_MS); + const response = await fetch(`${DISCORD_API}${path}`, init); + if (response.status === 204) return {}; + if (!response.ok) { + const text = await response.text().catch(() => ""); + // 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/format.ts b/src/resources/extensions/remote-questions/format.ts new file mode 100644 index 000000000..1e03c637b --- /dev/null +++ b/src/resources/extensions/remote-questions/format.ts @@ -0,0 +1,163 @@ +/** + * Remote Questions — payload formatting and parsing helpers + */ + +import type { RemotePrompt, RemoteQuestion, RemoteAnswer } from "./types.js"; + +export interface SlackBlock { + type: string; + text?: { type: string; text: string }; + elements?: Array<{ type: string; text: string }>; +} + +export interface DiscordEmbed { + title: string; + description: string; + color: number; + fields: Array<{ name: string; value: string; inline?: boolean }>; + footer?: { text: string }; +} + +const NUMBER_EMOJIS = ["1️⃣", "2️⃣", "3️⃣", "4️⃣", "5️⃣"]; +const MAX_USER_NOTE_LENGTH = 500; + +export function formatForSlack(prompt: RemotePrompt): SlackBlock[] { + const blocks: SlackBlock[] = [ + { + type: "header", + text: { type: "plain_text", text: "GSD needs your input" }, + }, + ]; + + 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}.`; + if (supportsReactions && NUMBER_EMOJIS[i]) reactionEmojis.push(NUMBER_EMOJIS[i]); + return `${emoji} **${opt.label}** — ${opt.description}`; + }); + + 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`; + + return { + title: q.header, + description: q.question, + color: 0x7c3aed, + fields: [{ name: "Options", value: optionLines.join("\n") }], + footer: { text: footerText }, + }; + }); + + return { embeds, reactionEmojis }; +} + +export function parseSlackReply(text: string, questions: RemoteQuestion[]): RemoteAnswer { + const answers: RemoteAnswer["answers"] = {}; + const trimmed = text.trim(); + + if (questions.length === 1) { + answers[questions[0].id] = parseAnswerForQuestion(trimmed, questions[0]); + return { answers }; + } + + const parts = trimmed.includes(";") + ? 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++) { + answers[questions[i].id] = parseAnswerForQuestion(parts[i] ?? "", questions[i]); + } + + return { answers }; +} + +export function parseDiscordResponse( + reactions: Array<{ emoji: string; count: number }>, + replyText: string | null, + questions: RemoteQuestion[], +): RemoteAnswer { + if (replyText) return parseSlackReply(replyText, questions); + + const answers: RemoteAnswer["answers"] = {}; + 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 }; + } + + 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 }; +} + +function parseAnswerForQuestion(text: string, q: RemoteQuestion): { answers: string[]; user_note?: string } { + if (!text) return { answers: [], user_note: "No response provided" }; + + if (/^[\d,\s]+$/.test(text)) { + const nums = text + .split(",") + .map((s) => parseInt(s.trim(), 10)) + .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); + return { answers: q.allowMultiple ? selected : [selected[0]] }; + } + } + + const single = parseInt(text, 10); + if (!Number.isNaN(single) && single >= 1 && single <= q.options.length) { + return { answers: [q.options[single - 1].label] }; + } + + 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; +} diff --git a/src/resources/extensions/remote-questions/manager.ts b/src/resources/extensions/remote-questions/manager.ts new file mode 100644 index 000000000..511668deb --- /dev/null +++ b/src/resources/extensions/remote-questions/manager.ts @@ -0,0 +1,187 @@ +/** + * 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", sanitizeError(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", sanitizeError(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", sanitizeError(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; +} + +// Strip token-like strings from error messages before surfacing +const TOKEN_PATTERNS = [ + /xoxb-[A-Za-z0-9\-]+/g, // Slack bot tokens + /xoxp-[A-Za-z0-9\-]+/g, // Slack user tokens + /xoxa-[A-Za-z0-9\-]+/g, // Slack app tokens + /[A-Za-z0-9_\-.]{20,}/g, // Long opaque secrets (Discord tokens, etc.) +]; + +export function sanitizeError(msg: string): string { + let sanitized = msg; + for (const pattern of TOKEN_PATTERNS) { + sanitized = sanitized.replace(pattern, "[REDACTED]"); + } + return sanitized; +} + +function errorResult(message: string, channel: string): ToolResult { + return { + content: [{ type: "text", text: sanitizeError(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 new file mode 100644 index 000000000..be5796ff2 --- /dev/null +++ b/src/resources/extensions/remote-questions/remote-command.ts @@ -0,0 +1,303 @@ +/** + * Remote Questions — /gsd remote command + */ + +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 { getGlobalGSDPreferencesPath, loadEffectiveGSDPreferences } from "../gsd/preferences.js"; +import { getRemoteConfigStatus, resolveRemoteConfig } from "./config.js"; +import { getLatestPromptSummary } from "./status.js"; + +export async function handleRemote( + subcommand: string, + ctx: ExtensionCommandContext, + _pi: ExtensionAPI, +): Promise { + const trimmed = subcommand.trim(); + + if (trimmed === "slack") return handleSetupSlack(ctx); + if (trimmed === "discord") return handleSetupDiscord(ctx); + if (trimmed === "status") return handleRemoteStatus(ctx); + if (trimmed === "disconnect") return handleDisconnect(ctx); + + return handleRemoteMenu(ctx); +} + +async function handleSetupSlack(ctx: ExtensionCommandContext): Promise { + const token = await promptMaskedInput(ctx, "Slack Bot Token", "Paste your xoxb-... token"); + 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"); + + ctx.ui.notify("Validating token...", "info"); + 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"); + + const channelId = await promptInput(ctx, "Channel ID", "Paste the Slack channel ID (e.g. C0123456789)"); + if (!channelId) return void ctx.ui.notify("Slack setup cancelled.", "info"); + + 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"); + + saveProviderToken("slack_bot", token); + process.env.SLACK_BOT_TOKEN = token; + saveRemoteQuestionsConfig("slack", channelId); + ctx.ui.notify(`Slack connected — remote questions enabled for channel ${channelId}.`, "info"); +} + +async function handleSetupDiscord(ctx: ExtensionCommandContext): Promise { + const token = await promptMaskedInput(ctx, "Discord Bot Token", "Paste your bot token"); + if (!token) return void ctx.ui.notify("Discord setup cancelled.", "info"); + + ctx.ui.notify("Validating token...", "info"); + 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"); + + const channelId = await promptInput(ctx, "Channel ID", "Paste the Discord channel ID (e.g. 1234567890123456789)"); + 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"); + } + + saveProviderToken("discord_bot", token); + process.env.DISCORD_BOT_TOKEN = token; + saveRemoteQuestionsConfig("discord", channelId); + ctx.ui.notify(`Discord connected — remote questions enabled for channel ${channelId}.`, "info"); +} + +async function handleRemoteStatus(ctx: ExtensionCommandContext): Promise { + const status = getRemoteConfigStatus(); + const config = resolveRemoteConfig(); + if (!config) { + ctx.ui.notify(status, status.includes("disabled") ? "warning" : "info"); + return; + } + + 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()}`); + } + + ctx.ui.notify(lines.join("\n"), "info"); +} + +async function handleDisconnect(ctx: ExtensionCommandContext): Promise { + const prefs = loadEffectiveGSDPreferences(); + const channel = prefs?.preferences.remote_questions?.channel; + if (!channel) return void ctx.ui.notify("No remote channel configured — nothing to disconnect.", "info"); + + removeRemoteQuestionsConfig(); + 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"); +} + +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", + ]; + + 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; + } +} + +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 = ""; + 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; + } + output += line[i] === " " ? " " : "*"; + i += 1; + } + return output; +} + +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 = { + 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 }); + 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", "─".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", "─".repeat(width))); + cachedLines = lines; + return lines; + }; + return { render, handleInput, invalidate: () => { cachedLines = undefined; } }; + }); +} + +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 = { + 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 }); + 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", "─".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", "─".repeat(width))); + cachedLines = lines; + return lines; + }; + return { render, handleInput, invalidate: () => { cachedLines = undefined; } }; + }); +} diff --git a/src/resources/extensions/remote-questions/send.ts b/src/resources/extensions/remote-questions/send.ts new file mode 100644 index 000000000..90d6e293b --- /dev/null +++ b/src/resources/extensions/remote-questions/send.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/slack-adapter.ts b/src/resources/extensions/remote-questions/slack-adapter.ts new file mode 100644 index 000000000..42b9fcc07 --- /dev/null +++ b/src/resources/extensions/remote-questions/slack-adapter.ts @@ -0,0 +1,92 @@ +/** + * Remote Questions — Slack adapter + */ + +import type { ChannelAdapter, RemotePrompt, RemoteDispatchResult, RemoteAnswer, RemotePromptRef } from "./types.js"; +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; + private botUserId: string | null = null; + 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", {}); + if (!res.ok) throw new Error(`Slack auth failed: ${res.error ?? "invalid token"}`); + this.botUserId = String(res.user_id ?? ""); + } + + async sendPrompt(prompt: RemotePrompt): Promise { + const res = await this.slackApi("chat.postMessage", { + channel: this.channelId, + text: "GSD needs your input", + blocks: formatForSlack(prompt), + }); + + if (!res.ok) throw new Error(`Slack postMessage failed: ${res.error ?? "unknown"}`); + + const ts = String(res.ts); + const channel = String(res.channel); + return { + ref: { + id: prompt.id, + channel: "slack", + messageId: ts, + threadTs: ts, + channelId: channel, + threadUrl: `https://slack.com/archives/${channel}/p${ts.replace(".", "")}`, + }, + }; + } + + async pollAnswer(prompt: RemotePrompt, ref: RemotePromptRef): Promise { + if (!this.botUserId) await this.validate(); + + 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 && m.user !== this.botUserId && m.text); + if (userReplies.length === 0) return null; + + return parseSlackReply(String(userReplies[0].text), prompt.questions); + } + + 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) { + 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}` }, signal: AbortSignal.timeout(PER_REQUEST_TIMEOUT_MS) }); + } else { + response = await fetch(url, { + method: "POST", + headers: { + Authorization: `Bearer ${this.token}`, + "Content-Type": "application/json; charset=utf-8", + }, + body: JSON.stringify(params), + signal: AbortSignal.timeout(PER_REQUEST_TIMEOUT_MS), + }); + } + + 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..dd4593488 --- /dev/null +++ b/src/resources/extensions/remote-questions/status.ts @@ -0,0 +1,31 @@ +/** + * 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")); + if (files.length === 0) return 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; +} 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; +} 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', + }, ] /**