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
|
@@ -539,13 +539,14 @@ describe('agent model resolution', () => {
|
|
|
539
539
|
const db = await getDb();
|
|
540
540
|
await db.delete(schema.channel_agents).where(orm.eq(schema.channel_agents.channel_id, TEXT_CHANNEL_ID));
|
|
541
541
|
await db.delete(schema.channel_models).where(orm.eq(schema.channel_models.channel_id, TEXT_CHANNEL_ID));
|
|
542
|
+
const existingThreadIds = new Set((await discord.channel(TEXT_CHANNEL_ID).getThreads()).map((thread) => thread.id));
|
|
542
543
|
await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
|
|
543
544
|
content: 'Reply with exactly: btw-source-msg',
|
|
544
545
|
});
|
|
545
546
|
const sourceThread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
|
|
546
547
|
timeout: 4_000,
|
|
547
|
-
predicate: (
|
|
548
|
-
return
|
|
548
|
+
predicate: (thread) => {
|
|
549
|
+
return !existingThreadIds.has(thread.id);
|
|
549
550
|
},
|
|
550
551
|
});
|
|
551
552
|
await waitForFooterMessage({
|
|
@@ -567,13 +568,14 @@ describe('agent model resolution', () => {
|
|
|
567
568
|
channelId: TEXT_CHANNEL_ID,
|
|
568
569
|
modelId: `${PROVIDER_NAME}/${CHANNEL_MODEL}`,
|
|
569
570
|
});
|
|
571
|
+
const existingForkThreadIds = new Set((await discord.channel(TEXT_CHANNEL_ID).getThreads()).map((thread) => thread.id));
|
|
570
572
|
await discord.thread(sourceThread.id).user(TEST_USER_ID).sendMessage({
|
|
571
573
|
content: 'Reply with exactly: btw-model-check. btw',
|
|
572
574
|
});
|
|
573
575
|
const forkedThread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
|
|
574
576
|
timeout: 4_000,
|
|
575
|
-
predicate: (
|
|
576
|
-
return
|
|
577
|
+
predicate: (thread) => {
|
|
578
|
+
return !existingForkThreadIds.has(thread.id);
|
|
577
579
|
},
|
|
578
580
|
});
|
|
579
581
|
await waitForFooterMessage({
|
|
@@ -98,6 +98,8 @@ cli
|
|
|
98
98
|
cli
|
|
99
99
|
.command('project list', 'List all registered projects with their Discord channels')
|
|
100
100
|
.option('--json', 'Output as JSON')
|
|
101
|
+
.option('--all', 'Include remote projects from other machines (scans Kimaki category in Discord)')
|
|
102
|
+
.option('-g, --guild <guildId>', 'Discord guild/server ID to scan (used with --all when no local projects exist)')
|
|
101
103
|
.option('--prune', 'Remove stale entries whose Discord channel no longer exists')
|
|
102
104
|
.action(async (options) => {
|
|
103
105
|
await initDatabase();
|
|
@@ -106,20 +108,19 @@ cli
|
|
|
106
108
|
where: { channel_type: 'text' },
|
|
107
109
|
orderBy: { created_at: 'desc' },
|
|
108
110
|
});
|
|
109
|
-
|
|
110
|
-
cliLogger.log('No projects registered');
|
|
111
|
-
process.exit(0);
|
|
112
|
-
}
|
|
113
|
-
// Fetch Discord channel names via REST API
|
|
111
|
+
// Fetch Discord channel names and guild IDs via REST API
|
|
114
112
|
const botRow = await getBotTokenWithMode();
|
|
115
113
|
const rest = botRow ? createDiscordRest(botRow.token) : null;
|
|
114
|
+
const localChannelIds = new Set(channels.map((ch) => ch.channel_id));
|
|
116
115
|
const enriched = await Promise.all(channels.map(async (ch) => {
|
|
117
116
|
let channelName = '';
|
|
117
|
+
let guildId = '';
|
|
118
118
|
let deleted = false;
|
|
119
119
|
if (rest) {
|
|
120
120
|
try {
|
|
121
121
|
const data = (await rest.get(Routes.channel(ch.channel_id)));
|
|
122
122
|
channelName = data.name || '';
|
|
123
|
+
guildId = data.guild_id || '';
|
|
123
124
|
}
|
|
124
125
|
catch (error) {
|
|
125
126
|
// Only mark as deleted for Unknown Channel (10003) or 404,
|
|
@@ -130,13 +131,100 @@ cli
|
|
|
130
131
|
deleted = isUnknownChannel;
|
|
131
132
|
}
|
|
132
133
|
}
|
|
133
|
-
return { ...ch, channelName, deleted };
|
|
134
|
+
return { ...ch, channelName, guildId, deleted, isLocal: true };
|
|
135
|
+
}));
|
|
136
|
+
// Fetch guild names for unique guild IDs (deduplicated to save API calls)
|
|
137
|
+
const guildNameMap = new Map();
|
|
138
|
+
// Collect guild IDs from local channels + explicit --guild flag
|
|
139
|
+
const guildIdsFromChannels = enriched.map((ch) => ch.guildId).filter(Boolean);
|
|
140
|
+
if (options.guild) {
|
|
141
|
+
guildIdsFromChannels.push(options.guild);
|
|
142
|
+
}
|
|
143
|
+
const uniqueGuildIds = [...new Set(guildIdsFromChannels)];
|
|
144
|
+
if (rest) {
|
|
145
|
+
await Promise.all(uniqueGuildIds.map(async (guildId) => {
|
|
146
|
+
try {
|
|
147
|
+
const data = (await rest.get(Routes.guild(guildId)));
|
|
148
|
+
guildNameMap.set(guildId, data.name || '');
|
|
149
|
+
}
|
|
150
|
+
catch (error) {
|
|
151
|
+
cliLogger.debug(`Failed to fetch guild ${guildId}:`, error instanceof Error ? error.stack : String(error));
|
|
152
|
+
}
|
|
153
|
+
}));
|
|
154
|
+
}
|
|
155
|
+
// When --all is passed, scan each guild's channels to find Kimaki category
|
|
156
|
+
// text channels not in our local DB (projects from other machines).
|
|
157
|
+
// Fail explicitly when prerequisites are missing so the user doesn't
|
|
158
|
+
// confuse "scan never ran" with "no remote projects found".
|
|
159
|
+
let remoteEntries = [];
|
|
160
|
+
if (options.all) {
|
|
161
|
+
if (!rest) {
|
|
162
|
+
cliLogger.error('Discord credentials are required to scan remote projects. Run `kimaki` first.');
|
|
163
|
+
process.exit(EXIT_NO_RESTART);
|
|
164
|
+
}
|
|
165
|
+
if (uniqueGuildIds.length === 0) {
|
|
166
|
+
cliLogger.error('Cannot determine which Discord server to scan. Pass `--guild <guildId>` or register a local project first.');
|
|
167
|
+
process.exit(EXIT_NO_RESTART);
|
|
168
|
+
}
|
|
169
|
+
let guildScanFailures = 0;
|
|
170
|
+
for (const guildId of uniqueGuildIds) {
|
|
171
|
+
try {
|
|
172
|
+
const guildChannels = (await rest.get(Routes.guildChannels(guildId)));
|
|
173
|
+
// Find Kimaki category channels (type 4 = GuildCategory)
|
|
174
|
+
const kimakiCategoryIds = new Set(guildChannels
|
|
175
|
+
.filter((ch) => ch.type === 4 && /^kimaki(\s|$)/i.test(ch.name))
|
|
176
|
+
.map((ch) => ch.id));
|
|
177
|
+
// Find text channels (type 0) in Kimaki categories that are not in our local DB
|
|
178
|
+
for (const ch of guildChannels) {
|
|
179
|
+
if (ch.type === 0 &&
|
|
180
|
+
ch.parent_id &&
|
|
181
|
+
kimakiCategoryIds.has(ch.parent_id) &&
|
|
182
|
+
!localChannelIds.has(ch.id)) {
|
|
183
|
+
remoteEntries.push({
|
|
184
|
+
channel_id: ch.id,
|
|
185
|
+
directory: '',
|
|
186
|
+
channel_type: 'text',
|
|
187
|
+
created_at: null,
|
|
188
|
+
channelName: ch.name,
|
|
189
|
+
guildId,
|
|
190
|
+
deleted: false,
|
|
191
|
+
isLocal: false,
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
catch (error) {
|
|
197
|
+
guildScanFailures++;
|
|
198
|
+
cliLogger.warn(`Failed to scan guild ${guildNameMap.get(guildId) || guildId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
if (guildScanFailures === uniqueGuildIds.length) {
|
|
202
|
+
cliLogger.error('Failed to scan all guilds. Check bot permissions or try again.');
|
|
203
|
+
process.exit(EXIT_NO_RESTART);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
// Build final enriched entries with guild names resolved
|
|
207
|
+
const allEntries = [...enriched, ...remoteEntries];
|
|
208
|
+
const enrichedWithGuild = allEntries.map((ch) => ({
|
|
209
|
+
...ch,
|
|
210
|
+
guildName: ch.guildId ? (guildNameMap.get(ch.guildId) || '') : '',
|
|
134
211
|
}));
|
|
212
|
+
// Warn on stderr if the same directory appears in multiple channels (multi-guild duplicates)
|
|
213
|
+
const directoryCounts = new Map();
|
|
214
|
+
for (const ch of enrichedWithGuild) {
|
|
215
|
+
if (!ch.deleted && ch.directory) {
|
|
216
|
+
directoryCounts.set(ch.directory, (directoryCounts.get(ch.directory) || 0) + 1);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
for (const [dir, count] of directoryCounts) {
|
|
220
|
+
if (count > 1) {
|
|
221
|
+
cliLogger.warn(`Directory "${dir}" is registered in ${count} channels. Use channel_id to disambiguate.`);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
135
224
|
// Prune stale entries if requested
|
|
225
|
+
let finalEntries = enrichedWithGuild;
|
|
136
226
|
if (options.prune) {
|
|
137
|
-
const stale =
|
|
138
|
-
return ch.deleted;
|
|
139
|
-
});
|
|
227
|
+
const stale = finalEntries.filter((ch) => ch.deleted && ch.isLocal);
|
|
140
228
|
if (stale.length === 0) {
|
|
141
229
|
cliLogger.log('No stale channels to prune');
|
|
142
230
|
}
|
|
@@ -147,37 +235,73 @@ cli
|
|
|
147
235
|
}
|
|
148
236
|
cliLogger.log(`Pruned ${stale.length} stale channel(s)`);
|
|
149
237
|
}
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
return !ch.deleted;
|
|
153
|
-
});
|
|
154
|
-
if (live.length === 0) {
|
|
238
|
+
finalEntries = finalEntries.filter((ch) => !ch.deleted);
|
|
239
|
+
if (finalEntries.length === 0) {
|
|
155
240
|
cliLogger.log('No projects registered');
|
|
156
241
|
process.exit(0);
|
|
157
242
|
}
|
|
158
|
-
|
|
159
|
-
|
|
243
|
+
}
|
|
244
|
+
if (finalEntries.length === 0) {
|
|
245
|
+
cliLogger.log('No projects registered');
|
|
246
|
+
process.exit(0);
|
|
160
247
|
}
|
|
161
248
|
if (options.json) {
|
|
162
|
-
const output =
|
|
249
|
+
const output = finalEntries.map((ch) => ({
|
|
163
250
|
channel_id: ch.channel_id,
|
|
164
251
|
channel_name: ch.channelName,
|
|
165
|
-
|
|
166
|
-
|
|
252
|
+
guild_id: ch.guildId,
|
|
253
|
+
guild_name: ch.guildName,
|
|
254
|
+
directory: ch.directory || null,
|
|
255
|
+
folder_name: ch.directory ? path.basename(ch.directory) : null,
|
|
167
256
|
deleted: ch.deleted,
|
|
257
|
+
is_local: ch.isLocal,
|
|
168
258
|
}));
|
|
169
259
|
console.log(JSON.stringify(output, null, 2));
|
|
170
260
|
process.exit(0);
|
|
171
261
|
}
|
|
172
|
-
for (const ch of
|
|
173
|
-
const folderName = path.basename(ch.directory);
|
|
262
|
+
for (const ch of finalEntries) {
|
|
174
263
|
const deletedTag = ch.deleted ? ' (deleted from Discord)' : '';
|
|
264
|
+
const remoteTag = !ch.isLocal ? ' [remote]' : '';
|
|
175
265
|
const channelLabel = ch.channelName ? `#${ch.channelName}` : ch.channel_id;
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
console.log(
|
|
266
|
+
const guildLabel = ch.guildName || ch.guildId || '';
|
|
267
|
+
const guildSuffix = guildLabel ? ` (${guildLabel})` : '';
|
|
268
|
+
console.log(`\n${channelLabel}${guildSuffix}${deletedTag}${remoteTag}`);
|
|
269
|
+
if (ch.isLocal && ch.directory) {
|
|
270
|
+
const folderName = path.basename(ch.directory);
|
|
271
|
+
console.log(` Folder: ${folderName}`);
|
|
272
|
+
console.log(` Directory: ${ch.directory}`);
|
|
273
|
+
}
|
|
274
|
+
else if (!ch.isLocal) {
|
|
275
|
+
console.log(` (Not registered on this machine)`);
|
|
276
|
+
}
|
|
179
277
|
console.log(` Channel ID: ${ch.channel_id}`);
|
|
278
|
+
if (ch.guildId) {
|
|
279
|
+
console.log(` Guild ID: ${ch.guildId}`);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
process.exit(0);
|
|
283
|
+
});
|
|
284
|
+
cli
|
|
285
|
+
.command('project remove <channelId>', 'Remove a project channel mapping from the local database (does not delete the Discord channel)')
|
|
286
|
+
.action(async (channelId) => {
|
|
287
|
+
await initDatabase();
|
|
288
|
+
const db = await getDb();
|
|
289
|
+
const row = await db.query.channel_directories.findFirst({
|
|
290
|
+
where: { channel_id: channelId },
|
|
291
|
+
});
|
|
292
|
+
if (!row) {
|
|
293
|
+
cliLogger.error(`No channel mapping found for channel ID: ${channelId}`);
|
|
294
|
+
process.exit(EXIT_NO_RESTART);
|
|
295
|
+
}
|
|
296
|
+
const removed = await deleteChannelDirectoryById(channelId);
|
|
297
|
+
if (!removed) {
|
|
298
|
+
cliLogger.error(`Channel mapping disappeared before it could be removed: ${channelId}`);
|
|
299
|
+
process.exit(EXIT_NO_RESTART);
|
|
180
300
|
}
|
|
301
|
+
cliLogger.log(`Removed channel mapping:`);
|
|
302
|
+
cliLogger.log(` Channel ID: ${channelId}`);
|
|
303
|
+
cliLogger.log(` Directory: ${row.directory}`);
|
|
304
|
+
cliLogger.log(` Type: ${row.channel_type}`);
|
|
181
305
|
process.exit(0);
|
|
182
306
|
});
|
|
183
307
|
cli
|
|
@@ -15,7 +15,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
15
15
|
import { spawn, execSync } from 'node:child_process';
|
|
16
16
|
import { createLogger, LogPrefix, initLogFile } from '../logger.js';
|
|
17
17
|
import { createDiscordClient, initDatabase, getChannelDirectory, initializeOpencodeForDirectory, createProjectChannels } from '../discord-bot.js';
|
|
18
|
-
import { getBotTokenWithMode, getThreadSession, getThreadIdBySessionId, getSessionEventSnapshot, getDb, createScheduledTask, listScheduledTasks, cancelScheduledTask, getScheduledTask, updateScheduledTask, getSessionStartSourcesBySessionIds, deleteChannelDirectoryById, findChannelsByDirectory } from '../database.js';
|
|
18
|
+
import { getBotTokenWithMode, getThreadSession, getThreadIdBySessionId, getSessionEventSnapshot, getDb, createScheduledTask, listScheduledTasks, cancelScheduledTask, getScheduledTask, updateScheduledTask, getSessionStartSourcesBySessionIds, deleteChannelDirectoryById, findChannelsByDirectory, getChannelWorktreesEnabled } from '../database.js';
|
|
19
19
|
import { ShareMarkdown } from '../markdown.js';
|
|
20
20
|
import { parseSessionSearchPattern, findFirstSessionSearchHit, buildSessionSearchSnippet, getPartSearchTexts } from '../session-search.js';
|
|
21
21
|
import { formatWorktreeName, formatAutoWorktreeName } from '../commands/new-worktree.js';
|
|
@@ -24,7 +24,7 @@ import { buildOpencodeEventLogLine } from '../session-handler/opencode-session-e
|
|
|
24
24
|
import { createDiscordRest } from '../discord-urls.js';
|
|
25
25
|
import { archiveThread, uploadFilesToDiscord, stripMentions } from '../discord-utils.js';
|
|
26
26
|
import { setDataDir, setProjectsDir, getDataDir, getProjectsDir } from '../config.js';
|
|
27
|
-
import { execAsync, resolveSessionWorkingDirectory } from '../worktrees.js';
|
|
27
|
+
import { execAsync, resolveSessionWorkingDirectory, isGitRepositoryRoot } from '../worktrees.js';
|
|
28
28
|
import { upgrade, getCurrentVersion } from '../upgrade.js';
|
|
29
29
|
import { getPromptPreview, parseSendAtValue, parseScheduledTaskPayload, serializeScheduledTaskPayload } from '../task-schedule.js';
|
|
30
30
|
import { EXIT_NO_RESTART, formatMemberLookupUnavailableMessage, formatRelativeTime, formatTaskScheduleLine, isDiscordMemberLookupUnavailable, isGuildMemberSearchResult, isThreadChannelType, printDiscordInstallUrlAndExit, resolveBotCredentials, resolveDiscordUserOption, sendDiscordMessageWithOptionalAttachment, } from '../cli-runner.js';
|
|
@@ -48,9 +48,12 @@ cli
|
|
|
48
48
|
'Actions: allow, deny, ask. Examples: --permission "bash:deny" --permission "edit:deny"'))
|
|
49
49
|
.option('--injection-guard <pattern>', z.array(z.string()).describe('Injection guard scan pattern (repeatable). Enables prompt injection detection for this session. ' +
|
|
50
50
|
'Format: "tool:argsGlob". Examples: --injection-guard "bash:*" --injection-guard "webfetch:*"'))
|
|
51
|
+
.option('-f, --file <path>', z.array(z.string()).describe('Local file to attach (repeatable). Images, text files, PDFs, etc. ' +
|
|
52
|
+
'Examples: --file screenshot.png --file report.pdf'))
|
|
51
53
|
.option('--send-at <schedule>', 'Schedule send for future (UTC ISO date/time ending in Z, or cron expression)')
|
|
52
54
|
.option('--thread <threadId>', 'Post prompt to an existing thread')
|
|
53
55
|
.option('--session <sessionId>', 'Post prompt to thread mapped to an existing session')
|
|
56
|
+
.option('--parent-session <sessionId>', 'Parent OpenCode session ID for newly created child sessions')
|
|
54
57
|
.option('--wait', 'Wait for session to complete, then print session text to stdout')
|
|
55
58
|
.action(async (options) => {
|
|
56
59
|
try {
|
|
@@ -79,16 +82,45 @@ cli
|
|
|
79
82
|
cliLogger.error('Prompt is required. Use --prompt <prompt>');
|
|
80
83
|
process.exit(EXIT_NO_RESTART);
|
|
81
84
|
}
|
|
85
|
+
const filePaths = options.file?.length
|
|
86
|
+
? options.file.map((f) => path.resolve(f))
|
|
87
|
+
: undefined;
|
|
82
88
|
if (sendAt) {
|
|
83
89
|
if (options.wait) {
|
|
84
90
|
cliLogger.error('Cannot use --wait with --send-at');
|
|
85
91
|
process.exit(EXIT_NO_RESTART);
|
|
86
92
|
}
|
|
93
|
+
if (filePaths?.length) {
|
|
94
|
+
cliLogger.error('Cannot use --file with --send-at');
|
|
95
|
+
process.exit(EXIT_NO_RESTART);
|
|
96
|
+
}
|
|
87
97
|
if (prompt.length > 1900) {
|
|
88
98
|
cliLogger.error('--send-at currently supports prompts up to 1900 characters');
|
|
89
99
|
process.exit(EXIT_NO_RESTART);
|
|
90
100
|
}
|
|
91
101
|
}
|
|
102
|
+
// Validate all --file paths exist and are regular files
|
|
103
|
+
if (filePaths?.length) {
|
|
104
|
+
// Discord allows max 10 attachments per message. Long prompts also
|
|
105
|
+
// consume one slot (prompt.md), so reserve space for that.
|
|
106
|
+
const maxUserFiles = prompt.length > 2000 ? 9 : 10;
|
|
107
|
+
if (filePaths.length > maxUserFiles) {
|
|
108
|
+
cliLogger.error(`Too many files: ${filePaths.length} provided, Discord allows at most ${maxUserFiles} attachments per message` +
|
|
109
|
+
(maxUserFiles === 9 ? ' (1 slot reserved for long prompt)' : ''));
|
|
110
|
+
process.exit(EXIT_NO_RESTART);
|
|
111
|
+
}
|
|
112
|
+
for (const file of filePaths) {
|
|
113
|
+
if (!fs.existsSync(file)) {
|
|
114
|
+
cliLogger.error(`File not found: ${file}`);
|
|
115
|
+
process.exit(EXIT_NO_RESTART);
|
|
116
|
+
}
|
|
117
|
+
const stat = fs.statSync(file);
|
|
118
|
+
if (!stat.isFile()) {
|
|
119
|
+
cliLogger.error(`Not a regular file: ${file}`);
|
|
120
|
+
process.exit(EXIT_NO_RESTART);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
92
124
|
const parsedSchedule = (() => {
|
|
93
125
|
if (!sendAt) {
|
|
94
126
|
return null;
|
|
@@ -299,6 +331,7 @@ cli
|
|
|
299
331
|
userId: null,
|
|
300
332
|
permissions: options.permission?.length ? options.permission : null,
|
|
301
333
|
injectionGuardPatterns: options.injectionGuard?.length ? options.injectionGuard : null,
|
|
334
|
+
parentSessionId: options.parentSession || null,
|
|
302
335
|
};
|
|
303
336
|
const taskId = await createScheduledTask({
|
|
304
337
|
scheduleKind: parsedSchedule.scheduleKind,
|
|
@@ -325,6 +358,7 @@ cli
|
|
|
325
358
|
...(options.model && { model: options.model }),
|
|
326
359
|
...(options.permission?.length ? { permissions: options.permission } : {}),
|
|
327
360
|
...(options.injectionGuard?.length ? { injectionGuardPatterns: options.injectionGuard } : {}),
|
|
361
|
+
...(options.parentSession && { parentSessionId: options.parentSession }),
|
|
328
362
|
};
|
|
329
363
|
const promptEmbed = [
|
|
330
364
|
{
|
|
@@ -342,6 +376,7 @@ cli
|
|
|
342
376
|
botToken,
|
|
343
377
|
embeds: promptEmbed,
|
|
344
378
|
rest,
|
|
379
|
+
files: filePaths,
|
|
345
380
|
});
|
|
346
381
|
const threadUrl = `https://discord.com/channels/${threadData.guild_id}/${threadData.id}`;
|
|
347
382
|
const existingSessionId = sessionId || await getThreadSession(targetThreadId);
|
|
@@ -415,12 +450,19 @@ cli
|
|
|
415
450
|
: cleanPrompt);
|
|
416
451
|
// Explicit string => use as-is via formatWorktreeName (no vowel strip).
|
|
417
452
|
// Boolean true => derived from thread/prompt, compress via formatAutoWorktreeName.
|
|
453
|
+
// When no --worktree flag but channel has worktrees enabled via toggle,
|
|
454
|
+
// the bot-side ThreadCreate handler auto-creates the worktree. We add
|
|
455
|
+
// the prefix here for cosmetic consistency (thread name shows 🌳).
|
|
456
|
+
const channelWorktreesEnabled = !options.worktree && !options.cwd && !notifyOnly && projectDirectory
|
|
457
|
+
? (await getChannelWorktreesEnabled(channelId)) &&
|
|
458
|
+
(await isGitRepositoryRoot(projectDirectory))
|
|
459
|
+
: false;
|
|
418
460
|
const worktreeName = options.worktree
|
|
419
461
|
? typeof options.worktree === 'string'
|
|
420
462
|
? formatWorktreeName(options.worktree)
|
|
421
463
|
: formatAutoWorktreeName(baseThreadName)
|
|
422
464
|
: undefined;
|
|
423
|
-
const threadName = worktreeName
|
|
465
|
+
const threadName = worktreeName || channelWorktreesEnabled
|
|
424
466
|
? `${WORKTREE_PREFIX}${baseThreadName}`
|
|
425
467
|
: baseThreadName;
|
|
426
468
|
if (parsedSchedule) {
|
|
@@ -438,6 +480,7 @@ cli
|
|
|
438
480
|
userId: resolvedUser?.id || null,
|
|
439
481
|
permissions: options.permission?.length ? options.permission : null,
|
|
440
482
|
injectionGuardPatterns: options.injectionGuard?.length ? options.injectionGuard : null,
|
|
483
|
+
parentSessionId: options.parentSession || null,
|
|
441
484
|
};
|
|
442
485
|
const taskId = await createScheduledTask({
|
|
443
486
|
scheduleKind: parsedSchedule.scheduleKind,
|
|
@@ -471,6 +514,7 @@ cli
|
|
|
471
514
|
...(options.model && { model: options.model }),
|
|
472
515
|
...(options.permission?.length && { permissions: options.permission }),
|
|
473
516
|
...(options.injectionGuard?.length && { injectionGuardPatterns: options.injectionGuard }),
|
|
517
|
+
...(options.parentSession && { parentSessionId: options.parentSession }),
|
|
474
518
|
};
|
|
475
519
|
const autoStartEmbed = embedMarker
|
|
476
520
|
? [{ color: 0x2b2d31, footer: { text: YAML.stringify(embedMarker) } }]
|
|
@@ -482,6 +526,7 @@ cli
|
|
|
482
526
|
embeds: autoStartEmbed,
|
|
483
527
|
rest,
|
|
484
528
|
splitInsteadOfAttach: notifyOnly,
|
|
529
|
+
files: filePaths,
|
|
485
530
|
});
|
|
486
531
|
// For notify-only on non-project channels, just post the message without
|
|
487
532
|
// creating a thread. There's no session to start, so a thread is unnecessary.
|
package/dist/cli-runner.js
CHANGED
|
@@ -12,6 +12,7 @@ import { discordApiUrl, getDiscordRestApiUrl, getGatewayProxyRestBaseUrl, getInt
|
|
|
12
12
|
import crypto from 'node:crypto';
|
|
13
13
|
import path from 'node:path';
|
|
14
14
|
import fs from 'node:fs';
|
|
15
|
+
import os from 'node:os';
|
|
15
16
|
import { spawn } from 'node:child_process';
|
|
16
17
|
import { createLogger, LogPrefix } from './logger.js';
|
|
17
18
|
import { notifyError } from './sentry.js';
|
|
@@ -134,8 +135,64 @@ export function isThreadChannelType(type) {
|
|
|
134
135
|
ChannelType.AnnouncementThread,
|
|
135
136
|
].includes(type);
|
|
136
137
|
}
|
|
137
|
-
export async function sendDiscordMessageWithOptionalAttachment({ channelId, prompt, botToken, embeds, rest, splitInsteadOfAttach, }) {
|
|
138
|
+
export async function sendDiscordMessageWithOptionalAttachment({ channelId, prompt, botToken, embeds, rest, splitInsteadOfAttach, files, }) {
|
|
138
139
|
const discordMaxLength = 2000;
|
|
140
|
+
// When files are provided, always use multipart FormData upload
|
|
141
|
+
if (files?.length) {
|
|
142
|
+
const { DISCORD_DEFAULT_MAX_FILE_SIZE } = await import('./discord-utils.js');
|
|
143
|
+
const { default: mime } = await import('mime');
|
|
144
|
+
for (const file of files) {
|
|
145
|
+
const stat = fs.statSync(file);
|
|
146
|
+
if (stat.size > DISCORD_DEFAULT_MAX_FILE_SIZE) {
|
|
147
|
+
const fileMB = (stat.size / 1024 / 1024).toFixed(1);
|
|
148
|
+
const limitMB = (DISCORD_DEFAULT_MAX_FILE_SIZE / 1024 / 1024).toFixed(0);
|
|
149
|
+
throw new Error(`File "${path.basename(file)}" is ${fileMB} MB, which exceeds Discord's ${limitMB} MB upload limit`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
// When prompt exceeds Discord's limit, attach it as prompt.md alongside
|
|
153
|
+
// user files so nothing is silently lost.
|
|
154
|
+
const isLongPrompt = prompt.length > discordMaxLength;
|
|
155
|
+
const content = isLongPrompt
|
|
156
|
+
? `Prompt attached as file (${prompt.length} chars)\n\n> ${prompt.slice(0, 100).replace(/\n/g, ' ')}...`
|
|
157
|
+
: prompt;
|
|
158
|
+
// Build attachment metadata: user files + optional prompt.md
|
|
159
|
+
const allFiles = files.map((file) => ({
|
|
160
|
+
filePath: file,
|
|
161
|
+
filename: path.basename(file),
|
|
162
|
+
mimeType: mime.getType(file) || 'application/octet-stream',
|
|
163
|
+
}));
|
|
164
|
+
if (isLongPrompt) {
|
|
165
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'kimaki-prompt-'));
|
|
166
|
+
const tmpFile = path.join(tmpDir, 'prompt.md');
|
|
167
|
+
fs.writeFileSync(tmpFile, prompt);
|
|
168
|
+
allFiles.push({ filePath: tmpFile, filename: 'prompt.md', mimeType: 'text/markdown' });
|
|
169
|
+
}
|
|
170
|
+
const attachments = allFiles.map((f, index) => ({
|
|
171
|
+
id: index,
|
|
172
|
+
filename: f.filename,
|
|
173
|
+
}));
|
|
174
|
+
const formData = new FormData();
|
|
175
|
+
formData.append('payload_json', JSON.stringify({
|
|
176
|
+
content,
|
|
177
|
+
attachments,
|
|
178
|
+
embeds,
|
|
179
|
+
allowed_mentions: { parse: store.getState().allowedMentions },
|
|
180
|
+
}));
|
|
181
|
+
for (const [index, f] of allFiles.entries()) {
|
|
182
|
+
const buffer = fs.readFileSync(f.filePath);
|
|
183
|
+
formData.append(`files[${index}]`, new Blob([buffer], { type: f.mimeType }), f.filename);
|
|
184
|
+
}
|
|
185
|
+
const response = await fetch(discordApiUrl(`/channels/${channelId}/messages`), {
|
|
186
|
+
method: 'POST',
|
|
187
|
+
headers: { Authorization: `Bot ${botToken}` },
|
|
188
|
+
body: formData,
|
|
189
|
+
});
|
|
190
|
+
if (!response.ok) {
|
|
191
|
+
const error = await response.text();
|
|
192
|
+
throw new Error(`Discord API error: ${response.status} - ${error}`);
|
|
193
|
+
}
|
|
194
|
+
return (await response.json());
|
|
195
|
+
}
|
|
139
196
|
if (prompt.length <= discordMaxLength) {
|
|
140
197
|
return (await rest.post(Routes.channelMessages(channelId), {
|
|
141
198
|
body: {
|
|
@@ -174,11 +231,8 @@ export async function sendDiscordMessageWithOptionalAttachment({ channelId, prom
|
|
|
174
231
|
}
|
|
175
232
|
const preview = prompt.slice(0, 100).replace(/\n/g, ' ');
|
|
176
233
|
const summaryContent = `Prompt attached as file (${prompt.length} chars)\n\n> ${preview}...`;
|
|
177
|
-
const tmpDir = path.join(
|
|
178
|
-
|
|
179
|
-
fs.mkdirSync(tmpDir, { recursive: true });
|
|
180
|
-
}
|
|
181
|
-
const tmpFile = path.join(tmpDir, `prompt-${Date.now()}.md`);
|
|
234
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'kimaki-prompt-'));
|
|
235
|
+
const tmpFile = path.join(tmpDir, 'prompt.md');
|
|
182
236
|
// Wrap long lines so the file is readable in Discord's preview
|
|
183
237
|
// (Discord doesn't wrap text in file attachments)
|
|
184
238
|
const wrappedPrompt = prompt
|
|
@@ -235,6 +289,7 @@ export async function sendDiscordMessageWithOptionalAttachment({ channelId, prom
|
|
|
235
289
|
}
|
|
236
290
|
finally {
|
|
237
291
|
fs.unlinkSync(tmpFile);
|
|
292
|
+
fs.rmdirSync(tmpDir);
|
|
238
293
|
}
|
|
239
294
|
}
|
|
240
295
|
export function formatRelativeTime(target) {
|
|
@@ -308,7 +363,7 @@ function readErrorField(error, key) {
|
|
|
308
363
|
}
|
|
309
364
|
return undefined;
|
|
310
365
|
}
|
|
311
|
-
/** Transient network errors that may resolve on retry (DNS down, gateway unreachable). */
|
|
366
|
+
/** Transient network errors that may resolve on retry (DNS down, gateway unreachable, TLS blips). */
|
|
312
367
|
const TRANSIENT_ERROR_CODES = new Set([
|
|
313
368
|
'ENOTFOUND',
|
|
314
369
|
'ECONNREFUSED',
|
|
@@ -318,13 +373,37 @@ const TRANSIENT_ERROR_CODES = new Set([
|
|
|
318
373
|
'EPIPE',
|
|
319
374
|
'EHOSTUNREACH',
|
|
320
375
|
'ENETUNREACH',
|
|
376
|
+
// TLS/cert errors can be transient (intermediate CA blip, MITM proxy, cert rotation).
|
|
377
|
+
// Without these, Discord login failure exits EXIT_NO_RESTART and the bin wrapper
|
|
378
|
+
// never restarts — the bot dies permanently on "unable to verify the first certificate".
|
|
379
|
+
'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
|
|
380
|
+
'UNABLE_TO_GET_ISSUER_CERT',
|
|
381
|
+
'UNABLE_TO_GET_ISSUER_CERT_LOCALLY',
|
|
382
|
+
'UNABLE_TO_DECODE_ISSUER_PUBLIC_KEY',
|
|
383
|
+
'CERT_SIGNATURE_FAILURE',
|
|
384
|
+
'CERT_NOT_YET_VALID',
|
|
385
|
+
'CERT_HAS_EXPIRED',
|
|
386
|
+
'CRL_HAS_EXPIRED',
|
|
387
|
+
'DEPTH_ZERO_SELF_SIGNED_CERT',
|
|
388
|
+
'SELF_SIGNED_CERT_IN_CHAIN',
|
|
389
|
+
'ERR_TLS_CERT_ALTNAME_INVALID',
|
|
321
390
|
]);
|
|
391
|
+
const TRANSIENT_ERROR_MESSAGE_PATTERNS = [
|
|
392
|
+
/unable to verify the first certificate/i,
|
|
393
|
+
/certificate has expired/i,
|
|
394
|
+
/self[- ]signed certificate/i,
|
|
395
|
+
/unable to get local issuer certificate/i,
|
|
396
|
+
];
|
|
322
397
|
export function isTransientNetworkError(error) {
|
|
323
398
|
if (!(error instanceof Error))
|
|
324
399
|
return false;
|
|
325
400
|
const code = error.code;
|
|
326
401
|
if (code && TRANSIENT_ERROR_CODES.has(code))
|
|
327
402
|
return true;
|
|
403
|
+
// Fallback when code is stripped by wrappers (discord.js sometimes rethrows by message only).
|
|
404
|
+
if (TRANSIENT_ERROR_MESSAGE_PATTERNS.some((pattern) => pattern.test(error.message))) {
|
|
405
|
+
return true;
|
|
406
|
+
}
|
|
328
407
|
// discord.js wraps errors in cause chains
|
|
329
408
|
if (error.cause instanceof Error)
|
|
330
409
|
return isTransientNetworkError(error.cause);
|
package/dist/cli-runner.test.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from 'vitest';
|
|
2
|
-
import { getOpenUrlCommand } from './cli-runner.js';
|
|
2
|
+
import { getOpenUrlCommand, isTransientNetworkError } from './cli-runner.js';
|
|
3
3
|
describe('getOpenUrlCommand', () => {
|
|
4
4
|
const installUrl = 'https://kimaki.dev/discord-install?clientId=abc&clientSecret=def';
|
|
5
5
|
test('uses a shell-free opener on Windows', () => {
|
|
@@ -21,3 +21,30 @@ describe('getOpenUrlCommand', () => {
|
|
|
21
21
|
});
|
|
22
22
|
});
|
|
23
23
|
});
|
|
24
|
+
describe('isTransientNetworkError', () => {
|
|
25
|
+
test('treats TLS leaf verification failures as transient', () => {
|
|
26
|
+
const error = Object.assign(new Error('unable to verify the first certificate'), {
|
|
27
|
+
code: 'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
|
|
28
|
+
});
|
|
29
|
+
expect(isTransientNetworkError(error)).toBe(true);
|
|
30
|
+
});
|
|
31
|
+
test('matches TLS cert failures by message when code is missing', () => {
|
|
32
|
+
expect(isTransientNetworkError(new Error('unable to verify the first certificate'))).toBe(true);
|
|
33
|
+
});
|
|
34
|
+
test('walks cause chains for nested TLS errors', () => {
|
|
35
|
+
const cause = Object.assign(new Error('unable to verify the first certificate'), {
|
|
36
|
+
code: 'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
|
|
37
|
+
});
|
|
38
|
+
expect(isTransientNetworkError(new Error('Discord login failed', { cause }))).toBe(true);
|
|
39
|
+
});
|
|
40
|
+
test('keeps fatal auth-style errors non-transient', () => {
|
|
41
|
+
expect(isTransientNetworkError(new Error('An invalid token was provided.'))).toBe(false);
|
|
42
|
+
expect(isTransientNetworkError(new Error('Used disallowed intents'))).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
test('still treats classic socket codes as transient', () => {
|
|
45
|
+
const error = Object.assign(new Error('getaddrinfo ENOTFOUND'), {
|
|
46
|
+
code: 'ENOTFOUND',
|
|
47
|
+
});
|
|
48
|
+
expect(isTransientNetworkError(error)).toBe(true);
|
|
49
|
+
});
|
|
50
|
+
});
|
package/dist/commands/btw.js
CHANGED
|
@@ -10,7 +10,7 @@ import { createLogger, LogPrefix } from '../logger.js';
|
|
|
10
10
|
import { initializeOpencodeForDirectory } from '../opencode.js';
|
|
11
11
|
import { copyCurrentSessionModel } from './model.js';
|
|
12
12
|
const logger = createLogger(LogPrefix.FORK);
|
|
13
|
-
export async function forkSessionToBtwThread({ sourceThread, projectDirectory, prompt, userId, username, appId, }) {
|
|
13
|
+
export async function forkSessionToBtwThread({ sourceThread, projectDirectory, sdkDirectory, prompt, userId, username, appId, }) {
|
|
14
14
|
// Parallelize: session lookup + opencode init + parent channel resolve are independent
|
|
15
15
|
const [sessionId, getClientResult, textChannel] = await Promise.all([
|
|
16
16
|
getThreadSession(sourceThread.id),
|
|
@@ -57,6 +57,9 @@ export async function forkSessionToBtwThread({ sourceThread, projectDirectory, p
|
|
|
57
57
|
sendThreadMessage(thread, `Reusing context from ${sourceThreadLink} to answer prompt...\n${prompt}`),
|
|
58
58
|
]);
|
|
59
59
|
logger.log(`Created btw fork session ${forkedSession.id} in thread ${thread.id} from source thread ${sourceThread.id} (session ${sessionId})`);
|
|
60
|
+
// Parent context stays in the user prompt only. Do NOT pass parentSessionId
|
|
61
|
+
// into enqueueIncoming: that would inject a parent block into the system
|
|
62
|
+
// message and bust prompt cache shared with the parent session.
|
|
60
63
|
const wrappedPrompt = [
|
|
61
64
|
`The user asked a side question while you were working on another task.`,
|
|
62
65
|
`This is a forked session whose ONLY goal is to answer this question.`,
|
|
@@ -71,7 +74,7 @@ export async function forkSessionToBtwThread({ sourceThread, projectDirectory, p
|
|
|
71
74
|
threadId: thread.id,
|
|
72
75
|
thread,
|
|
73
76
|
projectDirectory,
|
|
74
|
-
sdkDirectory
|
|
77
|
+
sdkDirectory,
|
|
75
78
|
channelId,
|
|
76
79
|
appId,
|
|
77
80
|
});
|
|
@@ -117,12 +120,13 @@ export async function handleBtwCommand({ command, appId, }) {
|
|
|
117
120
|
});
|
|
118
121
|
return;
|
|
119
122
|
}
|
|
120
|
-
const { projectDirectory } = resolved;
|
|
123
|
+
const { projectDirectory, workingDirectory } = resolved;
|
|
121
124
|
await command.deferReply({ flags: MessageFlags.Ephemeral });
|
|
122
125
|
try {
|
|
123
126
|
const result = await forkSessionToBtwThread({
|
|
124
127
|
sourceThread: threadChannel,
|
|
125
128
|
projectDirectory,
|
|
129
|
+
sdkDirectory: workingDirectory,
|
|
126
130
|
prompt,
|
|
127
131
|
userId: command.user.id,
|
|
128
132
|
username: command.user.displayName,
|
|
@@ -8,7 +8,7 @@ import { createPendingWorkspace, setWorkspaceReady, setWorkspaceError, getChanne
|
|
|
8
8
|
import { SILENT_MESSAGE_FLAGS, reactToThread, resolveProjectDirectoryFromAutocomplete, resolveTextChannel, sendThreadMessage, } from '../discord-utils.js';
|
|
9
9
|
import { createLogger, LogPrefix } from '../logger.js';
|
|
10
10
|
import { notifyError } from '../sentry.js';
|
|
11
|
-
import { execAsync, listBranchesByLastCommit, validateBranchRef, } from '../worktrees.js';
|
|
11
|
+
import { execAsync, listBranchesByLastCommit, resolveBestBaseRef, validateBranchRef, } from '../worktrees.js';
|
|
12
12
|
import { getOrCreateRuntime } from '../session-handler/thread-session-runtime.js';
|
|
13
13
|
import { buildSessionPermissions, initializeOpencodeForDirectory, } from '../opencode.js';
|
|
14
14
|
import { WORKTREE_PREFIX } from './merge-worktree.js';
|
|
@@ -22,10 +22,22 @@ async function resolveRequestedWorktreeBaseRef({ projectDirectory, rawBaseBranch
|
|
|
22
22
|
// unpublished commits in the main checkout.
|
|
23
23
|
return DEFAULT_WORKTREE_BASE_REF;
|
|
24
24
|
}
|
|
25
|
-
|
|
25
|
+
const validated = await validateBranchRef({
|
|
26
26
|
directory: projectDirectory,
|
|
27
27
|
ref: rawBaseBranch,
|
|
28
28
|
});
|
|
29
|
+
if (validated instanceof Error)
|
|
30
|
+
return validated;
|
|
31
|
+
// Fetch and prefer the remote tracking ref if it's strictly ahead of local.
|
|
32
|
+
// Handles the common case where user says "main" but upstream/main has new commits.
|
|
33
|
+
const bestRef = await resolveBestBaseRef({
|
|
34
|
+
directory: projectDirectory,
|
|
35
|
+
branch: validated,
|
|
36
|
+
});
|
|
37
|
+
if (bestRef !== validated) {
|
|
38
|
+
logger.log(`Base branch resolved: ${validated} → ${bestRef} (remote is ahead)`);
|
|
39
|
+
}
|
|
40
|
+
return bestRef;
|
|
29
41
|
}
|
|
30
42
|
/** Status message shown while a worktree is being created. */
|
|
31
43
|
export function worktreeCreatingMessage(worktreeName) {
|