@tuan_son.dinh/gsd 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (227) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +453 -0
  3. package/dist/app-paths.d.ts +4 -0
  4. package/dist/app-paths.js +6 -0
  5. package/dist/cli.d.ts +1 -0
  6. package/dist/cli.js +269 -0
  7. package/dist/loader.d.ts +2 -0
  8. package/dist/loader.js +70 -0
  9. package/dist/logo.d.ts +16 -0
  10. package/dist/logo.js +25 -0
  11. package/dist/onboarding.d.ts +43 -0
  12. package/dist/onboarding.js +418 -0
  13. package/dist/pi-migration.d.ts +14 -0
  14. package/dist/pi-migration.js +57 -0
  15. package/dist/resource-loader.d.ts +22 -0
  16. package/dist/resource-loader.js +60 -0
  17. package/dist/tool-bootstrap.d.ts +4 -0
  18. package/dist/tool-bootstrap.js +74 -0
  19. package/dist/wizard.d.ts +7 -0
  20. package/dist/wizard.js +25 -0
  21. package/package.json +60 -0
  22. package/patches/@mariozechner+pi-coding-agent+0.57.1.patch +108 -0
  23. package/patches/@mariozechner+pi-tui+0.57.1.patch +47 -0
  24. package/pkg/dist/modes/interactive/theme/dark.json +85 -0
  25. package/pkg/dist/modes/interactive/theme/light.json +84 -0
  26. package/pkg/dist/modes/interactive/theme/theme-schema.json +335 -0
  27. package/pkg/dist/modes/interactive/theme/theme.d.ts +78 -0
  28. package/pkg/dist/modes/interactive/theme/theme.d.ts.map +1 -0
  29. package/pkg/dist/modes/interactive/theme/theme.js +949 -0
  30. package/pkg/dist/modes/interactive/theme/theme.js.map +1 -0
  31. package/pkg/package.json +8 -0
  32. package/scripts/postinstall.js +127 -0
  33. package/src/resources/GSD-WORKFLOW.md +661 -0
  34. package/src/resources/agents/researcher.md +29 -0
  35. package/src/resources/agents/scout.md +56 -0
  36. package/src/resources/agents/worker.md +31 -0
  37. package/src/resources/extensions/ask-user-questions.ts +249 -0
  38. package/src/resources/extensions/bg-shell/index.ts +2808 -0
  39. package/src/resources/extensions/browser-tools/BROWSER-TOOLS-V2-PROPOSAL.md +1277 -0
  40. package/src/resources/extensions/browser-tools/core.js +1057 -0
  41. package/src/resources/extensions/browser-tools/index.ts +4989 -0
  42. package/src/resources/extensions/browser-tools/package.json +20 -0
  43. package/src/resources/extensions/context7/index.ts +428 -0
  44. package/src/resources/extensions/context7/package.json +11 -0
  45. package/src/resources/extensions/get-secrets-from-user.ts +352 -0
  46. package/src/resources/extensions/google-search/index.ts +323 -0
  47. package/src/resources/extensions/google-search/package.json +9 -0
  48. package/src/resources/extensions/gsd/activity-log.ts +69 -0
  49. package/src/resources/extensions/gsd/auto.ts +2744 -0
  50. package/src/resources/extensions/gsd/commands.ts +313 -0
  51. package/src/resources/extensions/gsd/crash-recovery.ts +85 -0
  52. package/src/resources/extensions/gsd/dashboard-overlay.ts +521 -0
  53. package/src/resources/extensions/gsd/docs/preferences-reference.md +176 -0
  54. package/src/resources/extensions/gsd/doctor.ts +690 -0
  55. package/src/resources/extensions/gsd/files.ts +732 -0
  56. package/src/resources/extensions/gsd/git-service.ts +597 -0
  57. package/src/resources/extensions/gsd/gitignore.ts +168 -0
  58. package/src/resources/extensions/gsd/guided-flow.ts +817 -0
  59. package/src/resources/extensions/gsd/index.ts +558 -0
  60. package/src/resources/extensions/gsd/metrics.ts +374 -0
  61. package/src/resources/extensions/gsd/migrate/command.ts +218 -0
  62. package/src/resources/extensions/gsd/migrate/index.ts +42 -0
  63. package/src/resources/extensions/gsd/migrate/parser.ts +323 -0
  64. package/src/resources/extensions/gsd/migrate/parsers.ts +624 -0
  65. package/src/resources/extensions/gsd/migrate/preview.ts +48 -0
  66. package/src/resources/extensions/gsd/migrate/transformer.ts +346 -0
  67. package/src/resources/extensions/gsd/migrate/types.ts +370 -0
  68. package/src/resources/extensions/gsd/migrate/validator.ts +55 -0
  69. package/src/resources/extensions/gsd/migrate/writer.ts +539 -0
  70. package/src/resources/extensions/gsd/observability-validator.ts +408 -0
  71. package/src/resources/extensions/gsd/package.json +11 -0
  72. package/src/resources/extensions/gsd/paths.ts +308 -0
  73. package/src/resources/extensions/gsd/preferences.ts +757 -0
  74. package/src/resources/extensions/gsd/prompt-loader.ts +50 -0
  75. package/src/resources/extensions/gsd/prompts/complete-milestone.md +25 -0
  76. package/src/resources/extensions/gsd/prompts/complete-slice.md +29 -0
  77. package/src/resources/extensions/gsd/prompts/discuss.md +189 -0
  78. package/src/resources/extensions/gsd/prompts/doctor-heal.md +29 -0
  79. package/src/resources/extensions/gsd/prompts/execute-task.md +61 -0
  80. package/src/resources/extensions/gsd/prompts/guided-complete-slice.md +1 -0
  81. package/src/resources/extensions/gsd/prompts/guided-discuss-milestone.md +3 -0
  82. package/src/resources/extensions/gsd/prompts/guided-discuss-slice.md +59 -0
  83. package/src/resources/extensions/gsd/prompts/guided-execute-task.md +1 -0
  84. package/src/resources/extensions/gsd/prompts/guided-plan-milestone.md +23 -0
  85. package/src/resources/extensions/gsd/prompts/guided-plan-slice.md +1 -0
  86. package/src/resources/extensions/gsd/prompts/guided-research-slice.md +11 -0
  87. package/src/resources/extensions/gsd/prompts/guided-resume-task.md +1 -0
  88. package/src/resources/extensions/gsd/prompts/plan-milestone.md +65 -0
  89. package/src/resources/extensions/gsd/prompts/plan-slice.md +51 -0
  90. package/src/resources/extensions/gsd/prompts/queue.md +85 -0
  91. package/src/resources/extensions/gsd/prompts/reassess-roadmap.md +48 -0
  92. package/src/resources/extensions/gsd/prompts/replan-slice.md +39 -0
  93. package/src/resources/extensions/gsd/prompts/research-milestone.md +37 -0
  94. package/src/resources/extensions/gsd/prompts/research-slice.md +28 -0
  95. package/src/resources/extensions/gsd/prompts/review-migration.md +66 -0
  96. package/src/resources/extensions/gsd/prompts/run-uat.md +109 -0
  97. package/src/resources/extensions/gsd/prompts/system.md +187 -0
  98. package/src/resources/extensions/gsd/prompts/worktree-merge.md +123 -0
  99. package/src/resources/extensions/gsd/session-forensics.ts +487 -0
  100. package/src/resources/extensions/gsd/skill-discovery.ts +137 -0
  101. package/src/resources/extensions/gsd/state.ts +460 -0
  102. package/src/resources/extensions/gsd/templates/context.md +76 -0
  103. package/src/resources/extensions/gsd/templates/decisions.md +8 -0
  104. package/src/resources/extensions/gsd/templates/milestone-summary.md +73 -0
  105. package/src/resources/extensions/gsd/templates/plan.md +131 -0
  106. package/src/resources/extensions/gsd/templates/preferences.md +24 -0
  107. package/src/resources/extensions/gsd/templates/project.md +31 -0
  108. package/src/resources/extensions/gsd/templates/reassessment.md +28 -0
  109. package/src/resources/extensions/gsd/templates/requirements.md +81 -0
  110. package/src/resources/extensions/gsd/templates/research.md +46 -0
  111. package/src/resources/extensions/gsd/templates/roadmap.md +118 -0
  112. package/src/resources/extensions/gsd/templates/slice-context.md +58 -0
  113. package/src/resources/extensions/gsd/templates/slice-summary.md +99 -0
  114. package/src/resources/extensions/gsd/templates/state.md +19 -0
  115. package/src/resources/extensions/gsd/templates/task-plan.md +52 -0
  116. package/src/resources/extensions/gsd/templates/task-summary.md +57 -0
  117. package/src/resources/extensions/gsd/templates/uat.md +54 -0
  118. package/src/resources/extensions/gsd/tests/activity-log-prune.test.ts +327 -0
  119. package/src/resources/extensions/gsd/tests/auto-preflight.test.ts +56 -0
  120. package/src/resources/extensions/gsd/tests/auto-supervisor.test.mjs +53 -0
  121. package/src/resources/extensions/gsd/tests/complete-milestone.test.ts +225 -0
  122. package/src/resources/extensions/gsd/tests/cost-projection.test.ts +160 -0
  123. package/src/resources/extensions/gsd/tests/derive-state-deps.test.ts +341 -0
  124. package/src/resources/extensions/gsd/tests/derive-state.test.ts +689 -0
  125. package/src/resources/extensions/gsd/tests/discuss-prompt.test.ts +38 -0
  126. package/src/resources/extensions/gsd/tests/doctor.test.ts +505 -0
  127. package/src/resources/extensions/gsd/tests/git-service.test.ts +1313 -0
  128. package/src/resources/extensions/gsd/tests/idle-recovery.test.ts +308 -0
  129. package/src/resources/extensions/gsd/tests/metrics-io.test.ts +201 -0
  130. package/src/resources/extensions/gsd/tests/metrics.test.ts +217 -0
  131. package/src/resources/extensions/gsd/tests/migrate-command.test.ts +390 -0
  132. package/src/resources/extensions/gsd/tests/migrate-parser.test.ts +786 -0
  133. package/src/resources/extensions/gsd/tests/migrate-transformer.test.ts +657 -0
  134. package/src/resources/extensions/gsd/tests/migrate-validator-parsers.test.ts +443 -0
  135. package/src/resources/extensions/gsd/tests/migrate-writer-integration.test.ts +318 -0
  136. package/src/resources/extensions/gsd/tests/migrate-writer.test.ts +420 -0
  137. package/src/resources/extensions/gsd/tests/must-have-parser.test.ts +309 -0
  138. package/src/resources/extensions/gsd/tests/parsers.test.ts +1351 -0
  139. package/src/resources/extensions/gsd/tests/plan-milestone.test.ts +163 -0
  140. package/src/resources/extensions/gsd/tests/plan-quality-validator.test.ts +386 -0
  141. package/src/resources/extensions/gsd/tests/reassess-prompt.test.ts +171 -0
  142. package/src/resources/extensions/gsd/tests/remote-questions.test.ts +155 -0
  143. package/src/resources/extensions/gsd/tests/remote-status.test.ts +99 -0
  144. package/src/resources/extensions/gsd/tests/replan-slice.test.ts +521 -0
  145. package/src/resources/extensions/gsd/tests/requirements.test.ts +125 -0
  146. package/src/resources/extensions/gsd/tests/resolve-ts-hooks.mjs +34 -0
  147. package/src/resources/extensions/gsd/tests/resolve-ts.mjs +11 -0
  148. package/src/resources/extensions/gsd/tests/run-uat.test.ts +348 -0
  149. package/src/resources/extensions/gsd/tests/unit-runtime.test.ts +247 -0
  150. package/src/resources/extensions/gsd/tests/workflow-config.test.mjs +53 -0
  151. package/src/resources/extensions/gsd/tests/workspace-index.test.ts +94 -0
  152. package/src/resources/extensions/gsd/tests/worktree-integration.test.ts +253 -0
  153. package/src/resources/extensions/gsd/tests/worktree-manager.test.ts +160 -0
  154. package/src/resources/extensions/gsd/tests/worktree.test.ts +264 -0
  155. package/src/resources/extensions/gsd/types.ts +159 -0
  156. package/src/resources/extensions/gsd/unit-runtime.ts +184 -0
  157. package/src/resources/extensions/gsd/workspace-index.ts +203 -0
  158. package/src/resources/extensions/gsd/worktree-command.ts +845 -0
  159. package/src/resources/extensions/gsd/worktree-manager.ts +392 -0
  160. package/src/resources/extensions/gsd/worktree.ts +183 -0
  161. package/src/resources/extensions/mac-tools/index.ts +852 -0
  162. package/src/resources/extensions/mac-tools/swift-cli/Package.swift +22 -0
  163. package/src/resources/extensions/mac-tools/swift-cli/Sources/main.swift +1318 -0
  164. package/src/resources/extensions/mcporter/index.ts +429 -0
  165. package/src/resources/extensions/remote-questions/config.ts +81 -0
  166. package/src/resources/extensions/remote-questions/discord-adapter.ts +128 -0
  167. package/src/resources/extensions/remote-questions/format.ts +163 -0
  168. package/src/resources/extensions/remote-questions/manager.ts +192 -0
  169. package/src/resources/extensions/remote-questions/remote-command.ts +307 -0
  170. package/src/resources/extensions/remote-questions/slack-adapter.ts +92 -0
  171. package/src/resources/extensions/remote-questions/status.ts +31 -0
  172. package/src/resources/extensions/remote-questions/store.ts +77 -0
  173. package/src/resources/extensions/remote-questions/types.ts +75 -0
  174. package/src/resources/extensions/search-the-web/cache.ts +78 -0
  175. package/src/resources/extensions/search-the-web/command-search-provider.ts +95 -0
  176. package/src/resources/extensions/search-the-web/format.ts +258 -0
  177. package/src/resources/extensions/search-the-web/http.ts +238 -0
  178. package/src/resources/extensions/search-the-web/index.ts +65 -0
  179. package/src/resources/extensions/search-the-web/native-search.ts +157 -0
  180. package/src/resources/extensions/search-the-web/provider.ts +118 -0
  181. package/src/resources/extensions/search-the-web/tavily.ts +116 -0
  182. package/src/resources/extensions/search-the-web/tool-fetch-page.ts +519 -0
  183. package/src/resources/extensions/search-the-web/tool-llm-context.ts +561 -0
  184. package/src/resources/extensions/search-the-web/tool-search.ts +576 -0
  185. package/src/resources/extensions/search-the-web/url-utils.ts +91 -0
  186. package/src/resources/extensions/shared/confirm-ui.ts +126 -0
  187. package/src/resources/extensions/shared/interview-ui.ts +613 -0
  188. package/src/resources/extensions/shared/next-action-ui.ts +197 -0
  189. package/src/resources/extensions/shared/progress-widget.ts +282 -0
  190. package/src/resources/extensions/shared/terminal.ts +23 -0
  191. package/src/resources/extensions/shared/thinking-widget.ts +107 -0
  192. package/src/resources/extensions/shared/ui.ts +400 -0
  193. package/src/resources/extensions/shared/wizard-ui.ts +551 -0
  194. package/src/resources/extensions/slash-commands/audit.ts +88 -0
  195. package/src/resources/extensions/slash-commands/clear.ts +10 -0
  196. package/src/resources/extensions/slash-commands/create-extension.ts +297 -0
  197. package/src/resources/extensions/slash-commands/create-slash-command.ts +234 -0
  198. package/src/resources/extensions/slash-commands/index.ts +12 -0
  199. package/src/resources/extensions/subagent/agents.ts +126 -0
  200. package/src/resources/extensions/subagent/index.ts +1020 -0
  201. package/src/resources/extensions/voice/index.ts +195 -0
  202. package/src/resources/extensions/voice/speech-recognizer.swift +154 -0
  203. package/src/resources/skills/debug-like-expert/SKILL.md +231 -0
  204. package/src/resources/skills/debug-like-expert/references/debugging-mindset.md +253 -0
  205. package/src/resources/skills/debug-like-expert/references/hypothesis-testing.md +373 -0
  206. package/src/resources/skills/debug-like-expert/references/investigation-techniques.md +337 -0
  207. package/src/resources/skills/debug-like-expert/references/verification-patterns.md +425 -0
  208. package/src/resources/skills/debug-like-expert/references/when-to-research.md +361 -0
  209. package/src/resources/skills/frontend-design/SKILL.md +45 -0
  210. package/src/resources/skills/swiftui/SKILL.md +208 -0
  211. package/src/resources/skills/swiftui/references/animations.md +921 -0
  212. package/src/resources/skills/swiftui/references/architecture.md +1561 -0
  213. package/src/resources/skills/swiftui/references/layout-system.md +1186 -0
  214. package/src/resources/skills/swiftui/references/navigation.md +1492 -0
  215. package/src/resources/skills/swiftui/references/networking-async.md +214 -0
  216. package/src/resources/skills/swiftui/references/performance.md +1706 -0
  217. package/src/resources/skills/swiftui/references/platform-integration.md +204 -0
  218. package/src/resources/skills/swiftui/references/state-management.md +1443 -0
  219. package/src/resources/skills/swiftui/references/swiftdata.md +297 -0
  220. package/src/resources/skills/swiftui/references/testing-debugging.md +247 -0
  221. package/src/resources/skills/swiftui/references/uikit-appkit-interop.md +218 -0
  222. package/src/resources/skills/swiftui/workflows/add-feature.md +191 -0
  223. package/src/resources/skills/swiftui/workflows/build-new-app.md +311 -0
  224. package/src/resources/skills/swiftui/workflows/debug-swiftui.md +192 -0
  225. package/src/resources/skills/swiftui/workflows/optimize-performance.md +197 -0
  226. package/src/resources/skills/swiftui/workflows/ship-app.md +203 -0
  227. package/src/resources/skills/swiftui/workflows/write-tests.md +235 -0
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Remote Questions — payload formatting and parsing helpers
3
+ */
4
+
5
+ import type { RemotePrompt, RemoteQuestion, RemoteAnswer } from "./types.js";
6
+
7
+ export interface SlackBlock {
8
+ type: string;
9
+ text?: { type: string; text: string };
10
+ elements?: Array<{ type: string; text: string }>;
11
+ }
12
+
13
+ export interface DiscordEmbed {
14
+ title: string;
15
+ description: string;
16
+ color: number;
17
+ fields: Array<{ name: string; value: string; inline?: boolean }>;
18
+ footer?: { text: string };
19
+ }
20
+
21
+ const NUMBER_EMOJIS = ["1️⃣", "2️⃣", "3️⃣", "4️⃣", "5️⃣"];
22
+ const MAX_USER_NOTE_LENGTH = 500;
23
+
24
+ export function formatForSlack(prompt: RemotePrompt): SlackBlock[] {
25
+ const blocks: SlackBlock[] = [
26
+ {
27
+ type: "header",
28
+ text: { type: "plain_text", text: "GSD needs your input" },
29
+ },
30
+ ];
31
+
32
+ for (const q of prompt.questions) {
33
+ blocks.push({
34
+ type: "section",
35
+ text: { type: "mrkdwn", text: `*${q.header}*\n${q.question}` },
36
+ });
37
+
38
+ blocks.push({
39
+ type: "section",
40
+ text: {
41
+ type: "mrkdwn",
42
+ text: q.options.map((opt, i) => `${i + 1}. *${opt.label}* — ${opt.description}`).join("\n"),
43
+ },
44
+ });
45
+
46
+ blocks.push({
47
+ type: "context",
48
+ elements: [{
49
+ type: "mrkdwn",
50
+ text: q.allowMultiple
51
+ ? "Reply in thread with comma-separated numbers (`1,3`) or free text."
52
+ : "Reply in thread with a number (`1`) or free text.",
53
+ }],
54
+ });
55
+
56
+ blocks.push({ type: "divider" });
57
+ }
58
+
59
+ return blocks;
60
+ }
61
+
62
+ export function formatForDiscord(prompt: RemotePrompt): { embeds: DiscordEmbed[]; reactionEmojis: string[] } {
63
+ const reactionEmojis: string[] = [];
64
+ const embeds: DiscordEmbed[] = prompt.questions.map((q, questionIndex) => {
65
+ const supportsReactions = prompt.questions.length === 1;
66
+ const optionLines = q.options.map((opt, i) => {
67
+ const emoji = NUMBER_EMOJIS[i] ?? `${i + 1}.`;
68
+ if (supportsReactions && NUMBER_EMOJIS[i]) reactionEmojis.push(NUMBER_EMOJIS[i]);
69
+ return `${emoji} **${opt.label}** — ${opt.description}`;
70
+ });
71
+
72
+ const footerText = supportsReactions
73
+ ? (q.allowMultiple
74
+ ? "Reply with comma-separated choices (`1,3`) or react with matching numbers"
75
+ : "Reply with a number or react with the matching number")
76
+ : `Question ${questionIndex + 1}/${prompt.questions.length} — reply with one line per question or use semicolons`;
77
+
78
+ return {
79
+ title: q.header,
80
+ description: q.question,
81
+ color: 0x7c3aed,
82
+ fields: [{ name: "Options", value: optionLines.join("\n") }],
83
+ footer: { text: footerText },
84
+ };
85
+ });
86
+
87
+ return { embeds, reactionEmojis };
88
+ }
89
+
90
+ export function parseSlackReply(text: string, questions: RemoteQuestion[]): RemoteAnswer {
91
+ const answers: RemoteAnswer["answers"] = {};
92
+ const trimmed = text.trim();
93
+
94
+ if (questions.length === 1) {
95
+ answers[questions[0].id] = parseAnswerForQuestion(trimmed, questions[0]);
96
+ return { answers };
97
+ }
98
+
99
+ const parts = trimmed.includes(";")
100
+ ? trimmed.split(";").map((s) => s.trim()).filter(Boolean)
101
+ : trimmed.split("\n").map((s) => s.trim()).filter(Boolean);
102
+
103
+ for (let i = 0; i < questions.length; i++) {
104
+ answers[questions[i].id] = parseAnswerForQuestion(parts[i] ?? "", questions[i]);
105
+ }
106
+
107
+ return { answers };
108
+ }
109
+
110
+ export function parseDiscordResponse(
111
+ reactions: Array<{ emoji: string; count: number }>,
112
+ replyText: string | null,
113
+ questions: RemoteQuestion[],
114
+ ): RemoteAnswer {
115
+ if (replyText) return parseSlackReply(replyText, questions);
116
+
117
+ const answers: RemoteAnswer["answers"] = {};
118
+ if (questions.length !== 1) {
119
+ for (const q of questions) {
120
+ answers[q.id] = { answers: [], user_note: "Discord reactions are only supported for single-question prompts" };
121
+ }
122
+ return { answers };
123
+ }
124
+
125
+ const q = questions[0];
126
+ const picked = reactions
127
+ .filter((r) => NUMBER_EMOJIS.includes(r.emoji) && r.count > 0)
128
+ .map((r) => q.options[NUMBER_EMOJIS.indexOf(r.emoji)]?.label)
129
+ .filter(Boolean) as string[];
130
+
131
+ answers[q.id] = picked.length > 0
132
+ ? { answers: q.allowMultiple ? picked : [picked[0]] }
133
+ : { answers: [], user_note: "No clear response via reactions" };
134
+
135
+ return { answers };
136
+ }
137
+
138
+ function parseAnswerForQuestion(text: string, q: RemoteQuestion): { answers: string[]; user_note?: string } {
139
+ if (!text) return { answers: [], user_note: "No response provided" };
140
+
141
+ if (/^[\d,\s]+$/.test(text)) {
142
+ const nums = text
143
+ .split(",")
144
+ .map((s) => parseInt(s.trim(), 10))
145
+ .filter((n) => !Number.isNaN(n) && n >= 1 && n <= q.options.length);
146
+
147
+ if (nums.length > 0) {
148
+ const selected = nums.map((n) => q.options[n - 1].label);
149
+ return { answers: q.allowMultiple ? selected : [selected[0]] };
150
+ }
151
+ }
152
+
153
+ const single = parseInt(text, 10);
154
+ if (!Number.isNaN(single) && single >= 1 && single <= q.options.length) {
155
+ return { answers: [q.options[single - 1].label] };
156
+ }
157
+
158
+ return { answers: [], user_note: truncateNote(text) };
159
+ }
160
+
161
+ function truncateNote(text: string): string {
162
+ return text.length > MAX_USER_NOTE_LENGTH ? text.slice(0, MAX_USER_NOTE_LENGTH) + "…" : text;
163
+ }
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Remote Questions — orchestration manager
3
+ */
4
+
5
+ import { randomUUID } from "node:crypto";
6
+ import type { ChannelAdapter, RemotePrompt, RemoteQuestion, RemoteAnswer } from "./types.js";
7
+ import { resolveRemoteConfig, type ResolvedConfig } from "./config.js";
8
+ import { SlackAdapter } from "./slack-adapter.js";
9
+ import { DiscordAdapter } from "./discord-adapter.js";
10
+ import { createPromptRecord, writePromptRecord, markPromptAnswered, markPromptDispatched, markPromptStatus, updatePromptRecord } from "./store.js";
11
+
12
+ interface ToolResult {
13
+ content: Array<{ type: "text"; text: string }>;
14
+ details?: Record<string, unknown>;
15
+ }
16
+
17
+ interface QuestionInput {
18
+ id: string;
19
+ header: string;
20
+ question: string;
21
+ options: Array<{ label: string; description: string }>;
22
+ allowMultiple?: boolean;
23
+ }
24
+
25
+ export async function tryRemoteQuestions(
26
+ questions: QuestionInput[],
27
+ signal?: AbortSignal,
28
+ ): Promise<ToolResult | null> {
29
+ const config = resolveRemoteConfig();
30
+ if (!config) return null;
31
+
32
+ const prompt = createPrompt(questions, config);
33
+ writePromptRecord(createPromptRecord(prompt));
34
+
35
+ const adapter = createAdapter(config);
36
+ try {
37
+ await adapter.validate();
38
+ } catch (err) {
39
+ markPromptStatus(prompt.id, "failed", sanitizeError(String((err as Error).message)));
40
+ return errorResult(`Remote auth failed (${config.channel}): ${(err as Error).message}`, config.channel);
41
+ }
42
+
43
+ let dispatch;
44
+ try {
45
+ dispatch = await adapter.sendPrompt(prompt);
46
+ markPromptDispatched(prompt.id, dispatch.ref);
47
+ } catch (err) {
48
+ markPromptStatus(prompt.id, "failed", sanitizeError(String((err as Error).message)));
49
+ return errorResult(`Failed to send questions via ${config.channel}: ${(err as Error).message}`, config.channel);
50
+ }
51
+
52
+ const answer = await pollUntilDone(adapter, prompt, dispatch.ref, signal);
53
+ if (!answer) {
54
+ markPromptStatus(prompt.id, signal?.aborted ? "cancelled" : "timed_out");
55
+ return {
56
+ content: [{
57
+ type: "text",
58
+ text: JSON.stringify({
59
+ timed_out: true,
60
+ channel: config.channel,
61
+ prompt_id: prompt.id,
62
+ timeout_minutes: config.timeoutMs / 60000,
63
+ thread_url: dispatch.ref.threadUrl ?? null,
64
+ message: `User did not respond within ${config.timeoutMs / 60000} minutes.`,
65
+ }),
66
+ }],
67
+ details: {
68
+ remote: true,
69
+ channel: config.channel,
70
+ timed_out: true,
71
+ promptId: prompt.id,
72
+ threadUrl: dispatch.ref.threadUrl,
73
+ status: signal?.aborted ? "cancelled" : "timed_out",
74
+ },
75
+ };
76
+ }
77
+
78
+ markPromptAnswered(prompt.id, answer);
79
+ return {
80
+ content: [{ type: "text", text: JSON.stringify({ answers: formatForTool(answer) }) }],
81
+ details: {
82
+ remote: true,
83
+ channel: config.channel,
84
+ timed_out: false,
85
+ promptId: prompt.id,
86
+ threadUrl: dispatch.ref.threadUrl,
87
+ questions,
88
+ response: answer,
89
+ status: "answered",
90
+ },
91
+ };
92
+ }
93
+
94
+ function createPrompt(questions: QuestionInput[], config: ResolvedConfig): RemotePrompt {
95
+ const createdAt = Date.now();
96
+ return {
97
+ id: randomUUID(),
98
+ channel: config.channel,
99
+ createdAt,
100
+ timeoutAt: createdAt + config.timeoutMs,
101
+ pollIntervalMs: config.pollIntervalMs,
102
+ context: { source: "ask_user_questions" },
103
+ questions: questions.map((q): RemoteQuestion => ({
104
+ id: q.id,
105
+ header: q.header,
106
+ question: q.question,
107
+ options: q.options,
108
+ allowMultiple: q.allowMultiple ?? false,
109
+ })),
110
+ };
111
+ }
112
+
113
+ function createAdapter(config: ResolvedConfig): ChannelAdapter {
114
+ return config.channel === "slack"
115
+ ? new SlackAdapter(config.token, config.channelId)
116
+ : new DiscordAdapter(config.token, config.channelId);
117
+ }
118
+
119
+ async function pollUntilDone(
120
+ adapter: ChannelAdapter,
121
+ prompt: RemotePrompt,
122
+ ref: import("./types.js").RemotePromptRef,
123
+ signal?: AbortSignal,
124
+ ): Promise<RemoteAnswer | null> {
125
+ let retryCount = 0;
126
+ while (Date.now() < prompt.timeoutAt && !signal?.aborted) {
127
+ try {
128
+ const answer = await adapter.pollAnswer(prompt, ref);
129
+ updatePromptRecord(prompt.id, { lastPollAt: Date.now() });
130
+ retryCount = 0;
131
+ if (answer) return answer;
132
+ } catch (err) {
133
+ retryCount++;
134
+ if (retryCount > 1) {
135
+ markPromptStatus(prompt.id, "failed", sanitizeError(String((err as Error).message)));
136
+ return null;
137
+ }
138
+ }
139
+
140
+ await sleep(prompt.pollIntervalMs, signal);
141
+ }
142
+
143
+ return null;
144
+ }
145
+
146
+ function sleep(ms: number, signal?: AbortSignal): Promise<void> {
147
+ return new Promise((resolve) => {
148
+ if (signal?.aborted) return resolve();
149
+ const timer = setTimeout(() => {
150
+ if (signal) signal.removeEventListener("abort", onAbort);
151
+ resolve();
152
+ }, ms);
153
+ const onAbort = () => {
154
+ clearTimeout(timer);
155
+ resolve();
156
+ };
157
+ signal?.addEventListener("abort", onAbort, { once: true });
158
+ });
159
+ }
160
+
161
+ function formatForTool(answer: RemoteAnswer): Record<string, { answers: string[] }> {
162
+ const out: Record<string, { answers: string[] }> = {};
163
+ for (const [id, data] of Object.entries(answer.answers)) {
164
+ const list = [...data.answers];
165
+ if (data.user_note) list.push(`user_note: ${data.user_note}`);
166
+ out[id] = { answers: list };
167
+ }
168
+ return out;
169
+ }
170
+
171
+ // Strip token-like strings from error messages before surfacing
172
+ const TOKEN_PATTERNS = [
173
+ /xoxb-[A-Za-z0-9\-]+/g, // Slack bot tokens
174
+ /xoxp-[A-Za-z0-9\-]+/g, // Slack user tokens
175
+ /xoxa-[A-Za-z0-9\-]+/g, // Slack app tokens
176
+ /[A-Za-z0-9_\-.]{20,}/g, // Long opaque secrets (Discord tokens, etc.)
177
+ ];
178
+
179
+ export function sanitizeError(msg: string): string {
180
+ let sanitized = msg;
181
+ for (const pattern of TOKEN_PATTERNS) {
182
+ sanitized = sanitized.replace(pattern, "[REDACTED]");
183
+ }
184
+ return sanitized;
185
+ }
186
+
187
+ function errorResult(message: string, channel: string): ToolResult {
188
+ return {
189
+ content: [{ type: "text", text: sanitizeError(message) }],
190
+ details: { remote: true, channel, error: true, status: "failed" },
191
+ };
192
+ }
@@ -0,0 +1,307 @@
1
+ /**
2
+ * Remote Questions — /gsd remote command
3
+ */
4
+
5
+ import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
6
+ import { AuthStorage } from "@mariozechner/pi-coding-agent";
7
+ import { CURSOR_MARKER, Editor, type EditorTheme, Key, matchesKey, truncateToWidth } from "@mariozechner/pi-tui";
8
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
9
+ import { dirname, join } from "node:path";
10
+ import { getGlobalGSDPreferencesPath, loadEffectiveGSDPreferences } from "../gsd/preferences.js";
11
+ import { getRemoteConfigStatus, isValidChannelId, resolveRemoteConfig } from "./config.js";
12
+ import { sanitizeError } from "./manager.js";
13
+ import { getLatestPromptSummary } from "./status.js";
14
+
15
+ export async function handleRemote(
16
+ subcommand: string,
17
+ ctx: ExtensionCommandContext,
18
+ _pi: ExtensionAPI,
19
+ ): Promise<void> {
20
+ const trimmed = subcommand.trim();
21
+
22
+ if (trimmed === "slack") return handleSetupSlack(ctx);
23
+ if (trimmed === "discord") return handleSetupDiscord(ctx);
24
+ if (trimmed === "status") return handleRemoteStatus(ctx);
25
+ if (trimmed === "disconnect") return handleDisconnect(ctx);
26
+
27
+ return handleRemoteMenu(ctx);
28
+ }
29
+
30
+ async function handleSetupSlack(ctx: ExtensionCommandContext): Promise<void> {
31
+ const token = await promptMaskedInput(ctx, "Slack Bot Token", "Paste your xoxb-... token");
32
+ if (!token) return void ctx.ui.notify("Slack setup cancelled.", "info");
33
+ if (!token.startsWith("xoxb-")) return void ctx.ui.notify("Invalid token format — Slack bot tokens start with xoxb-.", "warning");
34
+
35
+ ctx.ui.notify("Validating token...", "info");
36
+ const auth = await fetchJson("https://slack.com/api/auth.test", { headers: { Authorization: `Bearer ${token}` } });
37
+ if (!auth?.ok) return void ctx.ui.notify("Token validation failed — check the token and app install.", "error");
38
+
39
+ const channelId = await promptInput(ctx, "Channel ID", "Paste the Slack channel ID (e.g. C0123456789)");
40
+ if (!channelId) return void ctx.ui.notify("Slack setup cancelled.", "info");
41
+ if (!isValidChannelId("slack", channelId)) return void ctx.ui.notify("Invalid Slack channel ID format — expected 9-12 uppercase alphanumeric characters.", "error");
42
+
43
+ const send = await fetchJson("https://slack.com/api/chat.postMessage", {
44
+ method: "POST",
45
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json; charset=utf-8" },
46
+ body: JSON.stringify({ channel: channelId, text: "GSD remote questions connected." }),
47
+ });
48
+ if (!send?.ok) return void ctx.ui.notify(`Could not send to channel: ${send?.error ?? "unknown error"}`, "error");
49
+
50
+ saveProviderToken("slack_bot", token);
51
+ process.env.SLACK_BOT_TOKEN = token;
52
+ saveRemoteQuestionsConfig("slack", channelId);
53
+ ctx.ui.notify(`Slack connected — remote questions enabled for channel ${channelId}.`, "info");
54
+ }
55
+
56
+ async function handleSetupDiscord(ctx: ExtensionCommandContext): Promise<void> {
57
+ const token = await promptMaskedInput(ctx, "Discord Bot Token", "Paste your bot token");
58
+ if (!token) return void ctx.ui.notify("Discord setup cancelled.", "info");
59
+
60
+ ctx.ui.notify("Validating token...", "info");
61
+ const auth = await fetchJson("https://discord.com/api/v10/users/@me", { headers: { Authorization: `Bot ${token}` } });
62
+ if (!auth?.id) return void ctx.ui.notify("Token validation failed — check the bot token.", "error");
63
+
64
+ const channelId = await promptInput(ctx, "Channel ID", "Paste the Discord channel ID (e.g. 1234567890123456789)");
65
+ if (!channelId) return void ctx.ui.notify("Discord setup cancelled.", "info");
66
+ if (!isValidChannelId("discord", channelId)) return void ctx.ui.notify("Invalid Discord channel ID format — expected 17-20 digit numeric ID.", "error");
67
+
68
+ const sendResponse = await fetch(`https://discord.com/api/v10/channels/${channelId}/messages`, {
69
+ method: "POST",
70
+ headers: { Authorization: `Bot ${token}`, "Content-Type": "application/json" },
71
+ body: JSON.stringify({ content: "GSD remote questions connected." }),
72
+ signal: AbortSignal.timeout(15_000),
73
+ });
74
+ if (!sendResponse.ok) {
75
+ const body = await sendResponse.text().catch(() => "");
76
+ return void ctx.ui.notify(`Could not send to channel (HTTP ${sendResponse.status}): ${sanitizeError(body).slice(0, 200)}`, "error");
77
+ }
78
+
79
+ saveProviderToken("discord_bot", token);
80
+ process.env.DISCORD_BOT_TOKEN = token;
81
+ saveRemoteQuestionsConfig("discord", channelId);
82
+ ctx.ui.notify(`Discord connected — remote questions enabled for channel ${channelId}.`, "info");
83
+ }
84
+
85
+ async function handleRemoteStatus(ctx: ExtensionCommandContext): Promise<void> {
86
+ const status = getRemoteConfigStatus();
87
+ const config = resolveRemoteConfig();
88
+ if (!config) {
89
+ ctx.ui.notify(status, status.includes("disabled") ? "warning" : "info");
90
+ return;
91
+ }
92
+
93
+ const latestPrompt = getLatestPromptSummary();
94
+ const lines = [status];
95
+ if (latestPrompt) {
96
+ lines.push(`Last prompt: ${latestPrompt.id}`);
97
+ lines.push(` status: ${latestPrompt.status}`);
98
+ if (latestPrompt.updatedAt) lines.push(` updated: ${new Date(latestPrompt.updatedAt).toLocaleString()}`);
99
+ }
100
+
101
+ ctx.ui.notify(lines.join("\n"), "info");
102
+ }
103
+
104
+ async function handleDisconnect(ctx: ExtensionCommandContext): Promise<void> {
105
+ const prefs = loadEffectiveGSDPreferences();
106
+ const channel = prefs?.preferences.remote_questions?.channel;
107
+ if (!channel) return void ctx.ui.notify("No remote channel configured — nothing to disconnect.", "info");
108
+
109
+ removeRemoteQuestionsConfig();
110
+ removeProviderToken(channel === "slack" ? "slack_bot" : "discord_bot");
111
+ if (channel === "slack") delete process.env.SLACK_BOT_TOKEN;
112
+ if (channel === "discord") delete process.env.DISCORD_BOT_TOKEN;
113
+ ctx.ui.notify(`Remote questions disconnected (${channel}).`, "info");
114
+ }
115
+
116
+ async function handleRemoteMenu(ctx: ExtensionCommandContext): Promise<void> {
117
+ const config = resolveRemoteConfig();
118
+ const latestPrompt = getLatestPromptSummary();
119
+ const lines = config
120
+ ? [
121
+ `Remote questions: ${config.channel} configured`,
122
+ ` Timeout: ${config.timeoutMs / 60000}m, poll: ${config.pollIntervalMs / 1000}s`,
123
+ latestPrompt ? ` Last prompt: ${latestPrompt.id} (${latestPrompt.status})` : " No remote prompts recorded yet",
124
+ "",
125
+ "Commands:",
126
+ " /gsd remote status",
127
+ " /gsd remote disconnect",
128
+ " /gsd remote slack",
129
+ " /gsd remote discord",
130
+ ]
131
+ : [
132
+ "No remote question channel configured.",
133
+ "",
134
+ "Commands:",
135
+ " /gsd remote slack",
136
+ " /gsd remote discord",
137
+ " /gsd remote status",
138
+ ];
139
+
140
+ ctx.ui.notify(lines.join("\n"), "info");
141
+ }
142
+
143
+ async function fetchJson(url: string, init?: RequestInit): Promise<any> {
144
+ try {
145
+ const response = await fetch(url, { ...init, signal: AbortSignal.timeout(15_000) });
146
+ return await response.json();
147
+ } catch {
148
+ return null;
149
+ }
150
+ }
151
+
152
+ function getAuthStorage(): AuthStorage {
153
+ const authPath = join(process.env.HOME ?? "", ".gsd", "agent", "auth.json");
154
+ mkdirSync(dirname(authPath), { recursive: true });
155
+ return AuthStorage.create(authPath);
156
+ }
157
+
158
+ function saveProviderToken(provider: string, token: string): void {
159
+ const auth = getAuthStorage();
160
+ auth.set(provider, { type: "api_key", key: token });
161
+ }
162
+
163
+ function removeProviderToken(provider: string): void {
164
+ const auth = getAuthStorage();
165
+ auth.set(provider, { type: "api_key", key: "" });
166
+ }
167
+
168
+ function saveRemoteQuestionsConfig(channel: "slack" | "discord", channelId: string): void {
169
+ const prefsPath = getGlobalGSDPreferencesPath();
170
+ const block = [
171
+ "remote_questions:",
172
+ ` channel: ${channel}`,
173
+ ` channel_id: \"${channelId}\"`,
174
+ " timeout_minutes: 5",
175
+ " poll_interval_seconds: 5",
176
+ ].join("\n");
177
+
178
+ const content = existsSync(prefsPath) ? readFileSync(prefsPath, "utf-8") : "";
179
+ const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
180
+ let next = content;
181
+
182
+ if (fmMatch) {
183
+ let frontmatter = fmMatch[1];
184
+ const regex = /remote_questions:[\s\S]*?(?=\n[a-zA-Z_]|\n---|$)/;
185
+ frontmatter = regex.test(frontmatter) ? frontmatter.replace(regex, block) : `${frontmatter.trimEnd()}\n${block}`;
186
+ next = `---\n${frontmatter}\n---${content.slice(fmMatch[0].length)}`;
187
+ } else {
188
+ next = `---\n${block}\n---\n\n${content}`;
189
+ }
190
+
191
+ mkdirSync(dirname(prefsPath), { recursive: true });
192
+ writeFileSync(prefsPath, next, "utf-8");
193
+ }
194
+
195
+ function removeRemoteQuestionsConfig(): void {
196
+ const prefsPath = getGlobalGSDPreferencesPath();
197
+ if (!existsSync(prefsPath)) return;
198
+ const content = readFileSync(prefsPath, "utf-8");
199
+ const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
200
+ if (!fmMatch) return;
201
+ const frontmatter = fmMatch[1].replace(/remote_questions:[\s\S]*?(?=\n[a-zA-Z_]|\n---|$)/, "").trim();
202
+ const next = frontmatter ? `---\n${frontmatter}\n---${content.slice(fmMatch[0].length)}` : content.slice(fmMatch[0].length).replace(/^\n+/, "");
203
+ writeFileSync(prefsPath, next, "utf-8");
204
+ }
205
+
206
+ function maskEditorLine(line: string): string {
207
+ let output = "";
208
+ let i = 0;
209
+ while (i < line.length) {
210
+ if (line.startsWith(CURSOR_MARKER, i)) {
211
+ output += CURSOR_MARKER;
212
+ i += CURSOR_MARKER.length;
213
+ continue;
214
+ }
215
+ const ansiMatch = /^\x1b\[[0-9;]*m/.exec(line.slice(i));
216
+ if (ansiMatch) {
217
+ output += ansiMatch[0];
218
+ i += ansiMatch[0].length;
219
+ continue;
220
+ }
221
+ output += line[i] === " " ? " " : "*";
222
+ i += 1;
223
+ }
224
+ return output;
225
+ }
226
+
227
+ async function promptMaskedInput(ctx: ExtensionCommandContext, label: string, hint: string): Promise<string | null> {
228
+ if (!ctx.hasUI) return null;
229
+ return ctx.ui.custom<string | null>((tui: any, theme: any, _kb: any, done: (r: string | null) => void) => {
230
+ let cachedLines: string[] | undefined;
231
+ const editorTheme: EditorTheme = {
232
+ borderColor: (s: string) => theme.fg("accent", s),
233
+ selectList: {
234
+ selectedPrefix: (t: string) => theme.fg("accent", t),
235
+ selectedText: (t: string) => theme.fg("accent", t),
236
+ description: (t: string) => theme.fg("muted", t),
237
+ scrollInfo: (t: string) => theme.fg("dim", t),
238
+ noMatch: (t: string) => theme.fg("warning", t),
239
+ },
240
+ };
241
+ const editor = new Editor(tui, editorTheme, { paddingX: 1 });
242
+ const refresh = () => { cachedLines = undefined; tui.requestRender(); };
243
+ const handleInput = (data: string) => {
244
+ if (matchesKey(data, Key.enter)) return done(editor.getText().trim() || null);
245
+ if (matchesKey(data, Key.escape)) return done(null);
246
+ editor.handleInput(data); refresh();
247
+ };
248
+ const render = (width: number) => {
249
+ if (cachedLines) return cachedLines;
250
+ const lines: string[] = [];
251
+ const add = (s: string) => lines.push(truncateToWidth(s, width));
252
+ add(theme.fg("accent", "─".repeat(width)));
253
+ add(theme.fg("accent", theme.bold(` ${label}`)));
254
+ add(theme.fg("muted", ` ${hint}`));
255
+ lines.push("");
256
+ add(theme.fg("muted", " Enter value:"));
257
+ for (const line of editor.render(width - 2)) add(theme.fg("text", maskEditorLine(line)));
258
+ lines.push("");
259
+ add(theme.fg("dim", " enter to confirm | esc to cancel"));
260
+ add(theme.fg("accent", "─".repeat(width)));
261
+ cachedLines = lines;
262
+ return lines;
263
+ };
264
+ return { render, handleInput, invalidate: () => { cachedLines = undefined; } };
265
+ });
266
+ }
267
+
268
+ async function promptInput(ctx: ExtensionCommandContext, label: string, hint: string): Promise<string | null> {
269
+ if (!ctx.hasUI) return null;
270
+ return ctx.ui.custom<string | null>((tui: any, theme: any, _kb: any, done: (r: string | null) => void) => {
271
+ let cachedLines: string[] | undefined;
272
+ const editorTheme: EditorTheme = {
273
+ borderColor: (s: string) => theme.fg("accent", s),
274
+ selectList: {
275
+ selectedPrefix: (t: string) => theme.fg("accent", t),
276
+ selectedText: (t: string) => theme.fg("accent", t),
277
+ description: (t: string) => theme.fg("muted", t),
278
+ scrollInfo: (t: string) => theme.fg("dim", t),
279
+ noMatch: (t: string) => theme.fg("warning", t),
280
+ },
281
+ };
282
+ const editor = new Editor(tui, editorTheme, { paddingX: 1 });
283
+ const refresh = () => { cachedLines = undefined; tui.requestRender(); };
284
+ const handleInput = (data: string) => {
285
+ if (matchesKey(data, Key.enter)) return done(editor.getText().trim() || null);
286
+ if (matchesKey(data, Key.escape)) return done(null);
287
+ editor.handleInput(data); refresh();
288
+ };
289
+ const render = (width: number) => {
290
+ if (cachedLines) return cachedLines;
291
+ const lines: string[] = [];
292
+ const add = (s: string) => lines.push(truncateToWidth(s, width));
293
+ add(theme.fg("accent", "─".repeat(width)));
294
+ add(theme.fg("accent", theme.bold(` ${label}`)));
295
+ add(theme.fg("muted", ` ${hint}`));
296
+ lines.push("");
297
+ add(theme.fg("muted", " Enter value:"));
298
+ for (const line of editor.render(width - 2)) add(theme.fg("text", line));
299
+ lines.push("");
300
+ add(theme.fg("dim", " enter to confirm | esc to cancel"));
301
+ add(theme.fg("accent", "─".repeat(width)));
302
+ cachedLines = lines;
303
+ return lines;
304
+ };
305
+ return { render, handleInput, invalidate: () => { cachedLines = undefined; } };
306
+ });
307
+ }