feat: harden remote questions flow

This commit is contained in:
Lex Christopherson 2026-03-11 10:35:59 -06:00 committed by Facu_Viñas
parent 0643d63480
commit a37ef56146
14 changed files with 841 additions and 797 deletions

View file

@ -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);

View file

@ -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

View file

@ -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 {

View file

@ -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 };
});

View file

@ -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 });
});

View file

@ -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<string, string> = {
const ENV_KEYS: Record<RemoteChannel, string> = {
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));
}

View file

@ -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<void> {
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<SendResult> {
const { embeds, reactionEmojis } = formatForDiscord(questions);
async sendPrompt(prompt: RemotePrompt): Promise<RemoteDispatchResult> {
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<RemoteAnswer | null> {
return this.pollResponseWithQuestions(ref, []);
}
async pollAnswer(prompt: RemotePrompt, ref: RemotePromptRef): Promise<RemoteAnswer | null> {
if (!this.botUserId) await this.validate();
/**
* Poll with full question context for proper parsing.
*/
async pollResponseWithQuestions(
ref: PollReference,
questions: FormattedQuestion[],
): Promise<RemoteAnswer | null> {
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<RemoteAnswer | null> {
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<RemoteAnswer | null> {
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<RemoteAnswer | null> {
const messages = await this.discordApi(
"GET",
`/channels/${ref.channelId}/messages?after=${ref.messageId}&limit=10`,
);
private async checkReplies(prompt: RemotePrompt, ref: RemotePromptRef): Promise<RemoteAnswer | null> {
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<Record<string, unknown>> {
const url = `${DISCORD_API}${path}`;
const headers: Record<string, string> = {
Authorization: `Bot ${this.token}`,
};
private async discordApi(method: string, path: string, body?: unknown): Promise<any> {
const headers: Record<string, string> = { 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<string, unknown>;
return response.json();
}
}

View file

@ -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 };
}

View file

@ -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<string, unknown>;
}
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<ToolResult | null> {
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<RemoteAnswer | null> {
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<void> {
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<string, { answers: string[] }> {
const out: Record<string, { answers: string[] }> = {};
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" },
};
}

View file

@ -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<void> {
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<void> {
// 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<void> {
// 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<void> {
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<void> {
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<void> {
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<any> {
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<string | null> {
async function promptMaskedInput(ctx: ExtensionCommandContext, label: string, hint: string): Promise<string | null> {
if (!ctx.hasUI) return null;
return ctx.ui.custom<string | null>((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<string | null> {
async function promptInput(ctx: ExtensionCommandContext, label: string, hint: string): Promise<string | null> {
if (!ctx.hasUI) return null;
return ctx.ui.custom<string | null>((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<string, unknown> {
const path = getAuthFilePath();
if (!existsSync(path)) return {};
try {
return JSON.parse(readFileSync(path, "utf-8")) as Record<string, unknown>;
} catch {
return {};
}
}
function saveAuthJson(data: Record<string, unknown>): 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");
}

View file

@ -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<void> {
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<SendResult> {
const blocks = formatForSlack(questions);
async sendPrompt(prompt: RemotePrompt): Promise<RemoteDispatchResult> {
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<RemoteAnswer | null> {
// 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<RemoteAnswer | null> {
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<RemoteAnswer | null> {
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<string, unknown>,
): Promise<Record<string, unknown>> {
private async slackApi(method: string, params: Record<string, unknown>): Promise<Record<string, unknown>> {
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<string, string> = {};
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<string, unknown>;
}
}

View file

@ -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;
}

View file

@ -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>,
): 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 } : {}),
});
}

View file

@ -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<string, { answers: string[]; user_note?: string }>;
}
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<void>;
sendPrompt(prompt: RemotePrompt): Promise<RemoteDispatchResult>;
pollAnswer(prompt: RemotePrompt, ref: RemotePromptRef): Promise<RemoteAnswer | null>;
}