kimaki 0.20.0 → 0.21.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 (61) hide show
  1. package/dist/agent-model.e2e.test.js +6 -4
  2. package/dist/cli-commands/project.js +72 -15
  3. package/dist/cli-commands/send.js +5 -0
  4. package/dist/cli-runner.js +29 -6
  5. package/dist/cli-runner.test.js +28 -1
  6. package/dist/commands/btw.js +3 -0
  7. package/dist/commands/tasks.js +97 -19
  8. package/dist/commands/user-command.js +47 -5
  9. package/dist/database.js +14 -0
  10. package/dist/db.js +1 -0
  11. package/dist/discord-bot.js +39 -18
  12. package/dist/discord-utils.js +18 -1
  13. package/dist/interaction-handler.js +6 -15
  14. package/dist/message-formatting.js +8 -6
  15. package/dist/message-formatting.test.js +4 -4
  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 +47 -60
  22. package/dist/system-message.test.js +65 -58
  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/package.json +12 -5
  28. package/skills/egaki/SKILL.md +32 -69
  29. package/skills/goke/SKILL.md +27 -20
  30. package/skills/holocron/SKILL.md +49 -8
  31. package/skills/new-skill/SKILL.md +87 -0
  32. package/skills/playwriter/SKILL.md +4 -4
  33. package/skills/sigillo/SKILL.md +35 -4
  34. package/skills/spiceflow/SKILL.md +2 -0
  35. package/src/agent-model.e2e.test.ts +10 -4
  36. package/src/cli-commands/project.ts +89 -15
  37. package/src/cli-commands/send.ts +8 -0
  38. package/src/cli-runner.test.ts +39 -1
  39. package/src/cli-runner.ts +34 -6
  40. package/src/commands/btw.ts +3 -0
  41. package/src/commands/tasks.ts +111 -19
  42. package/src/commands/user-command.ts +65 -4
  43. package/src/database.ts +24 -0
  44. package/src/db.ts +1 -0
  45. package/src/discord-bot.ts +52 -19
  46. package/src/discord-utils.ts +24 -0
  47. package/src/interaction-handler.ts +7 -19
  48. package/src/message-formatting.test.ts +4 -4
  49. package/src/message-formatting.ts +9 -8
  50. package/src/queue-advanced-e2e-setup.ts +23 -0
  51. package/src/queue-delete-message.e2e.test.ts +102 -0
  52. package/src/schema.sql +1 -0
  53. package/src/schema.ts +3 -0
  54. package/src/session-handler/thread-runtime-state.ts +19 -0
  55. package/src/session-handler/thread-session-runtime.ts +75 -0
  56. package/src/system-message.test.ts +73 -58
  57. package/src/system-message.ts +60 -59
  58. package/src/task-runner.ts +34 -3
  59. package/src/task-schedule.ts +6 -0
  60. package/src/thread-message-queue.e2e.test.ts +4 -4
  61. package/src/voice-message.e2e.test.ts +1 -1
