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/src/commands/btw.ts
CHANGED
|
@@ -26,6 +26,7 @@ const logger = createLogger(LogPrefix.FORK)
|
|
|
26
26
|
export async function forkSessionToBtwThread({
|
|
27
27
|
sourceThread,
|
|
28
28
|
projectDirectory,
|
|
29
|
+
sdkDirectory,
|
|
29
30
|
prompt,
|
|
30
31
|
userId,
|
|
31
32
|
username,
|
|
@@ -33,6 +34,8 @@ export async function forkSessionToBtwThread({
|
|
|
33
34
|
}: {
|
|
34
35
|
sourceThread: ThreadChannel
|
|
35
36
|
projectDirectory: string
|
|
37
|
+
/** Worktree directory when forking from a worktree thread, otherwise same as projectDirectory */
|
|
38
|
+
sdkDirectory: string
|
|
36
39
|
prompt: string
|
|
37
40
|
userId: string
|
|
38
41
|
username: string
|
|
@@ -97,6 +100,9 @@ export async function forkSessionToBtwThread({
|
|
|
97
100
|
`Created btw fork session ${forkedSession.id} in thread ${thread.id} from source thread ${sourceThread.id} (session ${sessionId})`,
|
|
98
101
|
)
|
|
99
102
|
|
|
103
|
+
// Parent context stays in the user prompt only. Do NOT pass parentSessionId
|
|
104
|
+
// into enqueueIncoming: that would inject a parent block into the system
|
|
105
|
+
// message and bust prompt cache shared with the parent session.
|
|
100
106
|
const wrappedPrompt = [
|
|
101
107
|
`The user asked a side question while you were working on another task.`,
|
|
102
108
|
`This is a forked session whose ONLY goal is to answer this question.`,
|
|
@@ -112,7 +118,7 @@ export async function forkSessionToBtwThread({
|
|
|
112
118
|
threadId: thread.id,
|
|
113
119
|
thread,
|
|
114
120
|
projectDirectory,
|
|
115
|
-
sdkDirectory
|
|
121
|
+
sdkDirectory,
|
|
116
122
|
channelId,
|
|
117
123
|
appId,
|
|
118
124
|
})
|
|
@@ -173,7 +179,7 @@ export async function handleBtwCommand({
|
|
|
173
179
|
return
|
|
174
180
|
}
|
|
175
181
|
|
|
176
|
-
const { projectDirectory } = resolved
|
|
182
|
+
const { projectDirectory, workingDirectory } = resolved
|
|
177
183
|
|
|
178
184
|
await command.deferReply({ flags: MessageFlags.Ephemeral })
|
|
179
185
|
|
|
@@ -181,6 +187,7 @@ export async function handleBtwCommand({
|
|
|
181
187
|
const result = await forkSessionToBtwThread({
|
|
182
188
|
sourceThread: threadChannel,
|
|
183
189
|
projectDirectory,
|
|
190
|
+
sdkDirectory: workingDirectory,
|
|
184
191
|
prompt,
|
|
185
192
|
userId: command.user.id,
|
|
186
193
|
username: command.user.displayName,
|
|
@@ -32,6 +32,7 @@ import { notifyError } from '../sentry.js'
|
|
|
32
32
|
import {
|
|
33
33
|
execAsync,
|
|
34
34
|
listBranchesByLastCommit,
|
|
35
|
+
resolveBestBaseRef,
|
|
35
36
|
validateBranchRef,
|
|
36
37
|
} from '../worktrees.js'
|
|
37
38
|
import { getOrCreateRuntime } from '../session-handler/thread-session-runtime.js'
|
|
@@ -60,10 +61,22 @@ async function resolveRequestedWorktreeBaseRef({
|
|
|
60
61
|
return DEFAULT_WORKTREE_BASE_REF
|
|
61
62
|
}
|
|
62
63
|
|
|
63
|
-
|
|
64
|
+
const validated = await validateBranchRef({
|
|
64
65
|
directory: projectDirectory,
|
|
65
66
|
ref: rawBaseBranch,
|
|
66
67
|
})
|
|
68
|
+
if (validated instanceof Error) return validated
|
|
69
|
+
|
|
70
|
+
// Fetch and prefer the remote tracking ref if it's strictly ahead of local.
|
|
71
|
+
// Handles the common case where user says "main" but upstream/main has new commits.
|
|
72
|
+
const bestRef = await resolveBestBaseRef({
|
|
73
|
+
directory: projectDirectory,
|
|
74
|
+
branch: validated,
|
|
75
|
+
})
|
|
76
|
+
if (bestRef !== validated) {
|
|
77
|
+
logger.log(`Base branch resolved: ${validated} → ${bestRef} (remote is ahead)`)
|
|
78
|
+
}
|
|
79
|
+
return bestRef
|
|
67
80
|
}
|
|
68
81
|
|
|
69
82
|
/** Status message shown while a worktree is being created. */
|
package/src/commands/session.ts
CHANGED
|
@@ -1,12 +1,25 @@
|
|
|
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
|
|
|
3
|
-
import { ChannelType, type TextChannel } from 'discord.js'
|
|
6
|
+
import { ChannelType, type TextChannel, type ThreadChannel } from 'discord.js'
|
|
4
7
|
import fs from 'node:fs'
|
|
5
8
|
import path from 'node:path'
|
|
6
9
|
import type { CommandContext, AutocompleteContext } from './types.js'
|
|
7
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
getChannelDirectory,
|
|
12
|
+
getThreadWorktreeOrWorkspace,
|
|
13
|
+
createPendingWorkspace,
|
|
14
|
+
setWorkspaceReady,
|
|
15
|
+
} from '../database.js'
|
|
8
16
|
import { initializeOpencodeForDirectory } from '../opencode.js'
|
|
9
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
SILENT_MESSAGE_FLAGS,
|
|
19
|
+
resolveProjectDirectoryFromAutocomplete,
|
|
20
|
+
resolveWorkingDirectory,
|
|
21
|
+
resolveTextChannel,
|
|
22
|
+
} from '../discord-utils.js'
|
|
10
23
|
import { getOrCreateRuntime } from '../session-handler/thread-session-runtime.js'
|
|
11
24
|
import { createLogger, LogPrefix } from '../logger.js'
|
|
12
25
|
import * as errore from 'errore'
|
|
@@ -24,19 +37,52 @@ export async function handleSessionCommand({
|
|
|
24
37
|
const agent = command.options.getString('agent') || undefined
|
|
25
38
|
const channel = command.channel
|
|
26
39
|
|
|
27
|
-
|
|
28
|
-
|
|
40
|
+
const isThread = channel && [
|
|
41
|
+
ChannelType.PublicThread,
|
|
42
|
+
ChannelType.PrivateThread,
|
|
43
|
+
].includes(channel.type)
|
|
44
|
+
|
|
45
|
+
if (!channel || (channel.type !== ChannelType.GuildText && !isThread)) {
|
|
46
|
+
await command.editReply('This command can only be used in text channels or threads')
|
|
29
47
|
return
|
|
30
48
|
}
|
|
31
49
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
50
|
+
// Resolve project and working directories.
|
|
51
|
+
// In a thread: inherit from the thread's session (worktree/workspace aware).
|
|
52
|
+
// In a text channel: look up the channel's configured directory.
|
|
53
|
+
let projectDirectory: string
|
|
54
|
+
let sdkDirectory: string
|
|
55
|
+
let textChannel: TextChannel
|
|
56
|
+
let sourceWorkspace: Awaited<ReturnType<typeof getThreadWorktreeOrWorkspace>> | undefined
|
|
57
|
+
|
|
58
|
+
if (isThread) {
|
|
59
|
+
const threadChannel = channel as ThreadChannel
|
|
60
|
+
const [resolved, parentChannel, workspace] = await Promise.all([
|
|
61
|
+
resolveWorkingDirectory({ channel: threadChannel }),
|
|
62
|
+
resolveTextChannel(threadChannel),
|
|
63
|
+
getThreadWorktreeOrWorkspace(threadChannel.id),
|
|
64
|
+
])
|
|
65
|
+
if (!resolved) {
|
|
66
|
+
await command.editReply('Could not determine project directory for this thread')
|
|
67
|
+
return
|
|
68
|
+
}
|
|
69
|
+
if (!parentChannel) {
|
|
70
|
+
await command.editReply('Could not resolve parent text channel')
|
|
71
|
+
return
|
|
72
|
+
}
|
|
73
|
+
projectDirectory = resolved.projectDirectory
|
|
74
|
+
sdkDirectory = resolved.workingDirectory
|
|
75
|
+
textChannel = parentChannel
|
|
76
|
+
sourceWorkspace = workspace
|
|
77
|
+
} else {
|
|
78
|
+
const channelConfig = await getChannelDirectory(channel.id)
|
|
79
|
+
if (!channelConfig?.directory) {
|
|
80
|
+
await command.editReply('This channel is not configured with a project directory')
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
projectDirectory = channelConfig.directory
|
|
84
|
+
sdkDirectory = channelConfig.directory
|
|
85
|
+
textChannel = channel as TextChannel
|
|
40
86
|
}
|
|
41
87
|
|
|
42
88
|
if (!fs.existsSync(projectDirectory)) {
|
|
@@ -61,8 +107,8 @@ export async function handleSessionCommand({
|
|
|
61
107
|
fullPrompt = `${prompt}\n\n@${files.join(' @')}`
|
|
62
108
|
}
|
|
63
109
|
|
|
64
|
-
const starterMessage = await
|
|
65
|
-
content:
|
|
110
|
+
const starterMessage = await textChannel.send({
|
|
111
|
+
content: `**Starting OpenCode session**\n${prompt}${files.length > 0 ? `\nFiles: ${files.join(', ')}` : ''}`,
|
|
66
112
|
flags: SILENT_MESSAGE_FLAGS,
|
|
67
113
|
})
|
|
68
114
|
|
|
@@ -72,6 +118,22 @@ export async function handleSessionCommand({
|
|
|
72
118
|
reason: 'OpenCode session',
|
|
73
119
|
})
|
|
74
120
|
|
|
121
|
+
// Persist workspace association so commands in the new thread resolve the
|
|
122
|
+
// correct working directory, and the runtime survives bot restarts.
|
|
123
|
+
if (sourceWorkspace?.status === 'ready' && sourceWorkspace.workspace_directory) {
|
|
124
|
+
await createPendingWorkspace({
|
|
125
|
+
threadId: thread.id,
|
|
126
|
+
workspaceType: sourceWorkspace.workspace_type,
|
|
127
|
+
workspaceName: sourceWorkspace.workspace_name ?? '',
|
|
128
|
+
projectDirectory,
|
|
129
|
+
})
|
|
130
|
+
await setWorkspaceReady({
|
|
131
|
+
threadId: thread.id,
|
|
132
|
+
workspaceId: sourceWorkspace.workspace_id ?? undefined,
|
|
133
|
+
workspaceDirectory: sourceWorkspace.workspace_directory,
|
|
134
|
+
})
|
|
135
|
+
}
|
|
136
|
+
|
|
75
137
|
// Add user to thread so it appears in their sidebar
|
|
76
138
|
await thread.members.add(command.user.id)
|
|
77
139
|
|
|
@@ -81,8 +143,8 @@ export async function handleSessionCommand({
|
|
|
81
143
|
threadId: thread.id,
|
|
82
144
|
thread,
|
|
83
145
|
projectDirectory,
|
|
84
|
-
sdkDirectory
|
|
85
|
-
channelId:
|
|
146
|
+
sdkDirectory,
|
|
147
|
+
channelId: textChannel.id,
|
|
86
148
|
appId,
|
|
87
149
|
})
|
|
88
150
|
await runtime.enqueueIncoming({
|
package/src/commands/tasks.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
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
|
|
|
5
5
|
import {
|
|
6
6
|
ButtonInteraction,
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
cancelHtmlActionsForOwner,
|
|
24
24
|
registerHtmlAction,
|
|
25
25
|
} from '../html-actions.js'
|
|
26
|
+
import { runScheduledTaskNow } from '../task-runner.js'
|
|
26
27
|
import { formatTimeAgo } from './worktrees.js'
|
|
27
28
|
|
|
28
29
|
function formatTimeUntil(date: Date): string {
|
|
@@ -55,6 +56,10 @@ function scheduleLabel(task: ScheduledTask): string {
|
|
|
55
56
|
return 'one-time'
|
|
56
57
|
}
|
|
57
58
|
|
|
59
|
+
function canRunTask(task: ScheduledTask): boolean {
|
|
60
|
+
return task.status === 'planned'
|
|
61
|
+
}
|
|
62
|
+
|
|
58
63
|
function canCancelTask(task: ScheduledTask): boolean {
|
|
59
64
|
return task.status === 'planned' || task.status === 'running'
|
|
60
65
|
}
|
|
@@ -65,32 +70,43 @@ function sanitizeTableCell(value: string): string {
|
|
|
65
70
|
return value.replaceAll('|', '\\|').replace(/\s+/g, ' ').trim()
|
|
66
71
|
}
|
|
67
72
|
|
|
68
|
-
function
|
|
69
|
-
|
|
73
|
+
function buildRunCell(task: ScheduledTask): string {
|
|
74
|
+
if (!canRunTask(task)) {
|
|
75
|
+
return '-'
|
|
76
|
+
}
|
|
77
|
+
return `<button id="run-task-${task.id}" variant="primary">Run now</button>`
|
|
70
78
|
}
|
|
71
79
|
|
|
72
|
-
function
|
|
80
|
+
function buildCancelCell(task: ScheduledTask): string {
|
|
73
81
|
if (!canCancelTask(task)) {
|
|
74
82
|
return '-'
|
|
75
83
|
}
|
|
76
|
-
return
|
|
84
|
+
return `<button id="cancel-task-${task.id}" variant="secondary">Delete</button>`
|
|
77
85
|
}
|
|
78
86
|
|
|
79
87
|
// Cap rows to avoid exceeding Discord's 40-component CV2 limit.
|
|
80
|
-
// Each
|
|
81
|
-
// so
|
|
82
|
-
const MAX_TASK_ROWS =
|
|
88
|
+
// Each actionable row is text + action row + up to 2 buttons (~4 components),
|
|
89
|
+
// so 7 rows stays under the budget with separators.
|
|
90
|
+
const MAX_TASK_ROWS = 7
|
|
91
|
+
|
|
92
|
+
function formatChannelCell(task: ScheduledTask): string {
|
|
93
|
+
if (!task.channel_id) {
|
|
94
|
+
return '-'
|
|
95
|
+
}
|
|
96
|
+
return `<#${task.channel_id}>`
|
|
97
|
+
}
|
|
83
98
|
|
|
84
99
|
function buildTaskTable({
|
|
85
100
|
tasks,
|
|
86
101
|
}: {
|
|
87
102
|
tasks: ScheduledTask[]
|
|
88
103
|
}): string {
|
|
89
|
-
const header = '| ID | Status | Prompt | Schedule | Next Run |
|
|
90
|
-
const separator = '
|
|
104
|
+
const header = '| ID | Status | Channel | Prompt | Schedule | Next Run | Run | Delete |'
|
|
105
|
+
const separator = '|---|---|---|---|---|---|---|---|'
|
|
91
106
|
const rows = tasks.map((task) => {
|
|
92
107
|
const id = String(task.id)
|
|
93
108
|
const status = task.status
|
|
109
|
+
const channel = formatChannelCell(task)
|
|
94
110
|
const prompt = sanitizeTableCell(
|
|
95
111
|
task.prompt_preview.length > 240
|
|
96
112
|
? task.prompt_preview.slice(0, 237) + '...'
|
|
@@ -107,8 +123,9 @@ function buildTaskTable({
|
|
|
107
123
|
}
|
|
108
124
|
return formatTimeUntil(task.next_run_at)
|
|
109
125
|
})()
|
|
110
|
-
const
|
|
111
|
-
|
|
126
|
+
const run = buildRunCell(task)
|
|
127
|
+
const cancel = buildCancelCell(task)
|
|
128
|
+
return `| ${id} | ${status} | ${channel} | ${prompt} | ${schedule} | ${nextRun} | ${run} | ${cancel} |`
|
|
112
129
|
})
|
|
113
130
|
return [header, separator, ...rows].join('\n')
|
|
114
131
|
}
|
|
@@ -171,12 +188,15 @@ async function renderTasksReply({
|
|
|
171
188
|
: undefined
|
|
172
189
|
const combinedNotice = [notice, truncatedNotice].filter(Boolean).join('\n')
|
|
173
190
|
|
|
191
|
+
const runnableTasksByButtonId = new Map<string, ScheduledTask>()
|
|
174
192
|
const cancellableTasksByButtonId = new Map<string, ScheduledTask>()
|
|
175
193
|
tasks.forEach((task) => {
|
|
176
|
-
if (
|
|
177
|
-
|
|
194
|
+
if (canRunTask(task)) {
|
|
195
|
+
runnableTasksByButtonId.set(`run-task-${task.id}`, task)
|
|
196
|
+
}
|
|
197
|
+
if (canCancelTask(task)) {
|
|
198
|
+
cancellableTasksByButtonId.set(`cancel-task-${task.id}`, task)
|
|
178
199
|
}
|
|
179
|
-
cancellableTasksByButtonId.set(`cancel-task-${task.id}`, task)
|
|
180
200
|
})
|
|
181
201
|
|
|
182
202
|
const tableMarkdown = buildTaskTable({ tasks })
|
|
@@ -185,18 +205,34 @@ async function renderTasksReply({
|
|
|
185
205
|
: tableMarkdown
|
|
186
206
|
const segments = splitTablesFromMarkdown(markdown, {
|
|
187
207
|
resolveButtonCustomId: ({ button }) => {
|
|
188
|
-
const
|
|
189
|
-
if (
|
|
208
|
+
const runTask = runnableTasksByButtonId.get(button.id)
|
|
209
|
+
if (runTask) {
|
|
210
|
+
const actionId = registerHtmlAction({
|
|
211
|
+
ownerKey,
|
|
212
|
+
threadId: String(runTask.id),
|
|
213
|
+
run: async ({ interaction }) => {
|
|
214
|
+
await handleRunTaskAction({
|
|
215
|
+
interaction,
|
|
216
|
+
taskId: runTask.id,
|
|
217
|
+
showAll,
|
|
218
|
+
})
|
|
219
|
+
},
|
|
220
|
+
})
|
|
221
|
+
return buildHtmlActionCustomId(actionId)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const cancelTask = cancellableTasksByButtonId.get(button.id)
|
|
225
|
+
if (!cancelTask) {
|
|
190
226
|
return new Error(`No task registered for button ${button.id}`)
|
|
191
227
|
}
|
|
192
228
|
|
|
193
229
|
const actionId = registerHtmlAction({
|
|
194
230
|
ownerKey,
|
|
195
|
-
threadId: String(
|
|
231
|
+
threadId: String(cancelTask.id),
|
|
196
232
|
run: async ({ interaction }) => {
|
|
197
233
|
await handleCancelTaskAction({
|
|
198
234
|
interaction,
|
|
199
|
-
taskId:
|
|
235
|
+
taskId: cancelTask.id,
|
|
200
236
|
showAll,
|
|
201
237
|
})
|
|
202
238
|
},
|
|
@@ -224,6 +260,70 @@ async function renderTasksReply({
|
|
|
224
260
|
})
|
|
225
261
|
}
|
|
226
262
|
|
|
263
|
+
async function handleRunTaskAction({
|
|
264
|
+
interaction,
|
|
265
|
+
taskId,
|
|
266
|
+
showAll,
|
|
267
|
+
}: {
|
|
268
|
+
interaction: ButtonInteraction
|
|
269
|
+
taskId: number
|
|
270
|
+
showAll: boolean
|
|
271
|
+
}): Promise<void> {
|
|
272
|
+
const guildId = interaction.guildId
|
|
273
|
+
if (!guildId) {
|
|
274
|
+
await interaction.editReply({
|
|
275
|
+
components: [
|
|
276
|
+
{
|
|
277
|
+
type: ComponentType.TextDisplay,
|
|
278
|
+
content: 'This action can only be used in a server.',
|
|
279
|
+
},
|
|
280
|
+
],
|
|
281
|
+
flags: MessageFlags.IsComponentsV2,
|
|
282
|
+
})
|
|
283
|
+
return
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const token = interaction.client.token
|
|
287
|
+
if (!token) {
|
|
288
|
+
await renderTasksReply({
|
|
289
|
+
guildId,
|
|
290
|
+
userId: interaction.user.id,
|
|
291
|
+
channelId: interaction.channelId,
|
|
292
|
+
showAll,
|
|
293
|
+
notice: `Could not run task #${taskId}: bot token unavailable.`,
|
|
294
|
+
editReply: (options) => {
|
|
295
|
+
return interaction.editReply(options)
|
|
296
|
+
},
|
|
297
|
+
})
|
|
298
|
+
return
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const result = await runScheduledTaskNow({ token, taskId })
|
|
302
|
+
const notice = (() => {
|
|
303
|
+
if (result instanceof Error) {
|
|
304
|
+
return `Could not run task #${taskId}: ${result.message}`
|
|
305
|
+
}
|
|
306
|
+
if (result.kind === 'skipped') {
|
|
307
|
+
return `Task #${taskId} is already running or was claimed elsewhere.`
|
|
308
|
+
}
|
|
309
|
+
if (result.kind === 'failed') {
|
|
310
|
+
return `Task #${taskId} failed: ${result.error.message}`
|
|
311
|
+
}
|
|
312
|
+
return `Started task #${taskId}.`
|
|
313
|
+
})()
|
|
314
|
+
|
|
315
|
+
await renderTasksReply({
|
|
316
|
+
guildId,
|
|
317
|
+
userId: interaction.user.id,
|
|
318
|
+
channelId: interaction.channelId,
|
|
319
|
+
showAll,
|
|
320
|
+
notice,
|
|
321
|
+
editReply: (options) => {
|
|
322
|
+
return interaction.editReply(options)
|
|
323
|
+
},
|
|
324
|
+
})
|
|
325
|
+
}
|
|
326
|
+
|
|
227
327
|
async function handleCancelTaskAction({
|
|
228
328
|
interaction,
|
|
229
329
|
taskId,
|
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
type ThreadChannel,
|
|
11
11
|
} from 'discord.js'
|
|
12
12
|
import { getOrCreateRuntime } from '../session-handler/thread-session-runtime.js'
|
|
13
|
-
import { SILENT_MESSAGE_FLAGS } from '../discord-utils.js'
|
|
13
|
+
import { SILENT_MESSAGE_FLAGS, resolveWorkingDirectory } from '../discord-utils.js'
|
|
14
14
|
import { createLogger, LogPrefix } from '../logger.js'
|
|
15
15
|
import {
|
|
16
16
|
getChannelDirectory,
|
|
@@ -138,11 +138,12 @@ export const handleUserCommand: CommandHandler = async ({
|
|
|
138
138
|
// Running in existing thread - just send the command
|
|
139
139
|
await command.editReply(`Running ${commandInvocation}...`)
|
|
140
140
|
|
|
141
|
+
const resolved = await resolveWorkingDirectory({ channel: thread })
|
|
141
142
|
const runtime = getOrCreateRuntime({
|
|
142
143
|
threadId: thread.id,
|
|
143
144
|
thread,
|
|
144
145
|
projectDirectory,
|
|
145
|
-
sdkDirectory: projectDirectory,
|
|
146
|
+
sdkDirectory: resolved?.workingDirectory || projectDirectory,
|
|
146
147
|
channelId: textChannel?.id,
|
|
147
148
|
appId,
|
|
148
149
|
})
|
package/src/database.ts
CHANGED
|
@@ -534,6 +534,30 @@ export async function upsertThreadSession({ threadId, sessionId, source }: { thr
|
|
|
534
534
|
.onConflictDoUpdate({ target: schema.thread_sessions.thread_id, set: { session_id: sessionId, source } })
|
|
535
535
|
}
|
|
536
536
|
|
|
537
|
+
export async function getThreadParentSessionId(threadId: string) {
|
|
538
|
+
const db = await getDb()
|
|
539
|
+
return (
|
|
540
|
+
await db.query.thread_sessions.findFirst({
|
|
541
|
+
where: { thread_id: threadId },
|
|
542
|
+
columns: { parent_session_id: true },
|
|
543
|
+
})
|
|
544
|
+
)?.parent_session_id ?? undefined
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
export async function setThreadParentSessionId({
|
|
548
|
+
threadId,
|
|
549
|
+
parentSessionId,
|
|
550
|
+
}: {
|
|
551
|
+
threadId: string
|
|
552
|
+
parentSessionId: string
|
|
553
|
+
}) {
|
|
554
|
+
const db = await getDb()
|
|
555
|
+
await db
|
|
556
|
+
.update(schema.thread_sessions)
|
|
557
|
+
.set({ parent_session_id: parentSessionId })
|
|
558
|
+
.where(orm.eq(schema.thread_sessions.thread_id, threadId))
|
|
559
|
+
}
|
|
560
|
+
|
|
537
561
|
export async function getThreadSessionSource(threadId: string) {
|
|
538
562
|
const db = await getDb()
|
|
539
563
|
return (await db.query.thread_sessions.findFirst({ where: { thread_id: threadId }, columns: { source: true } }))?.source
|
package/src/db.ts
CHANGED
|
@@ -158,6 +158,7 @@ async function migrateSchema({
|
|
|
158
158
|
'ALTER TABLE bot_tokens ADD COLUMN last_used_at DATETIME',
|
|
159
159
|
"ALTER TABLE thread_sessions ADD COLUMN source TEXT DEFAULT 'kimaki'",
|
|
160
160
|
'ALTER TABLE thread_sessions ADD COLUMN last_synced_name TEXT',
|
|
161
|
+
'ALTER TABLE thread_sessions ADD COLUMN parent_session_id TEXT',
|
|
161
162
|
]
|
|
162
163
|
for (const stmt of alterStatements) {
|
|
163
164
|
await client.execute(stmt).catch(() => undefined)
|
package/src/discord-bot.ts
CHANGED
|
@@ -52,6 +52,7 @@ import {
|
|
|
52
52
|
} from './system-message.js'
|
|
53
53
|
import YAML from 'yaml'
|
|
54
54
|
import {
|
|
55
|
+
getFileAttachments,
|
|
55
56
|
getTextAttachments,
|
|
56
57
|
resolveMentions,
|
|
57
58
|
} from './message-formatting.js'
|
|
@@ -484,6 +485,9 @@ export async function startDiscordBot({
|
|
|
484
485
|
const cliInjectedInjectionGuardPatterns = isCliInjectedPrompt
|
|
485
486
|
? promptMarker?.injectionGuardPatterns
|
|
486
487
|
: undefined
|
|
488
|
+
const cliInjectedParentSessionId = isCliInjectedPrompt
|
|
489
|
+
? promptMarker?.parentSessionId
|
|
490
|
+
: undefined
|
|
487
491
|
|
|
488
492
|
// Always ignore our own messages (unless CLI-injected prompt above).
|
|
489
493
|
// Without this, assigning the Kimaki role to the bot itself would loop.
|
|
@@ -729,9 +733,15 @@ export async function startDiscordBot({
|
|
|
729
733
|
? extractBtwSuffix(message.content || '')
|
|
730
734
|
: null
|
|
731
735
|
if (btwResult?.forceBtw && projectDirectory && !isLeadingMentionToOtherUser) {
|
|
736
|
+
const btwSdkDir =
|
|
737
|
+
worktreeInfo?.status === 'ready' &&
|
|
738
|
+
worktreeInfo.workspace_directory
|
|
739
|
+
? worktreeInfo.workspace_directory
|
|
740
|
+
: projectDirectory
|
|
732
741
|
const result = await forkSessionToBtwThread({
|
|
733
742
|
sourceThread: thread,
|
|
734
743
|
projectDirectory,
|
|
744
|
+
sdkDirectory: btwSdkDir,
|
|
735
745
|
prompt: btwResult.prompt,
|
|
736
746
|
userId: message.author.id,
|
|
737
747
|
username:
|
|
@@ -829,6 +839,7 @@ export async function startDiscordBot({
|
|
|
829
839
|
model: cliInjectedModel,
|
|
830
840
|
permissions: cliInjectedPermissions,
|
|
831
841
|
injectionGuardPatterns: cliInjectedInjectionGuardPatterns,
|
|
842
|
+
parentSessionId: cliInjectedParentSessionId,
|
|
832
843
|
noReply: isLeadingMentionToOtherUser || undefined,
|
|
833
844
|
sessionStartSource: sessionStartSource
|
|
834
845
|
? {
|
|
@@ -851,7 +862,10 @@ export async function startDiscordBot({
|
|
|
851
862
|
|
|
852
863
|
// Notify when a voice message was queued instead of sent immediately
|
|
853
864
|
if (enqueueResult.queued && enqueueResult.position) {
|
|
854
|
-
await sendThreadMessage(
|
|
865
|
+
await sendThreadMessage(
|
|
866
|
+
thread,
|
|
867
|
+
`Queued at position ${enqueueResult.position}. Edit or delete your message to update the queue`,
|
|
868
|
+
)
|
|
855
869
|
}
|
|
856
870
|
}
|
|
857
871
|
|
|
@@ -1076,6 +1090,10 @@ export async function startDiscordBot({
|
|
|
1076
1090
|
if (!message) return
|
|
1077
1091
|
if (message.author.bot) return
|
|
1078
1092
|
if (!message.content) return
|
|
1093
|
+
// Discord fires MESSAGE_UPDATE for embed-only updates (link preview
|
|
1094
|
+
// unfurling) without the user actually editing the message content.
|
|
1095
|
+
// editedTimestamp is null for these; skip them to avoid false queue removals.
|
|
1096
|
+
if (!message.editedTimestamp) return
|
|
1079
1097
|
|
|
1080
1098
|
const channel = message.channel
|
|
1081
1099
|
const isThread = [
|
|
@@ -1131,6 +1149,35 @@ export async function startDiscordBot({
|
|
|
1131
1149
|
}
|
|
1132
1150
|
})
|
|
1133
1151
|
|
|
1152
|
+
// Handle user message deletes to remove queued messages.
|
|
1153
|
+
// Discord delete events do not include author/content, so attribution comes
|
|
1154
|
+
// from the queued item captured at enqueue time.
|
|
1155
|
+
discordClient.on(Events.MessageDelete, async (message) => {
|
|
1156
|
+
try {
|
|
1157
|
+
const channel = message.channel
|
|
1158
|
+
if (!channel.isThread()) return
|
|
1159
|
+
|
|
1160
|
+
const runtime = getRuntime(channel.id)
|
|
1161
|
+
if (!runtime) return
|
|
1162
|
+
|
|
1163
|
+
const removed = runtime.removeQueuedMessage(message.id)
|
|
1164
|
+
if (!removed) return
|
|
1165
|
+
|
|
1166
|
+
discordLogger.log(
|
|
1167
|
+
`[MESSAGE_DELETE] Removed queued message ${message.id} in thread ${channel.id}`,
|
|
1168
|
+
)
|
|
1169
|
+
await sendThreadMessage(
|
|
1170
|
+
channel,
|
|
1171
|
+
`⬦ **${removed.username}** removed message from queue`,
|
|
1172
|
+
)
|
|
1173
|
+
} catch (error) {
|
|
1174
|
+
discordLogger.error(
|
|
1175
|
+
'Error handling message delete:',
|
|
1176
|
+
error instanceof Error ? error.stack : String(error),
|
|
1177
|
+
)
|
|
1178
|
+
}
|
|
1179
|
+
})
|
|
1180
|
+
|
|
1134
1181
|
// Handle bot-initiated threads created by `kimaki send` (without --notify-only)
|
|
1135
1182
|
// Uses JSON embed marker to pass options (start, worktree name)
|
|
1136
1183
|
discordClient.on(Events.ThreadCreate, async (thread, newlyCreated) => {
|
|
@@ -1188,7 +1235,10 @@ export async function startDiscordBot({
|
|
|
1188
1235
|
`[BOT_SESSION] Detected bot-initiated thread: ${thread.name}`,
|
|
1189
1236
|
)
|
|
1190
1237
|
|
|
1191
|
-
const textAttachmentsContent = await
|
|
1238
|
+
const [textAttachmentsContent, fileAttachments] = await Promise.all([
|
|
1239
|
+
getTextAttachments(starterMessage),
|
|
1240
|
+
getFileAttachments(starterMessage),
|
|
1241
|
+
])
|
|
1192
1242
|
const messageText = resolveMentions(starterMessage).trim()
|
|
1193
1243
|
const prompt = textAttachmentsContent
|
|
1194
1244
|
? `${messageText}\n\n${textAttachmentsContent}`
|
|
@@ -1221,16 +1271,28 @@ export async function startDiscordBot({
|
|
|
1221
1271
|
return
|
|
1222
1272
|
}
|
|
1223
1273
|
|
|
1224
|
-
// Start worktree creation concurrently if requested
|
|
1274
|
+
// Start worktree creation concurrently if requested via marker OR
|
|
1275
|
+
// if the channel/global toggle enables auto-worktrees.
|
|
1225
1276
|
// The runtime is created immediately so follow-up messages queue
|
|
1226
1277
|
// naturally; the worktree promise is awaited inside enqueueIncoming.
|
|
1278
|
+
const autoWorktreeEnabled =
|
|
1279
|
+
!marker.worktree &&
|
|
1280
|
+
!marker.cwd &&
|
|
1281
|
+
(store.getState().useWorktrees ||
|
|
1282
|
+
(await getChannelWorktreesEnabled(parent.id)))
|
|
1283
|
+
const effectiveWorktreeName =
|
|
1284
|
+
marker.worktree ||
|
|
1285
|
+
(autoWorktreeEnabled
|
|
1286
|
+
? formatAutoWorktreeName(thread.name.slice(0, 50))
|
|
1287
|
+
: undefined)
|
|
1288
|
+
|
|
1227
1289
|
let worktreePromise: Promise<string | Error> | undefined
|
|
1228
|
-
if (
|
|
1229
|
-
discordLogger.log(`[BOT_SESSION] Creating worktree: ${
|
|
1290
|
+
if (effectiveWorktreeName && (await isGitRepositoryRoot(projectDirectory))) {
|
|
1291
|
+
discordLogger.log(`[BOT_SESSION] Creating worktree: ${effectiveWorktreeName}`)
|
|
1230
1292
|
|
|
1231
1293
|
const worktreeStatusMessage = await thread
|
|
1232
1294
|
.send({
|
|
1233
|
-
content: worktreeCreatingMessage(
|
|
1295
|
+
content: worktreeCreatingMessage(effectiveWorktreeName),
|
|
1234
1296
|
flags: SILENT_MESSAGE_FLAGS,
|
|
1235
1297
|
})
|
|
1236
1298
|
.catch(() => undefined)
|
|
@@ -1238,7 +1300,7 @@ export async function startDiscordBot({
|
|
|
1238
1300
|
worktreePromise = createWorktreeInBackground({
|
|
1239
1301
|
thread,
|
|
1240
1302
|
starterMessage: worktreeStatusMessage,
|
|
1241
|
-
worktreeName:
|
|
1303
|
+
worktreeName: effectiveWorktreeName,
|
|
1242
1304
|
projectDirectory,
|
|
1243
1305
|
rest: discordClient.rest,
|
|
1244
1306
|
})
|
|
@@ -1246,6 +1308,10 @@ export async function startDiscordBot({
|
|
|
1246
1308
|
discordLogger.warn(
|
|
1247
1309
|
`[BOT_SESSION] Skipping requested worktree for non-git project directory: ${projectDirectory}`,
|
|
1248
1310
|
)
|
|
1311
|
+
} else if (autoWorktreeEnabled) {
|
|
1312
|
+
discordLogger.warn(
|
|
1313
|
+
`[BOT_SESSION] Skipping auto-worktree for non-git project directory: ${projectDirectory}`,
|
|
1314
|
+
)
|
|
1249
1315
|
}
|
|
1250
1316
|
|
|
1251
1317
|
// --cwd: reuse an existing project subfolder or worktree directory. Revalidate at bot-time
|
|
@@ -1335,6 +1401,7 @@ export async function startDiscordBot({
|
|
|
1335
1401
|
model: marker.model,
|
|
1336
1402
|
permissions: marker.permissions,
|
|
1337
1403
|
injectionGuardPatterns: marker.injectionGuardPatterns,
|
|
1404
|
+
parentSessionId: marker.parentSessionId,
|
|
1338
1405
|
mode: 'opencode',
|
|
1339
1406
|
sessionStartSource: botThreadStartSource
|
|
1340
1407
|
? {
|
|
@@ -1346,7 +1413,12 @@ export async function startDiscordBot({
|
|
|
1346
1413
|
const permissionRules = await getChannelReferencePermissionRules({
|
|
1347
1414
|
message: starterMessage,
|
|
1348
1415
|
})
|
|
1349
|
-
return {
|
|
1416
|
+
return {
|
|
1417
|
+
prompt,
|
|
1418
|
+
permissionRules,
|
|
1419
|
+
mode: 'opencode',
|
|
1420
|
+
...(fileAttachments.length > 0 && { images: fileAttachments }),
|
|
1421
|
+
}
|
|
1350
1422
|
},
|
|
1351
1423
|
})
|
|
1352
1424
|
} catch (error) {
|