kimaki 0.20.1 → 0.22.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/agent-model.e2e.test.js +6 -4
- package/dist/cli-commands/project.js +148 -24
- package/dist/cli-commands/send.js +48 -3
- package/dist/cli-runner.js +86 -7
- package/dist/cli-runner.test.js +28 -1
- package/dist/commands/btw.js +7 -3
- package/dist/commands/new-worktree.js +14 -2
- package/dist/commands/session.js +66 -13
- package/dist/commands/tasks.js +104 -19
- package/dist/commands/user-command.js +3 -2
- package/dist/database.js +14 -0
- package/dist/db.js +1 -0
- package/dist/discord-bot.js +65 -9
- package/dist/interaction-handler.js +6 -15
- package/dist/message-preprocessing.js +9 -2
- package/dist/queue-advanced-e2e-setup.js +22 -0
- package/dist/queue-delete-message.e2e.test.js +75 -0
- package/dist/schema.js +3 -0
- package/dist/session-handler/thread-runtime-state.js +9 -0
- package/dist/session-handler/thread-session-runtime.js +49 -1
- package/dist/system-message.js +50 -63
- package/dist/system-message.test.js +65 -61
- package/dist/task-runner.js +18 -3
- package/dist/task-schedule.js +4 -0
- package/dist/thread-message-queue.e2e.test.js +4 -4
- package/dist/voice-message.e2e.test.js +1 -1
- package/dist/worktree-lifecycle.e2e.test.js +70 -1
- package/dist/worktrees.js +63 -0
- package/package.json +11 -4
- package/skills/egaki/SKILL.md +26 -80
- package/skills/goke/SKILL.md +27 -20
- package/skills/holocron/SKILL.md +72 -8
- package/skills/new-skill/SKILL.md +87 -0
- package/skills/playwriter/SKILL.md +4 -4
- package/skills/spiceflow/SKILL.md +2 -0
- package/src/agent-model.e2e.test.ts +10 -4
- package/src/cli-commands/project.ts +183 -26
- package/src/cli-commands/send.ts +64 -5
- package/src/cli-runner.test.ts +39 -1
- package/src/cli-runner.ts +115 -6
- package/src/commands/btw.ts +9 -2
- package/src/commands/new-worktree.ts +14 -1
- package/src/commands/session.ts +79 -17
- package/src/commands/tasks.ts +119 -19
- package/src/commands/user-command.ts +3 -2
- package/src/database.ts +24 -0
- package/src/db.ts +1 -0
- package/src/discord-bot.ts +80 -8
- package/src/interaction-handler.ts +7 -19
- package/src/message-preprocessing.ts +11 -2
- package/src/queue-advanced-e2e-setup.ts +23 -0
- package/src/queue-delete-message.e2e.test.ts +102 -0
- package/src/schema.sql +1 -0
- package/src/schema.ts +3 -0
- package/src/session-handler/thread-runtime-state.ts +19 -0
- package/src/session-handler/thread-session-runtime.ts +75 -0
- package/src/system-message.test.ts +73 -61
- package/src/system-message.ts +63 -62
- package/src/task-runner.ts +34 -3
- package/src/task-schedule.ts +6 -0
- package/src/thread-message-queue.e2e.test.ts +4 -4
- package/src/voice-message.e2e.test.ts +1 -1
- package/src/worktree-lifecycle.e2e.test.ts +88 -1
- package/src/worktrees.ts +74 -0
package/dist/commands/session.js
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
// /new-session command - Start a new OpenCode session.
|
|
2
|
+
// Works in both text channels and threads. When used in a thread, the new
|
|
3
|
+
// session inherits the same working directory (worktree/workspace) so the
|
|
4
|
+
// user stays in the same folder context.
|
|
2
5
|
import { ChannelType } from 'discord.js';
|
|
3
6
|
import fs from 'node:fs';
|
|
4
7
|
import path from 'node:path';
|
|
5
|
-
import { getChannelDirectory } from '../database.js';
|
|
8
|
+
import { getChannelDirectory, getThreadWorktreeOrWorkspace, createPendingWorkspace, setWorkspaceReady, } from '../database.js';
|
|
6
9
|
import { initializeOpencodeForDirectory } from '../opencode.js';
|
|
7
|
-
import { SILENT_MESSAGE_FLAGS, resolveProjectDirectoryFromAutocomplete } from '../discord-utils.js';
|
|
10
|
+
import { SILENT_MESSAGE_FLAGS, resolveProjectDirectoryFromAutocomplete, resolveWorkingDirectory, resolveTextChannel, } from '../discord-utils.js';
|
|
8
11
|
import { getOrCreateRuntime } from '../session-handler/thread-session-runtime.js';
|
|
9
12
|
import { createLogger, LogPrefix } from '../logger.js';
|
|
10
13
|
import * as errore from 'errore';
|
|
@@ -15,15 +18,50 @@ export async function handleSessionCommand({ command, appId, }) {
|
|
|
15
18
|
const filesString = command.options.getString('files') || '';
|
|
16
19
|
const agent = command.options.getString('agent') || undefined;
|
|
17
20
|
const channel = command.channel;
|
|
18
|
-
|
|
19
|
-
|
|
21
|
+
const isThread = channel && [
|
|
22
|
+
ChannelType.PublicThread,
|
|
23
|
+
ChannelType.PrivateThread,
|
|
24
|
+
].includes(channel.type);
|
|
25
|
+
if (!channel || (channel.type !== ChannelType.GuildText && !isThread)) {
|
|
26
|
+
await command.editReply('This command can only be used in text channels or threads');
|
|
20
27
|
return;
|
|
21
28
|
}
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
29
|
+
// Resolve project and working directories.
|
|
30
|
+
// In a thread: inherit from the thread's session (worktree/workspace aware).
|
|
31
|
+
// In a text channel: look up the channel's configured directory.
|
|
32
|
+
let projectDirectory;
|
|
33
|
+
let sdkDirectory;
|
|
34
|
+
let textChannel;
|
|
35
|
+
let sourceWorkspace;
|
|
36
|
+
if (isThread) {
|
|
37
|
+
const threadChannel = channel;
|
|
38
|
+
const [resolved, parentChannel, workspace] = await Promise.all([
|
|
39
|
+
resolveWorkingDirectory({ channel: threadChannel }),
|
|
40
|
+
resolveTextChannel(threadChannel),
|
|
41
|
+
getThreadWorktreeOrWorkspace(threadChannel.id),
|
|
42
|
+
]);
|
|
43
|
+
if (!resolved) {
|
|
44
|
+
await command.editReply('Could not determine project directory for this thread');
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (!parentChannel) {
|
|
48
|
+
await command.editReply('Could not resolve parent text channel');
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
projectDirectory = resolved.projectDirectory;
|
|
52
|
+
sdkDirectory = resolved.workingDirectory;
|
|
53
|
+
textChannel = parentChannel;
|
|
54
|
+
sourceWorkspace = workspace;
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
const channelConfig = await getChannelDirectory(channel.id);
|
|
58
|
+
if (!channelConfig?.directory) {
|
|
59
|
+
await command.editReply('This channel is not configured with a project directory');
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
projectDirectory = channelConfig.directory;
|
|
63
|
+
sdkDirectory = channelConfig.directory;
|
|
64
|
+
textChannel = channel;
|
|
27
65
|
}
|
|
28
66
|
if (!fs.existsSync(projectDirectory)) {
|
|
29
67
|
await command.editReply(`Directory does not exist: ${projectDirectory}`);
|
|
@@ -43,8 +81,8 @@ export async function handleSessionCommand({ command, appId, }) {
|
|
|
43
81
|
if (files.length > 0) {
|
|
44
82
|
fullPrompt = `${prompt}\n\n@${files.join(' @')}`;
|
|
45
83
|
}
|
|
46
|
-
const starterMessage = await
|
|
47
|
-
content:
|
|
84
|
+
const starterMessage = await textChannel.send({
|
|
85
|
+
content: `**Starting OpenCode session**\n${prompt}${files.length > 0 ? `\nFiles: ${files.join(', ')}` : ''}`,
|
|
48
86
|
flags: SILENT_MESSAGE_FLAGS,
|
|
49
87
|
});
|
|
50
88
|
const thread = await starterMessage.startThread({
|
|
@@ -52,6 +90,21 @@ export async function handleSessionCommand({ command, appId, }) {
|
|
|
52
90
|
autoArchiveDuration: 1440,
|
|
53
91
|
reason: 'OpenCode session',
|
|
54
92
|
});
|
|
93
|
+
// Persist workspace association so commands in the new thread resolve the
|
|
94
|
+
// correct working directory, and the runtime survives bot restarts.
|
|
95
|
+
if (sourceWorkspace?.status === 'ready' && sourceWorkspace.workspace_directory) {
|
|
96
|
+
await createPendingWorkspace({
|
|
97
|
+
threadId: thread.id,
|
|
98
|
+
workspaceType: sourceWorkspace.workspace_type,
|
|
99
|
+
workspaceName: sourceWorkspace.workspace_name ?? '',
|
|
100
|
+
projectDirectory,
|
|
101
|
+
});
|
|
102
|
+
await setWorkspaceReady({
|
|
103
|
+
threadId: thread.id,
|
|
104
|
+
workspaceId: sourceWorkspace.workspace_id ?? undefined,
|
|
105
|
+
workspaceDirectory: sourceWorkspace.workspace_directory,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
55
108
|
// Add user to thread so it appears in their sidebar
|
|
56
109
|
await thread.members.add(command.user.id);
|
|
57
110
|
await command.editReply(`Created new session in ${thread.toString()}`);
|
|
@@ -59,8 +112,8 @@ export async function handleSessionCommand({ command, appId, }) {
|
|
|
59
112
|
threadId: thread.id,
|
|
60
113
|
thread,
|
|
61
114
|
projectDirectory,
|
|
62
|
-
sdkDirectory
|
|
63
|
-
channelId:
|
|
115
|
+
sdkDirectory,
|
|
116
|
+
channelId: textChannel.id,
|
|
64
117
|
appId,
|
|
65
118
|
});
|
|
66
119
|
await runtime.enqueueIncoming({
|
package/dist/commands/tasks.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
// /tasks command — list all scheduled tasks sorted by next run time.
|
|
2
2
|
// Renders a markdown table that the CV2 pipeline auto-formats for Discord,
|
|
3
|
-
// including HTML-backed
|
|
3
|
+
// including HTML-backed Run now / Delete action buttons.
|
|
4
4
|
import { ButtonInteraction, ChatInputCommandInteraction, ComponentType, MessageFlags, } from 'discord.js';
|
|
5
5
|
import { cancelScheduledTask, listScheduledTasks, } from '../database.js';
|
|
6
6
|
import { splitTablesFromMarkdown } from '../format-tables.js';
|
|
7
7
|
import { buildHtmlActionCustomId, cancelHtmlActionsForOwner, registerHtmlAction, } from '../html-actions.js';
|
|
8
|
+
import { runScheduledTaskNow } from '../task-runner.js';
|
|
8
9
|
import { formatTimeAgo } from './worktrees.js';
|
|
9
10
|
function formatTimeUntil(date) {
|
|
10
11
|
const diffMs = date.getTime() - Date.now();
|
|
@@ -34,6 +35,9 @@ function scheduleLabel(task) {
|
|
|
34
35
|
}
|
|
35
36
|
return 'one-time';
|
|
36
37
|
}
|
|
38
|
+
function canRunTask(task) {
|
|
39
|
+
return task.status === 'planned';
|
|
40
|
+
}
|
|
37
41
|
function canCancelTask(task) {
|
|
38
42
|
return task.status === 'planned' || task.status === 'running';
|
|
39
43
|
}
|
|
@@ -42,25 +46,35 @@ function canCancelTask(task) {
|
|
|
42
46
|
function sanitizeTableCell(value) {
|
|
43
47
|
return value.replaceAll('|', '\\|').replace(/\s+/g, ' ').trim();
|
|
44
48
|
}
|
|
45
|
-
function
|
|
46
|
-
|
|
49
|
+
function buildRunCell(task) {
|
|
50
|
+
if (!canRunTask(task)) {
|
|
51
|
+
return '-';
|
|
52
|
+
}
|
|
53
|
+
return `<button id="run-task-${task.id}" variant="primary">Run now</button>`;
|
|
47
54
|
}
|
|
48
|
-
function
|
|
55
|
+
function buildCancelCell(task) {
|
|
49
56
|
if (!canCancelTask(task)) {
|
|
50
57
|
return '-';
|
|
51
58
|
}
|
|
52
|
-
return
|
|
59
|
+
return `<button id="cancel-task-${task.id}" variant="secondary">Delete</button>`;
|
|
53
60
|
}
|
|
54
61
|
// Cap rows to avoid exceeding Discord's 40-component CV2 limit.
|
|
55
|
-
// Each
|
|
56
|
-
// so
|
|
57
|
-
const MAX_TASK_ROWS =
|
|
62
|
+
// Each actionable row is text + action row + up to 2 buttons (~4 components),
|
|
63
|
+
// so 7 rows stays under the budget with separators.
|
|
64
|
+
const MAX_TASK_ROWS = 7;
|
|
65
|
+
function formatChannelCell(task) {
|
|
66
|
+
if (!task.channel_id) {
|
|
67
|
+
return '-';
|
|
68
|
+
}
|
|
69
|
+
return `<#${task.channel_id}>`;
|
|
70
|
+
}
|
|
58
71
|
function buildTaskTable({ tasks, }) {
|
|
59
|
-
const header = '| ID | Status | Prompt | Schedule | Next Run |
|
|
60
|
-
const separator = '
|
|
72
|
+
const header = '| ID | Status | Channel | Prompt | Schedule | Next Run | Run | Delete |';
|
|
73
|
+
const separator = '|---|---|---|---|---|---|---|---|';
|
|
61
74
|
const rows = tasks.map((task) => {
|
|
62
75
|
const id = String(task.id);
|
|
63
76
|
const status = task.status;
|
|
77
|
+
const channel = formatChannelCell(task);
|
|
64
78
|
const prompt = sanitizeTableCell(task.prompt_preview.length > 240
|
|
65
79
|
? task.prompt_preview.slice(0, 237) + '...'
|
|
66
80
|
: task.prompt_preview);
|
|
@@ -73,8 +87,9 @@ function buildTaskTable({ tasks, }) {
|
|
|
73
87
|
}
|
|
74
88
|
return formatTimeUntil(task.next_run_at);
|
|
75
89
|
})();
|
|
76
|
-
const
|
|
77
|
-
|
|
90
|
+
const run = buildRunCell(task);
|
|
91
|
+
const cancel = buildCancelCell(task);
|
|
92
|
+
return `| ${id} | ${status} | ${channel} | ${prompt} | ${schedule} | ${nextRun} | ${run} | ${cancel} |`;
|
|
78
93
|
});
|
|
79
94
|
return [header, separator, ...rows].join('\n');
|
|
80
95
|
}
|
|
@@ -107,12 +122,15 @@ async function renderTasksReply({ guildId, userId, channelId, showAll, notice, e
|
|
|
107
122
|
? `Showing ${MAX_TASK_ROWS}/${allTasks.length} tasks. Use \`kimaki task list\` for full list.`
|
|
108
123
|
: undefined;
|
|
109
124
|
const combinedNotice = [notice, truncatedNotice].filter(Boolean).join('\n');
|
|
125
|
+
const runnableTasksByButtonId = new Map();
|
|
110
126
|
const cancellableTasksByButtonId = new Map();
|
|
111
127
|
tasks.forEach((task) => {
|
|
112
|
-
if (
|
|
113
|
-
|
|
128
|
+
if (canRunTask(task)) {
|
|
129
|
+
runnableTasksByButtonId.set(`run-task-${task.id}`, task);
|
|
130
|
+
}
|
|
131
|
+
if (canCancelTask(task)) {
|
|
132
|
+
cancellableTasksByButtonId.set(`cancel-task-${task.id}`, task);
|
|
114
133
|
}
|
|
115
|
-
cancellableTasksByButtonId.set(`cancel-task-${task.id}`, task);
|
|
116
134
|
});
|
|
117
135
|
const tableMarkdown = buildTaskTable({ tasks });
|
|
118
136
|
const markdown = combinedNotice
|
|
@@ -120,17 +138,32 @@ async function renderTasksReply({ guildId, userId, channelId, showAll, notice, e
|
|
|
120
138
|
: tableMarkdown;
|
|
121
139
|
const segments = splitTablesFromMarkdown(markdown, {
|
|
122
140
|
resolveButtonCustomId: ({ button }) => {
|
|
123
|
-
const
|
|
124
|
-
if (
|
|
141
|
+
const runTask = runnableTasksByButtonId.get(button.id);
|
|
142
|
+
if (runTask) {
|
|
143
|
+
const actionId = registerHtmlAction({
|
|
144
|
+
ownerKey,
|
|
145
|
+
threadId: String(runTask.id),
|
|
146
|
+
run: async ({ interaction }) => {
|
|
147
|
+
await handleRunTaskAction({
|
|
148
|
+
interaction,
|
|
149
|
+
taskId: runTask.id,
|
|
150
|
+
showAll,
|
|
151
|
+
});
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
return buildHtmlActionCustomId(actionId);
|
|
155
|
+
}
|
|
156
|
+
const cancelTask = cancellableTasksByButtonId.get(button.id);
|
|
157
|
+
if (!cancelTask) {
|
|
125
158
|
return new Error(`No task registered for button ${button.id}`);
|
|
126
159
|
}
|
|
127
160
|
const actionId = registerHtmlAction({
|
|
128
161
|
ownerKey,
|
|
129
|
-
threadId: String(
|
|
162
|
+
threadId: String(cancelTask.id),
|
|
130
163
|
run: async ({ interaction }) => {
|
|
131
164
|
await handleCancelTaskAction({
|
|
132
165
|
interaction,
|
|
133
|
-
taskId:
|
|
166
|
+
taskId: cancelTask.id,
|
|
134
167
|
showAll,
|
|
135
168
|
});
|
|
136
169
|
},
|
|
@@ -153,6 +186,58 @@ async function renderTasksReply({ guildId, userId, channelId, showAll, notice, e
|
|
|
153
186
|
flags: MessageFlags.IsComponentsV2,
|
|
154
187
|
});
|
|
155
188
|
}
|
|
189
|
+
async function handleRunTaskAction({ interaction, taskId, showAll, }) {
|
|
190
|
+
const guildId = interaction.guildId;
|
|
191
|
+
if (!guildId) {
|
|
192
|
+
await interaction.editReply({
|
|
193
|
+
components: [
|
|
194
|
+
{
|
|
195
|
+
type: ComponentType.TextDisplay,
|
|
196
|
+
content: 'This action can only be used in a server.',
|
|
197
|
+
},
|
|
198
|
+
],
|
|
199
|
+
flags: MessageFlags.IsComponentsV2,
|
|
200
|
+
});
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
const token = interaction.client.token;
|
|
204
|
+
if (!token) {
|
|
205
|
+
await renderTasksReply({
|
|
206
|
+
guildId,
|
|
207
|
+
userId: interaction.user.id,
|
|
208
|
+
channelId: interaction.channelId,
|
|
209
|
+
showAll,
|
|
210
|
+
notice: `Could not run task #${taskId}: bot token unavailable.`,
|
|
211
|
+
editReply: (options) => {
|
|
212
|
+
return interaction.editReply(options);
|
|
213
|
+
},
|
|
214
|
+
});
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
const result = await runScheduledTaskNow({ token, taskId });
|
|
218
|
+
const notice = (() => {
|
|
219
|
+
if (result instanceof Error) {
|
|
220
|
+
return `Could not run task #${taskId}: ${result.message}`;
|
|
221
|
+
}
|
|
222
|
+
if (result.kind === 'skipped') {
|
|
223
|
+
return `Task #${taskId} is already running or was claimed elsewhere.`;
|
|
224
|
+
}
|
|
225
|
+
if (result.kind === 'failed') {
|
|
226
|
+
return `Task #${taskId} failed: ${result.error.message}`;
|
|
227
|
+
}
|
|
228
|
+
return `Started task #${taskId}.`;
|
|
229
|
+
})();
|
|
230
|
+
await renderTasksReply({
|
|
231
|
+
guildId,
|
|
232
|
+
userId: interaction.user.id,
|
|
233
|
+
channelId: interaction.channelId,
|
|
234
|
+
showAll,
|
|
235
|
+
notice,
|
|
236
|
+
editReply: (options) => {
|
|
237
|
+
return interaction.editReply(options);
|
|
238
|
+
},
|
|
239
|
+
});
|
|
240
|
+
}
|
|
156
241
|
async function handleCancelTaskAction({ interaction, taskId, showAll, }) {
|
|
157
242
|
const guildId = interaction.guildId;
|
|
158
243
|
if (!guildId) {
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Handles slash commands that map to user-configured commands in opencode.json.
|
|
3
3
|
import { ChannelType, MessageFlags, ThreadAutoArchiveDuration, } from 'discord.js';
|
|
4
4
|
import { getOrCreateRuntime } from '../session-handler/thread-session-runtime.js';
|
|
5
|
-
import { SILENT_MESSAGE_FLAGS } from '../discord-utils.js';
|
|
5
|
+
import { SILENT_MESSAGE_FLAGS, resolveWorkingDirectory } from '../discord-utils.js';
|
|
6
6
|
import { createLogger, LogPrefix } from '../logger.js';
|
|
7
7
|
import { getChannelDirectory, getChannelWorktreesEnabled, getThreadSession, } from '../database.js';
|
|
8
8
|
import { store } from '../store.js';
|
|
@@ -91,11 +91,12 @@ export const handleUserCommand = async ({ command, appId, }) => {
|
|
|
91
91
|
if (isThread && thread) {
|
|
92
92
|
// Running in existing thread - just send the command
|
|
93
93
|
await command.editReply(`Running ${commandInvocation}...`);
|
|
94
|
+
const resolved = await resolveWorkingDirectory({ channel: thread });
|
|
94
95
|
const runtime = getOrCreateRuntime({
|
|
95
96
|
threadId: thread.id,
|
|
96
97
|
thread,
|
|
97
98
|
projectDirectory,
|
|
98
|
-
sdkDirectory: projectDirectory,
|
|
99
|
+
sdkDirectory: resolved?.workingDirectory || projectDirectory,
|
|
99
100
|
channelId: textChannel?.id,
|
|
100
101
|
appId,
|
|
101
102
|
});
|
package/dist/database.js
CHANGED
|
@@ -380,6 +380,20 @@ export async function upsertThreadSession({ threadId, sessionId, source }) {
|
|
|
380
380
|
.values({ thread_id: threadId, session_id: sessionId, source })
|
|
381
381
|
.onConflictDoUpdate({ target: schema.thread_sessions.thread_id, set: { session_id: sessionId, source } });
|
|
382
382
|
}
|
|
383
|
+
export async function getThreadParentSessionId(threadId) {
|
|
384
|
+
const db = await getDb();
|
|
385
|
+
return (await db.query.thread_sessions.findFirst({
|
|
386
|
+
where: { thread_id: threadId },
|
|
387
|
+
columns: { parent_session_id: true },
|
|
388
|
+
}))?.parent_session_id ?? undefined;
|
|
389
|
+
}
|
|
390
|
+
export async function setThreadParentSessionId({ threadId, parentSessionId, }) {
|
|
391
|
+
const db = await getDb();
|
|
392
|
+
await db
|
|
393
|
+
.update(schema.thread_sessions)
|
|
394
|
+
.set({ parent_session_id: parentSessionId })
|
|
395
|
+
.where(orm.eq(schema.thread_sessions.thread_id, threadId));
|
|
396
|
+
}
|
|
383
397
|
export async function getThreadSessionSource(threadId) {
|
|
384
398
|
const db = await getDb();
|
|
385
399
|
return (await db.query.thread_sessions.findFirst({ where: { thread_id: threadId }, columns: { source: true } }))?.source;
|
package/dist/db.js
CHANGED
|
@@ -119,6 +119,7 @@ async function migrateSchema({ db, client, }) {
|
|
|
119
119
|
'ALTER TABLE bot_tokens ADD COLUMN last_used_at DATETIME',
|
|
120
120
|
"ALTER TABLE thread_sessions ADD COLUMN source TEXT DEFAULT 'kimaki'",
|
|
121
121
|
'ALTER TABLE thread_sessions ADD COLUMN last_synced_name TEXT',
|
|
122
|
+
'ALTER TABLE thread_sessions ADD COLUMN parent_session_id TEXT',
|
|
122
123
|
];
|
|
123
124
|
for (const stmt of alterStatements) {
|
|
124
125
|
await client.execute(stmt).catch(() => undefined);
|
package/dist/discord-bot.js
CHANGED
|
@@ -16,7 +16,7 @@ import { WORKTREE_PREFIX } from './commands/merge-worktree.js';
|
|
|
16
16
|
import { escapeBackticksInCodeBlocks, splitMarkdownForDiscord, sendThreadMessage, SILENT_MESSAGE_FLAGS, NOTIFY_MESSAGE_FLAGS, reactToThread, stripMentions, hasKimakiBotPermission, hasNoKimakiRole, resolveGuildMessageMember, } from './discord-utils.js';
|
|
17
17
|
import { getOpencodeSystemMessage, isInjectedPromptMarker, } from './system-message.js';
|
|
18
18
|
import YAML from 'yaml';
|
|
19
|
-
import { getTextAttachments, resolveMentions, } from './message-formatting.js';
|
|
19
|
+
import { getFileAttachments, getTextAttachments, resolveMentions, } from './message-formatting.js';
|
|
20
20
|
import { extractBtwSuffix } from './btw-prefix-detection.js';
|
|
21
21
|
import { isVoiceAttachment } from './voice-attachment.js';
|
|
22
22
|
import { forkSessionToBtwThread } from './commands/btw.js';
|
|
@@ -317,6 +317,9 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
317
317
|
const cliInjectedInjectionGuardPatterns = isCliInjectedPrompt
|
|
318
318
|
? promptMarker?.injectionGuardPatterns
|
|
319
319
|
: undefined;
|
|
320
|
+
const cliInjectedParentSessionId = isCliInjectedPrompt
|
|
321
|
+
? promptMarker?.parentSessionId
|
|
322
|
+
: undefined;
|
|
320
323
|
// Always ignore our own messages (unless CLI-injected prompt above).
|
|
321
324
|
// Without this, assigning the Kimaki role to the bot itself would loop.
|
|
322
325
|
if (isSelfBotMessage && !isCliInjectedPrompt) {
|
|
@@ -520,9 +523,14 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
520
523
|
? extractBtwSuffix(message.content || '')
|
|
521
524
|
: null;
|
|
522
525
|
if (btwResult?.forceBtw && projectDirectory && !isLeadingMentionToOtherUser) {
|
|
526
|
+
const btwSdkDir = worktreeInfo?.status === 'ready' &&
|
|
527
|
+
worktreeInfo.workspace_directory
|
|
528
|
+
? worktreeInfo.workspace_directory
|
|
529
|
+
: projectDirectory;
|
|
523
530
|
const result = await forkSessionToBtwThread({
|
|
524
531
|
sourceThread: thread,
|
|
525
532
|
projectDirectory,
|
|
533
|
+
sdkDirectory: btwSdkDir,
|
|
526
534
|
prompt: btwResult.prompt,
|
|
527
535
|
userId: message.author.id,
|
|
528
536
|
username: message.member?.displayName || message.author.displayName,
|
|
@@ -606,6 +614,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
606
614
|
model: cliInjectedModel,
|
|
607
615
|
permissions: cliInjectedPermissions,
|
|
608
616
|
injectionGuardPatterns: cliInjectedInjectionGuardPatterns,
|
|
617
|
+
parentSessionId: cliInjectedParentSessionId,
|
|
609
618
|
noReply: isLeadingMentionToOtherUser || undefined,
|
|
610
619
|
sessionStartSource: sessionStartSource
|
|
611
620
|
? {
|
|
@@ -627,7 +636,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
627
636
|
});
|
|
628
637
|
// Notify when a voice message was queued instead of sent immediately
|
|
629
638
|
if (enqueueResult.queued && enqueueResult.position) {
|
|
630
|
-
await sendThreadMessage(thread, `Queued at position ${enqueueResult.position}. Edit your message to update
|
|
639
|
+
await sendThreadMessage(thread, `Queued at position ${enqueueResult.position}. Edit or delete your message to update the queue`);
|
|
631
640
|
}
|
|
632
641
|
}
|
|
633
642
|
if (channel.type === ChannelType.GuildText) {
|
|
@@ -816,6 +825,11 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
816
825
|
return;
|
|
817
826
|
if (!message.content)
|
|
818
827
|
return;
|
|
828
|
+
// Discord fires MESSAGE_UPDATE for embed-only updates (link preview
|
|
829
|
+
// unfurling) without the user actually editing the message content.
|
|
830
|
+
// editedTimestamp is null for these; skip them to avoid false queue removals.
|
|
831
|
+
if (!message.editedTimestamp)
|
|
832
|
+
return;
|
|
819
833
|
const channel = message.channel;
|
|
820
834
|
const isThread = [
|
|
821
835
|
ChannelType.PublicThread,
|
|
@@ -850,6 +864,27 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
850
864
|
discordLogger.error('Error handling message update:', error instanceof Error ? error.stack : String(error));
|
|
851
865
|
}
|
|
852
866
|
});
|
|
867
|
+
// Handle user message deletes to remove queued messages.
|
|
868
|
+
// Discord delete events do not include author/content, so attribution comes
|
|
869
|
+
// from the queued item captured at enqueue time.
|
|
870
|
+
discordClient.on(Events.MessageDelete, async (message) => {
|
|
871
|
+
try {
|
|
872
|
+
const channel = message.channel;
|
|
873
|
+
if (!channel.isThread())
|
|
874
|
+
return;
|
|
875
|
+
const runtime = getRuntime(channel.id);
|
|
876
|
+
if (!runtime)
|
|
877
|
+
return;
|
|
878
|
+
const removed = runtime.removeQueuedMessage(message.id);
|
|
879
|
+
if (!removed)
|
|
880
|
+
return;
|
|
881
|
+
discordLogger.log(`[MESSAGE_DELETE] Removed queued message ${message.id} in thread ${channel.id}`);
|
|
882
|
+
await sendThreadMessage(channel, `⬦ **${removed.username}** removed message from queue`);
|
|
883
|
+
}
|
|
884
|
+
catch (error) {
|
|
885
|
+
discordLogger.error('Error handling message delete:', error instanceof Error ? error.stack : String(error));
|
|
886
|
+
}
|
|
887
|
+
});
|
|
853
888
|
// Handle bot-initiated threads created by `kimaki send` (without --notify-only)
|
|
854
889
|
// Uses JSON embed marker to pass options (start, worktree name)
|
|
855
890
|
discordClient.on(Events.ThreadCreate, async (thread, newlyCreated) => {
|
|
@@ -892,7 +927,10 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
892
927
|
return; // Not an auto-start thread
|
|
893
928
|
}
|
|
894
929
|
discordLogger.log(`[BOT_SESSION] Detected bot-initiated thread: ${thread.name}`);
|
|
895
|
-
const textAttachmentsContent = await
|
|
930
|
+
const [textAttachmentsContent, fileAttachments] = await Promise.all([
|
|
931
|
+
getTextAttachments(starterMessage),
|
|
932
|
+
getFileAttachments(starterMessage),
|
|
933
|
+
]);
|
|
896
934
|
const messageText = resolveMentions(starterMessage).trim();
|
|
897
935
|
const prompt = textAttachmentsContent
|
|
898
936
|
? `${messageText}\n\n${textAttachmentsContent}`
|
|
@@ -916,22 +954,31 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
916
954
|
});
|
|
917
955
|
return;
|
|
918
956
|
}
|
|
919
|
-
// Start worktree creation concurrently if requested
|
|
957
|
+
// Start worktree creation concurrently if requested via marker OR
|
|
958
|
+
// if the channel/global toggle enables auto-worktrees.
|
|
920
959
|
// The runtime is created immediately so follow-up messages queue
|
|
921
960
|
// naturally; the worktree promise is awaited inside enqueueIncoming.
|
|
961
|
+
const autoWorktreeEnabled = !marker.worktree &&
|
|
962
|
+
!marker.cwd &&
|
|
963
|
+
(store.getState().useWorktrees ||
|
|
964
|
+
(await getChannelWorktreesEnabled(parent.id)));
|
|
965
|
+
const effectiveWorktreeName = marker.worktree ||
|
|
966
|
+
(autoWorktreeEnabled
|
|
967
|
+
? formatAutoWorktreeName(thread.name.slice(0, 50))
|
|
968
|
+
: undefined);
|
|
922
969
|
let worktreePromise;
|
|
923
|
-
if (
|
|
924
|
-
discordLogger.log(`[BOT_SESSION] Creating worktree: ${
|
|
970
|
+
if (effectiveWorktreeName && (await isGitRepositoryRoot(projectDirectory))) {
|
|
971
|
+
discordLogger.log(`[BOT_SESSION] Creating worktree: ${effectiveWorktreeName}`);
|
|
925
972
|
const worktreeStatusMessage = await thread
|
|
926
973
|
.send({
|
|
927
|
-
content: worktreeCreatingMessage(
|
|
974
|
+
content: worktreeCreatingMessage(effectiveWorktreeName),
|
|
928
975
|
flags: SILENT_MESSAGE_FLAGS,
|
|
929
976
|
})
|
|
930
977
|
.catch(() => undefined);
|
|
931
978
|
worktreePromise = createWorktreeInBackground({
|
|
932
979
|
thread,
|
|
933
980
|
starterMessage: worktreeStatusMessage,
|
|
934
|
-
worktreeName:
|
|
981
|
+
worktreeName: effectiveWorktreeName,
|
|
935
982
|
projectDirectory,
|
|
936
983
|
rest: discordClient.rest,
|
|
937
984
|
});
|
|
@@ -939,6 +986,9 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
939
986
|
else if (marker.worktree) {
|
|
940
987
|
discordLogger.warn(`[BOT_SESSION] Skipping requested worktree for non-git project directory: ${projectDirectory}`);
|
|
941
988
|
}
|
|
989
|
+
else if (autoWorktreeEnabled) {
|
|
990
|
+
discordLogger.warn(`[BOT_SESSION] Skipping auto-worktree for non-git project directory: ${projectDirectory}`);
|
|
991
|
+
}
|
|
942
992
|
// --cwd: reuse an existing project subfolder or worktree directory. Revalidate at bot-time
|
|
943
993
|
// (CLI validated at send-time but the path could become stale).
|
|
944
994
|
// Only worktree directories are stored in thread_workspaces. Project
|
|
@@ -1017,6 +1067,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
1017
1067
|
model: marker.model,
|
|
1018
1068
|
permissions: marker.permissions,
|
|
1019
1069
|
injectionGuardPatterns: marker.injectionGuardPatterns,
|
|
1070
|
+
parentSessionId: marker.parentSessionId,
|
|
1020
1071
|
mode: 'opencode',
|
|
1021
1072
|
sessionStartSource: botThreadStartSource
|
|
1022
1073
|
? {
|
|
@@ -1028,7 +1079,12 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
1028
1079
|
const permissionRules = await getChannelReferencePermissionRules({
|
|
1029
1080
|
message: starterMessage,
|
|
1030
1081
|
});
|
|
1031
|
-
return {
|
|
1082
|
+
return {
|
|
1083
|
+
prompt,
|
|
1084
|
+
permissionRules,
|
|
1085
|
+
mode: 'opencode',
|
|
1086
|
+
...(fileAttachments.length > 0 && { images: fileAttachments }),
|
|
1087
|
+
};
|
|
1032
1088
|
},
|
|
1033
1089
|
});
|
|
1034
1090
|
}
|
|
@@ -103,22 +103,13 @@ export function registerInteractionHandler({ discordClient, appId, }) {
|
|
|
103
103
|
// Multi-machine routing: only handle interactions for channels owned
|
|
104
104
|
// by this machine (have a project directory configured in local db).
|
|
105
105
|
// If not owned, silently return so the other machine handles it.
|
|
106
|
+
// Setup commands (create-new-project, add-project) bypass this check
|
|
107
|
+
// because they are designed to run from any channel — they create new
|
|
108
|
+
// project channels rather than requiring one to already exist.
|
|
109
|
+
const isSetupCommand = interaction.isChatInputCommand() &&
|
|
110
|
+
SETUP_COMMANDS.has(interaction.commandName);
|
|
106
111
|
const owned = await isInteractionOwnedByThisMachine(interaction);
|
|
107
|
-
if (!owned) {
|
|
108
|
-
// Setup commands get a helpful reply instead of silent ignore.
|
|
109
|
-
// Both machines may race to reply; one wins, the other silently
|
|
110
|
-
// fails (interaction tokens are single-use). The message is the
|
|
111
|
-
// same from either machine so the race is harmless.
|
|
112
|
-
if (interaction.isChatInputCommand() &&
|
|
113
|
-
SETUP_COMMANDS.has(interaction.commandName)) {
|
|
114
|
-
await interaction.reply({
|
|
115
|
-
content: 'Run this command in an existing Kimaki project channel.\nTo add a new project channel from the terminal, run:\n```\nkimaki project add /path/to/folder\n```',
|
|
116
|
-
flags: MessageFlags.Ephemeral,
|
|
117
|
-
}).catch(() => {
|
|
118
|
-
// Another machine already responded, safe to ignore
|
|
119
|
-
});
|
|
120
|
-
return;
|
|
121
|
-
}
|
|
112
|
+
if (!owned && !isSetupCommand) {
|
|
122
113
|
interactionLogger.log(`[IGNORED] Channel ${interaction.channelId} has no project directory configured, skipping interaction`);
|
|
123
114
|
// Do not respond at all — consuming the interaction token would
|
|
124
115
|
// prevent the owning machine from responding (tokens are single-use).
|
|
@@ -276,15 +276,22 @@ export async function preprocessNewSessionMessage({ message, thread, projectDire
|
|
|
276
276
|
}
|
|
277
277
|
}
|
|
278
278
|
const qs = extractQueueSuffix(prompt);
|
|
279
|
+
const fileAttachments = await getFileAttachments(message);
|
|
280
|
+
const textAttachmentsContent = await getTextAttachments(message);
|
|
281
|
+
const finalPrompt = textAttachmentsContent
|
|
282
|
+
? `${qs.prompt}\n\n${textAttachmentsContent}`
|
|
283
|
+
: qs.prompt;
|
|
279
284
|
if (shouldSkipEmptyPrompt({
|
|
280
285
|
message,
|
|
281
|
-
prompt:
|
|
286
|
+
prompt: finalPrompt,
|
|
287
|
+
images: fileAttachments,
|
|
282
288
|
hasVoiceAttachment,
|
|
283
289
|
})) {
|
|
284
290
|
return { prompt: '', mode: 'opencode', skip: true };
|
|
285
291
|
}
|
|
286
292
|
return {
|
|
287
|
-
prompt:
|
|
293
|
+
prompt: finalPrompt,
|
|
294
|
+
images: fileAttachments.length > 0 ? fileAttachments : undefined,
|
|
288
295
|
repliedMessage,
|
|
289
296
|
permissionRules,
|
|
290
297
|
mode: qs.forceQueue || voiceResult?.queueMessage ? 'local-queue' : 'opencode',
|
|
@@ -99,6 +99,27 @@ export function createDeterministicMatchers() {
|
|
|
99
99
|
partDelaysMs: [0, 0, 0, 3_000, 0],
|
|
100
100
|
},
|
|
101
101
|
};
|
|
102
|
+
const slowBusyMatcher = {
|
|
103
|
+
id: 'slow-busy-reply',
|
|
104
|
+
priority: 100,
|
|
105
|
+
when: {
|
|
106
|
+
latestUserTextIncludes: 'SLOW_BUSY_MARKER',
|
|
107
|
+
},
|
|
108
|
+
then: {
|
|
109
|
+
parts: [
|
|
110
|
+
{ type: 'stream-start', warnings: [] },
|
|
111
|
+
{ type: 'text-start', id: 'slow-busy' },
|
|
112
|
+
{ type: 'text-delta', id: 'slow-busy', delta: 'slow-busy-reply' },
|
|
113
|
+
{ type: 'text-end', id: 'slow-busy' },
|
|
114
|
+
{
|
|
115
|
+
type: 'finish',
|
|
116
|
+
finishReason: 'stop',
|
|
117
|
+
usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
|
|
118
|
+
},
|
|
119
|
+
],
|
|
120
|
+
partDelaysMs: [0, 0, 0, 2_000, 0],
|
|
121
|
+
},
|
|
122
|
+
};
|
|
102
123
|
const toolFollowupMatcher = {
|
|
103
124
|
id: 'tool-followup',
|
|
104
125
|
priority: 50,
|
|
@@ -704,6 +725,7 @@ export function createDeterministicMatchers() {
|
|
|
704
725
|
};
|
|
705
726
|
return [
|
|
706
727
|
slowAbortMatcher,
|
|
728
|
+
slowBusyMatcher,
|
|
707
729
|
typingRepulseMatcher,
|
|
708
730
|
pluginTimeoutSleepMatcher,
|
|
709
731
|
actionButtonClickFollowupMatcher,
|