kimaki 0.20.1 → 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 (52) 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/database.js +14 -0
  9. package/dist/db.js +1 -0
  10. package/dist/discord-bot.js +27 -1
  11. package/dist/interaction-handler.js +6 -15
  12. package/dist/queue-advanced-e2e-setup.js +22 -0
  13. package/dist/queue-delete-message.e2e.test.js +75 -0
  14. package/dist/schema.js +3 -0
  15. package/dist/session-handler/thread-runtime-state.js +9 -0
  16. package/dist/session-handler/thread-session-runtime.js +49 -1
  17. package/dist/system-message.js +31 -56
  18. package/dist/system-message.test.js +49 -54
  19. package/dist/task-runner.js +18 -3
  20. package/dist/task-schedule.js +4 -0
  21. package/dist/thread-message-queue.e2e.test.js +4 -4
  22. package/dist/voice-message.e2e.test.js +1 -1
  23. package/package.json +12 -5
  24. package/skills/egaki/SKILL.md +26 -80
  25. package/skills/goke/SKILL.md +27 -20
  26. package/skills/holocron/SKILL.md +49 -8
  27. package/skills/new-skill/SKILL.md +87 -0
  28. package/skills/playwriter/SKILL.md +4 -4
  29. package/skills/spiceflow/SKILL.md +2 -0
  30. package/src/agent-model.e2e.test.ts +10 -4
  31. package/src/cli-commands/project.ts +89 -15
  32. package/src/cli-commands/send.ts +8 -0
  33. package/src/cli-runner.test.ts +39 -1
  34. package/src/cli-runner.ts +34 -6
  35. package/src/commands/btw.ts +3 -0
  36. package/src/commands/tasks.ts +111 -19
  37. package/src/database.ts +24 -0
  38. package/src/db.ts +1 -0
  39. package/src/discord-bot.ts +38 -1
  40. package/src/interaction-handler.ts +7 -19
  41. package/src/queue-advanced-e2e-setup.ts +23 -0
  42. package/src/queue-delete-message.e2e.test.ts +102 -0
  43. package/src/schema.sql +1 -0
  44. package/src/schema.ts +3 -0
  45. package/src/session-handler/thread-runtime-state.ts +19 -0
  46. package/src/session-handler/thread-session-runtime.ts +75 -0
  47. package/src/system-message.test.ts +57 -54
  48. package/src/system-message.ts +44 -55
  49. package/src/task-runner.ts +34 -3
  50. package/src/task-schedule.ts +6 -0
  51. package/src/thread-message-queue.e2e.test.ts +4 -4
  52. 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) {
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);
@@ -317,6 +317,9 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
317
317
  const cliInjectedInjectionGuardPatterns = isCliInjectedPrompt
318
318
  ? promptMarker?.injectionGuardPatterns
319
319
  : undefined;
320
+ const cliInjectedParentSessionId = isCliInjectedPrompt
321
+ ? promptMarker?.parentSessionId
322
+ : undefined;
320
323
  // Always ignore our own messages (unless CLI-injected prompt above).
321
324
  // Without this, assigning the Kimaki role to the bot itself would loop.
322
325
  if (isSelfBotMessage && !isCliInjectedPrompt) {
@@ -606,6 +609,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
606
609
  model: cliInjectedModel,
607
610
  permissions: cliInjectedPermissions,
608
611
  injectionGuardPatterns: cliInjectedInjectionGuardPatterns,
612
+ parentSessionId: cliInjectedParentSessionId,
609
613
  noReply: isLeadingMentionToOtherUser || undefined,
610
614
  sessionStartSource: sessionStartSource
611
615
  ? {
@@ -627,7 +631,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
627
631
  });
628
632
  // Notify when a voice message was queued instead of sent immediately
629
633
  if (enqueueResult.queued && enqueueResult.position) {
630
- await sendThreadMessage(thread, `Queued at position ${enqueueResult.position}. Edit your message to update it in queue`);
634
+ await sendThreadMessage(thread, `Queued at position ${enqueueResult.position}. Edit or delete your message to update the queue`);
631
635
  }
632
636
  }
633
637
  if (channel.type === ChannelType.GuildText) {
@@ -850,6 +854,27 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
850
854
  discordLogger.error('Error handling message update:', error instanceof Error ? error.stack : String(error));
851
855
  }
852
856
  });
857
+ // Handle user message deletes to remove queued messages.
858
+ // Discord delete events do not include author/content, so attribution comes
859
+ // from the queued item captured at enqueue time.
860
+ discordClient.on(Events.MessageDelete, async (message) => {
861
+ try {
862
+ const channel = message.channel;
863
+ if (!channel.isThread())
864
+ return;
865
+ const runtime = getRuntime(channel.id);
866
+ if (!runtime)
867
+ return;
868
+ const removed = runtime.removeQueuedMessage(message.id);
869
+ if (!removed)
870
+ return;
871
+ discordLogger.log(`[MESSAGE_DELETE] Removed queued message ${message.id} in thread ${channel.id}`);
872
+ await sendThreadMessage(channel, `⬦ **${removed.username}** removed message from queue`);
873
+ }
874
+ catch (error) {
875
+ discordLogger.error('Error handling message delete:', error instanceof Error ? error.stack : String(error));
876
+ }
877
+ });
853
878
  // Handle bot-initiated threads created by `kimaki send` (without --notify-only)
854
879
  // Uses JSON embed marker to pass options (start, worktree name)
855
880
  discordClient.on(Events.ThreadCreate, async (thread, newlyCreated) => {
@@ -1017,6 +1042,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
1017
1042
  model: marker.model,
1018
1043
  permissions: marker.permissions,
1019
1044
  injectionGuardPatterns: marker.injectionGuardPatterns,
1045
+ parentSessionId: marker.parentSessionId,
1020
1046
  mode: 'opencode',
1021
1047
  sessionStartSource: botThreadStartSource
1022
1048
  ? {
@@ -103,22 +103,13 @@ export function registerInteractionHandler({ discordClient, appId, }) {
103
103
  // Multi-machine routing: only handle interactions for channels owned
104
104
  // by this machine (have a project directory configured in local db).
105
105
  // If not owned, silently return so the other machine handles it.
106
+ // Setup commands (create-new-project, add-project) bypass this check
107
+ // because they are designed to run from any channel — they create new
108
+ // project channels rather than requiring one to already exist.
109
+ const isSetupCommand = interaction.isChatInputCommand() &&
110
+ SETUP_COMMANDS.has(interaction.commandName);
106
111
  const owned = await isInteractionOwnedByThisMachine(interaction);
107
- if (!owned) {
108
- // Setup commands get a helpful reply instead of silent ignore.
109
- // Both machines may race to reply; one wins, the other silently
110
- // fails (interaction tokens are single-use). The message is the
111
- // same from either machine so the race is harmless.
112
- if (interaction.isChatInputCommand() &&
113
- SETUP_COMMANDS.has(interaction.commandName)) {
114
- await interaction.reply({
115
- content: 'Run this command in an existing Kimaki project channel.\nTo add a new project channel from the terminal, run:\n```\nkimaki project add /path/to/folder\n```',
116
- flags: MessageFlags.Ephemeral,
117
- }).catch(() => {
118
- // Another machine already responded, safe to ignore
119
- });
120
- return;
121
- }
112
+ if (!owned && !isSetupCommand) {
122
113
  interactionLogger.log(`[IGNORED] Channel ${interaction.channelId} has no project directory configured, skipping interaction`);
123
114
  // Do not respond at all — consuming the interaction token would
124
115
  // prevent the owning machine from responding (tokens are single-use).