kimaki 0.4.75 → 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 +75 -37
- package/dist/commands/ask-question.js +30 -17
- 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 +19 -10
- 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/eventsource-parser.test.js +327 -0
- 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/message-preprocessing.js +4 -2
- 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 +300 -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/queue-advanced-question.e2e.test.js +120 -0
- package/dist/queue-advanced-typing-interrupt.e2e.test.js +109 -0
- package/dist/queue-advanced-typing.e2e.test.js +0 -94
- 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 +32 -2
- package/dist/system-message.js +26 -23
- package/dist/test-utils.js +16 -0
- package/dist/thread-message-queue.e2e.test.js +37 -24
- 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 +7 -6
- 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 +102 -40
- package/src/commands/ask-question.ts +36 -18
- 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 +21 -14
- package/src/discord-utils.ts +53 -0
- package/src/event-stream-real-capture.e2e.test.ts +4 -20
- package/src/eventsource-parser.test.ts +351 -0
- package/src/gateway-proxy.e2e.test.ts +2 -5
- package/src/kimaki-digital-twin.e2e.test.ts +2 -21
- package/src/message-preprocessing.ts +4 -2
- 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 +309 -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/queue-advanced-question.e2e.test.ts +158 -0
- package/src/queue-advanced-typing-interrupt.e2e.test.ts +138 -0
- package/src/queue-advanced-typing.e2e.test.ts +0 -112
- 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 +44 -2
- package/src/system-message.ts +26 -23
- package/src/test-utils.ts +17 -0
- package/src/thread-message-queue.e2e.test.ts +39 -24
- 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';
|
|
@@ -192,10 +193,11 @@ function exitNonInteractiveSetup() {
|
|
|
192
193
|
}
|
|
193
194
|
// Emit a structured JSON line on stdout for non-TTY consumers (cloud sandboxes, CI).
|
|
194
195
|
// Each line is a self-contained JSON object with a "type" field for easy parsing.
|
|
195
|
-
//
|
|
196
|
-
//
|
|
196
|
+
// Lines are prefixed with "data: " and terminated with "\n\n" (SSE format) so consumers
|
|
197
|
+
// can use the eventsource-parser npm package to robustly extract JSON events from noisy
|
|
198
|
+
// process output (other log lines, warnings, etc. are ignored by the parser).
|
|
197
199
|
function emitJsonEvent(event) {
|
|
198
|
-
process.stdout.write(JSON.stringify(event)
|
|
200
|
+
process.stdout.write(`data: ${JSON.stringify(event)}\n\n`);
|
|
199
201
|
}
|
|
200
202
|
async function resolveGatewayInstallCredentials() {
|
|
201
203
|
if (!KIMAKI_GATEWAY_APP_ID) {
|
|
@@ -341,7 +343,11 @@ async function ensureCommandAvailable({ name, envPathKey, installUnix, installWi
|
|
|
341
343
|
const foundInPath = await execAsync(`${whichCmd} ${name}`, {
|
|
342
344
|
env: process.env,
|
|
343
345
|
}).then((result) => {
|
|
344
|
-
|
|
346
|
+
const resolved = selectResolvedCommand({
|
|
347
|
+
output: result.stdout,
|
|
348
|
+
isWindows,
|
|
349
|
+
});
|
|
350
|
+
return resolved || '';
|
|
345
351
|
}, () => {
|
|
346
352
|
return '';
|
|
347
353
|
});
|
|
@@ -958,6 +964,42 @@ function showReadyMessage({ kimakiChannels, createdChannels, }) {
|
|
|
958
964
|
}
|
|
959
965
|
note('Leave this process running to keep the bot active.\n\nIf you close this process or restart your machine, run `npx kimaki` again to start the bot.', '⚠️ Keep Running');
|
|
960
966
|
}
|
|
967
|
+
/**
|
|
968
|
+
* Create the default kimaki channel in each guild and send a welcome message.
|
|
969
|
+
* Idempotent: skips guilds that already have the channel.
|
|
970
|
+
* Extracted so both the interactive and headless startup paths share the same logic.
|
|
971
|
+
*/
|
|
972
|
+
async function ensureDefaultChannelsWithWelcome({ guilds, discordClient, appId, isGatewayMode, installerDiscordUserId, }) {
|
|
973
|
+
const created = [];
|
|
974
|
+
for (const guild of guilds) {
|
|
975
|
+
try {
|
|
976
|
+
const result = await createDefaultKimakiChannel({
|
|
977
|
+
guild,
|
|
978
|
+
botName: discordClient.user?.username,
|
|
979
|
+
appId,
|
|
980
|
+
isGatewayMode,
|
|
981
|
+
});
|
|
982
|
+
if (result) {
|
|
983
|
+
created.push({
|
|
984
|
+
name: result.channelName,
|
|
985
|
+
id: result.textChannelId,
|
|
986
|
+
guildId: guild.id,
|
|
987
|
+
});
|
|
988
|
+
// Send welcome message to the newly created default channel.
|
|
989
|
+
// Mention the installer so they get a notification.
|
|
990
|
+
const mentionUserId = installerDiscordUserId || guild.ownerId;
|
|
991
|
+
await sendWelcomeMessage({
|
|
992
|
+
channel: result.textChannel,
|
|
993
|
+
mentionUserId,
|
|
994
|
+
});
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
catch (error) {
|
|
998
|
+
cliLogger.warn(`Failed to create default kimaki channel in ${guild.name}: ${error instanceof Error ? error.message : String(error)}`);
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
return created;
|
|
1002
|
+
}
|
|
961
1003
|
/**
|
|
962
1004
|
* Background initialization for quick start mode.
|
|
963
1005
|
* Starts OpenCode server and registers slash commands without blocking bot startup.
|
|
@@ -1136,7 +1178,7 @@ async function resolveCredentials({ forceRestartOnboarding, forceGateway, gatewa
|
|
|
1136
1178
|
emitJsonEvent({ type: 'install_url', url: oauthUrl });
|
|
1137
1179
|
}
|
|
1138
1180
|
// Poll until the user installs the bot in a Discord server.
|
|
1139
|
-
//
|
|
1181
|
+
// 100 attempts x 3s = 5 minutes timeout.
|
|
1140
1182
|
const s = isInteractive ? spinner() : undefined;
|
|
1141
1183
|
s?.start('Waiting for a Discord server with the bot installed...');
|
|
1142
1184
|
const pollUrl = new URL('/api/onboarding/status', KIMAKI_WEBSITE_URL);
|
|
@@ -1144,9 +1186,9 @@ async function resolveCredentials({ forceRestartOnboarding, forceGateway, gatewa
|
|
|
1144
1186
|
pollUrl.searchParams.set('secret', clientSecret);
|
|
1145
1187
|
let guildId;
|
|
1146
1188
|
let installerDiscordUserId;
|
|
1147
|
-
for (let attempt = 0; attempt <
|
|
1189
|
+
for (let attempt = 0; attempt < 100; attempt++) {
|
|
1148
1190
|
await new Promise((resolve) => {
|
|
1149
|
-
setTimeout(resolve,
|
|
1191
|
+
setTimeout(resolve, 3000);
|
|
1150
1192
|
});
|
|
1151
1193
|
// Progressive hints for interactive users who may be stuck
|
|
1152
1194
|
if (isInteractive) {
|
|
@@ -1180,9 +1222,9 @@ async function resolveCredentials({ forceRestartOnboarding, forceGateway, gatewa
|
|
|
1180
1222
|
s?.stop('Authorization timed out');
|
|
1181
1223
|
}
|
|
1182
1224
|
else {
|
|
1183
|
-
emitJsonEvent({ type: 'error', message: 'Authorization timed out after
|
|
1225
|
+
emitJsonEvent({ type: 'error', message: 'Authorization timed out after 5 minutes' });
|
|
1184
1226
|
}
|
|
1185
|
-
cliLogger.error('Bot authorization timed out after
|
|
1227
|
+
cliLogger.error('Bot authorization timed out after 5 minutes. Please try again.');
|
|
1186
1228
|
process.exit(EXIT_NO_RESTART);
|
|
1187
1229
|
}
|
|
1188
1230
|
if (isInteractive) {
|
|
@@ -1454,7 +1496,8 @@ async function run({ restartOnboarding, addChannels, useWorktrees, enableVoiceCh
|
|
|
1454
1496
|
cliLogger.log('Starting Discord bot...');
|
|
1455
1497
|
await startDiscordBot({ token, appId, discordClient, useWorktrees });
|
|
1456
1498
|
cliLogger.log('Discord bot is running!');
|
|
1457
|
-
// Background channel sync + role reconciliation
|
|
1499
|
+
// Background channel sync + role reconciliation + default channel creation.
|
|
1500
|
+
// Never blocks ready state.
|
|
1458
1501
|
void (async () => {
|
|
1459
1502
|
try {
|
|
1460
1503
|
const backgroundChannels = await collectKimakiChannels({
|
|
@@ -1467,6 +1510,20 @@ async function run({ restartOnboarding, addChannels, useWorktrees, enableVoiceCh
|
|
|
1467
1510
|
catch (error) {
|
|
1468
1511
|
cliLogger.warn('Background channel sync failed:', error instanceof Error ? error.message : String(error));
|
|
1469
1512
|
}
|
|
1513
|
+
// Create default kimaki channel + welcome message in each guild.
|
|
1514
|
+
// Runs after channel sync so existing channels are detected correctly.
|
|
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
|
+
}
|
|
1470
1527
|
})();
|
|
1471
1528
|
// Background: OpenCode init + slash command registration (non-blocking)
|
|
1472
1529
|
void backgroundInit({
|
|
@@ -1609,33 +1666,14 @@ async function run({ restartOnboarding, addChannels, useWorktrees, enableVoiceCh
|
|
|
1609
1666
|
}
|
|
1610
1667
|
// Create default kimaki channel for general-purpose tasks.
|
|
1611
1668
|
// Runs for every guild the bot is in, idempotent (skips if already exists).
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
if (result) {
|
|
1621
|
-
createdChannels.push({
|
|
1622
|
-
name: result.channelName,
|
|
1623
|
-
id: result.textChannelId,
|
|
1624
|
-
guildId: guild.id,
|
|
1625
|
-
});
|
|
1626
|
-
// Send welcome message to the newly created default channel.
|
|
1627
|
-
// Mention the installer so they get a notification.
|
|
1628
|
-
const mentionUserId = installerDiscordUserId || guild.ownerId;
|
|
1629
|
-
await sendWelcomeMessage({
|
|
1630
|
-
channel: result.textChannel,
|
|
1631
|
-
mentionUserId,
|
|
1632
|
-
});
|
|
1633
|
-
}
|
|
1634
|
-
}
|
|
1635
|
-
catch (error) {
|
|
1636
|
-
cliLogger.warn(`Failed to create default kimaki channel in ${guild.name}: ${error instanceof Error ? error.message : String(error)}`);
|
|
1637
|
-
}
|
|
1638
|
-
}
|
|
1669
|
+
const defaultChannelResults = await ensureDefaultChannelsWithWelcome({
|
|
1670
|
+
guilds,
|
|
1671
|
+
discordClient,
|
|
1672
|
+
appId,
|
|
1673
|
+
isGatewayMode,
|
|
1674
|
+
installerDiscordUserId,
|
|
1675
|
+
});
|
|
1676
|
+
createdChannels.push(...defaultChannelResults);
|
|
1639
1677
|
// Log available user commands
|
|
1640
1678
|
const registrableCommands = allUserCommands.filter((cmd) => !SKIP_USER_COMMANDS.includes(cmd.name));
|
|
1641
1679
|
if (registrableCommands.length > 0) {
|
|
@@ -29,27 +29,28 @@ export async function showAskUserQuestionDropdowns({ thread, sessionId, director
|
|
|
29
29
|
contextHash,
|
|
30
30
|
};
|
|
31
31
|
pendingQuestionContexts.set(contextHash, context);
|
|
32
|
-
//
|
|
33
|
-
//
|
|
32
|
+
// On TTL expiry: hide the dropdown UI and abort the session so OpenCode
|
|
33
|
+
// unblocks. We intentionally do NOT call question.reply() — sending 'Other'
|
|
34
|
+
// made the model think the user chose an option when they didn't.
|
|
34
35
|
setTimeout(async () => {
|
|
35
36
|
const ctx = pendingQuestionContexts.get(contextHash);
|
|
36
37
|
if (!ctx) {
|
|
37
38
|
return;
|
|
38
39
|
}
|
|
40
|
+
// Delete context first so the dropdown becomes inert immediately.
|
|
41
|
+
// Without this, a user clicking during the abort() await would still
|
|
42
|
+
// be accepted by handleAskQuestionSelectMenu, then abort() would
|
|
43
|
+
// kill that valid run.
|
|
44
|
+
pendingQuestionContexts.delete(contextHash);
|
|
45
|
+
// Abort the session so OpenCode isn't stuck waiting for a reply
|
|
39
46
|
const client = getOpencodeClient(ctx.directory);
|
|
40
47
|
if (client) {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
});
|
|
44
|
-
await client.question.reply({
|
|
45
|
-
requestID: ctx.requestId,
|
|
46
|
-
directory: ctx.directory,
|
|
47
|
-
answers,
|
|
48
|
+
await client.session.abort({
|
|
49
|
+
sessionID: ctx.sessionId,
|
|
48
50
|
}).catch((error) => {
|
|
49
|
-
logger.error('Failed to
|
|
51
|
+
logger.error('Failed to abort session after question expiry:', error);
|
|
50
52
|
});
|
|
51
53
|
}
|
|
52
|
-
pendingQuestionContexts.delete(contextHash);
|
|
53
54
|
}, QUESTION_CONTEXT_TTL_MS).unref();
|
|
54
55
|
// Send one message per question with its dropdown directly underneath
|
|
55
56
|
for (let i = 0; i < input.questions.length; i++) {
|
|
@@ -206,6 +207,11 @@ export function parseAskUserQuestionTool(part) {
|
|
|
206
207
|
/**
|
|
207
208
|
* Cancel a pending question for a thread (e.g., when user sends a new message).
|
|
208
209
|
* Sends the user's message as the answer to OpenCode so the model sees their actual response.
|
|
210
|
+
*
|
|
211
|
+
* Returns 'replied' if the question was answered successfully (caller should NOT
|
|
212
|
+
* enqueue the user message as a new prompt — it was consumed as the answer).
|
|
213
|
+
* Returns 'reply-failed' if reply failed (context kept pending so TTL can retry).
|
|
214
|
+
* Returns 'no-pending' if no question was pending for this thread.
|
|
209
215
|
*/
|
|
210
216
|
export async function cancelPendingQuestion(threadId, userMessage) {
|
|
211
217
|
// Find pending question for this thread
|
|
@@ -219,17 +225,22 @@ export async function cancelPendingQuestion(threadId, userMessage) {
|
|
|
219
225
|
}
|
|
220
226
|
}
|
|
221
227
|
if (!contextHash || !context) {
|
|
222
|
-
return
|
|
228
|
+
return 'no-pending';
|
|
229
|
+
}
|
|
230
|
+
// undefined means teardown/cleanup — just remove context, don't reply.
|
|
231
|
+
// The session is already being torn down. Empty string '' is a valid
|
|
232
|
+
// user message (attachment-only, voice, etc.) and must still go through.
|
|
233
|
+
if (userMessage === undefined) {
|
|
234
|
+
pendingQuestionContexts.delete(contextHash);
|
|
235
|
+
return 'no-pending';
|
|
223
236
|
}
|
|
224
237
|
try {
|
|
225
238
|
const client = getOpencodeClient(context.directory);
|
|
226
239
|
if (!client) {
|
|
227
240
|
throw new Error('OpenCode server not found for directory');
|
|
228
241
|
}
|
|
229
|
-
// Use user's message as answer if provided, otherwise mark as "Other"
|
|
230
|
-
const customAnswer = userMessage || 'Other';
|
|
231
242
|
const answers = context.questions.map((_, i) => {
|
|
232
|
-
return context.answers[i] || [
|
|
243
|
+
return context.answers[i] || [userMessage];
|
|
233
244
|
});
|
|
234
245
|
await client.question.reply({
|
|
235
246
|
requestID: context.requestId,
|
|
@@ -240,8 +251,10 @@ export async function cancelPendingQuestion(threadId, userMessage) {
|
|
|
240
251
|
}
|
|
241
252
|
catch (error) {
|
|
242
253
|
logger.error('Failed to answer question:', error);
|
|
254
|
+
// Keep context pending so TTL can still fire.
|
|
255
|
+
// Caller should not consume the user message since reply failed.
|
|
256
|
+
return 'reply-failed';
|
|
243
257
|
}
|
|
244
|
-
// Clean up regardless of whether the API call succeeded
|
|
245
258
|
pendingQuestionContexts.delete(contextHash);
|
|
246
|
-
return
|
|
259
|
+
return 'replied';
|
|
247
260
|
}
|
|
@@ -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;
|