discord-agent-bridge 0.1.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 (201) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +195 -0
  3. package/dist/app.js +210 -0
  4. package/dist/app.js.map +1 -0
  5. package/dist/app.test.js +117 -0
  6. package/dist/app.test.js.map +1 -0
  7. package/dist/cli.js +63 -0
  8. package/dist/cli.js.map +1 -0
  9. package/dist/cli.test.js +91 -0
  10. package/dist/cli.test.js.map +1 -0
  11. package/dist/core/auditLog.js +65 -0
  12. package/dist/core/auditLog.js.map +1 -0
  13. package/dist/core/auditLog.test.js +124 -0
  14. package/dist/core/auditLog.test.js.map +1 -0
  15. package/dist/core/auth.js +106 -0
  16. package/dist/core/auth.js.map +1 -0
  17. package/dist/core/auth.test.js +216 -0
  18. package/dist/core/auth.test.js.map +1 -0
  19. package/dist/core/channelRegistry.js +119 -0
  20. package/dist/core/channelRegistry.js.map +1 -0
  21. package/dist/core/channelRegistry.test.js +114 -0
  22. package/dist/core/channelRegistry.test.js.map +1 -0
  23. package/dist/core/commandPolicy.js +6 -0
  24. package/dist/core/commandPolicy.js.map +1 -0
  25. package/dist/core/commandRouter.js +9 -0
  26. package/dist/core/commandRouter.js.map +1 -0
  27. package/dist/core/config.js +141 -0
  28. package/dist/core/config.js.map +1 -0
  29. package/dist/core/config.test.js +149 -0
  30. package/dist/core/config.test.js.map +1 -0
  31. package/dist/core/configResolver.js +90 -0
  32. package/dist/core/configResolver.js.map +1 -0
  33. package/dist/core/configResolver.test.js +138 -0
  34. package/dist/core/configResolver.test.js.map +1 -0
  35. package/dist/core/configSchema.js +164 -0
  36. package/dist/core/configSchema.js.map +1 -0
  37. package/dist/core/contracts.js +5 -0
  38. package/dist/core/contracts.js.map +1 -0
  39. package/dist/core/eventBus.js +42 -0
  40. package/dist/core/eventBus.js.map +1 -0
  41. package/dist/core/eventBus.test.js +72 -0
  42. package/dist/core/eventBus.test.js.map +1 -0
  43. package/dist/core/hookBridge.js +11 -0
  44. package/dist/core/hookBridge.js.map +1 -0
  45. package/dist/core/logger.js +116 -0
  46. package/dist/core/logger.js.map +1 -0
  47. package/dist/core/logger.test.js +88 -0
  48. package/dist/core/logger.test.js.map +1 -0
  49. package/dist/core/modeRegistry.js +33 -0
  50. package/dist/core/modeRegistry.js.map +1 -0
  51. package/dist/core/permissionResolver.js +43 -0
  52. package/dist/core/permissionResolver.js.map +1 -0
  53. package/dist/core/permissionResolver.test.js +136 -0
  54. package/dist/core/permissionResolver.test.js.map +1 -0
  55. package/dist/core/providerCatalog.js +240 -0
  56. package/dist/core/providerCatalog.js.map +1 -0
  57. package/dist/core/providerCatalog.test.js +230 -0
  58. package/dist/core/providerCatalog.test.js.map +1 -0
  59. package/dist/core/sessionOrchestrator.js +463 -0
  60. package/dist/core/sessionOrchestrator.js.map +1 -0
  61. package/dist/core/sessionOrchestrator.test.js +344 -0
  62. package/dist/core/sessionOrchestrator.test.js.map +1 -0
  63. package/dist/core/state/schema.js +49 -0
  64. package/dist/core/state/schema.js.map +1 -0
  65. package/dist/core/state/store.js +86 -0
  66. package/dist/core/state/store.js.map +1 -0
  67. package/dist/core/state/store.test.js +99 -0
  68. package/dist/core/state/store.test.js.map +1 -0
  69. package/dist/core/usageService.js +310 -0
  70. package/dist/core/usageService.js.map +1 -0
  71. package/dist/core/usageService.test.js +234 -0
  72. package/dist/core/usageService.test.js.map +1 -0
  73. package/dist/discord/alwaysAllow.test.js +181 -0
  74. package/dist/discord/alwaysAllow.test.js.map +1 -0
  75. package/dist/discord/client.js +666 -0
  76. package/dist/discord/client.js.map +1 -0
  77. package/dist/discord/client.test.js +256 -0
  78. package/dist/discord/client.test.js.map +1 -0
  79. package/dist/discord/configPanel.js +270 -0
  80. package/dist/discord/configPanel.js.map +1 -0
  81. package/dist/discord/configPanel.test.js +332 -0
  82. package/dist/discord/configPanel.test.js.map +1 -0
  83. package/dist/discord/directoryBrowser.js +151 -0
  84. package/dist/discord/directoryBrowser.js.map +1 -0
  85. package/dist/discord/directoryBrowser.test.js +163 -0
  86. package/dist/discord/directoryBrowser.test.js.map +1 -0
  87. package/dist/discord/favorites.js +10 -0
  88. package/dist/discord/favorites.js.map +1 -0
  89. package/dist/discord/fileDownload.js +94 -0
  90. package/dist/discord/fileDownload.js.map +1 -0
  91. package/dist/discord/fileDownload.test.js +59 -0
  92. package/dist/discord/fileDownload.test.js.map +1 -0
  93. package/dist/discord/format.js +104 -0
  94. package/dist/discord/format.js.map +1 -0
  95. package/dist/discord/format.test.js +40 -0
  96. package/dist/discord/format.test.js.map +1 -0
  97. package/dist/discord/guildChannels.js +97 -0
  98. package/dist/discord/guildChannels.js.map +1 -0
  99. package/dist/discord/guildChannels.test.js +231 -0
  100. package/dist/discord/guildChannels.test.js.map +1 -0
  101. package/dist/discord/i18n.js +207 -0
  102. package/dist/discord/i18n.js.map +1 -0
  103. package/dist/discord/i18n.test.js +31 -0
  104. package/dist/discord/i18n.test.js.map +1 -0
  105. package/dist/discord/interactionRouter.js +954 -0
  106. package/dist/discord/interactionRouter.js.map +1 -0
  107. package/dist/discord/interactionRouter.test.js +1245 -0
  108. package/dist/discord/interactionRouter.test.js.map +1 -0
  109. package/dist/discord/messageRouter.js +221 -0
  110. package/dist/discord/messageRouter.js.map +1 -0
  111. package/dist/discord/messageRouter.test.js +360 -0
  112. package/dist/discord/messageRouter.test.js.map +1 -0
  113. package/dist/discord/ports.js +12 -0
  114. package/dist/discord/ports.js.map +1 -0
  115. package/dist/discord/renderers/defaultSet.test.js +97 -0
  116. package/dist/discord/renderers/defaultSet.test.js.map +1 -0
  117. package/dist/discord/renderers/diffView.js +73 -0
  118. package/dist/discord/renderers/diffView.js.map +1 -0
  119. package/dist/discord/renderers/diffView.test.js +54 -0
  120. package/dist/discord/renderers/diffView.test.js.map +1 -0
  121. package/dist/discord/renderers/index.js +143 -0
  122. package/dist/discord/renderers/index.js.map +1 -0
  123. package/dist/discord/renderers/index.test.js +124 -0
  124. package/dist/discord/renderers/index.test.js.map +1 -0
  125. package/dist/discord/renderers/mentionOnComplete.js +19 -0
  126. package/dist/discord/renderers/mentionOnComplete.js.map +1 -0
  127. package/dist/discord/renderers/mentionOnComplete.test.js +35 -0
  128. package/dist/discord/renderers/mentionOnComplete.test.js.map +1 -0
  129. package/dist/discord/renderers/permissionButtons.js +150 -0
  130. package/dist/discord/renderers/permissionButtons.js.map +1 -0
  131. package/dist/discord/renderers/permissionButtons.test.js +108 -0
  132. package/dist/discord/renderers/permissionButtons.test.js.map +1 -0
  133. package/dist/discord/renderers/resultLine.js +25 -0
  134. package/dist/discord/renderers/resultLine.js.map +1 -0
  135. package/dist/discord/renderers/statusEmbed.js +20 -0
  136. package/dist/discord/renderers/statusEmbed.js.map +1 -0
  137. package/dist/discord/renderers/statusEmbed.test.js +32 -0
  138. package/dist/discord/renderers/statusEmbed.test.js.map +1 -0
  139. package/dist/discord/renderers/streamEmbed.js +134 -0
  140. package/dist/discord/renderers/streamEmbed.js.map +1 -0
  141. package/dist/discord/renderers/streamEmbed.test.js +97 -0
  142. package/dist/discord/renderers/streamEmbed.test.js.map +1 -0
  143. package/dist/discord/renderers/toolThread.js +65 -0
  144. package/dist/discord/renderers/toolThread.js.map +1 -0
  145. package/dist/discord/renderers/toolThread.test.js +54 -0
  146. package/dist/discord/renderers/toolThread.test.js.map +1 -0
  147. package/dist/discord/renderers/transcriptFeed.js +33 -0
  148. package/dist/discord/renderers/transcriptFeed.js.map +1 -0
  149. package/dist/discord/renderers/usageEmbed.js +78 -0
  150. package/dist/discord/renderers/usageEmbed.js.map +1 -0
  151. package/dist/discord/renderers/usageEmbed.test.js +42 -0
  152. package/dist/discord/renderers/usageEmbed.test.js.map +1 -0
  153. package/dist/discord/wiring.js +214 -0
  154. package/dist/discord/wiring.js.map +1 -0
  155. package/dist/discord/wiring.test.js +252 -0
  156. package/dist/discord/wiring.test.js.map +1 -0
  157. package/dist/discord/wizard/channelWizard.js +305 -0
  158. package/dist/discord/wizard/channelWizard.js.map +1 -0
  159. package/dist/discord/wizard/channelWizard.test.js +298 -0
  160. package/dist/discord/wizard/channelWizard.test.js.map +1 -0
  161. package/dist/discord/wizard/resumeWizard.js +123 -0
  162. package/dist/discord/wizard/resumeWizard.js.map +1 -0
  163. package/dist/modes/claude/index.js +80 -0
  164. package/dist/modes/claude/index.js.map +1 -0
  165. package/dist/modes/claude/listResumable.test.js +72 -0
  166. package/dist/modes/claude/listResumable.test.js.map +1 -0
  167. package/dist/modes/claude/mcpFileTool.js +82 -0
  168. package/dist/modes/claude/mcpFileTool.js.map +1 -0
  169. package/dist/modes/claude/permissions.js +35 -0
  170. package/dist/modes/claude/permissions.js.map +1 -0
  171. package/dist/modes/claude/plugins.js +56 -0
  172. package/dist/modes/claude/plugins.js.map +1 -0
  173. package/dist/modes/claude/session.js +324 -0
  174. package/dist/modes/claude/session.js.map +1 -0
  175. package/dist/modes/claude/session.test.js +415 -0
  176. package/dist/modes/claude/session.test.js.map +1 -0
  177. package/dist/modes/codex/discovery.js +221 -0
  178. package/dist/modes/codex/discovery.js.map +1 -0
  179. package/dist/modes/codex/discovery.test.js +213 -0
  180. package/dist/modes/codex/discovery.test.js.map +1 -0
  181. package/dist/modes/codex/eventMapper.js +262 -0
  182. package/dist/modes/codex/eventMapper.js.map +1 -0
  183. package/dist/modes/codex/index.js +132 -0
  184. package/dist/modes/codex/index.js.map +1 -0
  185. package/dist/modes/codex/index.test.js +170 -0
  186. package/dist/modes/codex/index.test.js.map +1 -0
  187. package/dist/modes/codex/runner.js +207 -0
  188. package/dist/modes/codex/runner.js.map +1 -0
  189. package/dist/modes/codex/runner.test.js +587 -0
  190. package/dist/modes/codex/runner.test.js.map +1 -0
  191. package/dist/modes/codex/sqliteReader.js +171 -0
  192. package/dist/modes/codex/sqliteReader.js.map +1 -0
  193. package/dist/modes/codex/sqliteReader.test.js +137 -0
  194. package/dist/modes/codex/sqliteReader.test.js.map +1 -0
  195. package/dist/setup/wizard.js +115 -0
  196. package/dist/setup/wizard.js.map +1 -0
  197. package/dist/setup/wizard.test.js +216 -0
  198. package/dist/setup/wizard.test.js.map +1 -0
  199. package/dist/startBot.test.js +78 -0
  200. package/dist/startBot.test.js.map +1 -0
  201. package/package.json +58 -0