@@ -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({
@@ -110,16 +110,18 @@ cli
110
110
  cliLogger.log('No projects registered');
111
111
  process.exit(0);
112
112
  }
113
- // Fetch Discord channel names via REST API
113
+ // Fetch Discord channel names and guild IDs via REST API
114
114
  const botRow = await getBotTokenWithMode();
115
115
  const rest = botRow ? createDiscordRest(botRow.token) : null;
116
116
  const enriched = await Promise.all(channels.map(async (ch) => {
117
117
  let channelName = '';
118
+ let guildId = '';
118
119
  let deleted = false;
119
120
  if (rest) {
120
121
  try {
121
122
  const data = (await rest.get(Routes.channel(ch.channel_id)));
122
123
  channelName = data.name || '';
124
+ guildId = data.guild_id || '';
123
125
  }
124
126
  catch (error) {
125
127
  // Only mark as deleted for Unknown Channel (10003) or 404,
@@ -130,13 +132,43 @@ cli
130
132
  deleted = isUnknownChannel;
131
133
  }
132
134
  }
133
- return { ...ch, channelName, deleted };
135
+ return { ...ch, channelName, guildId, deleted };
134
136
  }));
137
+ // Fetch guild names for unique guild IDs (deduplicated to save API calls)
138
+ const guildNameMap = new Map();
139
+ if (rest) {
140
+ const uniqueGuildIds = [...new Set(enriched.map((ch) => ch.guildId).filter(Boolean))];
141
+ await Promise.all(uniqueGuildIds.map(async (guildId) => {
142
+ try {
143
+ const data = (await rest.get(Routes.guild(guildId)));
144
+ guildNameMap.set(guildId, data.name || '');
145
+ }
146
+ catch (error) {
147
+ cliLogger.debug(`Failed to fetch guild ${guildId}:`, error instanceof Error ? error.stack : String(error));
148
+ }
149
+ }));
150
+ }
151
+ // Build final enriched entries with guild names resolved
152
+ const enrichedWithGuild = enriched.map((ch) => ({
153
+ ...ch,
154
+ guildName: ch.guildId ? (guildNameMap.get(ch.guildId) || '') : '',
155
+ }));
156
+ // Warn on stderr if the same directory appears in multiple channels (multi-guild duplicates)
157
+ const directoryCounts = new Map();
158
+ for (const ch of enrichedWithGuild) {
159
+ if (!ch.deleted) {
160
+ directoryCounts.set(ch.directory, (directoryCounts.get(ch.directory) || 0) + 1);
161
+ }
162
+ }
163
+ for (const [dir, count] of directoryCounts) {
164
+ if (count > 1) {
165
+ cliLogger.warn(`Directory "${dir}" is registered in ${count} channels. Use channel_id to disambiguate.`);
166
+ }
167
+ }
135
168
  // Prune stale entries if requested
169
+ let finalEntries = enrichedWithGuild;
136
170
  if (options.prune) {
137
- const stale = enriched.filter((ch) => {
138
- return ch.deleted;
139
- });
171
+ const stale = finalEntries.filter((ch) => ch.deleted);
140
172
  if (stale.length === 0) {
141
173
  cliLogger.log('No stale channels to prune');
142
174
  }
@@ -147,21 +179,18 @@ cli
147
179
  }
148
180
  cliLogger.log(`Pruned ${stale.length} stale channel(s)`);
149
181
  }
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) {
182
+ finalEntries = finalEntries.filter((ch) => !ch.deleted);
183
+ if (finalEntries.length === 0) {
155
184
  cliLogger.log('No projects registered');
156
185
  process.exit(0);
157
186
  }
158
- enriched.length = 0;
159
- enriched.push(...live);
160
187
  }
