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.
Files changed (64) hide show
  1. package/dist/agent-model.e2e.test.js +6 -4
  2. package/dist/cli-commands/project.js +148 -24
  3. package/dist/cli-commands/send.js +48 -3
  4. package/dist/cli-runner.js +86 -7
  5. package/dist/cli-runner.test.js +28 -1
  6. package/dist/commands/btw.js +7 -3
  7. package/dist/commands/new-worktree.js +14 -2
  8. package/dist/commands/session.js +66 -13
  9. package/dist/commands/tasks.js +104 -19
  10. package/dist/commands/user-command.js +3 -2
  11. package/dist/database.js +14 -0
  12. package/dist/db.js +1 -0
  13. package/dist/discord-bot.js +65 -9
  14. package/dist/interaction-handler.js +6 -15
  15. package/dist/message-preprocessing.js +9 -2
  16. package/dist/queue-advanced-e2e-setup.js +22 -0
  17. package/dist/queue-delete-message.e2e.test.js +75 -0
  18. package/dist/schema.js +3 -0
  19. package/dist/session-handler/thread-runtime-state.js +9 -0
  20. package/dist/session-handler/thread-session-runtime.js +49 -1
  21. package/dist/system-message.js +50 -63
  22. package/dist/system-message.test.js +65 -61
  23. package/dist/task-runner.js +18 -3
  24. package/dist/task-schedule.js +4 -0
  25. package/dist/thread-message-queue.e2e.test.js +4 -4
  26. package/dist/voice-message.e2e.test.js +1 -1
  27. package/dist/worktree-lifecycle.e2e.test.js +70 -1
  28. package/dist/worktrees.js +63 -0
  29. package/package.json +11 -4
  30. package/skills/egaki/SKILL.md +26 -80
  31. package/skills/goke/SKILL.md +27 -20
  32. package/skills/holocron/SKILL.md +72 -8
  33. package/skills/new-skill/SKILL.md +87 -0
  34. package/skills/playwriter/SKILL.md +4 -4
  35. package/skills/spiceflow/SKILL.md +2 -0
  36. package/src/agent-model.e2e.test.ts +10 -4
  37. package/src/cli-commands/project.ts +183 -26
  38. package/src/cli-commands/send.ts +64 -5
  39. package/src/cli-runner.test.ts +39 -1
  40. package/src/cli-runner.ts +115 -6
  41. package/src/commands/btw.ts +9 -2
  42. package/src/commands/new-worktree.ts +14 -1
  43. package/src/commands/session.ts +79 -17
  44. package/src/commands/tasks.ts +119 -19
  45. package/src/commands/user-command.ts +3 -2
  46. package/src/database.ts +24 -0
  47. package/src/db.ts +1 -0
  48. package/src/discord-bot.ts +80 -8
  49. package/src/interaction-handler.ts +7 -19
  50. package/src/message-preprocessing.ts +11 -2
  51. package/src/queue-advanced-e2e-setup.ts +23 -0
  52. package/src/queue-delete-message.e2e.test.ts +102 -0
  53. package/src/schema.sql +1 -0
  54. package/src/schema.ts +3 -0
  55. package/src/session-handler/thread-runtime-state.ts +19 -0
  56. package/src/session-handler/thread-session-runtime.ts +75 -0
  57. package/src/system-message.test.ts +73 -61
  58. package/src/system-message.ts +63 -62
  59. package/src/task-runner.ts +34 -3
  60. package/src/task-schedule.ts +6 -0
  61. package/src/thread-message-queue.e2e.test.ts +4 -4
  62. package/src/voice-message.e2e.test.ts +1 -1
  63. package/src/worktree-lifecycle.e2e.test.ts +88 -1
  64. 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: (t) => {
548
- return t.name === 'Reply with exactly: btw-source-msg';
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: (t) => {
576
- return t.name === 'btw: Reply with exactly: btw-model-check';
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
- if (channels.length === 0) {
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 = enriched.filter((ch) => {
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
- // Re-filter to only show live entries after pruning
151
- const live = enriched.filter((ch) => {
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
- enriched.length = 0;
159
- enriched.push(...live);
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 = enriched.map((ch) => ({
249
+ const output = finalEntries.map((ch) => ({
163
250
  channel_id: ch.channel_id,
164
251
  channel_name: ch.channelName,
165
- directory: ch.directory,
166
- folder_name: path.basename(ch.directory),
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 enriched) {
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
- console.log(`\n${channelLabel}${deletedTag}`);
177
- console.log(` Folder: ${folderName}`);
178
- console.log(` Directory: ${ch.directory}`);
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.
@@ -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(process.cwd(), 'tmp');
178
- if (!fs.existsSync(tmpDir)) {
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);
@@ -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
+ });
@@ -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: projectDirectory,
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
- return validateBranchRef({
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) {