@@ -0,0 +1,954 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import { defaultEffortFor, effortChoicesFor, permissionChoicesFor, permissionModeLabel, } from '../core/providerCatalog.js';
4
+ import { ChannelWizard } from './wizard/channelWizard.js';
5
+ import { ResumeWizard } from './wizard/resumeWizard.js';
6
+ import { DirectoryBrowser } from './directoryBrowser.js';
7
+ import { parseCustomId } from './renderers/permissionButtons.js';
8
+ import { ConfigPanel, isConfigPanelId } from './configPanel.js';
9
+ import { buildStatusEmbed } from './renderers/statusEmbed.js';
10
+ import { ensureGuildChannels, createSessionChannel, } from './guildChannels.js';
11
+ import { setLocale, t } from './i18n.js';
12
+ // InteractionCreate router (Β§4, Β§7.1, Β§9). Authorizes FIRST (tier per action), then
13
+ // dispatches slash commands and component interactions. discord.js is not imported
14
+ // as a value: narrow interaction shapes below are satisfied structurally by the real
15
+ // discord.js interactions, so unit tests drive fakes. The client.ts handler narrows
16
+ // a raw Interaction with the discord.js type guards and calls handle().
17
+ // The tier each command requires (Β§7.1): stop-all is admin; the rest are execute.
18
+ const ACTION_TIER = {
19
+ 'agent.start': 'drive',
20
+ 'agent.resume': 'drive',
21
+ 'agent.close': 'drive',
22
+ 'mode.backend': 'drive',
23
+ 'mode.perm': 'drive',
24
+ stop: 'drive',
25
+ 'stop-all': 'admin',
26
+ };
27
+ function channelKey(guildId, channelId) {
28
+ return `${guildId}:${channelId}`;
29
+ }
30
+ export class InteractionRouter {
31
+ deps;
32
+ // Active wizards keyed by guildId:channelId, so follow-up component interactions
33
+ // (folder/backend/model/perm selects, confirm/cancel) route back to the same flow.
34
+ wizards = new Map();
35
+ // Active resume flows keyed by guildId:channelId (started from the folder step's
36
+ // "Resume Session" button), so the backend/session selects route back to the flow.
37
+ resumeFlows = new Map();
38
+ // Active /config panels keyed by guildId:channelId, so follow-up role/string
39
+ // selects + Save route back to the panel that holds the pending selections.
40
+ configPanels = new Map();
41
+ constructor(deps) {
42
+ this.deps = deps;
43
+ }
44
+ // Bind the live-gateway resolvers AFTER construction (app boot). The client depends
45
+ // on this router, so the router cannot capture the client at construction β€” these
46
+ // setters mirror wiring.setResolveChannel. Used by /init and /agent start's session-
47
+ // channel creation + intro post.
48
+ setResolveGuildProvisioner(fn) {
49
+ this.deps.resolveGuildProvisioner = fn;
50
+ }
51
+ setResolveChannel(fn) {
52
+ this.deps.resolveChannel = fn;
53
+ }
54
+ async handle(interaction) {
55
+ // Operator visibility (Β§7.3): log EVERY received interaction to the terminal so
56
+ // `node dist/cli.js` shows exactly what fired. Redaction is handled by the logger.
57
+ this.logReceipt(interaction);
58
+ if (interaction.kind === 'component') {
59
+ await this.handleComponent(interaction);
60
+ return;
61
+ }
62
+ if (interaction.kind === 'modalSubmit') {
63
+ await this.handleModalSubmit(interaction);
64
+ return;
65
+ }
66
+ await this.handleSlash(interaction);
67
+ }
68
+ // One info line per interaction: type + command/customId + guild + user. This is
69
+ // the operator's window into what the gateway delivered (see the bug where a slash
70
+ // command silently timed out β€” now the terminal shows it fired).
71
+ logReceipt(i) {
72
+ if (i.kind === 'slash') {
73
+ this.deps.logger.info('interaction received', {
74
+ type: 'slash',
75
+ command: i.subcommand ? `${i.commandName} ${i.subcommand}` : i.commandName,
76
+ guildId: i.guildId,
77
+ userId: i.user.id,
78
+ });
79
+ }
80
+ else {
81
+ this.deps.logger.info('interaction received', {
82
+ type: i.kind === 'modalSubmit' ? 'modalSubmit' : 'component',
83
+ customId: i.customId,
84
+ guildId: i.guildId,
85
+ userId: i.user.id,
86
+ });
87
+ }
88
+ }
89
+ // ---- Slash commands -----------------------------------------------------
90
+ async handleSlash(i) {
91
+ const actionKey = i.subcommand ? `${i.commandName}.${i.subcommand}` : i.commandName;
92
+ // Acknowledge IMMEDIATELY, before any slow work (auth, disk reads, panel/wizard
93
+ // build, session start/stop). Deferring buys the 15-minute follow-up window so we
94
+ // never miss Discord's 3-second ack deadline β€” the root cause of "application did
95
+ // not respond". Every subsequent user-facing message is an editReply/followUp.
96
+ // The defer is ephemeral (only the actor sees the ack + any notices); a command
97
+ // that needs a PUBLIC message (e.g. /mode backend's fresh-context warning) posts
98
+ // it as a non-ephemeral followUp.
99
+ if (!(await this.ackDefer(i, { ephemeral: true })))
100
+ return;
101
+ // /config has a bespoke bootstrap gate (Administrator OR admin tier) so it works
102
+ // on first run with an empty allowlist AND later for configured admins β€” handled
103
+ // outside the tier-per-action table below.
104
+ if (actionKey === 'config') {
105
+ await this.guarded(i, () => this.openConfigPanel(i));
106
+ return;
107
+ }
108
+ // /init creates the guild channel structure (control channel + sessions category).
109
+ // Same bootstrap gate as /config (Administrator OR admin tier) so a fresh server
110
+ // admin can run it before any role is configured.
111
+ if (actionKey === 'init') {
112
+ await this.guarded(i, () => this.runInit(i));
113
+ return;
114
+ }
115
+ const action = ACTION_TIER[actionKey] ?? 'drive';
116
+ if (!this.authorize(i, action))
117
+ return;
118
+ await this.guarded(i, async () => {
119
+ switch (actionKey) {
120
+ case 'agent.start':
121
+ await this.startWizard(i);
122
+ break;
123
+ case 'agent.resume':
124
+ await this.resume(i);
125
+ break;
126
+ case 'agent.close':
127
+ await this.close(i);
128
+ break;
129
+ case 'mode.backend':
130
+ await this.switchBackend(i);
131
+ break;
132
+ case 'mode.perm':
133
+ await this.switchPerm(i);
134
+ break;
135
+ case 'stop':
136
+ await this.stop(i);
137
+ break;
138
+ case 'stop-all':
139
+ await this.stopAll(i);
140
+ break;
141
+ default:
142
+ break;
143
+ }
144
+ });
145
+ }
146
+ // Defer the interaction (the first ack) with a best-effort guard. Returns false if
147
+ // the defer itself failed (a stale/expired interaction) so the caller bails out
148
+ // rather than doing work whose result can never be delivered. Logs the failure with
149
+ // its stack so the operator sees it in the terminal.
150
+ async ackDefer(i, options) {
151
+ try {
152
+ await i.deferReply(options);
153
+ return true;
154
+ }
155
+ catch (err) {
156
+ this.logError('interaction defer failed', err);
157
+ return false;
158
+ }
159
+ }
160
+ // Run a handler with a GUARANTEED user-visible ack: any thrown error becomes an
161
+ // ephemeral error message (editReply when already deferred/replied, else reply), so
162
+ // Discord never shows "did not respond" β€” worst case the user sees the error. The
163
+ // error is logged WITH its stack to the operator terminal (redacted by the logger).
164
+ async guarded(i, fn) {
165
+ try {
166
+ await fn();
167
+ }
168
+ catch (err) {
169
+ this.logError('interaction handler failed', err);
170
+ const payload = { content: t('cmd.error', { error: String(err) }), ephemeral: true };
171
+ await safe(i.acknowledged ? i.editReply(payload) : i.reply(payload));
172
+ }
173
+ }
174
+ // Log an error to the operator terminal WITH its stack trace (the logger redacts
175
+ // secrets). A bare `String(err)` drops the stack, so pass the Error through.
176
+ logError(message, err) {
177
+ if (err instanceof Error) {
178
+ this.deps.logger.error(message, { error: err.message, stack: err.stack });
179
+ }
180
+ else {
181
+ this.deps.logger.error(message, { error: String(err) });
182
+ }
183
+ }
184
+ // The permission-mode option list for a backend as English {value,label} pairs. The
185
+ // backend's declared capabilities.permissionModes stays authoritative for WHICH modes
186
+ // it accepts (Codex excludes dontAsk/auto); the provider catalog supplies the English
187
+ // label for each β€” so the dropdowns show original English identifiers, not Korean.
188
+ permModeChoicesFor(backend) {
189
+ const modes = this.deps.modeRegistry.get(backend).capabilities.permissionModes;
190
+ return modes.map((m) => ({ value: m, label: permissionModeLabel(m) }));
191
+ }
192
+ async startWizard(i) {
193
+ const guildId = i.guildId; // authorize() rejected DMs (no guild)
194
+ const resolved = this.deps.configResolver.resolve(guildId, i.channelId);
195
+ const config = this.deps.configStore.load();
196
+ const backends = this.deps.modeRegistry.list();
197
+ const profiles = Object.keys(config.profiles);
198
+ const backend = resolved.mode;
199
+ // Model list per backend (the router-supplied dynamic seam, falling back to the
200
+ // resolved model). The wizard reads it once the backend is chosen so a Codex pick
201
+ // shows Codex models, not Claude ones.
202
+ const modelsFor = (b) => this.deps.modelsFor?.(b) ?? [{ value: resolved.claudeModel, label: resolved.claudeModel }];
203
+ // Permission options per backend: Claude PermMode list vs Codex sandbox terms.
204
+ const permsFor = (b) => permissionChoicesFor(b);
205
+ // Reasoning-effort options per backend, narrowed for Claude to the chosen model's
206
+ // SDK-reported supportedEffortLevels when present.
207
+ const effortsFor = (b, model) => {
208
+ const supported = modelsFor(b).find((m) => m.value === model)?.supportedEffortLevels;
209
+ return effortChoicesFor(b, supported);
210
+ };
211
+ // Unbounded folder browsing by default (browse anywhere up to '/'), unless the
212
+ // operator configured explicit browse roots β€” so the admin can pick a cwd on any
213
+ // volume (Fix 1). Session file confinement is a separate mechanism and unaffected.
214
+ const browser = new DirectoryBrowser({
215
+ ...(this.deps.browseRoots && this.deps.browseRoots.length > 0
216
+ ? { allowedRoots: this.deps.browseRoots }
217
+ : {}),
218
+ });
219
+ const wizard = new ChannelWizard({
220
+ guildId,
221
+ channelId: i.channelId,
222
+ ownerId: i.user.id,
223
+ start: (params) => this.startSession(params),
224
+ defaults: {
225
+ backend,
226
+ model: resolved.claudeModel,
227
+ permMode: resolved.permissionMode,
228
+ profile: resolved.permissionProfile,
229
+ },
230
+ backends,
231
+ modelsFor,
232
+ profiles,
233
+ permsFor,
234
+ effortsFor,
235
+ defaultEffortFor,
236
+ browser,
237
+ });
238
+ this.wizards.set(channelKey(guildId, i.channelId), wizard);
239
+ // Render the FIRST step (the folder picker) and attach it to the deferred reply.
240
+ // Sending only cmd.start.launched left the user with a text line and nothing to
241
+ // click β€” the wizard's embed + component rows (folder select + ⬆/βœ… buttons) must
242
+ // ride the editReply so the picker actually appears (mirrors openConfigPanel).
243
+ const { embed, rows } = wizard.render();
244
+ await i.editReply({ content: t('cmd.start.launched'), embeds: [embed], components: rows });
245
+ }
246
+ // Open the /config role-tier + defaults panel. Bootstrap gate: allowed if the actor
247
+ // has the Discord Administrator permission OR our admin tier β€” so it works on first
248
+ // run with an empty allowlist AND later for configured admins (Β§7.1). Prefills the
249
+ // panel from the guild's current server-layer auth + resolved defaults; a bystander
250
+ // cannot advance it (owner-bound, mirroring the wizard).
251
+ async openConfigPanel(i) {
252
+ if (i.guildId === null) {
253
+ await i.editReply({ content: t('auth.denied', { reason: 'DM' }) });
254
+ return;
255
+ }
256
+ if (!this.authorizeConfig(i)) {
257
+ await i.editReply({ content: t('cmd.config.denied') });
258
+ return;
259
+ }
260
+ const guildId = i.guildId;
261
+ const global = this.deps.configStore.load();
262
+ const server = this.deps.configStore.loadServerConfig(guildId);
263
+ const resolved = this.deps.configResolver.resolve(guildId, i.channelId);
264
+ const backends = this.deps.modeRegistry.list();
265
+ const models = this.deps.modelsFor?.(resolved.mode) ?? [{ value: resolved.claudeModel, label: resolved.claudeModel }];
266
+ const permModes = this.permModeChoicesFor(resolved.mode);
267
+ // Current effective role tiers = server override when present, else global.
268
+ const panel = new ConfigPanel({
269
+ guildId,
270
+ ownerId: i.user.id,
271
+ configStore: this.deps.configStore,
272
+ defaults: {
273
+ adminRoleIds: server?.auth?.adminRoleIds ?? global.auth.adminRoleIds,
274
+ executeRoleIds: server?.auth?.executeRoleIds ?? global.auth.executeRoleIds,
275
+ readOnlyRoleIds: server?.auth?.readOnlyRoleIds ?? global.auth.readOnlyRoleIds,
276
+ backend: resolved.mode,
277
+ model: resolved.claudeModel,
278
+ // The /config default-permission select is Claude's PermMode vocabulary; take it
279
+ // from the config layer (server override else global), NOT the resolved value β€”
280
+ // a channel binding may carry a Codex sandbox mode, which does not belong here.
281
+ permMode: server?.defaults?.permissionMode ?? global.defaults.permissionMode,
282
+ // locale is per-guild (server override) or the global default. Codex home is
283
+ // NOT configured here β€” it auto-resolves to ~/.codex via the resolver default.
284
+ locale: server?.locale ?? global.locale,
285
+ },
286
+ backends,
287
+ models,
288
+ permModes,
289
+ });
290
+ this.configPanels.set(channelKey(guildId, i.channelId), panel);
291
+ // A single Discord message allows at most 5 action rows. Role tiers + Save (4 rows)
292
+ // ride the deferred reply; the defaults follow-up carries backend/model/permMode/
293
+ // locale selects (4 rows). Both are ephemeral.
294
+ const { embed, roleRows, defaultRows } = panel.render();
295
+ await i.editReply({ content: t('cmd.config.opened'), embeds: [embed], components: roleRows });
296
+ await i.followUp({ components: defaultRows, ephemeral: true });
297
+ }
298
+ // /init: idempotently create the A4D-style channel structure (control channel +
299
+ // sessions category) and persist the ids to servers/<guildId>.json. Re-running
300
+ // reuses existing channels by their stored ids. Same bootstrap gate as /config.
301
+ async runInit(i) {
302
+ if (i.guildId === null) {
303
+ await i.editReply({ content: t('auth.denied', { reason: 'DM' }) });
304
+ return;
305
+ }
306
+ if (!this.authorizeConfig(i)) {
307
+ await i.editReply({ content: t('cmd.config.denied') });
308
+ return;
309
+ }
310
+ const provisioner = this.deps.resolveGuildProvisioner
311
+ ? await this.deps.resolveGuildProvisioner(i.guildId)
312
+ : null;
313
+ if (!provisioner) {
314
+ await i.editReply({ content: t('cmd.init.unavailable') });
315
+ return;
316
+ }
317
+ const channels = await ensureGuildChannels(provisioner, this.deps.configStore);
318
+ await i.editReply({
319
+ content: t('cmd.init.done', { control: `<#${channels.controlChannelId}>` }),
320
+ });
321
+ }
322
+ // Route a /config panel component (role/string select or Save) to its panel. Gated
323
+ // by the same bootstrap rule as opening it and owner-bound: a bystander's stray
324
+ // select is acknowledged but ignored, so it cannot corrupt an admin's pending edit.
325
+ async handleConfigComponent(i) {
326
+ if (!i.guildId) {
327
+ await safe(i.deferUpdate());
328
+ return;
329
+ }
330
+ if (!this.authorizeConfig(i)) {
331
+ await safe(i.reply({ content: t('cmd.config.denied'), ephemeral: true }));
332
+ return;
333
+ }
334
+ const panel = this.configPanels.get(channelKey(i.guildId, i.channelId));
335
+ if (!panel || panel.ownerId !== i.user.id) {
336
+ await safe(i.deferUpdate());
337
+ return;
338
+ }
339
+ const input = {
340
+ id: i.customId,
341
+ ...(i.value !== undefined ? { value: i.value } : {}),
342
+ ...(i.values !== undefined ? { values: i.values } : {}),
343
+ };
344
+ const result = panel.handle(input);
345
+ if (result.kind === 'saved') {
346
+ this.configPanels.delete(channelKey(i.guildId, i.channelId));
347
+ // Save is a button on the primary (ephemeral) message; a fresh ephemeral reply
348
+ // carries the confirmation summary without disturbing the still-open panel.
349
+ await safe(i.reply({ content: result.summary, ephemeral: true }));
350
+ return;
351
+ }
352
+ if (result.kind === 'autosaved') {
353
+ // A defaults select persisted one field immediately; confirm it ephemerally
354
+ // (a fresh reply, keeping the panel open). If it was the locale select, drive
355
+ // setLocale so THIS session's subsequent responses use the chosen language.
356
+ this.applyLocaleIfLocaleSelect(i.customId, i.value);
357
+ await safe(i.reply({ content: result.notice, ephemeral: true }));
358
+ return;
359
+ }
360
+ // A pending selection or an ignored input: just acknowledge (keep the panel open).
361
+ await safe(i.deferUpdate());
362
+ }
363
+ // Route a submitted modal. The only modal the bot opens is the folder-step πŸ“ Create
364
+ // dialog (dir:create); its submit creates the subfolder and re-renders the browser.
365
+ // Any other modal id is a stray/replayed interaction β€” acknowledged with a generic
366
+ // ephemeral notice (no persistence) so it never shows "did not respond".
367
+ async handleModalSubmit(i) {
368
+ if (i.customId === 'dir:create') {
369
+ await this.guarded(i, () => this.handleCreateFolderModal(i));
370
+ return;
371
+ }
372
+ await safe(i.reply({ content: t('cmd.error.generic'), ephemeral: true }));
373
+ }
374
+ // When a /config auto-save was the LOCALE select, drive setLocale so this running
375
+ // process renders subsequent responses in the chosen language (the per-guild locale
376
+ // is also persisted; the global config.locale still seeds the boot default). A value
377
+ // outside the known set is left to the module default β€” never throws.
378
+ applyLocaleIfLocaleSelect(customId, value) {
379
+ if (customId !== 'config.default.locale' || !value)
380
+ return;
381
+ if (value === 'ko' || value === 'en')
382
+ setLocale(value);
383
+ }
384
+ // The /config bootstrap gate: allow if the actor has the Discord Administrator
385
+ // permission (works on first run with an empty allowlist) OR clears the admin tier
386
+ // (works once the allowlist is configured). Never uses the generic tier-denial
387
+ // reply β€” the caller sends the /config-specific notice.
388
+ authorizeConfig(i) {
389
+ if (i.hasAdminPermission === true)
390
+ return true;
391
+ const roleIds = i.member ? i.member.roles.cache.map((r) => r.id) : [];
392
+ return this.deps.authorizer.authorize({
393
+ userId: i.user.id,
394
+ roleIds,
395
+ action: 'admin',
396
+ context: { ...(i.guildId ? { guildId: i.guildId } : {}), channelId: i.channelId },
397
+ }).allowed;
398
+ }
399
+ // A4D-style session start: CREATE a dedicated session channel from the picked
400
+ // folder, start the session bound to THAT new channel (not the command's channel),
401
+ // wire renderers/permission/sendFile there, and post the status embed + intro. Falls
402
+ // back to the command's channel when the guild has no /init structure and no
403
+ // provisioner is available (so a session can still start), but the channel is created
404
+ // under the sessions category when /init has run. Returns the effective channel id so
405
+ // the wizard/router can link the new channel to the driver.
406
+ async startSession(params) {
407
+ const channelId = await this.resolveSessionChannelId(params);
408
+ const session = await this.startInChannel({ ...params, channelId });
409
+ await this.postSessionIntro(channelId, params, session);
410
+ return { session, channelId };
411
+ }
412
+ // orchestrator.start + wire renderers/permission/sendFile for the given channel. The
413
+ // /mode backend switch reuses this to restart IN PLACE (same channel) β€” it must not
414
+ // create a new session channel.
415
+ async startInChannel(params) {
416
+ const session = await this.deps.orchestrator.start(params);
417
+ await this.deps.wiring.attach(params.guildId, params.channelId, params.mode);
418
+ return session;
419
+ }
420
+ // Create the dedicated session channel for this start, or fall back to the command's
421
+ // channel when no provisioner is wired. When /init has run, the new channel is placed
422
+ // under the guild's sessions category; otherwise it is created without a parent (or,
423
+ // if creation is impossible, the command's channel is reused).
424
+ async resolveSessionChannelId(params) {
425
+ const provisioner = this.deps.resolveGuildProvisioner
426
+ ? await this.deps.resolveGuildProvisioner(params.guildId)
427
+ : null;
428
+ if (!provisioner)
429
+ return params.channelId;
430
+ const channels = this.guildChannels(params.guildId);
431
+ const created = await createSessionChannel(provisioner, params.cwd, channels?.sessionsCategoryId);
432
+ return created.id;
433
+ }
434
+ // The persisted /init channel structure for a guild, or undefined when /init has not
435
+ // run. A corrupt server file is treated as absent (loadServerConfig returns null).
436
+ guildChannels(guildId) {
437
+ return this.deps.configStore.loadServerConfig(guildId)?.channels;
438
+ }
439
+ // Post the pinned-style status embed + a short intro into the new session channel so
440
+ // the conversation happens there (A4D behavior). Best-effort: a resolve/post failure
441
+ // is logged but never fails the start (the session is already live).
442
+ async postSessionIntro(channelId, params, session) {
443
+ const resolve = this.deps.resolveChannel;
444
+ if (!resolve)
445
+ return;
446
+ try {
447
+ const channel = await resolve(channelId);
448
+ if (!channel)
449
+ return;
450
+ const usagePanel = this.deps.modeRegistry.has(params.mode)
451
+ ? this.deps.modeRegistry.get(params.mode).capabilities.usagePanel
452
+ : true;
453
+ const embed = buildStatusEmbed({
454
+ mode: params.mode,
455
+ cwd: params.cwd,
456
+ sessionId: session.sessionId,
457
+ permMode: params.permMode ?? 'default',
458
+ usagePanel,
459
+ });
460
+ await channel.send({ content: t('cmd.start.intro'), embeds: [embed] });
461
+ }
462
+ catch (err) {
463
+ this.logError('failed to post session intro', err);
464
+ }
465
+ }
466
+ async resume(i) {
467
+ const guildId = i.guildId;
468
+ const binding = this.deps.channelRegistry.get(guildId, i.channelId);
469
+ if (!binding || binding.archived) {
470
+ await i.editReply({ content: t('cmd.resume.none') });
471
+ return;
472
+ }
473
+ // For Claude, listResumable is currently [] β€” re-bind/inform gracefully.
474
+ await this.deps.wiring.attach(guildId, i.channelId, binding.mode);
475
+ await i.editReply({ content: t('cmd.resume.rebound') });
476
+ }
477
+ async close(i) {
478
+ const guildId = i.guildId;
479
+ await this.deps.orchestrator.stop(guildId, i.channelId);
480
+ this.deps.wiring.detach(guildId, i.channelId);
481
+ // A4D behavior: delete the dedicated session channel on close. Guarded β€” never
482
+ // delete the control channel or a channel that isn't the closed session's, and
483
+ // skip when no provisioner is wired. Best-effort: a delete failure still reports
484
+ // the session closed (the reply may not survive if this channel is the one deleted,
485
+ // which is expected). Reply BEFORE deleting so the ack lands first.
486
+ await i.editReply({ content: t('cmd.close.done') });
487
+ await this.deleteSessionChannel(guildId, i.channelId);
488
+ }
489
+ // Delete the session channel that was closed, unless it is the guild's control
490
+ // channel (never delete /init's control channel). No-op when no provisioner is wired.
491
+ async deleteSessionChannel(guildId, channelId) {
492
+ const channels = this.guildChannels(guildId);
493
+ if (channels && channelId === channels.controlChannelId)
494
+ return;
495
+ const provisioner = this.deps.resolveGuildProvisioner
496
+ ? await this.deps.resolveGuildProvisioner(guildId)
497
+ : null;
498
+ if (!provisioner)
499
+ return;
500
+ try {
501
+ await provisioner.deleteChannel(channelId);
502
+ }
503
+ catch (err) {
504
+ this.logError('failed to delete session channel', err);
505
+ }
506
+ }
507
+ async switchBackend(i) {
508
+ const guildId = i.guildId;
509
+ const backend = i.getString('backend');
510
+ if (!backend)
511
+ return;
512
+ // Validate the target backend BEFORE any teardown: an unregistered backend (e.g.
513
+ // Codex before Phase 2 registers it) must NOT stop/detach the running session.
514
+ if (!this.deps.modeRegistry.has(backend)) {
515
+ await i.editReply({ content: t('cmd.mode.unavailable', { backend }) });
516
+ return;
517
+ }
518
+ // Require an existing binding: there is no cwd/owner to carry over otherwise, and
519
+ // falling back to process.cwd() would start a session in the bot's own directory.
520
+ const binding = this.deps.channelRegistry.get(guildId, i.channelId);
521
+ if (!binding) {
522
+ await i.editReply({ content: t('router.noSession') });
523
+ return;
524
+ }
525
+ // Switching the backend starts a fresh context (Β§9 step 3): stop the current
526
+ // session, then start a new one on the same cwd/owner/permMode and re-wire.
527
+ const { cwd, ownerId, permMode, profile } = binding;
528
+ await this.deps.orchestrator.stop(guildId, i.channelId);
529
+ this.deps.wiring.detach(guildId, i.channelId);
530
+ await this.startInChannel({
531
+ guildId,
532
+ channelId: i.channelId,
533
+ mode: backend,
534
+ cwd,
535
+ ownerId,
536
+ permMode,
537
+ profile,
538
+ });
539
+ // Confirmation closes the ephemeral deferred reply (only the actor sees it). The
540
+ // fresh-context warning (Β§9 step 3) is PUBLIC so the whole channel sees the context
541
+ // reset β€” posted as a non-ephemeral followUp since the deferred reply is ephemeral.
542
+ await i.editReply({ content: t('cmd.mode.switched', { backend }) });
543
+ await safe(i.followUp({ content: t('cmd.mode.freshContext', { backend }), ephemeral: false }));
544
+ }
545
+ async switchPerm(i) {
546
+ const guildId = i.guildId;
547
+ const value = i.getString('value');
548
+ if (!value)
549
+ return;
550
+ const binding = this.deps.channelRegistry.get(guildId, i.channelId);
551
+ if (!binding) {
552
+ await i.editReply({ content: t('router.noSession') });
553
+ return;
554
+ }
555
+ // A value that names a known profile switches the profile; otherwise it is a raw
556
+ // permission mode. Either way the session is kept (applies on next turn/spawn).
557
+ const config = this.deps.configStore.load();
558
+ const isProfile = Object.prototype.hasOwnProperty.call(config.profiles, value);
559
+ const override = isProfile
560
+ ? { profile: value }
561
+ : { permMode: value };
562
+ const resolved = this.deps.permissionResolver.resolve(guildId, i.channelId, override);
563
+ this.deps.channelRegistry.set({
564
+ guildId,
565
+ channelId: i.channelId,
566
+ mode: binding.mode,
567
+ sessionId: binding.sessionId,
568
+ cwd: binding.cwd,
569
+ ownerId: binding.ownerId,
570
+ permMode: resolved.permMode,
571
+ profile: resolved.profile,
572
+ ...(binding.projectAuth ? { projectAuth: binding.projectAuth } : {}),
573
+ });
574
+ await i.editReply({ content: t('cmd.perm.switched', { perm: resolved.profile ?? resolved.permMode }) });
575
+ }
576
+ async stop(i) {
577
+ const guildId = i.guildId;
578
+ await this.deps.orchestrator.stop(guildId, i.channelId);
579
+ this.deps.wiring.detach(guildId, i.channelId);
580
+ await i.editReply({ content: t('cmd.stop.done') });
581
+ }
582
+ async stopAll(i) {
583
+ // Detach every wired channel first so no renderer lingers, then stop all.
584
+ const bindings = this.deps.channelRegistry.list().filter((b) => !b.archived);
585
+ for (const b of bindings)
586
+ this.deps.wiring.detach(b.guildId, b.channelId);
587
+ await this.deps.orchestrator.stopAll();
588
+ await i.editReply({ content: t('cmd.stopAll.done', { count: bindings.length }) });
589
+ }
590
+ // ---- Component interactions (buttons / selects) -------------------------
591
+ async handleComponent(i) {
592
+ // /config panel components own their own ack flow (deferUpdate on a pending pick,
593
+ // an ephemeral reply on Save / denial). Fast work only (in-memory panel state plus
594
+ // one small JSON write on Save), so they ack within the window without a leading
595
+ // defer. Same bootstrap gate as opening the panel; owner-bound.
596
+ if (isConfigPanelId(i.customId)) {
597
+ await this.handleConfigComponent(i);
598
+ return;
599
+ }
600
+ // Permission buttons: perm:<reqId>:<action>. These are gated to execute tier
601
+ // (the driver decides). Route to the channel's PermissionButtonsHandler, passing
602
+ // the acting user id so the handler enforces that ONLY the prompt's approver
603
+ // (the session owner) can resolve it β€” a bystander click is ignored (Β§7.1/Β§7.5).
604
+ if (parseCustomId(i.customId)) {
605
+ if (!this.authorize(i, 'drive'))
606
+ return;
607
+ // Acknowledge FIRST (deferUpdate keeps the existing message), THEN resolve the
608
+ // permission β€” resolvePermission touches the session and must never delay the
609
+ // ack past Discord's 3s window.
610
+ if (!(await this.ackDeferUpdate(i)))
611
+ return;
612
+ await this.guarded(i, async () => {
613
+ if (i.guildId) {
614
+ await this.deps.wiring.resolvePermission(i.guildId, i.channelId, i.customId, i.user.id);
615
+ }
616
+ });
617
+ return;
618
+ }
619
+ // The πŸ“ Create button opens a modal, and showModal IS the ack β€” it must NOT be
620
+ // preceded by a deferUpdate (a deferred component can no longer show a modal). So
621
+ // this is handled BEFORE the generic defer below. Drive-gated + owner-bound.
622
+ if (i.customId === 'dir:create') {
623
+ if (!this.authorize(i, 'drive'))
624
+ return;
625
+ await this.guarded(i, () => this.openCreateFolderModal(i));
626
+ return;
627
+ }
628
+ // The "Resume Session" button and the resume flow's own selects (resume.*) drive a
629
+ // separate resume state machine. Deferred-update first (listResumable/resume can
630
+ // exceed 3s), then routed to the flow.
631
+ if (i.customId === 'dir:resume' || i.customId.startsWith('resume.')) {
632
+ if (!this.authorize(i, 'drive'))
633
+ return;
634
+ if (!(await this.ackDeferUpdate(i)))
635
+ return;
636
+ if (!i.guildId)
637
+ return;
638
+ await this.guarded(i, () => this.handleResumeComponent(i));
639
+ return;
640
+ }
641
+ // Otherwise it is a wizard component (folder/backend/model/perm/confirm/cancel).
642
+ // The wizard flow is a drive action; only the driver who opened it advances it.
643
+ if (!this.authorize(i, 'drive'))
644
+ return;
645
+ // Acknowledge FIRST: the confirm step calls orchestrator.start (spawns an agent),
646
+ // which can exceed 3s β€” deferUpdate now, do the work after.
647
+ if (!(await this.ackDeferUpdate(i)))
648
+ return;
649
+ if (!i.guildId)
650
+ return;
651
+ await this.guarded(i, async () => {
652
+ const wizard = this.wizards.get(channelKey(i.guildId, i.channelId));
653
+ // Enforce wizard ownership: a component from anyone other than the driver who
654
+ // opened the wizard is acknowledged but ignored, so a bystander's stray select
655
+ // cannot corrupt another driver's flow (Β§7.1).
656
+ if (!wizard || wizard.ownerId !== i.user.id)
657
+ return;
658
+ const input = { id: i.customId, ...(i.value !== undefined ? { value: i.value } : {}) };
659
+ const step = await wizard.handle(input);
660
+ if (step === 'done' || step === 'cancelled') {
661
+ this.wizards.delete(channelKey(i.guildId, i.channelId));
662
+ }
663
+ // On a successful confirm the session was bound to a freshly created channel;
664
+ // link it back to the driver (A4D-style "session started in <#newChannel>").
665
+ if (step === 'done') {
666
+ const newChannelId = wizard.sessionChannelId();
667
+ if (newChannelId) {
668
+ await safe(i.editReply({ content: t('cmd.start.channelCreated', { channel: `<#${newChannelId}>` }) }));
669
+ }
670
+ return;
671
+ }
672
+ // Every non-terminal transition re-renders the CURRENT step (folder β†’ backend β†’
673
+ // model β†’ perm β†’ confirm) and edits it into the wizard's message, so each step's
674
+ // picker actually appears. A cancel renders the terminal notice with no rows. The
675
+ // component was deferUpdate'd above, so editReply updates that same message.
676
+ const { embed, rows } = wizard.render();
677
+ await safe(i.editReply({ embeds: [embed], components: rows }));
678
+ });
679
+ }
680
+ // ---- πŸ“ Create folder --------------------------------------------------
681
+ // Open the create-folder modal for the active wizard's folder step. Owner-bound: a
682
+ // bystander (or a stale button with no wizard) is acknowledged (deferUpdate) and
683
+ // ignored, so it never shows "did not respond". showModal is the ack for the owner's
684
+ // click β€” the modal-submit interaction (handleCreateFolderModal) does the mkdir.
685
+ async openCreateFolderModal(i) {
686
+ const wizard = i.guildId ? this.wizards.get(channelKey(i.guildId, i.channelId)) : undefined;
687
+ if (!wizard || wizard.ownerId !== i.user.id) {
688
+ await safe(i.deferUpdate());
689
+ return;
690
+ }
691
+ await i.showModal({
692
+ customId: 'dir:create',
693
+ title: t('dir.create.title'),
694
+ fields: [
695
+ {
696
+ customId: 'name',
697
+ label: t('dir.create.label'),
698
+ placeholder: t('dir.create.placeholder'),
699
+ required: true,
700
+ },
701
+ ],
702
+ });
703
+ }
704
+ // Handle the create-folder modal submit: validate the name, create it as a DIRECT
705
+ // subfolder of the wizard's CURRENT browsed directory, and re-render the folder step
706
+ // (so the new folder appears). The name must be a single path segment β€” reject '/'
707
+ // or '\\', '.'/'..' traversal, and any absolute path β€” so a crafted name can only
708
+ // ever create a direct child of the browsed dir, never escape it. Owner-bound.
709
+ async handleCreateFolderModal(i) {
710
+ if (!i.guildId) {
711
+ await safe(i.reply({ content: t('cmd.error.generic'), ephemeral: true }));
712
+ return;
713
+ }
714
+ const wizard = this.wizards.get(channelKey(i.guildId, i.channelId));
715
+ if (!wizard || wizard.ownerId !== i.user.id) {
716
+ await safe(i.reply({ content: t('cmd.error.generic'), ephemeral: true }));
717
+ return;
718
+ }
719
+ const name = i.getField('name').trim();
720
+ if (!isSafeFolderName(name)) {
721
+ await safe(i.reply({ content: t('dir.create.invalid'), ephemeral: true }));
722
+ return;
723
+ }
724
+ // Confine to a DIRECT child of the browsed dir: resolve and verify the parent is
725
+ // exactly the browsed dir (defense in depth beyond the name check above).
726
+ const parent = wizard.browserCwd();
727
+ const target = path.join(parent, name);
728
+ if (path.dirname(target) !== parent) {
729
+ await safe(i.reply({ content: t('dir.create.invalid'), ephemeral: true }));
730
+ return;
731
+ }
732
+ try {
733
+ fs.mkdirSync(target, { recursive: true });
734
+ }
735
+ catch (err) {
736
+ await safe(i.reply({ content: t('dir.create.failed', { error: String(err) }), ephemeral: true }));
737
+ return;
738
+ }
739
+ // Re-render the folder step (the browser re-lists children on render, so the new
740
+ // folder shows) and confirm the creation ephemerally. The modal submit is its own
741
+ // interaction, so we reply to it directly.
742
+ const { embed, rows } = wizard.render();
743
+ await safe(i.reply({ content: t('dir.create.done', { name }), embeds: [embed], components: rows, ephemeral: true }));
744
+ }
745
+ // ---- Resume Session flow -----------------------------------------------
746
+ // Route a resume-flow component: the "Resume Session" button starts a new flow (from
747
+ // the active wizard's browsed folder); the resume.* selects/buttons advance it. The
748
+ // interaction is already deferUpdate'd by the caller. Owner-bound to the wizard's
749
+ // driver so a bystander cannot hijack the resume of another driver's folder pick.
750
+ async handleResumeComponent(i) {
751
+ const guildId = i.guildId;
752
+ const key = channelKey(guildId, i.channelId);
753
+ if (i.customId === 'dir:resume') {
754
+ const wizard = this.wizards.get(key);
755
+ if (!wizard || wizard.ownerId !== i.user.id)
756
+ return; // owner-bound; ignore strays
757
+ const flow = this.buildResumeWizard(guildId, i.channelId, i.user.id, wizard.browserCwd());
758
+ this.resumeFlows.set(key, flow);
759
+ const { embed, rows } = flow.render();
760
+ await safe(i.editReply({ embeds: [embed], components: rows }));
761
+ return;
762
+ }
763
+ // A resume.* select/button for an existing flow.
764
+ const flow = this.resumeFlows.get(key);
765
+ if (!flow || flow.ownerId !== i.user.id)
766
+ return;
767
+ const step = await flow.handle({ id: i.customId, ...(i.value !== undefined ? { value: i.value } : {}) });
768
+ if (step === 'done' || step === 'cancelled' || step === 'empty') {
769
+ this.resumeFlows.delete(key);
770
+ }
771
+ if (step === 'done') {
772
+ const newChannelId = flow.sessionChannelId();
773
+ if (newChannelId) {
774
+ await safe(i.editReply({ content: t('resume.done', { channel: `<#${newChannelId}>` }), embeds: [], components: [] }));
775
+ }
776
+ return;
777
+ }
778
+ if (step === 'empty') {
779
+ // No resumable sessions for the picked backend: ephemeral notice, flow ends.
780
+ await safe(i.editReply({ content: t('resume.none'), embeds: [], components: [] }));
781
+ return;
782
+ }
783
+ const { embed, rows } = flow.render();
784
+ await safe(i.editReply({ embeds: [embed], components: rows }));
785
+ }
786
+ // Build a ResumeWizard bound to the driver + the folder in view. listResumableFor
787
+ // dispatches to the chosen backend's mode.listResumable (Claude via listSessions,
788
+ // Codex via CodexDiscovery); resume creates/binds a session channel and calls
789
+ // orchestrator.resume there (mirroring the start flow's channel creation).
790
+ buildResumeWizard(guildId, channelId, ownerId, cwd) {
791
+ const resolved = this.deps.configResolver.resolve(guildId, channelId);
792
+ return new ResumeWizard({
793
+ guildId,
794
+ channelId,
795
+ ownerId,
796
+ cwd,
797
+ backends: this.deps.modeRegistry.list(),
798
+ defaultBackend: resolved.mode,
799
+ listResumableFor: (backend, dir) => this.listResumableFor(backend, dir),
800
+ resume: (params) => this.resumeSession(params),
801
+ relativeTime,
802
+ });
803
+ }
804
+ // List resumable sessions for a backend, scoped to `cwd`, via the mode's optional
805
+ // listResumable. A mode without it (or a throw) yields [] so the picker shows the
806
+ // empty notice rather than failing. The ModeContext is a MINIMAL read-only context:
807
+ // listResumable only reads ctx.cwd/ctx.config/ctx.logger (never emits/starts).
808
+ async listResumableFor(backend, cwd) {
809
+ if (!this.deps.modeRegistry.has(backend))
810
+ return [];
811
+ const mode = this.deps.modeRegistry.get(backend);
812
+ if (!mode.listResumable)
813
+ return [];
814
+ try {
815
+ const ctx = this.deps.orchestrator.buildListContext(backend, cwd);
816
+ return await mode.listResumable(ctx);
817
+ }
818
+ catch (err) {
819
+ this.logError('listResumable failed', err);
820
+ return [];
821
+ }
822
+ }
823
+ // Resume a chosen session: create a dedicated session channel from the picked folder
824
+ // (like the start flow), resume the backend session bound to THAT channel via
825
+ // orchestrator.resume, wire renderers/permission/sendFile, and post a resumed-status
826
+ // embed. Returns the new channel id so the flow links it back to the driver.
827
+ async resumeSession(params) {
828
+ const startParams = {
829
+ guildId: params.guildId,
830
+ channelId: await this.resolveSessionChannelId({
831
+ guildId: params.guildId,
832
+ channelId: params.channelId,
833
+ mode: params.backend,
834
+ cwd: params.cwd,
835
+ ownerId: params.ownerId,
836
+ }),
837
+ mode: params.backend,
838
+ cwd: params.cwd,
839
+ ownerId: params.ownerId,
840
+ };
841
+ const session = await this.deps.orchestrator.resume(startParams, params.sessionId);
842
+ await this.deps.wiring.attach(startParams.guildId, startParams.channelId, startParams.mode);
843
+ await this.postResumeIntro(startParams.channelId, startParams, session);
844
+ return { session, channelId: startParams.channelId };
845
+ }
846
+ // Post the resumed-session status embed into the new channel (mirrors
847
+ // postSessionIntro but titled as a resume). Best-effort; never fails the resume.
848
+ async postResumeIntro(channelId, params, session) {
849
+ const resolve = this.deps.resolveChannel;
850
+ if (!resolve)
851
+ return;
852
+ try {
853
+ const channel = await resolve(channelId);
854
+ if (!channel)
855
+ return;
856
+ const usagePanel = this.deps.modeRegistry.has(params.mode)
857
+ ? this.deps.modeRegistry.get(params.mode).capabilities.usagePanel
858
+ : true;
859
+ const embed = buildStatusEmbed({
860
+ mode: params.mode,
861
+ cwd: params.cwd,
862
+ sessionId: session.sessionId,
863
+ permMode: params.permMode ?? 'default',
864
+ usagePanel,
865
+ });
866
+ await channel.send({ content: t('cmd.start.intro'), embeds: [{ ...embed, title: t('resume.status.title') }] });
867
+ }
868
+ catch (err) {
869
+ this.logError('failed to post resume intro', err);
870
+ }
871
+ }
872
+ // deferUpdate the component interaction (the first ack, keeping its message). Returns
873
+ // false when the ack itself failed (stale interaction) so the caller bails out.
874
+ async ackDeferUpdate(i) {
875
+ try {
876
+ await i.deferUpdate();
877
+ return true;
878
+ }
879
+ catch (err) {
880
+ this.logError('interaction deferUpdate failed', err);
881
+ return false;
882
+ }
883
+ }
884
+ // ---- Auth ---------------------------------------------------------------
885
+ // Authorize the interaction for an action; on denial send an ephemeral notice and
886
+ // return false. DMs (no guild) are rejected by the Authorizer's dmPolicy.
887
+ authorize(i, action) {
888
+ const roleIds = i.member ? i.member.roles.cache.map((r) => r.id) : [];
889
+ const decision = this.deps.authorizer.authorize({
890
+ userId: i.user.id,
891
+ roleIds,
892
+ action,
893
+ context: { ...(i.guildId ? { guildId: i.guildId } : {}), channelId: i.channelId },
894
+ // A Discord Administrator is granted the admin tier unconditionally (never
895
+ // locked out); the adapter populates hasAdminPermission from member permissions.
896
+ ...(i.hasAdminPermission === true ? { isAdministrator: true } : {}),
897
+ });
898
+ if (!decision.allowed) {
899
+ // Ephemeral denial. A slash interaction is already deferred (edit its reply); a
900
+ // component interaction is not yet acked (send a fresh ephemeral reply).
901
+ const payload = { content: t('auth.denied', { reason: decision.reason ?? '' }), ephemeral: true };
902
+ void safe(i.acknowledged ? i.editReply(payload) : i.reply(payload));
903
+ return false;
904
+ }
905
+ return true;
906
+ }
907
+ }
908
+ async function safe(p) {
909
+ try {
910
+ await p;
911
+ }
912
+ catch {
913
+ // best-effort
914
+ }
915
+ }
916
+ // True when `name` is a safe SINGLE folder segment for the πŸ“ Create flow: non-empty,
917
+ // no path separators ('/' or '\\'), not '.'/'..' traversal, and not absolute. This
918
+ // guarantees the created folder is a DIRECT child of the current browsed directory and
919
+ // can never escape it (the router additionally verifies dirname(target) === parent).
920
+ function isSafeFolderName(name) {
921
+ if (name.length === 0)
922
+ return false;
923
+ if (name === '.' || name === '..')
924
+ return false;
925
+ if (name.includes('/') || name.includes('\\'))
926
+ return false;
927
+ if (path.isAbsolute(name))
928
+ return false;
929
+ // A segment that path treats as anything other than itself (e.g. contains a NUL) is
930
+ // rejected; path.basename normalizes trailing separators, so require an exact match.
931
+ if (path.basename(name) !== name)
932
+ return false;
933
+ return true;
934
+ }
935
+ // Render an updatedAt ISO string as a short relative time for the resume picker
936
+ // (A4D-style "3λΆ„ μ „"). Absent/unparseable β†’ empty (the option just shows its label).
937
+ function relativeTime(updatedAt) {
938
+ if (!updatedAt)
939
+ return '';
940
+ const then = Date.parse(updatedAt);
941
+ if (Number.isNaN(then))
942
+ return '';
943
+ const seconds = Math.max(0, Math.floor((Date.now() - then) / 1000));
944
+ if (seconds < 60)
945
+ return t('resume.time.now');
946
+ const minutes = Math.floor(seconds / 60);
947
+ if (minutes < 60)
948
+ return t('resume.time.min', { n: minutes });
949
+ const hours = Math.floor(minutes / 60);
950
+ if (hours < 24)
951
+ return t('resume.time.hour', { n: hours });
952
+ return t('resume.time.day', { n: Math.floor(hours / 24) });
953
+ }
954
+ //# sourceMappingURL=interactionRouter.js.map