kimaki 0.21.0 ā 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/cli-commands/project.js +82 -15
- package/dist/cli-commands/send.js +43 -3
- package/dist/cli-runner.js +57 -1
- package/dist/commands/btw.js +4 -3
- package/dist/commands/new-worktree.js +14 -2
- package/dist/commands/session.js +66 -13
- package/dist/commands/tasks.js +10 -3
- package/dist/commands/user-command.js +3 -2
- package/dist/discord-bot.js +38 -8
- package/dist/message-preprocessing.js +9 -2
- package/dist/system-message.js +20 -8
- package/dist/system-message.test.js +17 -8
- package/dist/worktree-lifecycle.e2e.test.js +70 -1
- package/dist/worktrees.js +63 -0
- package/package.json +5 -5
- package/skills/holocron/SKILL.md +23 -0
- package/src/cli-commands/project.ts +100 -17
- package/src/cli-commands/send.ts +56 -5
- package/src/cli-runner.ts +81 -0
- package/src/commands/btw.ts +6 -2
- package/src/commands/new-worktree.ts +14 -1
- package/src/commands/session.ts +79 -17
- package/src/commands/tasks.ts +11 -3
- package/src/commands/user-command.ts +3 -2
- package/src/discord-bot.ts +42 -7
- package/src/message-preprocessing.ts +11 -2
- package/src/system-message.test.ts +17 -8
- package/src/system-message.ts +20 -8
- package/src/worktree-lifecycle.e2e.test.ts +88 -1
- package/src/worktrees.ts +74 -0
|
@@ -163,8 +163,10 @@ cli
|
|
|
163
163
|
'List all registered projects with their Discord channels',
|
|
164
164
|
)
|
|
165
165
|
.option('--json', 'Output as JSON')
|
|
166
|
+
.option('--all', 'Include remote projects from other machines (scans Kimaki category in Discord)')
|
|
167
|
+
.option('-g, --guild <guildId>', 'Discord guild/server ID to scan (used with --all when no local projects exist)')
|
|
166
168
|
.option('--prune', 'Remove stale entries whose Discord channel no longer exists')
|
|
167
|
-
.action(async (options
|
|
169
|
+
.action(async (options) => {
|
|
168
170
|
await initDatabase()
|
|
169
171
|
|
|
170
172
|
const db = await getDb()
|
|
@@ -173,15 +175,12 @@ cli
|
|
|
173
175
|
orderBy: { created_at: 'desc' },
|
|
174
176
|
})
|
|
175
177
|
|
|
176
|
-
if (channels.length === 0) {
|
|
177
|
-
cliLogger.log('No projects registered')
|
|
178
|
-
process.exit(0)
|
|
179
|
-
}
|
|
180
|
-
|
|
181
178
|
// Fetch Discord channel names and guild IDs via REST API
|
|
182
179
|
const botRow = await getBotTokenWithMode()
|
|
183
180
|
const rest = botRow ? createDiscordRest(botRow.token) : null
|
|
184
181
|
|
|
182
|
+
const localChannelIds = new Set(channels.map((ch) => ch.channel_id))
|
|
183
|
+
|
|
185
184
|
const enriched = await Promise.all(
|
|
186
185
|
channels.map(async (ch) => {
|
|
187
186
|
let channelName = ''
|
|
@@ -204,14 +203,19 @@ cli
|
|
|
204
203
|
deleted = isUnknownChannel
|
|
205
204
|
}
|
|
206
205
|
}
|
|
207
|
-
return { ...ch, channelName, guildId, deleted }
|
|
206
|
+
return { ...ch, channelName, guildId, deleted, isLocal: true as boolean }
|
|
208
207
|
}),
|
|
209
208
|
)
|
|
210
209
|
|
|
211
210
|
// Fetch guild names for unique guild IDs (deduplicated to save API calls)
|
|
212
211
|
const guildNameMap = new Map<string, string>()
|
|
212
|
+
// Collect guild IDs from local channels + explicit --guild flag
|
|
213
|
+
const guildIdsFromChannels = enriched.map((ch) => ch.guildId).filter(Boolean)
|
|
214
|
+
if (options.guild) {
|
|
215
|
+
guildIdsFromChannels.push(options.guild)
|
|
216
|
+
}
|
|
217
|
+
const uniqueGuildIds = [...new Set(guildIdsFromChannels)]
|
|
213
218
|
if (rest) {
|
|
214
|
-
const uniqueGuildIds = [...new Set(enriched.map((ch) => ch.guildId).filter(Boolean))]
|
|
215
219
|
await Promise.all(
|
|
216
220
|
uniqueGuildIds.map(async (guildId) => {
|
|
217
221
|
try {
|
|
@@ -227,8 +231,76 @@ cli
|
|
|
227
231
|
)
|
|
228
232
|
}
|
|
229
233
|
|
|
234
|
+
// When --all is passed, scan each guild's channels to find Kimaki category
|
|
235
|
+
// text channels not in our local DB (projects from other machines).
|
|
236
|
+
// Fail explicitly when prerequisites are missing so the user doesn't
|
|
237
|
+
// confuse "scan never ran" with "no remote projects found".
|
|
238
|
+
let remoteEntries: typeof enriched = []
|
|
239
|
+
if (options.all) {
|
|
240
|
+
if (!rest) {
|
|
241
|
+
cliLogger.error('Discord credentials are required to scan remote projects. Run `kimaki` first.')
|
|
242
|
+
process.exit(EXIT_NO_RESTART)
|
|
243
|
+
}
|
|
244
|
+
if (uniqueGuildIds.length === 0) {
|
|
245
|
+
cliLogger.error(
|
|
246
|
+
'Cannot determine which Discord server to scan. Pass `--guild <guildId>` or register a local project first.',
|
|
247
|
+
)
|
|
248
|
+
process.exit(EXIT_NO_RESTART)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
let guildScanFailures = 0
|
|
252
|
+
for (const guildId of uniqueGuildIds) {
|
|
253
|
+
try {
|
|
254
|
+
const guildChannels = (await rest.get(Routes.guildChannels(guildId))) as Array<{
|
|
255
|
+
id: string
|
|
256
|
+
name: string
|
|
257
|
+
type: number
|
|
258
|
+
parent_id: string | null
|
|
259
|
+
}>
|
|
260
|
+
|
|
261
|
+
// Find Kimaki category channels (type 4 = GuildCategory)
|
|
262
|
+
const kimakiCategoryIds = new Set(
|
|
263
|
+
guildChannels
|
|
264
|
+
.filter((ch) => ch.type === 4 && /^kimaki(\s|$)/i.test(ch.name))
|
|
265
|
+
.map((ch) => ch.id),
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
// Find text channels (type 0) in Kimaki categories that are not in our local DB
|
|
269
|
+
for (const ch of guildChannels) {
|
|
270
|
+
if (
|
|
271
|
+
ch.type === 0 &&
|
|
272
|
+
ch.parent_id &&
|
|
273
|
+
kimakiCategoryIds.has(ch.parent_id) &&
|
|
274
|
+
!localChannelIds.has(ch.id)
|
|
275
|
+
) {
|
|
276
|
+
remoteEntries.push({
|
|
277
|
+
channel_id: ch.id,
|
|
278
|
+
directory: '',
|
|
279
|
+
channel_type: 'text' as const,
|
|
280
|
+
created_at: null,
|
|
281
|
+
channelName: ch.name,
|
|
282
|
+
guildId,
|
|
283
|
+
deleted: false,
|
|
284
|
+
isLocal: false,
|
|
285
|
+
})
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
} catch (error) {
|
|
289
|
+
guildScanFailures++
|
|
290
|
+
cliLogger.warn(
|
|
291
|
+
`Failed to scan guild ${guildNameMap.get(guildId) || guildId}: ${error instanceof Error ? error.message : String(error)}`,
|
|
292
|
+
)
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
if (guildScanFailures === uniqueGuildIds.length) {
|
|
296
|
+
cliLogger.error('Failed to scan all guilds. Check bot permissions or try again.')
|
|
297
|
+
process.exit(EXIT_NO_RESTART)
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
230
301
|
// Build final enriched entries with guild names resolved
|
|
231
|
-
const
|
|
302
|
+
const allEntries = [...enriched, ...remoteEntries]
|
|
303
|
+
const enrichedWithGuild = allEntries.map((ch) => ({
|
|
232
304
|
...ch,
|
|
233
305
|
guildName: ch.guildId ? (guildNameMap.get(ch.guildId) || '') : '',
|
|
234
306
|
}))
|
|
@@ -236,7 +308,7 @@ cli
|
|
|
236
308
|
// Warn on stderr if the same directory appears in multiple channels (multi-guild duplicates)
|
|
237
309
|
const directoryCounts = new Map<string, number>()
|
|
238
310
|
for (const ch of enrichedWithGuild) {
|
|
239
|
-
if (!ch.deleted) {
|
|
311
|
+
if (!ch.deleted && ch.directory) {
|
|
240
312
|
directoryCounts.set(ch.directory, (directoryCounts.get(ch.directory) || 0) + 1)
|
|
241
313
|
}
|
|
242
314
|
}
|
|
@@ -251,7 +323,7 @@ cli
|
|
|
251
323
|
// Prune stale entries if requested
|
|
252
324
|
let finalEntries = enrichedWithGuild
|
|
253
325
|
if (options.prune) {
|
|
254
|
-
const stale = finalEntries.filter((ch) => ch.deleted)
|
|
326
|
+
const stale = finalEntries.filter((ch) => ch.deleted && ch.isLocal)
|
|
255
327
|
if (stale.length === 0) {
|
|
256
328
|
cliLogger.log('No stale channels to prune')
|
|
257
329
|
} else {
|
|
@@ -268,29 +340,40 @@ cli
|
|
|
268
340
|
}
|
|
269
341
|
}
|
|
270
342
|
|
|
343
|
+
if (finalEntries.length === 0) {
|
|
344
|
+
cliLogger.log('No projects registered')
|
|
345
|
+
process.exit(0)
|
|
346
|
+
}
|
|
347
|
+
|
|
271
348
|
if (options.json) {
|
|
272
349
|
const output = finalEntries.map((ch) => ({
|
|
273
350
|
channel_id: ch.channel_id,
|
|
274
351
|
channel_name: ch.channelName,
|
|
275
352
|
guild_id: ch.guildId,
|
|
276
353
|
guild_name: ch.guildName,
|
|
277
|
-
directory: ch.directory,
|
|
278
|
-
folder_name: path.basename(ch.directory),
|
|
354
|
+
directory: ch.directory || null,
|
|
355
|
+
folder_name: ch.directory ? path.basename(ch.directory) : null,
|
|
279
356
|
deleted: ch.deleted,
|
|
357
|
+
is_local: ch.isLocal,
|
|
280
358
|
}))
|
|
281
359
|
console.log(JSON.stringify(output, null, 2))
|
|
282
360
|
process.exit(0)
|
|
283
361
|
}
|
|
284
362
|
|
|
285
363
|
for (const ch of finalEntries) {
|
|
286
|
-
const folderName = path.basename(ch.directory)
|
|
287
364
|
const deletedTag = ch.deleted ? ' (deleted from Discord)' : ''
|
|
365
|
+
const remoteTag = !ch.isLocal ? ' [remote]' : ''
|
|
288
366
|
const channelLabel = ch.channelName ? `#${ch.channelName}` : ch.channel_id
|
|
289
367
|
const guildLabel = ch.guildName || ch.guildId || ''
|
|
290
368
|
const guildSuffix = guildLabel ? ` (${guildLabel})` : ''
|
|
291
|
-
console.log(`\n${channelLabel}${guildSuffix}${deletedTag}`)
|
|
292
|
-
|
|
293
|
-
|
|
369
|
+
console.log(`\n${channelLabel}${guildSuffix}${deletedTag}${remoteTag}`)
|
|
370
|
+
if (ch.isLocal && ch.directory) {
|
|
371
|
+
const folderName = path.basename(ch.directory)
|
|
372
|
+
console.log(` Folder: ${folderName}`)
|
|
373
|
+
console.log(` Directory: ${ch.directory}`)
|
|
374
|
+
} else if (!ch.isLocal) {
|
|
375
|
+
console.log(` (Not registered on this machine)`)
|
|
376
|
+
}
|
|
294
377
|
console.log(` Channel ID: ${ch.channel_id}`)
|
|
295
378
|
if (ch.guildId) {
|
|
296
379
|
console.log(` Guild ID: ${ch.guildId}`)
|
package/src/cli-commands/send.ts
CHANGED
|
@@ -16,7 +16,7 @@ import { fileURLToPath } from 'node:url'
|
|
|
16
16
|
import { spawn, execSync } from 'node:child_process'
|
|
17
17
|
import { createLogger, LogPrefix, initLogFile } from '../logger.js'
|
|
18
18
|
import { createDiscordClient, initDatabase, getChannelDirectory, initializeOpencodeForDirectory, createProjectChannels } from '../discord-bot.js'
|
|
19
|
-
import { getBotTokenWithMode, getThreadSession, getThreadIdBySessionId, getSessionEventSnapshot, getDb, createScheduledTask, listScheduledTasks, cancelScheduledTask, getScheduledTask, updateScheduledTask, getSessionStartSourcesBySessionIds, deleteChannelDirectoryById, findChannelsByDirectory } from '../database.js'
|
|
19
|
+
import { getBotTokenWithMode, getThreadSession, getThreadIdBySessionId, getSessionEventSnapshot, getDb, createScheduledTask, listScheduledTasks, cancelScheduledTask, getScheduledTask, updateScheduledTask, getSessionStartSourcesBySessionIds, deleteChannelDirectoryById, findChannelsByDirectory, getChannelWorktreesEnabled } from '../database.js'
|
|
20
20
|
import { ShareMarkdown } from '../markdown.js'
|
|
21
21
|
import { parseSessionSearchPattern, findFirstSessionSearchHit, buildSessionSearchSnippet, getPartSearchTexts } from '../session-search.js'
|
|
22
22
|
import { formatWorktreeName, formatAutoWorktreeName } from '../commands/new-worktree.js'
|
|
@@ -26,7 +26,7 @@ import { buildOpencodeEventLogLine } from '../session-handler/opencode-session-e
|
|
|
26
26
|
import { createDiscordRest } from '../discord-urls.js'
|
|
27
27
|
import { archiveThread, uploadFilesToDiscord, stripMentions } from '../discord-utils.js'
|
|
28
28
|
import { setDataDir, setProjectsDir, getDataDir, getProjectsDir } from '../config.js'
|
|
29
|
-
import { execAsync, resolveSessionWorkingDirectory } from '../worktrees.js'
|
|
29
|
+
import { execAsync, resolveSessionWorkingDirectory, isGitRepositoryRoot } from '../worktrees.js'
|
|
30
30
|
import { upgrade, getCurrentVersion } from '../upgrade.js'
|
|
31
31
|
import { getPromptPreview, parseSendAtValue, parseScheduledTaskPayload, serializeScheduledTaskPayload, type ScheduledTaskPayload } from '../task-schedule.js'
|
|
32
32
|
import {
|
|
@@ -95,6 +95,13 @@ cli
|
|
|
95
95
|
'Format: "tool:argsGlob". Examples: --injection-guard "bash:*" --injection-guard "webfetch:*"',
|
|
96
96
|
),
|
|
97
97
|
)
|
|
98
|
+
.option(
|
|
99
|
+
'-f, --file <path>',
|
|
100
|
+
z.array(z.string()).describe(
|
|
101
|
+
'Local file to attach (repeatable). Images, text files, PDFs, etc. ' +
|
|
102
|
+
'Examples: --file screenshot.png --file report.pdf',
|
|
103
|
+
),
|
|
104
|
+
)
|
|
98
105
|
.option(
|
|
99
106
|
'--send-at <schedule>',
|
|
100
107
|
'Schedule send for future (UTC ISO date/time ending in Z, or cron expression)',
|
|
@@ -153,11 +160,19 @@ cli
|
|
|
153
160
|
process.exit(EXIT_NO_RESTART)
|
|
154
161
|
}
|
|
155
162
|
|
|
163
|
+
const filePaths = options.file?.length
|
|
164
|
+
? options.file.map((f: string) => path.resolve(f))
|
|
165
|
+
: undefined
|
|
166
|
+
|
|
156
167
|
if (sendAt) {
|
|
157
168
|
if (options.wait) {
|
|
158
169
|
cliLogger.error('Cannot use --wait with --send-at')
|
|
159
170
|
process.exit(EXIT_NO_RESTART)
|
|
160
171
|
}
|
|
172
|
+
if (filePaths?.length) {
|
|
173
|
+
cliLogger.error('Cannot use --file with --send-at')
|
|
174
|
+
process.exit(EXIT_NO_RESTART)
|
|
175
|
+
}
|
|
161
176
|
if (prompt.length > 1900) {
|
|
162
177
|
cliLogger.error(
|
|
163
178
|
'--send-at currently supports prompts up to 1900 characters',
|
|
@@ -166,6 +181,31 @@ cli
|
|
|
166
181
|
}
|
|
167
182
|
}
|
|
168
183
|
|
|
184
|
+
// Validate all --file paths exist and are regular files
|
|
185
|
+
if (filePaths?.length) {
|
|
186
|
+
// Discord allows max 10 attachments per message. Long prompts also
|
|
187
|
+
// consume one slot (prompt.md), so reserve space for that.
|
|
188
|
+
const maxUserFiles = prompt.length > 2000 ? 9 : 10
|
|
189
|
+
if (filePaths.length > maxUserFiles) {
|
|
190
|
+
cliLogger.error(
|
|
191
|
+
`Too many files: ${filePaths.length} provided, Discord allows at most ${maxUserFiles} attachments per message` +
|
|
192
|
+
(maxUserFiles === 9 ? ' (1 slot reserved for long prompt)' : ''),
|
|
193
|
+
)
|
|
194
|
+
process.exit(EXIT_NO_RESTART)
|
|
195
|
+
}
|
|
196
|
+
for (const file of filePaths) {
|
|
197
|
+
if (!fs.existsSync(file)) {
|
|
198
|
+
cliLogger.error(`File not found: ${file}`)
|
|
199
|
+
process.exit(EXIT_NO_RESTART)
|
|
200
|
+
}
|
|
201
|
+
const stat = fs.statSync(file)
|
|
202
|
+
if (!stat.isFile()) {
|
|
203
|
+
cliLogger.error(`Not a regular file: ${file}`)
|
|
204
|
+
process.exit(EXIT_NO_RESTART)
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
169
209
|
const parsedSchedule = (() => {
|
|
170
210
|
if (!sendAt) {
|
|
171
211
|
return null
|
|
@@ -483,6 +523,7 @@ cli
|
|
|
483
523
|
botToken,
|
|
484
524
|
embeds: promptEmbed,
|
|
485
525
|
rest,
|
|
526
|
+
files: filePaths,
|
|
486
527
|
})
|
|
487
528
|
|
|
488
529
|
const threadUrl = `https://discord.com/channels/${threadData.guild_id}/${threadData.id}`
|
|
@@ -578,14 +619,23 @@ cli
|
|
|
578
619
|
: cleanPrompt)
|
|
579
620
|
// Explicit string => use as-is via formatWorktreeName (no vowel strip).
|
|
580
621
|
// Boolean true => derived from thread/prompt, compress via formatAutoWorktreeName.
|
|
622
|
+
// When no --worktree flag but channel has worktrees enabled via toggle,
|
|
623
|
+
// the bot-side ThreadCreate handler auto-creates the worktree. We add
|
|
624
|
+
// the prefix here for cosmetic consistency (thread name shows š³).
|
|
625
|
+
const channelWorktreesEnabled =
|
|
626
|
+
!options.worktree && !options.cwd && !notifyOnly && projectDirectory
|
|
627
|
+
? (await getChannelWorktreesEnabled(channelId)) &&
|
|
628
|
+
(await isGitRepositoryRoot(projectDirectory))
|
|
629
|
+
: false
|
|
581
630
|
const worktreeName = options.worktree
|
|
582
631
|
? typeof options.worktree === 'string'
|
|
583
632
|
? formatWorktreeName(options.worktree)
|
|
584
633
|
: formatAutoWorktreeName(baseThreadName)
|
|
585
634
|
: undefined
|
|
586
|
-
const threadName =
|
|
587
|
-
|
|
588
|
-
|
|
635
|
+
const threadName =
|
|
636
|
+
worktreeName || channelWorktreesEnabled
|
|
637
|
+
? `${WORKTREE_PREFIX}${baseThreadName}`
|
|
638
|
+
: baseThreadName
|
|
589
639
|
|
|
590
640
|
if (parsedSchedule) {
|
|
591
641
|
const payload: ScheduledTaskPayload = {
|
|
@@ -654,6 +704,7 @@ cli
|
|
|
654
704
|
embeds: autoStartEmbed,
|
|
655
705
|
rest,
|
|
656
706
|
splitInsteadOfAttach: notifyOnly,
|
|
707
|
+
files: filePaths,
|
|
657
708
|
})
|
|
658
709
|
|
|
659
710
|
// For notify-only on non-project channels, just post the message without
|
package/src/cli-runner.ts
CHANGED
|
@@ -214,6 +214,7 @@ export async function sendDiscordMessageWithOptionalAttachment({
|
|
|
214
214
|
embeds,
|
|
215
215
|
rest,
|
|
216
216
|
splitInsteadOfAttach,
|
|
217
|
+
files,
|
|
217
218
|
}: {
|
|
218
219
|
channelId: string
|
|
219
220
|
prompt: string
|
|
@@ -224,8 +225,88 @@ export async function sendDiscordMessageWithOptionalAttachment({
|
|
|
224
225
|
* being attached as a file. Useful for notify-only messages where the content
|
|
225
226
|
* should be directly visible in the channel. */
|
|
226
227
|
splitInsteadOfAttach?: boolean
|
|
228
|
+
/** Local file paths to attach to the message (images, text files, etc.). */
|
|
229
|
+
files?: string[]
|
|
227
230
|
}): Promise<{ id: string }> {
|
|
228
231
|
const discordMaxLength = 2000
|
|
232
|
+
|
|
233
|
+
// When files are provided, always use multipart FormData upload
|
|
234
|
+
if (files?.length) {
|
|
235
|
+
const { DISCORD_DEFAULT_MAX_FILE_SIZE } = await import('./discord-utils.js')
|
|
236
|
+
const { default: mime } = await import('mime')
|
|
237
|
+
|
|
238
|
+
for (const file of files) {
|
|
239
|
+
const stat = fs.statSync(file)
|
|
240
|
+
if (stat.size > DISCORD_DEFAULT_MAX_FILE_SIZE) {
|
|
241
|
+
const fileMB = (stat.size / 1024 / 1024).toFixed(1)
|
|
242
|
+
const limitMB = (DISCORD_DEFAULT_MAX_FILE_SIZE / 1024 / 1024).toFixed(0)
|
|
243
|
+
throw new Error(
|
|
244
|
+
`File "${path.basename(file)}" is ${fileMB} MB, which exceeds Discord's ${limitMB} MB upload limit`,
|
|
245
|
+
)
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// When prompt exceeds Discord's limit, attach it as prompt.md alongside
|
|
250
|
+
// user files so nothing is silently lost.
|
|
251
|
+
const isLongPrompt = prompt.length > discordMaxLength
|
|
252
|
+
const content = isLongPrompt
|
|
253
|
+
? `Prompt attached as file (${prompt.length} chars)\n\n> ${prompt.slice(0, 100).replace(/\n/g, ' ')}...`
|
|
254
|
+
: prompt
|
|
255
|
+
|
|
256
|
+
// Build attachment metadata: user files + optional prompt.md
|
|
257
|
+
const allFiles: Array<{ filePath: string; filename: string; mimeType: string }> = files.map((file) => ({
|
|
258
|
+
filePath: file,
|
|
259
|
+
filename: path.basename(file),
|
|
260
|
+
mimeType: mime.getType(file) || 'application/octet-stream',
|
|
261
|
+
}))
|
|
262
|
+
|
|
263
|
+
if (isLongPrompt) {
|
|
264
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'kimaki-prompt-'))
|
|
265
|
+
const tmpFile = path.join(tmpDir, 'prompt.md')
|
|
266
|
+
fs.writeFileSync(tmpFile, prompt)
|
|
267
|
+
allFiles.push({ filePath: tmpFile, filename: 'prompt.md', mimeType: 'text/markdown' })
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const attachments = allFiles.map((f, index) => ({
|
|
271
|
+
id: index,
|
|
272
|
+
filename: f.filename,
|
|
273
|
+
}))
|
|
274
|
+
|
|
275
|
+
const formData = new FormData()
|
|
276
|
+
formData.append(
|
|
277
|
+
'payload_json',
|
|
278
|
+
JSON.stringify({
|
|
279
|
+
content,
|
|
280
|
+
attachments,
|
|
281
|
+
embeds,
|
|
282
|
+
allowed_mentions: { parse: store.getState().allowedMentions },
|
|
283
|
+
}),
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
for (const [index, f] of allFiles.entries()) {
|
|
287
|
+
const buffer = fs.readFileSync(f.filePath)
|
|
288
|
+
formData.append(
|
|
289
|
+
`files[${index}]`,
|
|
290
|
+
new Blob([buffer], { type: f.mimeType }),
|
|
291
|
+
f.filename,
|
|
292
|
+
)
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const response = await fetch(
|
|
296
|
+
discordApiUrl(`/channels/${channelId}/messages`),
|
|
297
|
+
{
|
|
298
|
+
method: 'POST',
|
|
299
|
+
headers: { Authorization: `Bot ${botToken}` },
|
|
300
|
+
body: formData,
|
|
301
|
+
},
|
|
302
|
+
)
|
|
303
|
+
if (!response.ok) {
|
|
304
|
+
const error = await response.text()
|
|
305
|
+
throw new Error(`Discord API error: ${response.status} - ${error}`)
|
|
306
|
+
}
|
|
307
|
+
return (await response.json()) as { id: string }
|
|
308
|
+
}
|
|
309
|
+
|
|
229
310
|
if (prompt.length <= discordMaxLength) {
|
|
230
311
|
return (await rest.post(Routes.channelMessages(channelId), {
|
|
231
312
|
body: {
|
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
|
|
@@ -115,7 +118,7 @@ export async function forkSessionToBtwThread({
|
|
|
115
118
|
threadId: thread.id,
|
|
116
119
|
thread,
|
|
117
120
|
projectDirectory,
|
|
118
|
-
sdkDirectory
|
|
121
|
+
sdkDirectory,
|
|
119
122
|
channelId,
|
|
120
123
|
appId,
|
|
121
124
|
})
|
|
@@ -176,7 +179,7 @@ export async function handleBtwCommand({
|
|
|
176
179
|
return
|
|
177
180
|
}
|
|
178
181
|
|
|
179
|
-
const { projectDirectory } = resolved
|
|
182
|
+
const { projectDirectory, workingDirectory } = resolved
|
|
180
183
|
|
|
181
184
|
await command.deferReply({ flags: MessageFlags.Ephemeral })
|
|
182
185
|
|
|
@@ -184,6 +187,7 @@ export async function handleBtwCommand({
|
|
|
184
187
|
const result = await forkSessionToBtwThread({
|
|
185
188
|
sourceThread: threadChannel,
|
|
186
189
|
projectDirectory,
|
|
190
|
+
sdkDirectory: workingDirectory,
|
|
187
191
|
prompt,
|
|
188
192
|
userId: command.user.id,
|
|
189
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
|
@@ -89,16 +89,24 @@ function buildCancelCell(task: ScheduledTask): string {
|
|
|
89
89
|
// so 7 rows stays under the budget with separators.
|
|
90
90
|
const MAX_TASK_ROWS = 7
|
|
91
91
|
|
|
92
|
+
function formatChannelCell(task: ScheduledTask): string {
|
|
93
|
+
if (!task.channel_id) {
|
|
94
|
+
return '-'
|
|
95
|
+
}
|
|
96
|
+
return `<#${task.channel_id}>`
|
|
97
|
+
}
|
|
98
|
+
|
|
92
99
|
function buildTaskTable({
|
|
93
100
|
tasks,
|
|
94
101
|
}: {
|
|
95
102
|
tasks: ScheduledTask[]
|
|
96
103
|
}): string {
|
|
97
|
-
const header = '| ID | Status | Prompt | Schedule | Next Run | Run | Delete |'
|
|
98
|
-
const separator = '
|
|
104
|
+
const header = '| ID | Status | Channel | Prompt | Schedule | Next Run | Run | Delete |'
|
|
105
|
+
const separator = '|---|---|---|---|---|---|---|---|'
|
|
99
106
|
const rows = tasks.map((task) => {
|
|
100
107
|
const id = String(task.id)
|
|
101
108
|
const status = task.status
|
|
109
|
+
const channel = formatChannelCell(task)
|
|
102
110
|
const prompt = sanitizeTableCell(
|
|
103
111
|
task.prompt_preview.length > 240
|
|
104
112
|
? task.prompt_preview.slice(0, 237) + '...'
|
|
@@ -117,7 +125,7 @@ function buildTaskTable({
|
|
|
117
125
|
})()
|
|
118
126
|
const run = buildRunCell(task)
|
|
119
127
|
const cancel = buildCancelCell(task)
|
|
120
|
-
return `| ${id} | ${status} | ${prompt} | ${schedule} | ${nextRun} | ${run} | ${cancel} |`
|
|
128
|
+
return `| ${id} | ${status} | ${channel} | ${prompt} | ${schedule} | ${nextRun} | ${run} | ${cancel} |`
|
|
121
129
|
})
|
|
122
130
|
return [header, separator, ...rows].join('\n')
|
|
123
131
|
}
|
|
@@ -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
|
})
|