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.
- package/LICENSE +21 -0
- package/README.md +195 -0
- package/dist/app.js +210 -0
- package/dist/app.js.map +1 -0
- package/dist/app.test.js +117 -0
- package/dist/app.test.js.map +1 -0
- package/dist/cli.js +63 -0
- package/dist/cli.js.map +1 -0
- package/dist/cli.test.js +91 -0
- package/dist/cli.test.js.map +1 -0
- package/dist/core/auditLog.js +65 -0
- package/dist/core/auditLog.js.map +1 -0
- package/dist/core/auditLog.test.js +124 -0
- package/dist/core/auditLog.test.js.map +1 -0
- package/dist/core/auth.js +106 -0
- package/dist/core/auth.js.map +1 -0
- package/dist/core/auth.test.js +216 -0
- package/dist/core/auth.test.js.map +1 -0
- package/dist/core/channelRegistry.js +119 -0
- package/dist/core/channelRegistry.js.map +1 -0
- package/dist/core/channelRegistry.test.js +114 -0
- package/dist/core/channelRegistry.test.js.map +1 -0
- package/dist/core/commandPolicy.js +6 -0
- package/dist/core/commandPolicy.js.map +1 -0
- package/dist/core/commandRouter.js +9 -0
- package/dist/core/commandRouter.js.map +1 -0
- package/dist/core/config.js +141 -0
- package/dist/core/config.js.map +1 -0
- package/dist/core/config.test.js +149 -0
- package/dist/core/config.test.js.map +1 -0
- package/dist/core/configResolver.js +90 -0
- package/dist/core/configResolver.js.map +1 -0
- package/dist/core/configResolver.test.js +138 -0
- package/dist/core/configResolver.test.js.map +1 -0
- package/dist/core/configSchema.js +164 -0
- package/dist/core/configSchema.js.map +1 -0
- package/dist/core/contracts.js +5 -0
- package/dist/core/contracts.js.map +1 -0
- package/dist/core/eventBus.js +42 -0
- package/dist/core/eventBus.js.map +1 -0
- package/dist/core/eventBus.test.js +72 -0
- package/dist/core/eventBus.test.js.map +1 -0
- package/dist/core/hookBridge.js +11 -0
- package/dist/core/hookBridge.js.map +1 -0
- package/dist/core/logger.js +116 -0
- package/dist/core/logger.js.map +1 -0
- package/dist/core/logger.test.js +88 -0
- package/dist/core/logger.test.js.map +1 -0
- package/dist/core/modeRegistry.js +33 -0
- package/dist/core/modeRegistry.js.map +1 -0
- package/dist/core/permissionResolver.js +43 -0
- package/dist/core/permissionResolver.js.map +1 -0
- package/dist/core/permissionResolver.test.js +136 -0
- package/dist/core/permissionResolver.test.js.map +1 -0
- package/dist/core/providerCatalog.js +240 -0
- package/dist/core/providerCatalog.js.map +1 -0
- package/dist/core/providerCatalog.test.js +230 -0
- package/dist/core/providerCatalog.test.js.map +1 -0
- package/dist/core/sessionOrchestrator.js +463 -0
- package/dist/core/sessionOrchestrator.js.map +1 -0
- package/dist/core/sessionOrchestrator.test.js +344 -0
- package/dist/core/sessionOrchestrator.test.js.map +1 -0
- package/dist/core/state/schema.js +49 -0
- package/dist/core/state/schema.js.map +1 -0
- package/dist/core/state/store.js +86 -0
- package/dist/core/state/store.js.map +1 -0
- package/dist/core/state/store.test.js +99 -0
- package/dist/core/state/store.test.js.map +1 -0
- package/dist/core/usageService.js +310 -0
- package/dist/core/usageService.js.map +1 -0
- package/dist/core/usageService.test.js +234 -0
- package/dist/core/usageService.test.js.map +1 -0
- package/dist/discord/alwaysAllow.test.js +181 -0
- package/dist/discord/alwaysAllow.test.js.map +1 -0
- package/dist/discord/client.js +666 -0
- package/dist/discord/client.js.map +1 -0
- package/dist/discord/client.test.js +256 -0
- package/dist/discord/client.test.js.map +1 -0
- package/dist/discord/configPanel.js +270 -0
- package/dist/discord/configPanel.js.map +1 -0
- package/dist/discord/configPanel.test.js +332 -0
- package/dist/discord/configPanel.test.js.map +1 -0
- package/dist/discord/directoryBrowser.js +151 -0
- package/dist/discord/directoryBrowser.js.map +1 -0
- package/dist/discord/directoryBrowser.test.js +163 -0
- package/dist/discord/directoryBrowser.test.js.map +1 -0
- package/dist/discord/favorites.js +10 -0
- package/dist/discord/favorites.js.map +1 -0
- package/dist/discord/fileDownload.js +94 -0
- package/dist/discord/fileDownload.js.map +1 -0
- package/dist/discord/fileDownload.test.js +59 -0
- package/dist/discord/fileDownload.test.js.map +1 -0
- package/dist/discord/format.js +104 -0
- package/dist/discord/format.js.map +1 -0
- package/dist/discord/format.test.js +40 -0
- package/dist/discord/format.test.js.map +1 -0
- package/dist/discord/guildChannels.js +97 -0
- package/dist/discord/guildChannels.js.map +1 -0
- package/dist/discord/guildChannels.test.js +231 -0
- package/dist/discord/guildChannels.test.js.map +1 -0
- package/dist/discord/i18n.js +207 -0
- package/dist/discord/i18n.js.map +1 -0
- package/dist/discord/i18n.test.js +31 -0
- package/dist/discord/i18n.test.js.map +1 -0
- package/dist/discord/interactionRouter.js +954 -0
- package/dist/discord/interactionRouter.js.map +1 -0
- package/dist/discord/interactionRouter.test.js +1245 -0
- package/dist/discord/interactionRouter.test.js.map +1 -0
- package/dist/discord/messageRouter.js +221 -0
- package/dist/discord/messageRouter.js.map +1 -0
- package/dist/discord/messageRouter.test.js +360 -0
- package/dist/discord/messageRouter.test.js.map +1 -0
- package/dist/discord/ports.js +12 -0
- package/dist/discord/ports.js.map +1 -0
- package/dist/discord/renderers/defaultSet.test.js +97 -0
- package/dist/discord/renderers/defaultSet.test.js.map +1 -0
- package/dist/discord/renderers/diffView.js +73 -0
- package/dist/discord/renderers/diffView.js.map +1 -0
- package/dist/discord/renderers/diffView.test.js +54 -0
- package/dist/discord/renderers/diffView.test.js.map +1 -0
- package/dist/discord/renderers/index.js +143 -0
- package/dist/discord/renderers/index.js.map +1 -0
- package/dist/discord/renderers/index.test.js +124 -0
- package/dist/discord/renderers/index.test.js.map +1 -0
- package/dist/discord/renderers/mentionOnComplete.js +19 -0
- package/dist/discord/renderers/mentionOnComplete.js.map +1 -0
- package/dist/discord/renderers/mentionOnComplete.test.js +35 -0
- package/dist/discord/renderers/mentionOnComplete.test.js.map +1 -0
- package/dist/discord/renderers/permissionButtons.js +150 -0
- package/dist/discord/renderers/permissionButtons.js.map +1 -0
- package/dist/discord/renderers/permissionButtons.test.js +108 -0
- package/dist/discord/renderers/permissionButtons.test.js.map +1 -0
- package/dist/discord/renderers/resultLine.js +25 -0
- package/dist/discord/renderers/resultLine.js.map +1 -0
- package/dist/discord/renderers/statusEmbed.js +20 -0
- package/dist/discord/renderers/statusEmbed.js.map +1 -0
- package/dist/discord/renderers/statusEmbed.test.js +32 -0
- package/dist/discord/renderers/statusEmbed.test.js.map +1 -0
- package/dist/discord/renderers/streamEmbed.js +134 -0
- package/dist/discord/renderers/streamEmbed.js.map +1 -0
- package/dist/discord/renderers/streamEmbed.test.js +97 -0
- package/dist/discord/renderers/streamEmbed.test.js.map +1 -0
- package/dist/discord/renderers/toolThread.js +65 -0
- package/dist/discord/renderers/toolThread.js.map +1 -0
- package/dist/discord/renderers/toolThread.test.js +54 -0
- package/dist/discord/renderers/toolThread.test.js.map +1 -0
- package/dist/discord/renderers/transcriptFeed.js +33 -0
- package/dist/discord/renderers/transcriptFeed.js.map +1 -0
- package/dist/discord/renderers/usageEmbed.js +78 -0
- package/dist/discord/renderers/usageEmbed.js.map +1 -0
- package/dist/discord/renderers/usageEmbed.test.js +42 -0
- package/dist/discord/renderers/usageEmbed.test.js.map +1 -0
- package/dist/discord/wiring.js +214 -0
- package/dist/discord/wiring.js.map +1 -0
- package/dist/discord/wiring.test.js +252 -0
- package/dist/discord/wiring.test.js.map +1 -0
- package/dist/discord/wizard/channelWizard.js +305 -0
- package/dist/discord/wizard/channelWizard.js.map +1 -0
- package/dist/discord/wizard/channelWizard.test.js +298 -0
- package/dist/discord/wizard/channelWizard.test.js.map +1 -0
- package/dist/discord/wizard/resumeWizard.js +123 -0
- package/dist/discord/wizard/resumeWizard.js.map +1 -0
- package/dist/modes/claude/index.js +80 -0
- package/dist/modes/claude/index.js.map +1 -0
- package/dist/modes/claude/listResumable.test.js +72 -0
- package/dist/modes/claude/listResumable.test.js.map +1 -0
- package/dist/modes/claude/mcpFileTool.js +82 -0
- package/dist/modes/claude/mcpFileTool.js.map +1 -0
- package/dist/modes/claude/permissions.js +35 -0
- package/dist/modes/claude/permissions.js.map +1 -0
- package/dist/modes/claude/plugins.js +56 -0
- package/dist/modes/claude/plugins.js.map +1 -0
- package/dist/modes/claude/session.js +324 -0
- package/dist/modes/claude/session.js.map +1 -0
- package/dist/modes/claude/session.test.js +415 -0
- package/dist/modes/claude/session.test.js.map +1 -0
- package/dist/modes/codex/discovery.js +221 -0
- package/dist/modes/codex/discovery.js.map +1 -0
- package/dist/modes/codex/discovery.test.js +213 -0
- package/dist/modes/codex/discovery.test.js.map +1 -0
- package/dist/modes/codex/eventMapper.js +262 -0
- package/dist/modes/codex/eventMapper.js.map +1 -0
- package/dist/modes/codex/index.js +132 -0
- package/dist/modes/codex/index.js.map +1 -0
- package/dist/modes/codex/index.test.js +170 -0
- package/dist/modes/codex/index.test.js.map +1 -0
- package/dist/modes/codex/runner.js +207 -0
- package/dist/modes/codex/runner.js.map +1 -0
- package/dist/modes/codex/runner.test.js +587 -0
- package/dist/modes/codex/runner.test.js.map +1 -0
- package/dist/modes/codex/sqliteReader.js +171 -0
- package/dist/modes/codex/sqliteReader.js.map +1 -0
- package/dist/modes/codex/sqliteReader.test.js +137 -0
- package/dist/modes/codex/sqliteReader.test.js.map +1 -0
- package/dist/setup/wizard.js +115 -0
- package/dist/setup/wizard.js.map +1 -0
- package/dist/setup/wizard.test.js +216 -0
- package/dist/setup/wizard.test.js.map +1 -0
- package/dist/startBot.test.js +78 -0
- package/dist/startBot.test.js.map +1 -0
- 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
|