kimaki 0.13.0 → 0.14.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/dist/anthropic-auth-plugin.js +15 -15
- package/dist/anthropic-auth-state.js +1 -1
- package/dist/anthropic-auth-state.test.js +2 -2
- package/dist/channel-reference-permissions.e2e.test.js +2 -0
- package/dist/cli-parsing.test.js +1 -1
- package/dist/cli.js +19 -1
- package/dist/commands/action-buttons.js +2 -0
- package/dist/commands/ask-question.js +2 -0
- package/dist/commands/compact.js +2 -5
- package/dist/commands/file-upload.js +5 -1
- package/dist/commands/model-variant.js +22 -17
- package/dist/commands/model.js +42 -14
- package/dist/commands/new-worktree.js +107 -59
- package/dist/commands/permissions.js +13 -3
- package/dist/config.js +8 -0
- package/dist/context-awareness-plugin.js +9 -4
- package/dist/discord-bot.js +50 -35
- package/dist/message-finish-field.e2e.test.js +1 -0
- package/dist/openai-auth-plugin.js +16 -16
- package/dist/openai-auth-state.js +1 -1
- package/dist/opencode-command.js +25 -1
- package/dist/opencode-command.test.js +64 -2
- package/dist/opencode-interrupt-plugin.js +192 -343
- package/dist/opencode-interrupt-plugin.test.js +168 -381
- package/dist/opencode.js +44 -0
- package/dist/plugin-opencode-client.js +43 -0
- package/dist/queue-advanced-action-buttons.e2e.test.js +1 -0
- package/dist/queue-advanced-footer.e2e.test.js +8 -1
- package/dist/queue-advanced-model-switch.e2e.test.js +1 -1
- package/dist/queue-advanced-permissions-typing.e2e.test.js +1 -0
- package/dist/queue-question-select-drain.e2e.test.js +2 -0
- package/dist/session-handler/event-stream-state.js +3 -1
- package/dist/session-handler/event-stream-state.test.js +67 -1
- package/dist/session-handler/global-event-listener.js +179 -0
- package/dist/session-handler/thread-runtime-state.js +0 -1
- package/dist/session-handler/thread-session-runtime.js +33 -220
- package/dist/store.js +1 -0
- package/dist/subagent-rate-limit-plugin.js +12 -12
- package/dist/system-message.js +4 -4
- package/dist/system-message.test.js +5 -3
- package/dist/thread-message-queue.e2e.test.js +6 -22
- package/dist/undo-redo.e2e.test.js +1 -0
- package/dist/voice-message.e2e.test.js +1 -1
- package/dist/voice.js +3 -2
- package/dist/worktree-lifecycle.e2e.test.js +130 -50
- package/package.json +6 -6
- package/skills/holocron/SKILL.md +192 -14
- package/skills/new-skill/SKILL.md +7 -7
- package/skills/sigillo/SKILL.md +4 -4
- package/skills/spiceflow/SKILL.md +12 -4
- package/skills/strada/SKILL.md +236 -0
- package/skills/termcast/SKILL.md +2 -0
- package/skills/tuistory/SKILL.md +38 -2
- package/src/anthropic-auth-plugin.ts +17 -16
- package/src/anthropic-auth-state.test.ts +2 -2
- package/src/anthropic-auth-state.ts +4 -4
- package/src/channel-reference-permissions.e2e.test.ts +2 -0
- package/src/cli-parsing.test.ts +1 -1
- package/src/cli.ts +25 -1
- package/src/commands/action-buttons.ts +6 -0
- package/src/commands/ask-question.ts +6 -0
- package/src/commands/compact.ts +2 -5
- package/src/commands/file-upload.ts +9 -1
- package/src/commands/model-variant.ts +22 -17
- package/src/commands/model.ts +53 -15
- package/src/commands/new-worktree.ts +136 -81
- package/src/commands/permissions.ts +14 -3
- package/src/config.ts +9 -0
- package/src/context-awareness-plugin.ts +15 -8
- package/src/discord-bot.ts +63 -37
- package/src/message-finish-field.e2e.test.ts +1 -0
- package/src/openai-auth-plugin.ts +18 -17
- package/src/openai-auth-state.ts +4 -4
- package/src/opencode-command.test.ts +81 -1
- package/src/opencode-command.ts +26 -1
- package/src/opencode-interrupt-plugin.test.ts +201 -520
- package/src/opencode-interrupt-plugin.ts +213 -429
- package/src/opencode.ts +67 -0
- package/src/plugin-opencode-client.ts +60 -0
- package/src/queue-advanced-action-buttons.e2e.test.ts +1 -0
- package/src/queue-advanced-footer.e2e.test.ts +8 -1
- package/src/queue-advanced-model-switch.e2e.test.ts +1 -1
- package/src/queue-advanced-permissions-typing.e2e.test.ts +1 -0
- package/src/queue-question-select-drain.e2e.test.ts +2 -0
- package/src/session-handler/event-stream-state.test.ts +72 -2
- package/src/session-handler/event-stream-state.ts +3 -1
- package/src/session-handler/global-event-listener.ts +224 -0
- package/src/session-handler/thread-runtime-state.ts +0 -8
- package/src/session-handler/thread-session-runtime.ts +41 -276
- package/src/store.ts +10 -0
- package/src/subagent-rate-limit-plugin.ts +13 -12
- package/src/system-message.test.ts +5 -3
- package/src/system-message.ts +8 -4
- package/src/thread-message-queue.e2e.test.ts +6 -24
- package/src/undo-redo.e2e.test.ts +1 -0
- package/src/voice-message.e2e.test.ts +1 -1
- package/src/voice.ts +3 -2
- package/src/worktree-lifecycle.e2e.test.ts +138 -53
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
* - https://github.com/badlogic/pi-mono/blob/main/packages/ai/src/providers/anthropic.ts
|
|
24
24
|
*/
|
|
25
25
|
import { appendToastSessionMarker } from "./plugin-logger.js";
|
|
26
|
+
import { createPluginClient } from "./plugin-opencode-client.js";
|
|
26
27
|
import { loadAccountStore, rememberAnthropicOAuth, rotateAnthropicAccount, saveAccountStore, setAnthropicAuth, shouldRotateAuth, upsertAccount, withAuthStateLock, } from "./anthropic-auth-state.js";
|
|
27
28
|
import { extractAnthropicAccountIdentity, } from "./anthropic-account-identity.js";
|
|
28
29
|
// PKCE (Proof Key for Code Exchange) using Web Crypto API.
|
|
@@ -766,7 +767,10 @@ async function getFreshOAuth(getAuth, client) {
|
|
|
766
767
|
pendingRefresh.delete(auth.refresh);
|
|
767
768
|
});
|
|
768
769
|
}
|
|
769
|
-
const AnthropicAuthPlugin = async ({
|
|
770
|
+
const AnthropicAuthPlugin = async ({ serverUrl, directory }) => {
|
|
771
|
+
// Build our own v2 client. The plugin-provided ctx.client (v1) does not
|
|
772
|
+
// reliably make REST calls from inside the plugin process.
|
|
773
|
+
const client = createPluginClient({ serverUrl, directory });
|
|
770
774
|
return {
|
|
771
775
|
"chat.headers": async (input, output) => {
|
|
772
776
|
if (input.model.providerID !== "anthropic") {
|
|
@@ -816,13 +820,11 @@ const AnthropicAuthPlugin = async ({ client }) => {
|
|
|
816
820
|
const rewritten = rewriteRequestPayload(originalBody, (msg) => {
|
|
817
821
|
client.tui
|
|
818
822
|
.showToast({
|
|
819
|
-
|
|
820
|
-
message:
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
variant: "error",
|
|
825
|
-
},
|
|
823
|
+
message: appendToastSessionMarker({
|
|
824
|
+
message: msg,
|
|
825
|
+
sessionId,
|
|
826
|
+
}),
|
|
827
|
+
variant: "error",
|
|
826
828
|
})
|
|
827
829
|
.catch(() => { });
|
|
828
830
|
});
|
|
@@ -859,13 +861,11 @@ const AnthropicAuthPlugin = async ({ client }) => {
|
|
|
859
861
|
// Show toast notification so Discord thread shows the rotation
|
|
860
862
|
client.tui
|
|
861
863
|
.showToast({
|
|
862
|
-
|
|
863
|
-
message:
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
variant: "info",
|
|
868
|
-
},
|
|
864
|
+
message: appendToastSessionMarker({
|
|
865
|
+
message: `Switching from account ${rotated.fromLabel} to account ${rotated.toLabel}`,
|
|
866
|
+
sessionId,
|
|
867
|
+
}),
|
|
868
|
+
variant: "info",
|
|
869
869
|
})
|
|
870
870
|
.catch(() => { });
|
|
871
871
|
const retryAuth = await getFreshOAuth(getAuth, client);
|
|
@@ -55,7 +55,7 @@ async function writeAnthropicAuthFile(auth) {
|
|
|
55
55
|
}
|
|
56
56
|
export async function setAnthropicAuth(auth, client) {
|
|
57
57
|
await writeAnthropicAuthFile(auth);
|
|
58
|
-
await client.auth.set({
|
|
58
|
+
await client.auth.set({ providerID: 'anthropic', auth });
|
|
59
59
|
}
|
|
60
60
|
// --- Current account ---
|
|
61
61
|
export async function getCurrentAnthropicAccount() {
|
|
@@ -97,8 +97,8 @@ describe('rotateAnthropicAccount', () => {
|
|
|
97
97
|
expect(authJson.anthropic?.refresh).toBe('refresh-second');
|
|
98
98
|
expect(authSetCalls).toEqual([
|
|
99
99
|
{
|
|
100
|
-
|
|
101
|
-
|
|
100
|
+
providerID: 'anthropic',
|
|
101
|
+
auth: {
|
|
102
102
|
type: 'oauth',
|
|
103
103
|
refresh: 'refresh-second',
|
|
104
104
|
access: 'access-second',
|
|
@@ -71,12 +71,14 @@ describe('channel reference permissions', () => {
|
|
|
71
71
|
--- from: assistant (TestBot)
|
|
72
72
|
*using deterministic-provider/deterministic-v2*
|
|
73
73
|
⬥ reading referenced channel directory
|
|
74
|
+
┣ read *allowed.txt*
|
|
74
75
|
⬥ channel-reference-read-done
|
|
75
76
|
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
|
|
76
77
|
--- from: user (channel-reference-tester)
|
|
77
78
|
Use <#200000000000001022> CHANNEL_REFERENCE_PERMISSION_MARKER followup
|
|
78
79
|
--- from: assistant (TestBot)
|
|
79
80
|
⬥ reading referenced channel directory
|
|
81
|
+
┣ read *allowed.txt*
|
|
80
82
|
⬥ channel-reference-read-done
|
|
81
83
|
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
|
|
82
84
|
`);
|
package/dist/cli-parsing.test.js
CHANGED
|
@@ -15,7 +15,7 @@ async function parseWithGoke(argv) {
|
|
|
15
15
|
"cli.command('multioauth anthropic remove <indexOrEmail>', 'Remove stored Anthropic account')",
|
|
16
16
|
"cli.command('multioauth openai list', 'List stored OpenAI accounts')",
|
|
17
17
|
"cli.command('multioauth openai remove <indexOrEmail>', 'Remove stored OpenAI account')",
|
|
18
|
-
`const result = cli.parse(${JSON.stringify(argv)}, { run: false })`,
|
|
18
|
+
`const result = await cli.parse(${JSON.stringify(argv)}, { run: false })`,
|
|
19
19
|
'process.stdout.write(JSON.stringify({ args: result.args, options: result.options }))',
|
|
20
20
|
].join(';');
|
|
21
21
|
const { stdout } = await execAsync(`node --input-type=module -e ${JSON.stringify(script)}`, {
|
package/dist/cli.js
CHANGED
|
@@ -40,6 +40,7 @@ cli
|
|
|
40
40
|
.option('--no-critique', 'Disable automatic diff upload to critique.work in system prompts')
|
|
41
41
|
.option('--auto-restart', 'Automatically restart the bot on crash or OOM kill')
|
|
42
42
|
.option('--allow-all-users', 'Allow all Discord users to start sessions without needing Kimaki role or admin permissions (no-kimaki role still blocks)')
|
|
43
|
+
.option('--permission-timeout-minutes <minutes>', 'Permission prompt timeout in minutes before auto-rejecting (default: 10)')
|
|
43
44
|
.option('--disable-sync', 'Disable background sync of external OpenCode sessions into Discord')
|
|
44
45
|
.option('--no-sentry', 'Disable Sentry error reporting')
|
|
45
46
|
.option('--gateway', 'Force gateway mode (use the gateway Kimaki bot instead of a self-hosted bot)')
|
|
@@ -134,6 +135,19 @@ cli
|
|
|
134
135
|
}
|
|
135
136
|
}
|
|
136
137
|
}
|
|
138
|
+
// --permission-timeout-minutes validation
|
|
139
|
+
// Node setTimeout max is 2_147_483_647ms; larger values fire immediately.
|
|
140
|
+
const MAX_TIMEOUT_MINUTES = Math.floor(2_147_483_647 / 60_000);
|
|
141
|
+
const permissionTimeoutMs = (() => {
|
|
142
|
+
if (!options.permissionTimeoutMinutes)
|
|
143
|
+
return undefined;
|
|
144
|
+
const parsed = Number(options.permissionTimeoutMinutes);
|
|
145
|
+
if (!Number.isInteger(parsed) || parsed <= 0 || parsed > MAX_TIMEOUT_MINUTES) {
|
|
146
|
+
cliLogger.error(`Invalid permission timeout: ${options.permissionTimeoutMinutes}. Must be a positive whole number of minutes (max ${MAX_TIMEOUT_MINUTES}).`);
|
|
147
|
+
process.exit(EXIT_NO_RESTART);
|
|
148
|
+
}
|
|
149
|
+
return parsed * 60_000;
|
|
150
|
+
})();
|
|
137
151
|
store.setState({
|
|
138
152
|
...(defaultVerbosity && {
|
|
139
153
|
defaultVerbosity,
|
|
@@ -141,6 +155,7 @@ cli
|
|
|
141
155
|
...(options.mentionMode && { defaultMentionMode: true }),
|
|
142
156
|
...(options.noCritique && { critiqueEnabled: false }),
|
|
143
157
|
...(options.allowAllUsers && { allowAllUsers: true }),
|
|
158
|
+
...(permissionTimeoutMs !== undefined && { permissionTimeoutMs }),
|
|
144
159
|
...(options.disableSync && { syncEnabled: false }),
|
|
145
160
|
...(enabledSkills.length > 0 && { enabledSkills }),
|
|
146
161
|
...(disabledSkills.length > 0 && { disabledSkills }),
|
|
@@ -155,6 +170,9 @@ cli
|
|
|
155
170
|
if (options.allowAllUsers) {
|
|
156
171
|
cliLogger.log('Allow all users: any Discord member can start sessions (no-kimaki role still blocks)');
|
|
157
172
|
}
|
|
173
|
+
if (permissionTimeoutMs !== undefined) {
|
|
174
|
+
cliLogger.log(`Permission timeout set to ${options.permissionTimeoutMinutes} minutes`);
|
|
175
|
+
}
|
|
158
176
|
if (options.verbosity) {
|
|
159
177
|
cliLogger.log(`Default verbosity: ${options.verbosity}`);
|
|
160
178
|
}
|
|
@@ -207,4 +225,4 @@ cli.use(sessionCommands);
|
|
|
207
225
|
cli.use(maintenanceCommands);
|
|
208
226
|
cli.version(getCurrentVersion());
|
|
209
227
|
cli.help();
|
|
210
|
-
cli.parse();
|
|
228
|
+
void cli.parse();
|
|
@@ -212,7 +212,9 @@ export async function handleActionButton(interaction) {
|
|
|
212
212
|
content: `**Action Required**\n_Selected: ${button.label}_`,
|
|
213
213
|
components: [],
|
|
214
214
|
});
|
|
215
|
+
const username = interaction.user.globalName || interaction.user.username;
|
|
215
216
|
const prompt = `User clicked: ${button.label}`;
|
|
217
|
+
await sendThreadMessage(thread, `» **${username}:** ${button.label}`);
|
|
216
218
|
try {
|
|
217
219
|
await sendClickedActionToModel({
|
|
218
220
|
interaction,
|
|
@@ -183,6 +183,8 @@ export async function handleAskQuestionSelectMenu(interaction) {
|
|
|
183
183
|
content: `**${question.header}**\n${question.question}\n✓ _${answeredText}_`,
|
|
184
184
|
components: [], // Remove the dropdown
|
|
185
185
|
});
|
|
186
|
+
const username = interaction.user.globalName || interaction.user.username;
|
|
187
|
+
await sendThreadMessage(context.thread, `» **${username}:** ${answeredText}`);
|
|
186
188
|
// Check if all questions are answered
|
|
187
189
|
if (context.answeredCount >= context.totalQuestions) {
|
|
188
190
|
// All questions answered - send result back to session
|
package/dist/commands/compact.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// /compact command - Trigger context compaction (summarization) for the current session.
|
|
2
2
|
import { ChannelType, MessageFlags, } from 'discord.js';
|
|
3
3
|
import { getThreadSession } from '../database.js';
|
|
4
|
-
import { initializeOpencodeForDirectory, getOpencodeClient, } from '../opencode.js';
|
|
4
|
+
import { initializeOpencodeForDirectory, getOpencodeClient, extractSdkErrorMessage, } from '../opencode.js';
|
|
5
5
|
import { resolveWorkingDirectory, SILENT_MESSAGE_FLAGS, } from '../discord-utils.js';
|
|
6
6
|
import { createLogger, LogPrefix } from '../logger.js';
|
|
7
7
|
const logger = createLogger(LogPrefix.COMPACT);
|
|
@@ -97,10 +97,7 @@ export async function handleCompactCommand({ command, }) {
|
|
|
97
97
|
});
|
|
98
98
|
if (result.error) {
|
|
99
99
|
logger.error('[COMPACT] Error:', result.error);
|
|
100
|
-
const
|
|
101
|
-
const errorMessage = errorData && typeof errorData === 'object' && 'message' in errorData
|
|
102
|
-
? String(errorData.message || 'Unknown error')
|
|
103
|
-
: 'Unknown error';
|
|
100
|
+
const errorMessage = extractSdkErrorMessage(result.error);
|
|
104
101
|
await command.editReply({
|
|
105
102
|
content: `Failed to compact: ${errorMessage}`,
|
|
106
103
|
});
|
|
@@ -11,7 +11,7 @@ import fs from 'node:fs';
|
|
|
11
11
|
import path from 'node:path';
|
|
12
12
|
import { createLogger, LogPrefix } from '../logger.js';
|
|
13
13
|
import { notifyError } from '../sentry.js';
|
|
14
|
-
import { NOTIFY_MESSAGE_FLAGS } from '../discord-utils.js';
|
|
14
|
+
import { NOTIFY_MESSAGE_FLAGS, sendThreadMessage } from '../discord-utils.js';
|
|
15
15
|
const logger = createLogger(LogPrefix.FILE_UPLOAD);
|
|
16
16
|
// 5 minute TTL for pending contexts - if user doesn't click within this time,
|
|
17
17
|
// clean up the context and resolve with empty array to unblock the plugin tool
|
|
@@ -219,6 +219,10 @@ export async function handleFileUploadModalSubmit(interaction) {
|
|
|
219
219
|
return `Upload failed: ${errors.join('; ')}`;
|
|
220
220
|
})();
|
|
221
221
|
await interaction.editReply({ content: summary });
|
|
222
|
+
if (downloadedPaths.length > 0) {
|
|
223
|
+
const username = interaction.user.globalName || interaction.user.username;
|
|
224
|
+
await sendThreadMessage(context.thread, `» **${username}:** Uploaded ${fileNames.join(', ')}`);
|
|
225
|
+
}
|
|
222
226
|
resolveContext(context, downloadedPaths);
|
|
223
227
|
logger.log(`File upload completed for session ${context.sessionId}: ${downloadedPaths.length} files`);
|
|
224
228
|
}
|
|
@@ -7,11 +7,10 @@
|
|
|
7
7
|
// Map. Whichever menu fires second sees the first selection stored and applies.
|
|
8
8
|
import { ChatInputCommandInteraction, StringSelectMenuInteraction, StringSelectMenuBuilder, ActionRowBuilder, ChannelType, MessageFlags, } from 'discord.js';
|
|
9
9
|
import crypto from 'node:crypto';
|
|
10
|
-
import { setChannelModel,
|
|
10
|
+
import { setChannelModel, getThreadSession, setGlobalModel, getVariantCascade, } from '../database.js';
|
|
11
11
|
import { initializeOpencodeForDirectory } from '../opencode.js';
|
|
12
12
|
import { resolveTextChannel, getKimakiMetadata } from '../discord-utils.js';
|
|
13
|
-
import { getCurrentModelInfo, ensureSessionPreferencesSnapshot, } from './model.js';
|
|
14
|
-
import { getRuntime } from '../session-handler/thread-session-runtime.js';
|
|
13
|
+
import { getCurrentModelInfo, ensureSessionPreferencesSnapshot, applyToCurrentSession, } from './model.js';
|
|
15
14
|
import { getThinkingValuesForModel } from '../thinking-utils.js';
|
|
16
15
|
import { createLogger, LogPrefix } from '../logger.js';
|
|
17
16
|
const logger = createLogger(LogPrefix.MODEL);
|
|
@@ -292,7 +291,7 @@ export async function handleVariantScopeSelectMenu(interaction) {
|
|
|
292
291
|
async function applyVariant({ interaction, context, variant, scope, contextHash, }) {
|
|
293
292
|
const modelId = context.modelId;
|
|
294
293
|
const variantSuffix = variant ? ` (${variant})` : '';
|
|
295
|
-
const agentTip = '\n_Tip: create [agent .md files](https://kimaki.dev/docs/model-switching) in .opencode/agent/ for one-command model switching_';
|
|
294
|
+
const agentTip = '\n_Tip: create [agent .md files](https://kimaki.dev/docs/getting-started/model-switching) in .opencode/agent/ for one-command model switching_';
|
|
296
295
|
try {
|
|
297
296
|
if (scope === 'session') {
|
|
298
297
|
if (!context.sessionId) {
|
|
@@ -303,22 +302,14 @@ async function applyVariant({ interaction, context, variant, scope, contextHash,
|
|
|
303
302
|
});
|
|
304
303
|
return;
|
|
305
304
|
}
|
|
306
|
-
await
|
|
305
|
+
const retried = await applyToCurrentSession({
|
|
307
306
|
sessionId: context.sessionId,
|
|
307
|
+
thread: context.thread,
|
|
308
308
|
modelId,
|
|
309
309
|
variant,
|
|
310
310
|
});
|
|
311
|
+
const retryNote = retried ? '\n_Restarting current request with new variant..._' : '';
|
|
311
312
|
logger.log(`Set variant ${variant ?? 'none'} for session ${context.sessionId} (model ${modelId})`);
|
|
312
|
-
let retried = false;
|
|
313
|
-
if (context.thread) {
|
|
314
|
-
const runtime = getRuntime(context.thread.id);
|
|
315
|
-
if (runtime) {
|
|
316
|
-
retried = await runtime.retryLastUserPrompt();
|
|
317
|
-
}
|
|
318
|
-
}
|
|
319
|
-
const retryNote = retried
|
|
320
|
-
? '\n_Restarting current request with new variant..._'
|
|
321
|
-
: '';
|
|
322
313
|
await interaction.editReply({
|
|
323
314
|
content: `Variant set for this session:\n**${context.providerName}** / **${context.modelName}**${variantSuffix}\n\`${modelId}\`${retryNote}${agentTip}`,
|
|
324
315
|
flags: MessageFlags.SuppressEmbeds,
|
|
@@ -332,9 +323,16 @@ async function applyVariant({ interaction, context, variant, scope, contextHash,
|
|
|
332
323
|
modelId,
|
|
333
324
|
variant,
|
|
334
325
|
});
|
|
326
|
+
const retried = await applyToCurrentSession({
|
|
327
|
+
sessionId: context.sessionId,
|
|
328
|
+
thread: context.thread,
|
|
329
|
+
modelId,
|
|
330
|
+
variant,
|
|
331
|
+
});
|
|
332
|
+
const retryNote = retried ? '\n_Restarting current request with new variant..._' : '';
|
|
335
333
|
logger.log(`Set global variant ${variant ?? 'none'} for app ${context.appId} and channel ${context.channelId} (model ${modelId})`);
|
|
336
334
|
await interaction.editReply({
|
|
337
|
-
content: `Variant set for this channel and as global default:\n**${context.providerName}** / **${context.modelName}**${variantSuffix}\n\`${modelId}\`\nAll channels will use this variant (unless they have their own override).${agentTip}`,
|
|
335
|
+
content: `Variant set for this channel and as global default:\n**${context.providerName}** / **${context.modelName}**${variantSuffix}\n\`${modelId}\`\nAll channels will use this variant (unless they have their own override).${retryNote}${agentTip}`,
|
|
338
336
|
flags: MessageFlags.SuppressEmbeds,
|
|
339
337
|
components: [],
|
|
340
338
|
});
|
|
@@ -346,9 +344,16 @@ async function applyVariant({ interaction, context, variant, scope, contextHash,
|
|
|
346
344
|
modelId,
|
|
347
345
|
variant,
|
|
348
346
|
});
|
|
347
|
+
const retried = await applyToCurrentSession({
|
|
348
|
+
sessionId: context.sessionId,
|
|
349
|
+
thread: context.thread,
|
|
350
|
+
modelId,
|
|
351
|
+
variant,
|
|
352
|
+
});
|
|
353
|
+
const retryNote = retried ? '\n_Restarting current request with new variant..._' : '';
|
|
349
354
|
logger.log(`Set channel variant ${variant ?? 'none'} for channel ${context.channelId} (model ${modelId})`);
|
|
350
355
|
await interaction.editReply({
|
|
351
|
-
content: `Variant set for this channel:\n**${context.providerName}** / **${context.modelName}**${variantSuffix}\n\`${modelId}\`\nAll new sessions in this channel will use this variant.${agentTip}`,
|
|
356
|
+
content: `Variant set for this channel:\n**${context.providerName}** / **${context.modelName}**${variantSuffix}\n\`${modelId}\`\nAll new sessions in this channel will use this variant.${retryNote}${agentTip}`,
|
|
352
357
|
flags: MessageFlags.SuppressEmbeds,
|
|
353
358
|
components: [],
|
|
354
359
|
});
|
package/dist/commands/model.js
CHANGED
|
@@ -693,6 +693,24 @@ async function showScopeMenu({ interaction, contextHash, context, }) {
|
|
|
693
693
|
components: [actionRow],
|
|
694
694
|
});
|
|
695
695
|
}
|
|
696
|
+
/**
|
|
697
|
+
* If a session is active, also update its model preference and retry.
|
|
698
|
+
* Returns true if the current request was restarted.
|
|
699
|
+
*/
|
|
700
|
+
export async function applyToCurrentSession({ sessionId, thread, modelId, variant, }) {
|
|
701
|
+
if (!sessionId) {
|
|
702
|
+
return false;
|
|
703
|
+
}
|
|
704
|
+
await setSessionModel({ sessionId, modelId, variant });
|
|
705
|
+
if (!thread) {
|
|
706
|
+
return false;
|
|
707
|
+
}
|
|
708
|
+
const runtime = getRuntime(thread.id);
|
|
709
|
+
if (!runtime) {
|
|
710
|
+
return false;
|
|
711
|
+
}
|
|
712
|
+
return runtime.retryLastUserPrompt();
|
|
713
|
+
}
|
|
696
714
|
/**
|
|
697
715
|
* Handle the scope select menu interaction.
|
|
698
716
|
* Applies the model to either the channel or globally.
|
|
@@ -728,7 +746,7 @@ export async function handleModelScopeSelectMenu(interaction) {
|
|
|
728
746
|
const modelDisplay = modelId.split('/')[1] || modelId;
|
|
729
747
|
const variant = context.selectedVariant ?? null;
|
|
730
748
|
const variantSuffix = variant ? ` (${variant})` : '';
|
|
731
|
-
const agentTip = '\n_Tip: create [agent .md files](https://kimaki.dev/docs/model-switching) in .opencode/agent/ for one-command model switching_';
|
|
749
|
+
const agentTip = '\n_Tip: create [agent .md files](https://kimaki.dev/docs/getting-started/model-switching) in .opencode/agent/ for one-command model switching_';
|
|
732
750
|
try {
|
|
733
751
|
if (selectedScope === 'session') {
|
|
734
752
|
if (!context.sessionId) {
|
|
@@ -739,18 +757,14 @@ export async function handleModelScopeSelectMenu(interaction) {
|
|
|
739
757
|
});
|
|
740
758
|
return;
|
|
741
759
|
}
|
|
742
|
-
await
|
|
760
|
+
const retried = await applyToCurrentSession({
|
|
761
|
+
sessionId: context.sessionId,
|
|
762
|
+
thread: context.thread,
|
|
763
|
+
modelId,
|
|
764
|
+
variant,
|
|
765
|
+
});
|
|
766
|
+
const retryNote = retried ? '\n_Restarting current request with new model..._' : '';
|
|
743
767
|
modelLogger.log(`Set model ${modelId}${variantSuffix} for session ${context.sessionId}`);
|
|
744
|
-
let retried = false;
|
|
745
|
-
if (context.thread) {
|
|
746
|
-
const runtime = getRuntime(context.thread.id);
|
|
747
|
-
if (runtime) {
|
|
748
|
-
retried = await runtime.retryLastUserPrompt();
|
|
749
|
-
}
|
|
750
|
-
}
|
|
751
|
-
const retryNote = retried
|
|
752
|
-
? '\n_Restarting current request with new model..._'
|
|
753
|
-
: '';
|
|
754
768
|
await interaction.editReply({
|
|
755
769
|
content: `Model set for this session:\n**${context.providerName}** / **${modelDisplay}**${variantSuffix}\n\`${modelId}\`${retryNote}${agentTip}`,
|
|
756
770
|
flags: MessageFlags.SuppressEmbeds,
|
|
@@ -768,9 +782,16 @@ export async function handleModelScopeSelectMenu(interaction) {
|
|
|
768
782
|
}
|
|
769
783
|
await setGlobalModel({ appId: context.appId, modelId, variant });
|
|
770
784
|
await setChannelModel({ channelId: context.channelId, modelId, variant });
|
|
785
|
+
const retried = await applyToCurrentSession({
|
|
786
|
+
sessionId: context.sessionId,
|
|
787
|
+
thread: context.thread,
|
|
788
|
+
modelId,
|
|
789
|
+
variant,
|
|
790
|
+
});
|
|
791
|
+
const retryNote = retried ? '\n_Restarting current request with new model..._' : '';
|
|
771
792
|
modelLogger.log(`Set global model ${modelId}${variantSuffix} for app ${context.appId} and channel ${context.channelId}`);
|
|
772
793
|
await interaction.editReply({
|
|
773
|
-
content: `Model set for this channel and as global default:\n**${context.providerName}** / **${modelDisplay}**${variantSuffix}\n\`${modelId}\`\nAll channels will use this model (unless they have their own override).${agentTip}`,
|
|
794
|
+
content: `Model set for this channel and as global default:\n**${context.providerName}** / **${modelDisplay}**${variantSuffix}\n\`${modelId}\`\nAll channels will use this model (unless they have their own override).${retryNote}${agentTip}`,
|
|
774
795
|
flags: MessageFlags.SuppressEmbeds,
|
|
775
796
|
components: [],
|
|
776
797
|
});
|
|
@@ -778,9 +799,16 @@ export async function handleModelScopeSelectMenu(interaction) {
|
|
|
778
799
|
else {
|
|
779
800
|
// channel scope
|
|
780
801
|
await setChannelModel({ channelId: context.channelId, modelId, variant });
|
|
802
|
+
const retried = await applyToCurrentSession({
|
|
803
|
+
sessionId: context.sessionId,
|
|
804
|
+
thread: context.thread,
|
|
805
|
+
modelId,
|
|
806
|
+
variant,
|
|
807
|
+
});
|
|
808
|
+
const retryNote = retried ? '\n_Restarting current request with new model..._' : '';
|
|
781
809
|
modelLogger.log(`Set model ${modelId}${variantSuffix} for channel ${context.channelId}`);
|
|
782
810
|
await interaction.editReply({
|
|
783
|
-
content: `Model preference set for this channel:\n**${context.providerName}** / **${modelDisplay}**${variantSuffix}\n\`${modelId}\`\nAll new sessions in this channel will use this model.${agentTip}`,
|
|
811
|
+
content: `Model preference set for this channel:\n**${context.providerName}** / **${modelDisplay}**${variantSuffix}\n\`${modelId}\`\nAll new sessions in this channel will use this model.${retryNote}${agentTip}`,
|
|
784
812
|
flags: MessageFlags.SuppressEmbeds,
|
|
785
813
|
components: [],
|
|
786
814
|
});
|
|
@@ -3,12 +3,13 @@
|
|
|
3
3
|
// Creates thread immediately, then worktree in background so user can type
|
|
4
4
|
import { ChannelType, REST, } from 'discord.js';
|
|
5
5
|
import fs from 'node:fs';
|
|
6
|
-
import { createPendingWorktree, setWorktreeReady, setWorktreeError, getChannelDirectory,
|
|
7
|
-
import { SILENT_MESSAGE_FLAGS, reactToThread, resolveProjectDirectoryFromAutocomplete, } from '../discord-utils.js';
|
|
6
|
+
import { createPendingWorktree, setWorktreeReady, setWorktreeError, getChannelDirectory, getThreadSession, setThreadSession, } from '../database.js';
|
|
7
|
+
import { SILENT_MESSAGE_FLAGS, reactToThread, resolveProjectDirectoryFromAutocomplete, resolveTextChannel, sendThreadMessage, } 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';
|
|
11
|
-
import {
|
|
11
|
+
import { getOrCreateRuntime } from '../session-handler/thread-session-runtime.js';
|
|
12
|
+
import { buildSessionPermissions, initializeOpencodeForDirectory, } from '../opencode.js';
|
|
12
13
|
import { WORKTREE_PREFIX } from './merge-worktree.js';
|
|
13
14
|
import * as errore from 'errore';
|
|
14
15
|
const logger = createLogger(LogPrefix.WORKTREE);
|
|
@@ -187,10 +188,6 @@ export async function createWorktreeInBackground({ thread, starterMessage, workt
|
|
|
187
188
|
threadId: thread.id,
|
|
188
189
|
worktreeDirectory: worktreeResult.directory,
|
|
189
190
|
});
|
|
190
|
-
await denyPreviousCheckoutForExistingSession({
|
|
191
|
-
threadId: thread.id,
|
|
192
|
-
projectDirectory,
|
|
193
|
-
});
|
|
194
191
|
// React with tree emoji to mark as worktree thread
|
|
195
192
|
await reactToThread({
|
|
196
193
|
rest,
|
|
@@ -210,41 +207,6 @@ export async function createWorktreeInBackground({ thread, starterMessage, workt
|
|
|
210
207
|
},
|
|
211
208
|
});
|
|
212
209
|
}
|
|
213
|
-
async function denyPreviousCheckoutForExistingSession({ threadId, projectDirectory, }) {
|
|
214
|
-
const sessionId = await getThreadSession(threadId);
|
|
215
|
-
if (!sessionId) {
|
|
216
|
-
return;
|
|
217
|
-
}
|
|
218
|
-
const initializeResult = await initializeOpencodeForDirectory(projectDirectory);
|
|
219
|
-
if (initializeResult instanceof Error) {
|
|
220
|
-
logger.warn(`[WORKTREE] Failed to initialize OpenCode before denying previous checkout for thread ${threadId}: ${initializeResult.message}`);
|
|
221
|
-
return;
|
|
222
|
-
}
|
|
223
|
-
const client = getOpencodeClient(projectDirectory);
|
|
224
|
-
if (!client) {
|
|
225
|
-
logger.warn(`[WORKTREE] Missing OpenCode client for previous checkout deny update in thread ${threadId}`);
|
|
226
|
-
return;
|
|
227
|
-
}
|
|
228
|
-
const updateResult = await errore.tryAsync({
|
|
229
|
-
try: async () => {
|
|
230
|
-
await client.session.update({
|
|
231
|
-
sessionID: sessionId,
|
|
232
|
-
permission: buildExternalDirectoryPermissionRules({
|
|
233
|
-
resolvedPattern: projectDirectory.replaceAll('\\', '/'),
|
|
234
|
-
action: 'deny',
|
|
235
|
-
}),
|
|
236
|
-
});
|
|
237
|
-
},
|
|
238
|
-
catch: (e) => new Error('Failed to deny previous checkout for existing session', {
|
|
239
|
-
cause: e,
|
|
240
|
-
}),
|
|
241
|
-
});
|
|
242
|
-
if (updateResult instanceof Error) {
|
|
243
|
-
logger.warn(`[WORKTREE] Failed to deny previous checkout for existing session in thread ${threadId}: ${updateResult.message}`);
|
|
244
|
-
return;
|
|
245
|
-
}
|
|
246
|
-
logger.log(`[WORKTREE] Denied previous checkout for existing session ${sessionId} in thread ${threadId}`);
|
|
247
|
-
}
|
|
248
210
|
async function findExistingWorktreePath({ projectDirectory, worktreeName, }) {
|
|
249
211
|
const listResult = await errore.tryAsync({
|
|
250
212
|
try: () => execAsync('git worktree list --porcelain', { cwd: projectDirectory }),
|
|
@@ -268,7 +230,7 @@ async function findExistingWorktreePath({ projectDirectory, worktreeName, }) {
|
|
|
268
230
|
}
|
|
269
231
|
return undefined;
|
|
270
232
|
}
|
|
271
|
-
export async function handleNewWorktreeCommand({ command, }) {
|
|
233
|
+
export async function handleNewWorktreeCommand({ command, appId, }) {
|
|
272
234
|
await command.deferReply();
|
|
273
235
|
const channel = command.channel;
|
|
274
236
|
if (!channel) {
|
|
@@ -281,6 +243,7 @@ export async function handleNewWorktreeCommand({ command, }) {
|
|
|
281
243
|
await handleWorktreeInThread({
|
|
282
244
|
command,
|
|
283
245
|
thread: channel,
|
|
246
|
+
appId,
|
|
284
247
|
});
|
|
285
248
|
return;
|
|
286
249
|
}
|
|
@@ -351,7 +314,7 @@ export async function handleNewWorktreeCommand({ command, }) {
|
|
|
351
314
|
const { thread, starterMessage } = result;
|
|
352
315
|
await command.editReply(`Creating worktree in ${thread.toString()}`);
|
|
353
316
|
// Create worktree in background (don't await)
|
|
354
|
-
createWorktreeInBackground({
|
|
317
|
+
void createWorktreeInBackground({
|
|
355
318
|
thread,
|
|
356
319
|
starterMessage,
|
|
357
320
|
worktreeName,
|
|
@@ -365,14 +328,10 @@ export async function handleNewWorktreeCommand({ command, }) {
|
|
|
365
328
|
}
|
|
366
329
|
/**
|
|
367
330
|
* Handle /new-worktree when called inside an existing thread.
|
|
368
|
-
*
|
|
331
|
+
* Creates a separate worktree thread, using the source thread name if no name
|
|
332
|
+
* is provided. The source thread stays bound to its original directory.
|
|
369
333
|
*/
|
|
370
|
-
async function handleWorktreeInThread({ command, thread, }) {
|
|
371
|
-
// Error if thread already has a worktree
|
|
372
|
-
if (await getThreadWorktree(thread.id)) {
|
|
373
|
-
await command.editReply('This thread already has a worktree attached.');
|
|
374
|
-
return;
|
|
375
|
-
}
|
|
334
|
+
async function handleWorktreeInThread({ command, thread, appId, }) {
|
|
376
335
|
// Get worktree name from parameter or derive from thread name
|
|
377
336
|
const rawName = command.options.getString('name');
|
|
378
337
|
const rawBaseBranch = command.options.getString('base-branch') || undefined;
|
|
@@ -414,20 +373,109 @@ async function handleWorktreeInThread({ command, thread, }) {
|
|
|
414
373
|
await command.editReply(`Worktree \`${worktreeName}\` already exists at \`${existingWorktreePath}\``);
|
|
415
374
|
return;
|
|
416
375
|
}
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
376
|
+
const textChannel = await resolveTextChannel(thread);
|
|
377
|
+
if (!textChannel) {
|
|
378
|
+
await command.editReply('Could not resolve parent text channel');
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
const threadResult = await errore.tryAsync({
|
|
382
|
+
try: async () => {
|
|
383
|
+
const worktreeThread = await textChannel.threads.create({
|
|
384
|
+
name: `${WORKTREE_PREFIX}worktree: ${worktreeName}`.slice(0, 100),
|
|
385
|
+
autoArchiveDuration: 1440,
|
|
386
|
+
reason: `Worktree fork from thread ${thread.id}`,
|
|
387
|
+
});
|
|
388
|
+
await worktreeThread.members.add(command.user.id);
|
|
389
|
+
const statusMessage = await worktreeThread.send({
|
|
390
|
+
content: worktreeCreatingMessage(worktreeName),
|
|
391
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
392
|
+
});
|
|
393
|
+
return { worktreeThread, statusMessage };
|
|
394
|
+
},
|
|
395
|
+
catch: (e) => new WorktreeError('Failed to create worktree thread', { cause: e }),
|
|
421
396
|
});
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
397
|
+
if (threadResult instanceof Error) {
|
|
398
|
+
await command.editReply(threadResult.message);
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
const { worktreeThread, statusMessage } = threadResult;
|
|
402
|
+
await command.editReply(`Creating worktree in ${worktreeThread.toString()}`);
|
|
403
|
+
void createWorktreeInBackground({
|
|
404
|
+
thread: worktreeThread,
|
|
425
405
|
starterMessage: statusMessage,
|
|
426
406
|
worktreeName,
|
|
427
407
|
projectDirectory,
|
|
428
408
|
baseBranch,
|
|
429
409
|
rest: command.client.rest,
|
|
430
|
-
})
|
|
410
|
+
})
|
|
411
|
+
.then(async (result) => {
|
|
412
|
+
if (result instanceof Error) {
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
const sourceSessionId = await getThreadSession(thread.id);
|
|
416
|
+
if (!sourceSessionId) {
|
|
417
|
+
await sendThreadMessage(worktreeThread, 'Worktree is ready. Send a message here to start a fresh session in this checkout.');
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
const getClient = await initializeOpencodeForDirectory(result, {
|
|
421
|
+
originalRepoDirectory: projectDirectory,
|
|
422
|
+
channelId: parent.id,
|
|
423
|
+
});
|
|
424
|
+
if (getClient instanceof Error) {
|
|
425
|
+
await sendThreadMessage(worktreeThread, `✗ Worktree is ready, but failed to initialize OpenCode for context reuse: ${getClient.message}`);
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
const forkResponse = await errore.tryAsync(() => {
|
|
429
|
+
return getClient().session.fork({
|
|
430
|
+
sessionID: sourceSessionId,
|
|
431
|
+
directory: result,
|
|
432
|
+
});
|
|
433
|
+
});
|
|
434
|
+
if (forkResponse instanceof Error) {
|
|
435
|
+
logger.error('[NEW-WORKTREE] Failed to fork session into worktree:', forkResponse);
|
|
436
|
+
void notifyError(forkResponse, 'Failed to fork session into worktree');
|
|
437
|
+
await sendThreadMessage(worktreeThread, `✗ Worktree is ready, but failed to reuse session context there: ${forkResponse.message}`);
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
const forkedSession = forkResponse.data;
|
|
441
|
+
if (!forkedSession) {
|
|
442
|
+
const error = new Error('OpenCode did not return a forked session');
|
|
443
|
+
logger.error('[NEW-WORKTREE] Failed to fork session into worktree:', error);
|
|
444
|
+
void notifyError(error, 'Failed to fork session into worktree');
|
|
445
|
+
await sendThreadMessage(worktreeThread, `✗ Worktree is ready, but failed to reuse session context there: ${error.message}`);
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
const permissionResponse = await errore.tryAsync(() => {
|
|
449
|
+
return getClient().session.update({
|
|
450
|
+
sessionID: forkedSession.id,
|
|
451
|
+
directory: result,
|
|
452
|
+
permission: buildSessionPermissions({
|
|
453
|
+
directory: result,
|
|
454
|
+
originalRepoDirectory: projectDirectory,
|
|
455
|
+
}),
|
|
456
|
+
});
|
|
457
|
+
});
|
|
458
|
+
if (permissionResponse instanceof Error || permissionResponse.error) {
|
|
459
|
+
const error = permissionResponse instanceof Error
|
|
460
|
+
? permissionResponse
|
|
461
|
+
: new Error('OpenCode rejected forked session permission update');
|
|
462
|
+
logger.error('[NEW-WORKTREE] Failed to update forked session permissions:', error);
|
|
463
|
+
void notifyError(error, 'Failed to update forked session permissions');
|
|
464
|
+
await sendThreadMessage(worktreeThread, `✗ Worktree is ready, but failed to update forked session permissions: ${error.message}`);
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
await setThreadSession(worktreeThread.id, forkedSession.id);
|
|
468
|
+
getOrCreateRuntime({
|
|
469
|
+
threadId: worktreeThread.id,
|
|
470
|
+
thread: worktreeThread,
|
|
471
|
+
projectDirectory,
|
|
472
|
+
sdkDirectory: result,
|
|
473
|
+
channelId: parent.id,
|
|
474
|
+
appId,
|
|
475
|
+
});
|
|
476
|
+
await sendThreadMessage(worktreeThread, `Reusing context from <#${thread.id}> in worktree session \`${forkedSession.id}\`.`);
|
|
477
|
+
})
|
|
478
|
+
.catch((e) => {
|
|
431
479
|
logger.error('[NEW-WORKTREE] Background error:', e);
|
|
432
480
|
void notifyError(e, 'Background worktree creation failed (in-thread)');
|
|
433
481
|
});
|