161
188
  if (options.json) {
162
- const output = enriched.map((ch) => ({
189
+ const output = finalEntries.map((ch) => ({
163
190
  channel_id: ch.channel_id,
164
191
  channel_name: ch.channelName,
192
+ guild_id: ch.guildId,
193
+ guild_name: ch.guildName,
165
194
  directory: ch.directory,
166
195
  folder_name: path.basename(ch.directory),
167
196
  deleted: ch.deleted,
@@ -169,15 +198,43 @@ cli
169
198
  console.log(JSON.stringify(output, null, 2));
170
199
  process.exit(0);
171
200
  }
172
- for (const ch of enriched) {
201
+ for (const ch of finalEntries) {
173
202
  const folderName = path.basename(ch.directory);
174
203
  const deletedTag = ch.deleted ? ' (deleted from Discord)' : '';
175
204
  const channelLabel = ch.channelName ? `#${ch.channelName}` : ch.channel_id;
176
- console.log(`\n${channelLabel}${deletedTag}`);
205
+ const guildLabel = ch.guildName || ch.guildId || '';
206
+ const guildSuffix = guildLabel ? ` (${guildLabel})` : '';
207
+ console.log(`\n${channelLabel}${guildSuffix}${deletedTag}`);
177
208
  console.log(` Folder: ${folderName}`);
178
209
  console.log(` Directory: ${ch.directory}`);
179
210
  console.log(` Channel ID: ${ch.channel_id}`);
211
+ if (ch.guildId) {
212
+ console.log(` Guild ID: ${ch.guildId}`);
213
+ }
214
+ }
215
+ process.exit(0);
216
+ });
217
+ cli
218
+ .command('project remove <channelId>', 'Remove a project channel mapping from the local database (does not delete the Discord channel)')
219
+ .action(async (channelId) => {
220
+ await initDatabase();
221
+ const db = await getDb();
222
+ const row = await db.query.channel_directories.findFirst({
223
+ where: { channel_id: channelId },
224
+ });
225
+ if (!row) {
226
+ cliLogger.error(`No channel mapping found for channel ID: ${channelId}`);
227
+ process.exit(EXIT_NO_RESTART);
228
+ }
229
+ const removed = await deleteChannelDirectoryById(channelId);
230
+ if (!removed) {
231
+ cliLogger.error(`Channel mapping disappeared before it could be removed: ${channelId}`);
232
+ process.exit(EXIT_NO_RESTART);
180
233
  }
234
+ cliLogger.log(`Removed channel mapping:`);
235
+ cliLogger.log(` Channel ID: ${channelId}`);
236
+ cliLogger.log(` Directory: ${row.directory}`);
237
+ cliLogger.log(` Type: ${row.channel_type}`);
181
238
  process.exit(0);
182
239
  });
183
240
  cli
@@ -51,6 +51,7 @@ cli
51
51
  .option('--send-at <schedule>', 'Schedule send for future (UTC ISO date/time ending in Z, or cron expression)')
52
52
  .option('--thread <threadId>', 'Post prompt to an existing thread')
53
53
  .option('--session <sessionId>', 'Post prompt to thread mapped to an existing session')
54
+ .option('--parent-session <sessionId>', 'Parent OpenCode session ID for newly created child sessions')
54
55
  .option('--wait', 'Wait for session to complete, then print session text to stdout')
55
56
  .action(async (options) => {
56
57
  try {
@@ -299,6 +300,7 @@ cli
299
300
  userId: null,
300
301
  permissions: options.permission?.length ? options.permission : null,
301
302
  injectionGuardPatterns: options.injectionGuard?.length ? options.injectionGuard : null,
303
+ parentSessionId: options.parentSession || null,
302
304
  };
303
305
  const taskId = await createScheduledTask({
304
306
  scheduleKind: parsedSchedule.scheduleKind,
@@ -325,6 +327,7 @@ cli
325
327
  ...(options.model && { model: options.model }),
326
328
  ...(options.permission?.length ? { permissions: options.permission } : {}),
327
329
  ...(options.injectionGuard?.length ? { injectionGuardPatterns: options.injectionGuard } : {}),
330
+ ...(options.parentSession && { parentSessionId: options.parentSession }),
328
331
  };
329
332
  const promptEmbed = [
330
333
  {
@@ -438,6 +441,7 @@ cli
438
441
  userId: resolvedUser?.id || null,
439
442
  permissions: options.permission?.length ? options.permission : null,
440
443
  injectionGuardPatterns: options.injectionGuard?.length ? options.injectionGuard : null,
444
+ parentSessionId: options.parentSession || null,
441
445
  };
442
446
  const taskId = await createScheduledTask({
443
447
  scheduleKind: parsedSchedule.scheduleKind,
@@ -471,6 +475,7 @@ cli
471
475
  ...(options.model && { model: options.model }),
472
476
  ...(options.permission?.length && { permissions: options.permission }),
473
477
  ...(options.injectionGuard?.length && { injectionGuardPatterns: options.injectionGuard }),
478
+ ...(options.parentSession && { parentSessionId: options.parentSession }),
474
479
  };
475
480
  const autoStartEmbed = embedMarker
476
481
  ? [{ color: 0x2b2d31, footer: { text: YAML.stringify(embedMarker) } }]
@@ -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';
@@ -174,11 +175,8 @@ export async function sendDiscordMessageWithOptionalAttachment({ channelId, prom
174
175
  }
175
176
  const preview = prompt.slice(0, 100).replace(/\n/g, ' ');
176
177
  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`);
178
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'kimaki-prompt-'));
179
+ const tmpFile = path.join(tmpDir, 'prompt.md');
182
180
  // Wrap long lines so the file is readable in Discord's preview
183
181
  // (Discord doesn't wrap text in file attachments)
184
182
  const wrappedPrompt = prompt
@@ -235,6 +233,7 @@ export async function sendDiscordMessageWithOptionalAttachment({ channelId, prom
235
233
  }
236
234
  finally {
237
235
  fs.unlinkSync(tmpFile);
236
+ fs.rmdirSync(tmpDir);
238
237
  }
239
238
  }
240
239
  export function formatRelativeTime(target) {
@@ -308,7 +307,7 @@ function readErrorField(error, key) {
308
307
  }
309
308
  return undefined;
310
309
  }
311
- /** Transient network errors that may resolve on retry (DNS down, gateway unreachable). */
310
+ /** Transient network errors that may resolve on retry (DNS down, gateway unreachable, TLS blips). */
312
311
  const TRANSIENT_ERROR_CODES = new Set([
313
312
  'ENOTFOUND',
314
313
  'ECONNREFUSED',
@@ -318,13 +317,37 @@ const TRANSIENT_ERROR_CODES = new Set([
318
317
  'EPIPE',
319
318
  'EHOSTUNREACH',
320
319
  'ENETUNREACH',
320
+ // TLS/cert errors can be transient (intermediate CA blip, MITM proxy, cert rotation).
321
+ // Without these, Discord login failure exits EXIT_NO_RESTART and the bin wrapper
322
+ // never restarts — the bot dies permanently on "unable to verify the first certificate".
323
+ 'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
324
+ 'UNABLE_TO_GET_ISSUER_CERT',
325
+ 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY',
326
+ 'UNABLE_TO_DECODE_ISSUER_PUBLIC_KEY',
327
+ 'CERT_SIGNATURE_FAILURE',
328
+ 'CERT_NOT_YET_VALID',
329
+ 'CERT_HAS_EXPIRED',
330
+ 'CRL_HAS_EXPIRED',
331
+ 'DEPTH_ZERO_SELF_SIGNED_CERT',
332
+ 'SELF_SIGNED_CERT_IN_CHAIN',
333
+ 'ERR_TLS_CERT_ALTNAME_INVALID',
321
334
  ]);
335
+ const TRANSIENT_ERROR_MESSAGE_PATTERNS = [
336
+ /unable to verify the first certificate/i,
337
+ /certificate has expired/i,
338
+ /self[- ]signed certificate/i,
339
+ /unable to get local issuer certificate/i,
340
+ ];
322
341
  export function isTransientNetworkError(error) {
323
342
  if (!(error instanceof Error))
324
343
  return false;
325
344
  const code = error.code;
326
345
  if (code && TRANSIENT_ERROR_CODES.has(code))
327
346
  return true;
347
+ // Fallback when code is stripped by wrappers (discord.js sometimes rethrows by message only).
348
+ if (TRANSIENT_ERROR_MESSAGE_PATTERNS.some((pattern) => pattern.test(error.message))) {
349
+ return true;
350
+ }
328
351
  // discord.js wraps errors in cause chains
329
352
  if (error.cause instanceof Error)
330
353
  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
+ });
@@ -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.`,
@@ -1,10 +1,11 @@
1
1
  // /tasks command — list all scheduled tasks sorted by next run time.
2
2
  // Renders a markdown table that the CV2 pipeline auto-formats for Discord,
3
- // including HTML-backed action buttons for cancellable tasks.
3
+ // including HTML-backed Run now / Delete action buttons.
4
4
  import { ButtonInteraction, ChatInputCommandInteraction, ComponentType, MessageFlags, } from 'discord.js';
5
5
  import { cancelScheduledTask, listScheduledTasks, } from '../database.js';
6
6
  import { splitTablesFromMarkdown } from '../format-tables.js';
7
7
  import { buildHtmlActionCustomId, cancelHtmlActionsForOwner, registerHtmlAction, } from '../html-actions.js';
8
+ import { runScheduledTaskNow } from '../task-runner.js';
8
9
  import { formatTimeAgo } from './worktrees.js';
9
10
  function formatTimeUntil(date) {
10
11
  const diffMs = date.getTime() - Date.now();
@@ -34,6 +35,9 @@ function scheduleLabel(task) {
34
35
  }
35
36
  return 'one-time';
36
37
  }
38
+ function canRunTask(task) {
39
+ return task.status === 'planned';
40
+ }
37
41
  function canCancelTask(task) {
38
42
  return task.status === 'planned' || task.status === 'running';
39
43
  }
@@ -42,22 +46,25 @@ function canCancelTask(task) {
42
46
  function sanitizeTableCell(value) {
43
47
  return value.replaceAll('|', '\\|').replace(/\s+/g, ' ').trim();
44
48
  }
45
- function buildCancelButtonHtml({ buttonId }) {
46
- return `<button id="${buttonId}" variant="secondary">Delete</button>`;
49
+ function buildRunCell(task) {
50
+ if (!canRunTask(task)) {
51
+ return '-';
52
+ }
53
+ return `<button id="run-task-${task.id}" variant="primary">Run now</button>`;
47
54
  }
48
- function buildActionCell(task) {
55
+ function buildCancelCell(task) {
49
56
  if (!canCancelTask(task)) {
50
57
  return '-';
51
58
  }
52
- return buildCancelButtonHtml({ buttonId: `cancel-task-${task.id}` });
59
+ return `<button id="cancel-task-${task.id}" variant="secondary">Delete</button>`;
53
60
  }
54
61
  // Cap rows to avoid exceeding Discord's 40-component CV2 limit.
55
- // Each cancellable row renders as text + action row + button (~4 components),
56
- // so 10 rows is a safe ceiling.
57
- const MAX_TASK_ROWS = 10;
62
+ // Each actionable row is text + action row + up to 2 buttons (~4 components),
63
+ // so 7 rows stays under the budget with separators.
64
+ const MAX_TASK_ROWS = 7;
58
65
  function buildTaskTable({ tasks, }) {
59
- const header = '| ID | Status | Prompt | Schedule | Next Run | Action |';
60
- const separator = '|---|---|---|---|---|---|';
66
+ const header = '| ID | Status | Prompt | Schedule | Next Run | Run | Delete |';
67
+ const separator = '|---|---|---|---|---|---|---|';
61
68
  const rows = tasks.map((task) => {
62
69
  const id = String(task.id);
63
70
  const status = task.status;
@@ -73,8 +80,9 @@ function buildTaskTable({ tasks, }) {
73
80
  }
74
81
  return formatTimeUntil(task.next_run_at);
75
82
  })();
76
- const action = buildActionCell(task);
77
- return `| ${id} | ${status} | ${prompt} | ${schedule} | ${nextRun} | ${action} |`;
83
+ const run = buildRunCell(task);
84
+ const cancel = buildCancelCell(task);
85
+ return `| ${id} | ${status} | ${prompt} | ${schedule} | ${nextRun} | ${run} | ${cancel} |`;
78
86
  });
79
87
  return [header, separator, ...rows].join('\n');
80
88
  }
@@ -107,12 +115,15 @@ async function renderTasksReply({ guildId, userId, channelId, showAll, notice, e
107
115
  ? `Showing ${MAX_TASK_ROWS}/${allTasks.length} tasks. Use \`kimaki task list\` for full list.`
108
116
  : undefined;
109
117
  const combinedNotice = [notice, truncatedNotice].filter(Boolean).join('\n');
118
+ const runnableTasksByButtonId = new Map();
110
119
  const cancellableTasksByButtonId = new Map();
111
120
  tasks.forEach((task) => {
112
- if (!canCancelTask(task)) {
113
- return;
121
+ if (canRunTask(task)) {
122
+ runnableTasksByButtonId.set(`run-task-${task.id}`, task);
123
+ }
124
+ if (canCancelTask(task)) {
125
+ cancellableTasksByButtonId.set(`cancel-task-${task.id}`, task);
114
126
  }
115
- cancellableTasksByButtonId.set(`cancel-task-${task.id}`, task);
116
127
  });
117
128
  const tableMarkdown = buildTaskTable({ tasks });
118
129
  const markdown = combinedNotice
@@ -120,17 +131,32 @@ async function renderTasksReply({ guildId, userId, channelId, showAll, notice, e
120
131
  : tableMarkdown;
121
132
  const segments = splitTablesFromMarkdown(markdown, {
122
133
  resolveButtonCustomId: ({ button }) => {
123
- const task = cancellableTasksByButtonId.get(button.id);
124
- if (!task) {
134
+ const runTask = runnableTasksByButtonId.get(button.id);
135
+ if (runTask) {
136
+ const actionId = registerHtmlAction({
137
+ ownerKey,
138
+ threadId: String(runTask.id),
139
+ run: async ({ interaction }) => {
140
+ await handleRunTaskAction({
141
+ interaction,
142
+ taskId: runTask.id,
143
+ showAll,
144
+ });
145
+ },
146
+ });
147
+ return buildHtmlActionCustomId(actionId);
148
+ }
149
+ const cancelTask = cancellableTasksByButtonId.get(button.id);
150
+ if (!cancelTask) {
125
151
  return new Error(`No task registered for button ${button.id}`);
126
152
  }
127
153
  const actionId = registerHtmlAction({
128
154
  ownerKey,
129
- threadId: String(task.id),
155
+ threadId: String(cancelTask.id),
130
156
  run: async ({ interaction }) => {
131
157
  await handleCancelTaskAction({
132
158
  interaction,
133
- taskId: task.id,
159
+ taskId: cancelTask.id,
134
160
  showAll,
135
161
  });
136
162
  },
@@ -153,6 +179,58 @@ async function renderTasksReply({ guildId, userId, channelId, showAll, notice, e
153
179
  flags: MessageFlags.IsComponentsV2,
154
180
  });
155
181
  }
182
+ async function handleRunTaskAction({ interaction, taskId, showAll, }) {
183
+ const guildId = interaction.guildId;
184
+ if (!guildId) {
185
+ await interaction.editReply({
186
+ components: [
187
+ {
188
+ type: ComponentType.TextDisplay,
189
+ content: 'This action can only be used in a server.',
190
+ },
191
+ ],
192
+ flags: MessageFlags.IsComponentsV2,
193
+ });
194
+ return;
195
+ }
196
+ const token = interaction.client.token;
197
+ if (!token) {
198
+ await renderTasksReply({
199
+ guildId,
200
+ userId: interaction.user.id,
201
+ channelId: interaction.channelId,
202
+ showAll,
203
+ notice: `Could not run task #${taskId}: bot token unavailable.`,
204
+ editReply: (options) => {
205
+ return interaction.editReply(options);
206
+ },
207
+ });
208
+ return;
209
+ }
210
+ const result = await runScheduledTaskNow({ token, taskId });
211
+ const notice = (() => {
212
+ if (result instanceof Error) {
213
+ return `Could not run task #${taskId}: ${result.message}`;
214
+ }
215
+ if (result.kind === 'skipped') {
216
+ return `Task #${taskId} is already running or was claimed elsewhere.`;
217
+ }
218
+ if (result.kind === 'failed') {
219
+ return `Task #${taskId} failed: ${result.error.message}`;
220
+ }
221
+ return `Started task #${taskId}.`;
222
+ })();
223
+ await renderTasksReply({
224
+ guildId,
225
+ userId: interaction.user.id,
226
+ channelId: interaction.channelId,
227
+ showAll,
228
+ notice,
229
+ editReply: (options) => {
230
+ return interaction.editReply(options);
231
+ },
232
+ });
233
+ }
156
234
  async function handleCancelTaskAction({ interaction, taskId, showAll, }) {
157
235
  const guildId = interaction.guildId;
158
236
  if (!guildId) {
@@ -1,11 +1,14 @@
1
1
  // User-defined OpenCode command handler.
2
2
  // Handles slash commands that map to user-configured commands in opencode.json.
3
- import { ChannelType, MessageFlags, } from 'discord.js';
3
+ import { ChannelType, MessageFlags, ThreadAutoArchiveDuration, } from 'discord.js';
4
4
  import { getOrCreateRuntime } from '../session-handler/thread-session-runtime.js';
5
5
  import { SILENT_MESSAGE_FLAGS } from '../discord-utils.js';
6
6
  import { createLogger, LogPrefix } from '../logger.js';
7
- import { getChannelDirectory, getThreadSession } from '../database.js';
7
+ import { getChannelDirectory, getChannelWorktreesEnabled, getThreadSession, } from '../database.js';
8
8
  import { store } from '../store.js';
9
+ import { isGitRepositoryRoot } from '../worktrees.js';
10
+ import { formatAutoWorktreeName, createWorktreeInBackground, worktreeCreatingMessage, } from './new-worktree.js';
11
+ import { WORKTREE_PREFIX } from './merge-worktree.js';
9
12
  import fs from 'node:fs';
10
13
  const userCommandLogger = createLogger(LogPrefix.USER_CMD);
11
14
  const DISCORD_MESSAGE_LIMIT = 2000;
@@ -107,23 +110,62 @@ export const handleUserCommand = async ({ command, appId, }) => {
107
110
  }
108
111
  else if (textChannel) {
109
112
  // Running in text channel - create a new thread
113
+ // Check if worktrees should be enabled (CLI flag OR channel setting),
114
+ // mirroring the logic in discord-bot.ts message handler.
115
+ const wantsWorktrees = store.getState().useWorktrees ||
116
+ (await getChannelWorktreesEnabled(textChannel.id));
117
+ const shouldUseWorktrees = wantsWorktrees && (await isGitRepositoryRoot(projectDirectory));
118
+ if (wantsWorktrees && !shouldUseWorktrees) {
119
+ userCommandLogger.warn(`[WORKTREE] Skipping automatic worktree for non-git project directory: ${projectDirectory}`);
120
+ }
121
+ const baseThreadName = commandInvocation.slice(0, DISCORD_THREAD_NAME_LIMIT);
122
+ const threadName = shouldUseWorktrees
123
+ ? `${WORKTREE_PREFIX}${baseThreadName}`
124
+ : baseThreadName;
110
125
  const starterMessage = await textChannel.send({
111
126
  content: threadOpeningMessage,
112
127
  flags: SILENT_MESSAGE_FLAGS,
113
128
  });
114
129
  const newThread = await starterMessage.startThread({
115
- name: commandInvocation.slice(0, DISCORD_THREAD_NAME_LIMIT),
116
- autoArchiveDuration: 1440,
130
+ name: threadName.slice(0, DISCORD_THREAD_NAME_LIMIT),
131
+ autoArchiveDuration: ThreadAutoArchiveDuration.OneDay,
117
132
  reason: `OpenCode command: ${commandName}`,
118
133
  });
119
134
  // Add user to thread so it appears in their sidebar
120
135
  await newThread.members.add(command.user.id);
136
+ // Create worktree in background if enabled, same as discord-bot.ts
137
+ let worktreePromise;
138
+ if (shouldUseWorktrees) {
139
+ const worktreeName = formatAutoWorktreeName(baseThreadName.slice(0, 50));
140
+ userCommandLogger.log(`[WORKTREE] Creating worktree: ${worktreeName}`);
141
+ const worktreeStatusMessage = await newThread
142
+ .send({
143
+ content: worktreeCreatingMessage(worktreeName),
144
+ flags: SILENT_MESSAGE_FLAGS,
145
+ })
146
+ .catch(() => undefined);
147
+ worktreePromise = createWorktreeInBackground({
148
+ thread: newThread,
149
+ starterMessage: worktreeStatusMessage,
150
+ worktreeName,
151
+ projectDirectory,
152
+ rest: command.client.rest,
153
+ });
154
+ }
155
+ const sessionDirectory = await (async () => {
156
+ if (!worktreePromise)
157
+ return projectDirectory;
158
+ const result = await worktreePromise;
159
+ if (result instanceof Error)
160
+ return projectDirectory;
161
+ return result;
162
+ })();
121
163
  await command.editReply(`Started /${commandName} in ${newThread.toString()}`);
122
164
  const runtime = getOrCreateRuntime({
123
165
  threadId: newThread.id,
124
166
  thread: newThread,
125
167
  projectDirectory,
126
- sdkDirectory: projectDirectory,
168
+ sdkDirectory: sessionDirectory,
127
169
  channelId: textChannel.id,
128
170
  appId,
129
171
  });
package/dist/database.js CHANGED
@@ -380,6 +380,20 @@ export async function upsertThreadSession({ threadId, sessionId, source }) {
380
380
  .values({ thread_id: threadId, session_id: sessionId, source })
381
381
  .onConflictDoUpdate({ target: schema.thread_sessions.thread_id, set: { session_id: sessionId, source } });
382
382
  }
383
+ export async function getThreadParentSessionId(threadId) {
384
+ const db = await getDb();
385
+ return (await db.query.thread_sessions.findFirst({
386
+ where: { thread_id: threadId },
387
+ columns: { parent_session_id: true },
388
+ }))?.parent_session_id ?? undefined;
389
+ }
390
+ export async function setThreadParentSessionId({ threadId, parentSessionId, }) {
391
+ const db = await getDb();
392
+ await db
393
+ .update(schema.thread_sessions)
394
+ .set({ parent_session_id: parentSessionId })
395
+ .where(orm.eq(schema.thread_sessions.thread_id, threadId));
396
+ }
383
397
  export async function getThreadSessionSource(threadId) {
384
398
  const db = await getDb();
385
399
  return (await db.query.thread_sessions.findFirst({ where: { thread_id: threadId }, columns: { source: true } }))?.source;
package/dist/db.js CHANGED
@@ -119,6 +119,7 @@ async function migrateSchema({ db, client, }) {
119
119
  'ALTER TABLE bot_tokens ADD COLUMN last_used_at DATETIME',
120
120
  "ALTER TABLE thread_sessions ADD COLUMN source TEXT DEFAULT 'kimaki'",
121
121
  'ALTER TABLE thread_sessions ADD COLUMN last_synced_name TEXT',
122
+ 'ALTER TABLE thread_sessions ADD COLUMN parent_session_id TEXT',
122
123
  ];
123
124
  for (const stmt of alterStatements) {
124
125
  await client.execute(stmt).catch(() => undefined);