kimaki 0.4.76 → 0.4.77
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/dist/adapter-rest-boundary.test.js +34 -0
- package/dist/agent-model.e2e.test.js +2 -20
- package/dist/cli.js +23 -13
- package/dist/commands/channel-ref.js +16 -0
- package/dist/commands/merge-worktree.js +5 -17
- package/dist/commands/new-worktree.js +5 -9
- package/dist/commands/permissions.js +77 -11
- package/dist/commands/resume.js +5 -9
- package/dist/commands/session.js +6 -17
- package/dist/discord-bot.js +18 -13
- package/dist/discord-js-import-boundary.test.js +62 -0
- package/dist/discord-utils.js +44 -0
- package/dist/event-stream-real-capture.e2e.test.js +2 -20
- package/dist/gateway-proxy.e2e.test.js +2 -5
- package/dist/generated/cloudflare/browser.js +17 -0
- package/dist/generated/cloudflare/client.js +34 -0
- package/dist/generated/cloudflare/commonInputTypes.js +10 -0
- package/dist/generated/cloudflare/enums.js +48 -0
- package/dist/generated/cloudflare/internal/class.js +47 -0
- package/dist/generated/cloudflare/internal/prismaNamespace.js +252 -0
- package/dist/generated/cloudflare/internal/prismaNamespaceBrowser.js +222 -0
- package/dist/generated/cloudflare/internal/query_compiler_fast_bg.js +135 -0
- package/dist/generated/cloudflare/models/bot_api_keys.js +1 -0
- package/dist/generated/cloudflare/models/bot_tokens.js +1 -0
- package/dist/generated/cloudflare/models/channel_agents.js +1 -0
- package/dist/generated/cloudflare/models/channel_directories.js +1 -0
- package/dist/generated/cloudflare/models/channel_mention_mode.js +1 -0
- package/dist/generated/cloudflare/models/channel_models.js +1 -0
- package/dist/generated/cloudflare/models/channel_verbosity.js +1 -0
- package/dist/generated/cloudflare/models/channel_worktrees.js +1 -0
- package/dist/generated/cloudflare/models/forum_sync_configs.js +1 -0
- package/dist/generated/cloudflare/models/global_models.js +1 -0
- package/dist/generated/cloudflare/models/ipc_requests.js +1 -0
- package/dist/generated/cloudflare/models/part_messages.js +1 -0
- package/dist/generated/cloudflare/models/scheduled_tasks.js +1 -0
- package/dist/generated/cloudflare/models/session_agents.js +1 -0
- package/dist/generated/cloudflare/models/session_events.js +1 -0
- package/dist/generated/cloudflare/models/session_models.js +1 -0
- package/dist/generated/cloudflare/models/session_start_sources.js +1 -0
- package/dist/generated/cloudflare/models/thread_sessions.js +1 -0
- package/dist/generated/cloudflare/models/thread_worktrees.js +1 -0
- package/dist/generated/cloudflare/models.js +1 -0
- package/dist/generated/node/browser.js +17 -0
- package/dist/generated/node/client.js +37 -0
- package/dist/generated/node/commonInputTypes.js +10 -0
- package/dist/generated/node/enums.js +48 -0
- package/dist/generated/node/internal/class.js +49 -0
- package/dist/generated/node/internal/prismaNamespace.js +252 -0
- package/dist/generated/node/internal/prismaNamespaceBrowser.js +222 -0
- package/dist/generated/node/models/bot_api_keys.js +1 -0
- package/dist/generated/node/models/bot_tokens.js +1 -0
- package/dist/generated/node/models/channel_agents.js +1 -0
- package/dist/generated/node/models/channel_directories.js +1 -0
- package/dist/generated/node/models/channel_mention_mode.js +1 -0
- package/dist/generated/node/models/channel_models.js +1 -0
- package/dist/generated/node/models/channel_verbosity.js +1 -0
- package/dist/generated/node/models/channel_worktrees.js +1 -0
- package/dist/generated/node/models/forum_sync_configs.js +1 -0
- package/dist/generated/node/models/global_models.js +1 -0
- package/dist/generated/node/models/ipc_requests.js +1 -0
- package/dist/generated/node/models/part_messages.js +1 -0
- package/dist/generated/node/models/scheduled_tasks.js +1 -0
- package/dist/generated/node/models/session_agents.js +1 -0
- package/dist/generated/node/models/session_events.js +1 -0
- package/dist/generated/node/models/session_models.js +1 -0
- package/dist/generated/node/models/session_start_sources.js +1 -0
- package/dist/generated/node/models/thread_sessions.js +1 -0
- package/dist/generated/node/models/thread_worktrees.js +1 -0
- package/dist/generated/node/models.js +1 -0
- package/dist/kimaki-digital-twin.e2e.test.js +2 -20
- package/dist/message-flags-boundary.test.js +54 -0
- package/dist/opencode-command.js +129 -0
- package/dist/opencode-command.test.js +48 -0
- package/dist/opencode-interrupt-plugin.js +19 -1
- package/dist/opencode-interrupt-plugin.test.js +0 -5
- package/dist/opencode-plugin-loading.e2e.test.js +9 -20
- package/dist/opencode.js +150 -27
- package/dist/platform/components-v2.js +20 -0
- package/dist/platform/discord-adapter.js +1440 -0
- package/dist/platform/discord-routes.js +31 -0
- package/dist/platform/message-flags.js +8 -0
- package/dist/platform/platform-value.js +41 -0
- package/dist/platform/slack-adapter.js +872 -0
- package/dist/platform/slack-markdown.js +169 -0
- package/dist/platform/types.js +4 -0
- package/dist/queue-advanced-e2e-setup.js +265 -0
- package/dist/queue-advanced-footer.e2e.test.js +173 -0
- package/dist/queue-advanced-model-switch.e2e.test.js +299 -0
- package/dist/queue-advanced-permissions-typing.e2e.test.js +73 -1
- package/dist/runtime-lifecycle.e2e.test.js +2 -20
- package/dist/session-handler/event-stream-state.js +5 -0
- package/dist/session-handler/event-stream-state.test.js +6 -2
- package/dist/session-handler/thread-session-runtime.js +31 -1
- package/dist/system-message.js +26 -23
- package/dist/test-utils.js +16 -0
- package/dist/thread-message-queue.e2e.test.js +2 -20
- package/dist/utils.js +3 -1
- package/dist/voice-message.e2e.test.js +2 -20
- package/dist/voice.js +122 -9
- package/dist/voice.test.js +17 -2
- package/dist/worktree-lifecycle.e2e.test.js +308 -0
- package/package.json +4 -4
- package/skills/critique/SKILL.md +17 -0
- package/skills/egaki/SKILL.md +35 -0
- package/skills/event-sourcing-state/SKILL.md +98 -0
- package/skills/goke/SKILL.md +1 -0
- package/skills/npm-package/SKILL.md +21 -2
- package/skills/x-articles/SKILL.md +554 -0
- package/src/agent-model.e2e.test.ts +4 -19
- package/src/cli.ts +25 -13
- package/src/commands/merge-worktree.ts +5 -21
- package/src/commands/new-worktree.ts +5 -11
- package/src/commands/permissions.ts +100 -15
- package/src/commands/resume.ts +5 -12
- package/src/commands/session.ts +6 -23
- package/src/discord-bot.ts +19 -14
- package/src/discord-utils.ts +53 -0
- package/src/event-stream-real-capture.e2e.test.ts +4 -20
- package/src/gateway-proxy.e2e.test.ts +2 -5
- package/src/kimaki-digital-twin.e2e.test.ts +2 -21
- package/src/opencode-command.test.ts +70 -0
- package/src/opencode-command.ts +188 -0
- package/src/opencode-interrupt-plugin.test.ts +0 -5
- package/src/opencode-interrupt-plugin.ts +34 -1
- package/src/opencode-plugin-loading.e2e.test.ts +25 -35
- package/src/opencode.ts +199 -32
- package/src/queue-advanced-e2e-setup.ts +273 -0
- package/src/queue-advanced-footer.e2e.test.ts +211 -0
- package/src/queue-advanced-model-switch.e2e.test.ts +383 -0
- package/src/queue-advanced-permissions-typing.e2e.test.ts +92 -0
- package/src/runtime-lifecycle.e2e.test.ts +4 -19
- package/src/session-handler/event-stream-state.test.ts +6 -2
- package/src/session-handler/event-stream-state.ts +5 -0
- package/src/session-handler/thread-session-runtime.ts +43 -1
- package/src/system-message.ts +26 -23
- package/src/test-utils.ts +17 -0
- package/src/thread-message-queue.e2e.test.ts +2 -20
- package/src/utils.ts +3 -1
- package/src/voice-message.e2e.test.ts +3 -20
- package/src/voice.test.ts +26 -2
- package/src/voice.ts +147 -9
- package/src/worktree-lifecycle.e2e.test.ts +391 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Guardrail test to keep REST helpers out of adapter-consumer runtime files.
|
|
2
|
+
// CLI/task-runner/discord-utils must use platform adapter interfaces instead of
|
|
3
|
+
// direct createDiscordRest/discordRoutes/discordApiUrl imports.
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { describe, expect, test } from 'vitest';
|
|
7
|
+
const TARGET_FILES = [
|
|
8
|
+
'src/cli.ts',
|
|
9
|
+
'src/task-runner.ts',
|
|
10
|
+
'src/discord-utils.ts',
|
|
11
|
+
];
|
|
12
|
+
const PROJECT_ROOT = path.resolve(import.meta.dirname, '..');
|
|
13
|
+
describe('adapter rest boundary', () => {
|
|
14
|
+
test('forbids direct REST helper imports in migrated files', () => {
|
|
15
|
+
const violations = TARGET_FILES.flatMap((relativePath) => {
|
|
16
|
+
const filePath = path.join(PROJECT_ROOT, relativePath);
|
|
17
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
18
|
+
const matches = [
|
|
19
|
+
/\bcreateDiscordRest\b/.test(content)
|
|
20
|
+
? 'createDiscordRest'
|
|
21
|
+
: null,
|
|
22
|
+
/\bdiscordRoutes\b/.test(content) ? 'discordRoutes' : null,
|
|
23
|
+
/\bdiscordApiUrl\b/.test(content) ? 'discordApiUrl' : null,
|
|
24
|
+
].filter((match) => {
|
|
25
|
+
return Boolean(match);
|
|
26
|
+
});
|
|
27
|
+
if (matches.length === 0) {
|
|
28
|
+
return [];
|
|
29
|
+
}
|
|
30
|
+
return [`${relativePath}: ${matches.join(', ')}`];
|
|
31
|
+
});
|
|
32
|
+
expect(violations).toMatchInlineSnapshot(`[]`);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
@@ -10,7 +10,6 @@
|
|
|
10
10
|
// Uses opencode-deterministic-provider (no real LLM calls).
|
|
11
11
|
// Poll timeouts: 4s max, 100ms interval.
|
|
12
12
|
import fs from 'node:fs';
|
|
13
|
-
import net from 'node:net';
|
|
14
13
|
import path from 'node:path';
|
|
15
14
|
import url from 'node:url';
|
|
16
15
|
import { describe, beforeAll, afterAll, test, expect, } from 'vitest';
|
|
@@ -24,7 +23,7 @@ import { setBotToken, initDatabase, closeDatabase, setChannelDirectory, setChann
|
|
|
24
23
|
import { getPrisma } from './db.js';
|
|
25
24
|
import { startHranaServer, stopHranaServer } from './hrana-server.js';
|
|
26
25
|
import { initializeOpencodeForDirectory, stopOpencodeServer } from './opencode.js';
|
|
27
|
-
import { cleanupTestSessions, waitForBotMessageContaining, waitForFooterMessage, } from './test-utils.js';
|
|
26
|
+
import { chooseLockPort, cleanupTestSessions, waitForBotMessageContaining, waitForFooterMessage, } from './test-utils.js';
|
|
28
27
|
import { buildQuickAgentCommandDescription } from './commands/agent.js';
|
|
29
28
|
const TEST_USER_ID = '200000000000000920';
|
|
30
29
|
const TEXT_CHANNEL_ID = '200000000000000921';
|
|
@@ -41,23 +40,6 @@ function createRunDirectories() {
|
|
|
41
40
|
fs.mkdirSync(projectDirectory, { recursive: true });
|
|
42
41
|
return { root, dataDir, projectDirectory };
|
|
43
42
|
}
|
|
44
|
-
function chooseLockPort() {
|
|
45
|
-
return new Promise((resolve, reject) => {
|
|
46
|
-
const server = net.createServer();
|
|
47
|
-
server.listen(0, () => {
|
|
48
|
-
const address = server.address();
|
|
49
|
-
if (!address || typeof address === 'string') {
|
|
50
|
-
server.close();
|
|
51
|
-
reject(new Error('Failed to resolve lock port'));
|
|
52
|
-
return;
|
|
53
|
-
}
|
|
54
|
-
const port = address.port;
|
|
55
|
-
server.close(() => {
|
|
56
|
-
resolve(port);
|
|
57
|
-
});
|
|
58
|
-
});
|
|
59
|
-
});
|
|
60
|
-
}
|
|
61
43
|
function createDiscordJsClient({ restUrl }) {
|
|
62
44
|
return new Client({
|
|
63
45
|
intents: [
|
|
@@ -158,7 +140,7 @@ describe('agent model resolution', () => {
|
|
|
158
140
|
beforeAll(async () => {
|
|
159
141
|
testStartTime = Date.now();
|
|
160
142
|
directories = createRunDirectories();
|
|
161
|
-
const lockPort =
|
|
143
|
+
const lockPort = chooseLockPort({ key: TEXT_CHANNEL_ID });
|
|
162
144
|
process.env['KIMAKI_LOCK_PORT'] = String(lockPort);
|
|
163
145
|
setDataDir(directories.dataDir);
|
|
164
146
|
previousDefaultVerbosity = store.getState().defaultVerbosity;
|
package/dist/cli.js
CHANGED
|
@@ -13,6 +13,7 @@ import { formatWorktreeName } from './commands/new-worktree.js';
|
|
|
13
13
|
import { WORKTREE_PREFIX } from './commands/merge-worktree.js';
|
|
14
14
|
import { sendWelcomeMessage } from './onboarding-welcome.js';
|
|
15
15
|
import { buildOpencodeEventLogLine } from './session-handler/opencode-session-event-log.js';
|
|
16
|
+
import { selectResolvedCommand } from './opencode-command.js';
|
|
16
17
|
import yaml from 'js-yaml';
|
|
17
18
|
import { Events, ChannelType, ActivityType, Routes, SlashCommandBuilder, AttachmentBuilder, } from 'discord.js';
|
|
18
19
|
import { createDiscordRest, discordApiUrl, getDiscordRestApiUrl, getGatewayProxyRestBaseUrl } from './discord-urls.js';
|
|
@@ -342,7 +343,11 @@ async function ensureCommandAvailable({ name, envPathKey, installUnix, installWi
|
|
|
342
343
|
const foundInPath = await execAsync(`${whichCmd} ${name}`, {
|
|
343
344
|
env: process.env,
|
|
344
345
|
}).then((result) => {
|
|
345
|
-
|
|
346
|
+
const resolved = selectResolvedCommand({
|
|
347
|
+
output: result.stdout,
|
|
348
|
+
isWindows,
|
|
349
|
+
});
|
|
350
|
+
return resolved || '';
|
|
346
351
|
}, () => {
|
|
347
352
|
return '';
|
|
348
353
|
});
|
|
@@ -1173,7 +1178,7 @@ async function resolveCredentials({ forceRestartOnboarding, forceGateway, gatewa
|
|
|
1173
1178
|
emitJsonEvent({ type: 'install_url', url: oauthUrl });
|
|
1174
1179
|
}
|
|
1175
1180
|
// Poll until the user installs the bot in a Discord server.
|
|
1176
|
-
//
|
|
1181
|
+
// 100 attempts x 3s = 5 minutes timeout.
|
|
1177
1182
|
const s = isInteractive ? spinner() : undefined;
|
|
1178
1183
|
s?.start('Waiting for a Discord server with the bot installed...');
|
|
1179
1184
|
const pollUrl = new URL('/api/onboarding/status', KIMAKI_WEBSITE_URL);
|
|
@@ -1181,9 +1186,9 @@ async function resolveCredentials({ forceRestartOnboarding, forceGateway, gatewa
|
|
|
1181
1186
|
pollUrl.searchParams.set('secret', clientSecret);
|
|
1182
1187
|
let guildId;
|
|
1183
1188
|
let installerDiscordUserId;
|
|
1184
|
-
for (let attempt = 0; attempt <
|
|
1189
|
+
for (let attempt = 0; attempt < 100; attempt++) {
|
|
1185
1190
|
await new Promise((resolve) => {
|
|
1186
|
-
setTimeout(resolve,
|
|
1191
|
+
setTimeout(resolve, 3000);
|
|
1187
1192
|
});
|
|
1188
1193
|
// Progressive hints for interactive users who may be stuck
|
|
1189
1194
|
if (isInteractive) {
|
|
@@ -1217,9 +1222,9 @@ async function resolveCredentials({ forceRestartOnboarding, forceGateway, gatewa
|
|
|
1217
1222
|
s?.stop('Authorization timed out');
|
|
1218
1223
|
}
|
|
1219
1224
|
else {
|
|
1220
|
-
emitJsonEvent({ type: 'error', message: 'Authorization timed out after
|
|
1225
|
+
emitJsonEvent({ type: 'error', message: 'Authorization timed out after 5 minutes' });
|
|
1221
1226
|
}
|
|
1222
|
-
cliLogger.error('Bot authorization timed out after
|
|
1227
|
+
cliLogger.error('Bot authorization timed out after 5 minutes. Please try again.');
|
|
1223
1228
|
process.exit(EXIT_NO_RESTART);
|
|
1224
1229
|
}
|
|
1225
1230
|
if (isInteractive) {
|
|
@@ -1507,13 +1512,18 @@ async function run({ restartOnboarding, addChannels, useWorktrees, enableVoiceCh
|
|
|
1507
1512
|
}
|
|
1508
1513
|
// Create default kimaki channel + welcome message in each guild.
|
|
1509
1514
|
// Runs after channel sync so existing channels are detected correctly.
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1515
|
+
try {
|
|
1516
|
+
await ensureDefaultChannelsWithWelcome({
|
|
1517
|
+
guilds,
|
|
1518
|
+
discordClient,
|
|
1519
|
+
appId,
|
|
1520
|
+
isGatewayMode,
|
|
1521
|
+
installerDiscordUserId,
|
|
1522
|
+
});
|
|
1523
|
+
}
|
|
1524
|
+
catch (error) {
|
|
1525
|
+
cliLogger.warn('Background default channel creation failed:', error instanceof Error ? error.message : String(error));
|
|
1526
|
+
}
|
|
1517
1527
|
})();
|
|
1518
1528
|
// Background: OpenCode init + slash command registration (non-blocking)
|
|
1519
1529
|
void backgroundInit({
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Helpers for working with normalized platform channel references in commands.
|
|
2
|
+
export function isThreadChannel(channel) {
|
|
3
|
+
return channel?.kind === 'thread';
|
|
4
|
+
}
|
|
5
|
+
export function isTextChannel(channel) {
|
|
6
|
+
return channel?.kind === 'text';
|
|
7
|
+
}
|
|
8
|
+
export function getRootChannelId(channel) {
|
|
9
|
+
if (!channel) {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
if (channel.kind === 'thread') {
|
|
13
|
+
return channel.parentId || channel.id;
|
|
14
|
+
}
|
|
15
|
+
return channel.id;
|
|
16
|
+
}
|
|
@@ -6,7 +6,7 @@ import { getThreadWorktree, getThreadSession, getChannelDirectory, } from '../da
|
|
|
6
6
|
import { createLogger, LogPrefix } from '../logger.js';
|
|
7
7
|
import { notifyError } from '../sentry.js';
|
|
8
8
|
import { mergeWorktree, listBranchesByLastCommit, validateBranchRef } from '../worktrees.js';
|
|
9
|
-
import { sendThreadMessage, resolveWorkingDirectory,
|
|
9
|
+
import { sendThreadMessage, resolveWorkingDirectory, resolveProjectDirectoryFromAutocomplete, } from '../discord-utils.js';
|
|
10
10
|
import { getOrCreateRuntime, } from '../session-handler/thread-session-runtime.js';
|
|
11
11
|
import { RebaseConflictError, DirtyWorktreeError } from '../errors.js';
|
|
12
12
|
const logger = createLogger(LogPrefix.WORKTREE);
|
|
@@ -131,22 +131,10 @@ export async function handleMergeWorktreeCommand({ command, appId, }) {
|
|
|
131
131
|
export async function handleMergeWorktreeAutocomplete({ interaction, }) {
|
|
132
132
|
try {
|
|
133
133
|
const focusedValue = interaction.options.getFocused();
|
|
134
|
-
|
|
135
|
-
//
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
if (worktreeInfo?.project_directory) {
|
|
139
|
-
projectDirectory = worktreeInfo.project_directory;
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
// Fallback: resolve from parent channel
|
|
143
|
-
if (!projectDirectory && interaction.channel) {
|
|
144
|
-
const textChannel = await resolveTextChannel(interaction.channel);
|
|
145
|
-
if (textChannel) {
|
|
146
|
-
const channelConfig = await getChannelDirectory(textChannel.id);
|
|
147
|
-
projectDirectory = channelConfig?.directory;
|
|
148
|
-
}
|
|
149
|
-
}
|
|
134
|
+
// interaction.channel can be null when the channel isn't cached
|
|
135
|
+
// (common with gateway-proxy). Use channelId which is always available
|
|
136
|
+
// from the raw interaction payload.
|
|
137
|
+
const projectDirectory = await resolveProjectDirectoryFromAutocomplete(interaction);
|
|
150
138
|
if (!projectDirectory) {
|
|
151
139
|
await interaction.respond([]);
|
|
152
140
|
return;
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import { ChannelType, REST, } from 'discord.js';
|
|
5
5
|
import fs from 'node:fs';
|
|
6
6
|
import { createPendingWorktree, setWorktreeReady, setWorktreeError, getChannelDirectory, getThreadWorktree, } from '../database.js';
|
|
7
|
-
import { SILENT_MESSAGE_FLAGS, reactToThread,
|
|
7
|
+
import { SILENT_MESSAGE_FLAGS, reactToThread, resolveProjectDirectoryFromAutocomplete, } from '../discord-utils.js';
|
|
8
8
|
import { createLogger, LogPrefix } from '../logger.js';
|
|
9
9
|
import { notifyError } from '../sentry.js';
|
|
10
10
|
import { createWorktreeWithSubmodules, execAsync, listBranchesByLastCommit, validateBranchRef, } from '../worktrees.js';
|
|
@@ -313,14 +313,10 @@ async function handleWorktreeInThread({ command, thread, }) {
|
|
|
313
313
|
export async function handleNewWorktreeAutocomplete({ interaction, }) {
|
|
314
314
|
try {
|
|
315
315
|
const focusedValue = interaction.options.getFocused();
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
const channelConfig = await getChannelDirectory(textChannel.id);
|
|
321
|
-
projectDirectory = channelConfig?.directory;
|
|
322
|
-
}
|
|
323
|
-
}
|
|
316
|
+
// interaction.channel can be null when the channel isn't cached
|
|
317
|
+
// (common with gateway-proxy). Use channelId which is always available
|
|
318
|
+
// from the raw interaction payload.
|
|
319
|
+
const projectDirectory = await resolveProjectDirectoryFromAutocomplete(interaction);
|
|
324
320
|
if (!projectDirectory) {
|
|
325
321
|
await interaction.respond([]);
|
|
326
322
|
return;
|
|
@@ -118,9 +118,83 @@ export async function showPermissionButtons({ thread, permission, directory, per
|
|
|
118
118
|
components: [actionRow],
|
|
119
119
|
flags: NOTIFY_MESSAGE_FLAGS | MessageFlags.SuppressEmbeds,
|
|
120
120
|
});
|
|
121
|
+
context.messageId = permissionMessage.id;
|
|
121
122
|
logger.log(`Showed permission buttons for ${permission.id}`);
|
|
122
123
|
return { messageId: permissionMessage.id, contextHash };
|
|
123
124
|
}
|
|
125
|
+
function updatePermissionMessage({ context, status, }) {
|
|
126
|
+
if (!context.messageId) {
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
context.thread.messages
|
|
130
|
+
.fetch(context.messageId)
|
|
131
|
+
.then((message) => {
|
|
132
|
+
const patternStr = compactPermissionPatterns(context.permission.patterns).join(', ');
|
|
133
|
+
const externalDirLine = context.permission.permission === 'external_directory'
|
|
134
|
+
? 'Agent is accessing files outside the project. [Learn more](https://opencode.ai/docs/permissions/#external-directories)\n'
|
|
135
|
+
: '';
|
|
136
|
+
return message.edit({
|
|
137
|
+
content: `⚠️ **Permission Required**\n` +
|
|
138
|
+
`**Type:** \`${context.permission.permission}\`\n` +
|
|
139
|
+
externalDirLine +
|
|
140
|
+
(patternStr ? `**Pattern:** \`${patternStr}\`\n` : '') +
|
|
141
|
+
status,
|
|
142
|
+
components: [],
|
|
143
|
+
});
|
|
144
|
+
})
|
|
145
|
+
.catch((error) => {
|
|
146
|
+
logger.error('Failed to update permission message:', error);
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
export async function cancelPendingPermission(threadId) {
|
|
150
|
+
const contexts = Array.from(pendingPermissionContexts.values()).filter((context) => {
|
|
151
|
+
return context.thread.id === threadId;
|
|
152
|
+
});
|
|
153
|
+
if (contexts.length === 0) {
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
let cancelledCount = 0;
|
|
157
|
+
for (const context of contexts) {
|
|
158
|
+
const pendingContext = takePendingPermissionContext(context.contextHash);
|
|
159
|
+
if (!pendingContext) {
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
const client = getOpencodeClient(pendingContext.directory);
|
|
163
|
+
if (!client) {
|
|
164
|
+
pendingPermissionContexts.set(pendingContext.contextHash, pendingContext);
|
|
165
|
+
logger.error('Failed to dismiss pending permission: OpenCode server not found');
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
const requestIds = pendingContext.requestIds.length > 0
|
|
169
|
+
? pendingContext.requestIds
|
|
170
|
+
: [pendingContext.permission.id];
|
|
171
|
+
const result = await Promise.all(requestIds.map((requestId) => {
|
|
172
|
+
return client.permission.reply({
|
|
173
|
+
requestID: requestId,
|
|
174
|
+
directory: pendingContext.permissionDirectory,
|
|
175
|
+
reply: 'reject',
|
|
176
|
+
});
|
|
177
|
+
})).then(() => {
|
|
178
|
+
return 'ok';
|
|
179
|
+
}).catch((error) => {
|
|
180
|
+
pendingPermissionContexts.set(pendingContext.contextHash, pendingContext);
|
|
181
|
+
logger.error('Failed to dismiss pending permission:', error);
|
|
182
|
+
return 'error';
|
|
183
|
+
});
|
|
184
|
+
if (result === 'error') {
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
updatePermissionMessage({
|
|
188
|
+
context: pendingContext,
|
|
189
|
+
status: '_Permission dismissed - user sent a new message._',
|
|
190
|
+
});
|
|
191
|
+
cancelledCount++;
|
|
192
|
+
}
|
|
193
|
+
if (cancelledCount > 0) {
|
|
194
|
+
logger.log(`Dismissed ${cancelledCount} pending permission request(s) for thread ${threadId}`);
|
|
195
|
+
}
|
|
196
|
+
return cancelledCount > 0;
|
|
197
|
+
}
|
|
124
198
|
/**
|
|
125
199
|
* Handle button click for permission.
|
|
126
200
|
*/
|
|
@@ -166,17 +240,9 @@ export async function handlePermissionButton(interaction) {
|
|
|
166
240
|
return '❌ Permission **rejected**';
|
|
167
241
|
}
|
|
168
242
|
})();
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
: '';
|
|
173
|
-
await interaction.editReply({
|
|
174
|
-
content: `⚠️ **Permission Required**\n` +
|
|
175
|
-
`**Type:** \`${context.permission.permission}\`\n` +
|
|
176
|
-
externalDirLine +
|
|
177
|
-
(patternStr ? `**Pattern:** \`${patternStr}\`\n` : '') +
|
|
178
|
-
resultText,
|
|
179
|
-
components: [], // Remove the buttons
|
|
243
|
+
updatePermissionMessage({
|
|
244
|
+
context,
|
|
245
|
+
status: resultText,
|
|
180
246
|
});
|
|
181
247
|
logger.log(`Permission ${context.permission.id} ${response} (${requestIds.length} request(s))`);
|
|
182
248
|
}
|
package/dist/commands/resume.js
CHANGED
|
@@ -3,7 +3,7 @@ import { ChannelType, ThreadAutoArchiveDuration, } from 'discord.js';
|
|
|
3
3
|
import fs from 'node:fs';
|
|
4
4
|
import { getChannelDirectory, setThreadSession, setPartMessagesBatch, getAllThreadSessionIds, } from '../database.js';
|
|
5
5
|
import { initializeOpencodeForDirectory } from '../opencode.js';
|
|
6
|
-
import { sendThreadMessage,
|
|
6
|
+
import { sendThreadMessage, resolveProjectDirectoryFromAutocomplete } from '../discord-utils.js';
|
|
7
7
|
import { collectLastAssistantParts } from '../message-formatting.js';
|
|
8
8
|
import { createLogger, LogPrefix } from '../logger.js';
|
|
9
9
|
import * as errore from 'errore';
|
|
@@ -100,14 +100,10 @@ export async function handleResumeCommand({ command, }) {
|
|
|
100
100
|
}
|
|
101
101
|
export async function handleResumeAutocomplete({ interaction, }) {
|
|
102
102
|
const focusedValue = interaction.options.getFocused();
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
const channelConfig = await getChannelDirectory(textChannel.id);
|
|
108
|
-
projectDirectory = channelConfig?.directory;
|
|
109
|
-
}
|
|
110
|
-
}
|
|
103
|
+
// interaction.channel can be null when the channel isn't cached
|
|
104
|
+
// (common with gateway-proxy). Use channelId which is always available
|
|
105
|
+
// from the raw interaction payload.
|
|
106
|
+
const projectDirectory = await resolveProjectDirectoryFromAutocomplete(interaction);
|
|
111
107
|
if (!projectDirectory) {
|
|
112
108
|
await interaction.respond([]);
|
|
113
109
|
return;
|
package/dist/commands/session.js
CHANGED
|
@@ -4,7 +4,7 @@ import fs from 'node:fs';
|
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { getChannelDirectory } from '../database.js';
|
|
6
6
|
import { initializeOpencodeForDirectory } from '../opencode.js';
|
|
7
|
-
import { SILENT_MESSAGE_FLAGS } from '../discord-utils.js';
|
|
7
|
+
import { SILENT_MESSAGE_FLAGS, resolveProjectDirectoryFromAutocomplete } from '../discord-utils.js';
|
|
8
8
|
import { getOrCreateRuntime } from '../session-handler/thread-session-runtime.js';
|
|
9
9
|
import { createLogger, LogPrefix } from '../logger.js';
|
|
10
10
|
import * as errore from 'errore';
|
|
@@ -80,14 +80,10 @@ export async function handleSessionCommand({ command, appId, }) {
|
|
|
80
80
|
}
|
|
81
81
|
async function handleAgentAutocomplete({ interaction, }) {
|
|
82
82
|
const focusedValue = interaction.options.getFocused();
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
if (channelConfig) {
|
|
88
|
-
projectDirectory = channelConfig.directory;
|
|
89
|
-
}
|
|
90
|
-
}
|
|
83
|
+
// interaction.channel can be null when the channel isn't cached
|
|
84
|
+
// (common with gateway-proxy). Use channelId which is always available
|
|
85
|
+
// from the raw interaction payload.
|
|
86
|
+
const projectDirectory = await resolveProjectDirectoryFromAutocomplete(interaction);
|
|
91
87
|
if (!projectDirectory) {
|
|
92
88
|
await interaction.respond([]);
|
|
93
89
|
return;
|
|
@@ -139,14 +135,7 @@ export async function handleSessionAutocomplete({ interaction, }) {
|
|
|
139
135
|
.map((f) => f.trim())
|
|
140
136
|
.filter((f) => f);
|
|
141
137
|
const currentQuery = (parts[parts.length - 1] || '').trim();
|
|
142
|
-
|
|
143
|
-
if (interaction.channel &&
|
|
144
|
-
interaction.channel.type === ChannelType.GuildText) {
|
|
145
|
-
const channelConfig = await getChannelDirectory(interaction.channel.id);
|
|
146
|
-
if (channelConfig) {
|
|
147
|
-
projectDirectory = channelConfig.directory;
|
|
148
|
-
}
|
|
149
|
-
}
|
|
138
|
+
const projectDirectory = await resolveProjectDirectoryFromAutocomplete(interaction);
|
|
150
139
|
if (!projectDirectory) {
|
|
151
140
|
await interaction.respond([]);
|
|
152
141
|
return;
|
package/dist/discord-bot.js
CHANGED
|
@@ -14,6 +14,7 @@ import { preprocessExistingThreadMessage, preprocessNewThreadMessage, } from './
|
|
|
14
14
|
import { cancelPendingActionButtons } from './commands/action-buttons.js';
|
|
15
15
|
import { cancelPendingQuestion } from './commands/ask-question.js';
|
|
16
16
|
import { cancelPendingFileUpload } from './commands/file-upload.js';
|
|
17
|
+
import { cancelPendingPermission } from './commands/permissions.js';
|
|
17
18
|
import { cancelHtmlActionsForThread } from './html-actions.js';
|
|
18
19
|
import { ensureKimakiCategory, ensureKimakiAudioCategory, createProjectChannels, getChannelsWithDescriptions, } from './channel-management.js';
|
|
19
20
|
import { voiceConnections, cleanupVoiceConnection, registerVoiceStateHandler, } from './voice-handler.js';
|
|
@@ -330,19 +331,6 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
330
331
|
if (isThread) {
|
|
331
332
|
const thread = channel;
|
|
332
333
|
discordLogger.log(`Message in thread ${thread.name} (${thread.id})`);
|
|
333
|
-
// Cancel interactive UI when a real user sends a message.
|
|
334
|
-
// If a question was pending and answered with the user's text,
|
|
335
|
-
// early-return: the message was consumed as the question answer
|
|
336
|
-
// and must NOT also be sent as a new prompt (causes abort loops).
|
|
337
|
-
if (!message.author.bot && !isCliInjectedPrompt) {
|
|
338
|
-
cancelPendingActionButtons(thread.id);
|
|
339
|
-
cancelHtmlActionsForThread(thread.id);
|
|
340
|
-
const questionResult = await cancelPendingQuestion(thread.id, message.content);
|
|
341
|
-
void cancelPendingFileUpload(thread.id);
|
|
342
|
-
if (questionResult === 'replied') {
|
|
343
|
-
return;
|
|
344
|
-
}
|
|
345
|
-
}
|
|
346
334
|
const parent = thread.parent;
|
|
347
335
|
let projectDirectory;
|
|
348
336
|
if (parent) {
|
|
@@ -424,6 +412,23 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
424
412
|
channelId: parent?.id || undefined,
|
|
425
413
|
appId: currentAppId,
|
|
426
414
|
});
|
|
415
|
+
// Cancel interactive UI when a real user sends a message.
|
|
416
|
+
// If a question was pending and answered with the user's text,
|
|
417
|
+
// early-return: the message was consumed as the question answer
|
|
418
|
+
// and must NOT also be sent as a new prompt (causes abort loops).
|
|
419
|
+
if (!message.author.bot && !isCliInjectedPrompt) {
|
|
420
|
+
cancelPendingActionButtons(thread.id);
|
|
421
|
+
cancelHtmlActionsForThread(thread.id);
|
|
422
|
+
const dismissedPermission = await cancelPendingPermission(thread.id);
|
|
423
|
+
if (dismissedPermission) {
|
|
424
|
+
runtime.abortActiveRun('user sent a new message while permission was pending');
|
|
425
|
+
}
|
|
426
|
+
const questionResult = await cancelPendingQuestion(thread.id, message.content);
|
|
427
|
+
void cancelPendingFileUpload(thread.id);
|
|
428
|
+
if (questionResult === 'replied') {
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
427
432
|
// Expensive pre-processing (voice transcription, context fetch,
|
|
428
433
|
// attachment download) runs inside the runtime's serialized
|
|
429
434
|
// preprocess chain, preserving Discord arrival order without
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Guardrail test for adapter boundary imports.
|
|
2
|
+
// Runtime modules must not import discord.js directly outside the Discord adapter,
|
|
3
|
+
// forum-sync bridge, and voice handler.
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { describe, expect, test } from 'vitest';
|
|
7
|
+
const SRC_DIR = path.resolve(import.meta.dirname);
|
|
8
|
+
function collectTsFiles(dir) {
|
|
9
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
10
|
+
return entries.flatMap((entry) => {
|
|
11
|
+
const fullPath = path.join(dir, entry.name);
|
|
12
|
+
if (entry.isDirectory()) {
|
|
13
|
+
return collectTsFiles(fullPath);
|
|
14
|
+
}
|
|
15
|
+
if (!entry.name.endsWith('.ts')) {
|
|
16
|
+
return [];
|
|
17
|
+
}
|
|
18
|
+
if (entry.name.endsWith('.test.ts')) {
|
|
19
|
+
return [];
|
|
20
|
+
}
|
|
21
|
+
if (entry.name.includes('e2e')) {
|
|
22
|
+
return [];
|
|
23
|
+
}
|
|
24
|
+
return [fullPath];
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
function toWorkspaceRelative(filePath) {
|
|
28
|
+
return path.relative(path.resolve(import.meta.dirname, '..'), filePath);
|
|
29
|
+
}
|
|
30
|
+
function isAllowedBoundaryFile(relativePath) {
|
|
31
|
+
if (relativePath === 'src/platform/discord-adapter.ts') {
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
if (relativePath === 'src/voice-handler.ts') {
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
if (relativePath.startsWith('src/forum-sync/')) {
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
describe('discord.js import boundary', () => {
|
|
43
|
+
test('does not import discord.js outside allowed modules', () => {
|
|
44
|
+
const violations = collectTsFiles(SRC_DIR)
|
|
45
|
+
.map((filePath) => {
|
|
46
|
+
return {
|
|
47
|
+
relativePath: toWorkspaceRelative(filePath),
|
|
48
|
+
content: fs.readFileSync(filePath, 'utf8'),
|
|
49
|
+
};
|
|
50
|
+
})
|
|
51
|
+
.filter(({ relativePath }) => {
|
|
52
|
+
return !isAllowedBoundaryFile(relativePath);
|
|
53
|
+
})
|
|
54
|
+
.filter(({ content }) => {
|
|
55
|
+
return /from\s+['"]discord\.js['"]/.test(content);
|
|
56
|
+
})
|
|
57
|
+
.map(({ relativePath }) => {
|
|
58
|
+
return relativePath;
|
|
59
|
+
});
|
|
60
|
+
expect(violations).toMatchInlineSnapshot(`[]`);
|
|
61
|
+
});
|
|
62
|
+
});
|
package/dist/discord-utils.js
CHANGED
|
@@ -480,6 +480,50 @@ export async function getKimakiMetadata(textChannel) {
|
|
|
480
480
|
projectDirectory: channelConfig.directory,
|
|
481
481
|
};
|
|
482
482
|
}
|
|
483
|
+
/**
|
|
484
|
+
* Resolve project directory from an autocomplete interaction.
|
|
485
|
+
* Uses interaction.channelId (always available from raw payload) instead of
|
|
486
|
+
* interaction.channel (cache-based getter, often null with gateway-proxy).
|
|
487
|
+
* Checks the channel ID directly in DB, then tries thread worktree lookup,
|
|
488
|
+
* then falls back to fetching the channel to resolve thread parent.
|
|
489
|
+
*/
|
|
490
|
+
export async function resolveProjectDirectoryFromAutocomplete(interaction) {
|
|
491
|
+
const channelId = interaction.channelId;
|
|
492
|
+
// Direct channel lookup — works when the command is run from a project text channel
|
|
493
|
+
const channelConfig = await getChannelDirectory(channelId);
|
|
494
|
+
if (channelConfig) {
|
|
495
|
+
return channelConfig.directory;
|
|
496
|
+
}
|
|
497
|
+
// If we're in a thread, try worktree info first (has project_directory)
|
|
498
|
+
const worktreeInfo = await getThreadWorktree(channelId);
|
|
499
|
+
if (worktreeInfo?.project_directory) {
|
|
500
|
+
return worktreeInfo.project_directory;
|
|
501
|
+
}
|
|
502
|
+
// Thread fallback: resolve parent channel ID and look up its directory.
|
|
503
|
+
// Try cached channel first, then fetch if cache misses (gateway-proxy scenario).
|
|
504
|
+
const cachedParentId = interaction.channel?.isThread() ? interaction.channel.parentId : null;
|
|
505
|
+
if (cachedParentId) {
|
|
506
|
+
const parentConfig = await getChannelDirectory(cachedParentId);
|
|
507
|
+
if (parentConfig) {
|
|
508
|
+
return parentConfig.directory;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
// Last resort: fetch the channel from Discord API to get parentId for threads
|
|
512
|
+
// when the channel isn't cached at all (common with gateway-proxy).
|
|
513
|
+
if (!cachedParentId) {
|
|
514
|
+
const fetched = await errore.tryAsync({
|
|
515
|
+
try: () => { return interaction.client.channels.fetch(channelId); },
|
|
516
|
+
catch: (e) => { return e; },
|
|
517
|
+
});
|
|
518
|
+
if (!(fetched instanceof Error) && fetched?.isThread() && fetched.parentId) {
|
|
519
|
+
const parentConfig = await getChannelDirectory(fetched.parentId);
|
|
520
|
+
if (parentConfig) {
|
|
521
|
+
return parentConfig.directory;
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
return undefined;
|
|
526
|
+
}
|
|
483
527
|
/**
|
|
484
528
|
* Resolve the working directory for a channel or thread.
|
|
485
529
|
* Returns both the base project directory (for server init) and the working directory
|
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
// Uses opencode-cached-provider + Gemini to record real tool/lifecycle streams
|
|
3
3
|
// (task, interruption, permission, action buttons, and question flows).
|
|
4
4
|
import fs from 'node:fs';
|
|
5
|
-
import net from 'node:net';
|
|
6
5
|
import path from 'node:path';
|
|
7
6
|
import { afterAll, afterEach, beforeAll, describe, expect, test } from 'vitest';
|
|
8
7
|
import { ChannelType, Client, GatewayIntentBits, Partials } from 'discord.js';
|
|
@@ -13,7 +12,7 @@ import { store } from './store.js';
|
|
|
13
12
|
import { startDiscordBot } from './discord-bot.js';
|
|
14
13
|
import { closeDatabase, getChannelVerbosity, initDatabase, setBotToken, setChannelDirectory, setChannelVerbosity, } from './database.js';
|
|
15
14
|
import { startHranaServer, stopHranaServer } from './hrana-server.js';
|
|
16
|
-
import { cleanupTestSessions } from './test-utils.js';
|
|
15
|
+
import { chooseLockPort, cleanupTestSessions } from './test-utils.js';
|
|
17
16
|
import { waitForBotMessageContaining, waitForBotReplyAfterUserMessage } from './test-utils.js';
|
|
18
17
|
import { stopOpencodeServer } from './opencode.js';
|
|
19
18
|
import { disposeRuntime, pendingPermissions } from './session-handler/thread-session-runtime.js';
|
|
@@ -46,23 +45,6 @@ function createRunDirectories() {
|
|
|
46
45
|
fixtureOutputDir,
|
|
47
46
|
};
|
|
48
47
|
}
|
|
49
|
-
function chooseLockPort() {
|
|
50
|
-
return new Promise((resolve, reject) => {
|
|
51
|
-
const server = net.createServer();
|
|
52
|
-
server.listen(0, () => {
|
|
53
|
-
const address = server.address();
|
|
54
|
-
if (!address || typeof address === 'string') {
|
|
55
|
-
server.close();
|
|
56
|
-
reject(new Error('Failed to resolve lock port'));
|
|
57
|
-
return;
|
|
58
|
-
}
|
|
59
|
-
const port = address.port;
|
|
60
|
-
server.close(() => {
|
|
61
|
-
resolve(port);
|
|
62
|
-
});
|
|
63
|
-
});
|
|
64
|
-
});
|
|
65
|
-
}
|
|
66
48
|
function createDiscordJsClient({ restUrl }) {
|
|
67
49
|
return new Client({
|
|
68
50
|
intents: [
|
|
@@ -248,7 +230,7 @@ describe('real event stream capture fixtures (cached provider)', () => {
|
|
|
248
230
|
}
|
|
249
231
|
beforeAll(async () => {
|
|
250
232
|
testStartTime = Date.now();
|
|
251
|
-
lockPort =
|
|
233
|
+
lockPort = chooseLockPort({ key: TEXT_CHANNEL_ID });
|
|
252
234
|
listJsonlFiles(directories.sessionEventsDir).forEach((fileName) => {
|
|
253
235
|
fs.rmSync(path.join(directories.sessionEventsDir, fileName), {
|
|
254
236
|
force: true,
|