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
@@ -261,9 +261,8 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
261
261
  discordLogger.warn(`[GATEWAY] Shard ${shardId} reconnecting: ${parts.join(', ')}`);
262
262
  if (state.attempts >= MAX_RECONNECT_ATTEMPTS) {
263
263
  discordLogger.error(`[GATEWAY] Shard ${shardId} exceeded ${MAX_RECONNECT_ATTEMPTS} reconnect attempts, self-restarting`);
264
- // Self-restart: cleanup then spawn a fresh process. This works whether
265
- // the bin.ts wrapper is present or not (unlike process.exit(1) which
266
- // only restarts when the wrapper is the parent).
264
+ // Self-restart: cleanup, then exit non-zero so the bin.ts wrapper
265
+ // restarts us. Without the wrapper this exits after logging a warning.
267
266
  void selfRestart('gateway-reconnect-limit');
268
267
  }
269
268
  });
@@ -318,6 +317,9 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
318
317
  const cliInjectedInjectionGuardPatterns = isCliInjectedPrompt
319
318
  ? promptMarker?.injectionGuardPatterns
320
319
  : undefined;
320
+ const cliInjectedParentSessionId = isCliInjectedPrompt
321
+ ? promptMarker?.parentSessionId
322
+ : undefined;
321
323
  // Always ignore our own messages (unless CLI-injected prompt above).
322
324
  // Without this, assigning the Kimaki role to the bot itself would loop.
323
325
  if (isSelfBotMessage && !isCliInjectedPrompt) {
@@ -607,6 +609,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
607
609
  model: cliInjectedModel,
608
610
  permissions: cliInjectedPermissions,
609
611
  injectionGuardPatterns: cliInjectedInjectionGuardPatterns,
612
+ parentSessionId: cliInjectedParentSessionId,
610
613
  noReply: isLeadingMentionToOtherUser || undefined,
611
614
  sessionStartSource: sessionStartSource
612
615
  ? {
@@ -628,7 +631,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
628
631
  });
629
632
  // Notify when a voice message was queued instead of sent immediately
630
633
  if (enqueueResult.queued && enqueueResult.position) {
631
- 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`);
632
635
  }
633
636
  }
634
637
  if (channel.type === ChannelType.GuildText) {
@@ -851,6 +854,27 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
851
854
  discordLogger.error('Error handling message update:', error instanceof Error ? error.stack : String(error));
852
855
  }
853
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
+ });
854
878
  // Handle bot-initiated threads created by `kimaki send` (without --notify-only)
855
879
  // Uses JSON embed marker to pass options (start, worktree name)
856
880
  discordClient.on(Events.ThreadCreate, async (thread, newlyCreated) => {
@@ -1018,6 +1042,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
1018
1042
  model: marker.model,
1019
1043
  permissions: marker.permissions,
1020
1044
  injectionGuardPatterns: marker.injectionGuardPatterns,
1045
+ parentSessionId: marker.parentSessionId,
1021
1046
  mode: 'opencode',
1022
1047
  sessionStartSource: botThreadStartSource
1023
1048
  ? {
@@ -1166,7 +1191,13 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
1166
1191
  discordLogger.error('Failed to write heap snapshot:', e instanceof Error ? e.message : String(e));
1167
1192
  });
1168
1193
  });
1169
- // Self-restart: prefer bin.ts wrapper (keeps Ctrl+C), fall back to detached spawn.
1194
+ // Self-restart: exit with code 1 so the bin.ts wrapper restarts us with
1195
+ // exponential backoff and crash-loop detection. When running without the
1196
+ // wrapper (e.g. `tsx src/cli.ts`), the process just exits and the user
1197
+ // sees the non-zero exit code — they should use `tsx src/bin.ts` instead
1198
+ // for auto-restart support. The previous detached-spawn fallback was
1199
+ // unreliable: process.exit(0) could kill the child before it started,
1200
+ // there was no backoff between restarts, and spawn failures were silent.
1170
1201
  let selfRestarting = false;
1171
1202
  async function selfRestart(reason) {
1172
1203
  if (selfRestarting) {
@@ -1181,20 +1212,10 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
1181
1212
  catch (error) {
1182
1213
  voiceLogger.error(`[${reason}] Error during shutdown:`, error);
1183
1214
  }
1184
- if (process.env.__KIMAKI_CHILD) {
1185
- discordLogger.log('Wrapper detected, exiting for wrapper restart');
1186
- process.exit(1);
1215
+ if (!process.env.__KIMAKI_CHILD) {
1216
+ discordLogger.warn('No restart wrapper detected. Run via `tsx src/bin.ts` (dev) or `kimaki` (npm) for auto-restart on crash.');
1187
1217
  }
1188
- const { spawn } = await import('node:child_process');
1189
- const env = { ...process.env };
1190
- delete env.__KIMAKI_CHILD;
1191
- spawn(process.argv[0], [...process.execArgv, ...process.argv.slice(1)], {
1192
- stdio: 'inherit',
1193
- detached: true,
1194
- cwd: process.cwd(),
1195
- env,
1196
- }).unref();
1197
- process.exit(0);
1218
+ process.exit(1);
1198
1219
  }
1199
1220
  process.on('SIGUSR2', () => {
1200
1221
  discordLogger.log('Received SIGUSR2, restarting after cleanup...');
@@ -590,14 +590,31 @@ export async function resolveWorkingDirectory({ channel, }) {
590
590
  workingDirectory,
591
591
  };
592
592
  }
593
+ // Discord upload size limits per server boost tier (bytes).
594
+ // Bots default to 25 MB; boosted servers raise the ceiling.
595
+ export const DISCORD_DEFAULT_MAX_FILE_SIZE = 25 * 1024 * 1024;
593
596
  /**
594
597
  * Upload files to a Discord thread/channel in a single message.
595
598
  * Sending all files in one message causes Discord to display images in a grid layout.
599
+ *
600
+ * Files are validated against the Discord upload size limit before reading them
601
+ * into memory. Pass `maxFileSize` if you know the guild's boost tier limit;
602
+ * otherwise the conservative 25 MB bot default is used.
596
603
  */
597
- export async function uploadFilesToDiscord({ threadId, botToken, files, }) {
604
+ export async function uploadFilesToDiscord({ threadId, botToken, files, maxFileSize = DISCORD_DEFAULT_MAX_FILE_SIZE, }) {
598
605
  if (files.length === 0) {
599
606
  return;
600
607
  }
608
+ // Fail fast: check file sizes before reading anything into memory
609
+ const sizeLimit = maxFileSize ?? DISCORD_DEFAULT_MAX_FILE_SIZE;
610
+ for (const file of files) {
611
+ const stat = fs.statSync(file);
612
+ if (stat.size > sizeLimit) {
613
+ const fileMB = (stat.size / 1024 / 1024).toFixed(1);
614
+ const limitMB = (sizeLimit / 1024 / 1024).toFixed(0);
615
+ throw new Error(`File "${path.basename(file)}" is ${fileMB} MB, which exceeds Discord's ${limitMB} MB upload limit`);
616
+ }
617
+ }
601
618
  // Build attachments array for all files
602
619
  const attachments = files.map((file, index) => ({
603
620
  id: index,
@@ -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).
@@ -272,9 +272,9 @@ const MAX_BASH_COMMAND_INLINE_LENGTH = 100;
272
272
  * 2. Long/multiline command with description → show description
273
273
  * 3. Long/multiline command without description → truncate first line of command
274
274
  *
275
- * The description field was removed from the bash tool schema in newer opencode
276
- * versions, so case 3 is the common path now. Without this fallback, long commands
277
- * would render as just "┣ bash" with no context.
275
+ * The description field was removed from the opencode v2 bash tool schema but
276
+ * kimaki's system prompt instructs models to always send it as an extra field.
277
+ * Case 3 is the fallback when a model omits it.
278
278
  */
279
279
  export function formatBashToolTitle({ command, description, stateTitle, }) {
280
280
  if (!command && !description && !stateTitle)
@@ -292,10 +292,12 @@ export function formatBashToolTitle({ command, description, stateTitle, }) {
292
292
  return ` _${escapeInlineMarkdown(description)}_`;
293
293
  }
294
294
  if (firstMeaningfulLine.length > 0) {
295
- const truncated = firstMeaningfulLine.length > MAX_BASH_COMMAND_INLINE_LENGTH
296
- ? firstMeaningfulLine.slice(0, MAX_BASH_COMMAND_INLINE_LENGTH) + '…'
295
+ const needsTruncation = firstMeaningfulLine.length > MAX_BASH_COMMAND_INLINE_LENGTH;
296
+ const base = needsTruncation
297
+ ? firstMeaningfulLine.slice(0, MAX_BASH_COMMAND_INLINE_LENGTH)
297
298
  : firstMeaningfulLine;
298
- return ` _${escapeInlineMarkdown(truncated)}_`;
299
+ // Always add ellipsis when showing a partial command (multiline or length-truncated)
300
+ return ` _${escapeInlineMarkdown(base)}…_`;
299
301
  }
300
302
  if (stateTitle) {
301
303
  return ` _${escapeInlineMarkdown(stateTitle)}_`;
@@ -47,7 +47,7 @@ describe('formatBashToolTitle', () => {
47
47
  expect(formatBashToolTitle({ command: 'echo hello' })).toMatchInlineSnapshot(`" _echo hello_"`);
48
48
  });
49
49
  test('multiline command without description truncates to first line', () => {
50
- expect(formatBashToolTitle({ command: 'echo hello\necho world\necho done' })).toMatchInlineSnapshot(`" _echo hello_"`);
50
+ expect(formatBashToolTitle({ command: 'echo hello\necho world\necho done' })).toMatchInlineSnapshot(`" _echo hello…_"`);
51
51
  });
52
52
  test('long single-line command is truncated with ellipsis', () => {
53
53
  const longCommand = 'a'.repeat(150);
@@ -68,16 +68,16 @@ describe('formatBashToolTitle', () => {
68
68
  expect(formatBashToolTitle({ command: '' })).toBe('');
69
69
  });
70
70
  test('leading blank line skipped, uses first meaningful line', () => {
71
- expect(formatBashToolTitle({ command: '\npnpm test\npnpm build' })).toMatchInlineSnapshot(`" _pnpm test_"`);
71
+ expect(formatBashToolTitle({ command: '\npnpm test\npnpm build' })).toMatchInlineSnapshot(`" _pnpm test…_"`);
72
72
  });
73
73
  test('whitespace-only first line skipped', () => {
74
- expect(formatBashToolTitle({ command: ' \npnpm test' })).toMatchInlineSnapshot(`" _pnpm test_"`);
74
+ expect(formatBashToolTitle({ command: ' \npnpm test' })).toMatchInlineSnapshot(`" _pnpm test…_"`);
75
75
  });
76
76
  test('no description field (new opencode) with multiline command', () => {
77
77
  // This is the exact scenario that was broken: opencode removed `description`
78
78
  // from the bash tool schema, so multiline commands rendered as just "┣ bash"
79
79
  const command = 'git diff HEAD~1 --stat && git log --oneline -5';
80
- expect(formatBashToolTitle({ command: command + '\n' + 'echo done' })).toMatchInlineSnapshot(`" _git diff HEAD\\~1 --stat && git log --oneline -5_"`);
80
+ expect(formatBashToolTitle({ command: command + '\n' + 'echo done' })).toMatchInlineSnapshot(`" _git diff HEAD\\~1 --stat && git log --oneline -5…_"`);
81
81
  });
82
82
  });
83
83
  describe('formatTodoList', () => {
@@ -99,6 +99,27 @@ export function createDeterministicMatchers() {
99
99
  partDelaysMs: [0, 0, 0, 3_000, 0],
100
100
  },
101
101
  };
102
+ const slowBusyMatcher = {
103
+ id: 'slow-busy-reply',
104
+ priority: 100,
105
+ when: {
106
+ latestUserTextIncludes: 'SLOW_BUSY_MARKER',
107
+ },
108
+ then: {
109
+ parts: [
110
+ { type: 'stream-start', warnings: [] },
111
+ { type: 'text-start', id: 'slow-busy' },
112
+ { type: 'text-delta', id: 'slow-busy', delta: 'slow-busy-reply' },
113
+ { type: 'text-end', id: 'slow-busy' },
114
+ {
115
+ type: 'finish',
116
+ finishReason: 'stop',
117
+ usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
118
+ },
119
+ ],
120
+ partDelaysMs: [0, 0, 0, 2_000, 0],
121
+ },
122
+ };
102
123
  const toolFollowupMatcher = {
103
124
  id: 'tool-followup',
104
125
  priority: 50,
@@ -704,6 +725,7 @@ export function createDeterministicMatchers() {
704
725
  };
705
726
  return [
706
727
  slowAbortMatcher,
728
+ slowBusyMatcher,
707
729
  typingRepulseMatcher,
708
730
  pluginTimeoutSleepMatcher,
709
731
  actionButtonClickFollowupMatcher,
@@ -0,0 +1,75 @@
1
+ // E2e tests for deleting queued Discord messages.
2
+ // Covers the MessageDelete path that removes pending local queue items before drain.
3
+ import { describe, test, expect } from 'vitest';
4
+ import { setupQueueAdvancedSuite, TEST_USER_ID, } from './queue-advanced-e2e-setup.js';
5
+ import { waitForBotMessageContaining, waitForFooterMessage, waitForThreadState, } from './test-utils.js';
6
+ const TEXT_CHANNEL_ID = '200000000000001071';
7
+ const e2eTest = describe;
8
+ e2eTest('queue delete message', () => {
9
+ const ctx = setupQueueAdvancedSuite({
10
+ channelId: TEXT_CHANNEL_ID,
11
+ channelName: 'queue-delete-message-e2e',
12
+ dirName: 'queue-delete-message-e2e',
13
+ username: 'queue-delete-tester',
14
+ });
15
+ test('deleting a queued Discord message removes it from queue', async () => {
16
+ await ctx.discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
17
+ content: 'SLOW_BUSY_MARKER Reply with exactly: delete-queue-setup',
18
+ });
19
+ const thread = await ctx.discord.channel(TEXT_CHANNEL_ID).waitForThread({
20
+ timeout: 4_000,
21
+ predicate: (t) => {
22
+ return t.name === 'SLOW_BUSY_MARKER Reply with exactly: delete-queue-setup';
23
+ },
24
+ });
25
+ const th = ctx.discord.thread(thread.id);
26
+ await th.waitForBotReply({ timeout: 4_000 });
27
+ const queuedMsg = await th.user(TEST_USER_ID).sendMessage({
28
+ content: 'Reply with exactly: deleted-queue. queue',
29
+ });
30
+ await waitForThreadState({
31
+ threadId: thread.id,
32
+ predicate: (state) => {
33
+ return state.queueItems.some((item) => {
34
+ return item.sourceMessageId === queuedMsg.id;
35
+ });
36
+ },
37
+ timeout: 4_000,
38
+ description: 'queue has item to delete',
39
+ });
40
+ await th.user(TEST_USER_ID).deleteMessage({ messageId: queuedMsg.id });
41
+ await waitForThreadState({
42
+ threadId: thread.id,
43
+ predicate: (state) => {
44
+ return !state.queueItems.some((item) => {
45
+ return item.sourceMessageId === queuedMsg.id;
46
+ });
47
+ },
48
+ timeout: 4_000,
49
+ description: 'queued item removed after Discord delete',
50
+ });
51
+ await waitForBotMessageContaining({
52
+ discord: ctx.discord,
53
+ threadId: thread.id,
54
+ text: 'removed message from queue',
55
+ timeout: 4_000,
56
+ });
57
+ await waitForFooterMessage({
58
+ discord: ctx.discord,
59
+ threadId: thread.id,
60
+ timeout: 8_000,
61
+ });
62
+ expect(await th.text()).toMatchInlineSnapshot(`
63
+ "--- from: user (queue-delete-tester)
64
+ SLOW_BUSY_MARKER Reply with exactly: delete-queue-setup
65
+ --- from: assistant (TestBot)
66
+ *using deterministic-provider/deterministic-v2*
67
+ Queued at position 1. Edit or delete your message to update the queue
68
+ ⬦ **queue-delete-tester** removed message from queue
69
+ ⬥ slow-busy-reply
70
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
71
+ `);
72
+ const finalText = await th.text();
73
+ expect(finalText).not.toContain('» **queue-delete-tester:** Reply with exactly: deleted-queue');
74
+ }, 12_000);
75
+ });
package/dist/schema.js CHANGED
@@ -23,6 +23,9 @@ export const thread_sessions = sqliteCore.sqliteTable('thread_sessions', {
23
23
  session_id: sqliteCore.text('session_id').notNull(),
24
24
  source: sqliteCore.text('source', { enum: ['kimaki', 'external_poll'] }).notNull().default('kimaki'),
25
25
  last_synced_name: sqliteCore.text('last_synced_name'),
26
+ // Parent OpenCode session that spawned this thread via kimaki send --parent-session.
27
+ // Survives bot restarts so child multi-turn system prompts keep the parent ID.
28
+ parent_session_id: sqliteCore.text('parent_session_id'),
26
29
  created_at: datetime('created_at').default(orm.sql `CURRENT_TIMESTAMP`),
27
30
  });
28
31
  export const session_events = sqliteCore.sqliteTable('session_events', {
@@ -17,6 +17,7 @@ export function initialThreadState() {
17
17
  sessionId: undefined,
18
18
  sessionUsername: undefined,
19
19
  sessionUserId: undefined,
20
+ parentSessionId: undefined,
20
21
  queueItems: [],
21
22
  sentPartIds: new Set(),
22
23
  };
@@ -77,6 +78,14 @@ export function setSessionUserId(threadId, userId) {
77
78
  return { ...t, sessionUserId: userId };
78
79
  });
79
80
  }
81
+ export function setParentSessionId(threadId, parentSessionId) {
82
+ updateThread(threadId, (t) => {
83
+ if (t.parentSessionId) {
84
+ return t;
85
+ }
86
+ return { ...t, parentSessionId };
87
+ });
88
+ }
80
89
  export function enqueueItem(threadId, item) {
81
90
  updateThread(threadId, (t) => ({
82
91
  ...t,
@@ -17,7 +17,7 @@ import { registerEventListener, unregisterEventListener, } from './global-event-
17
17
  import { createLogger, LogPrefix } from '../logger.js';
18
18
  import { sendThreadMessage, SILENT_MESSAGE_FLAGS, NOTIFY_MESSAGE_FLAGS, } from '../discord-utils.js';
19
19
  import { formatPart } from '../message-formatting.js';
20
- import { getChannelVerbosity, getPartMessageIds, getDb, setPartMessage, getThreadSession, setThreadSession, getThreadWorktreeOrWorkspace, setSessionAgent, clearSessionModel, getVariantCascade, setSessionStartSource, appendSessionEventsSinceLastTimestamp, getSessionEventSnapshot, } from '../database.js';
20
+ import { getChannelVerbosity, getPartMessageIds, getDb, setPartMessage, getThreadSession, setThreadSession, getThreadParentSessionId, setThreadParentSessionId, getThreadWorktreeOrWorkspace, setSessionAgent, clearSessionModel, getVariantCascade, setSessionStartSource, appendSessionEventsSinceLastTimestamp, getSessionEventSnapshot, } from '../database.js';
21
21
  import * as orm from 'drizzle-orm';
22
22
  import * as schema from '../schema.js';
23
23
  import { showPermissionButtons, addPermissionRequestToContext, arePatternsCoveredBy, pendingPermissionContexts, } from '../commands/permissions.js';
@@ -2274,6 +2274,7 @@ export class ThreadSessionRuntime {
2274
2274
  agents: availableAgents,
2275
2275
  username: this.state?.sessionUsername || input.username,
2276
2276
  userId: this.state?.sessionUserId || input.userId,
2277
+ parentSessionId: this.state?.parentSessionId || input.parentSessionId,
2277
2278
  }),
2278
2279
  ...(resolvedAgent ? { agent: resolvedAgent } : {}),
2279
2280
  ...(modelField ? { model: modelField } : {}),
@@ -2321,6 +2322,7 @@ export class ThreadSessionRuntime {
2321
2322
  permissions: input.permissions,
2322
2323
  permissionRules: input.permissionRules,
2323
2324
  injectionGuardPatterns: input.injectionGuardPatterns,
2325
+ parentSessionId: input.parentSessionId,
2324
2326
  sourceMessageId: input.sourceMessageId,
2325
2327
  sourceThreadId: input.sourceThreadId,
2326
2328
  repliedMessage: input.repliedMessage,
@@ -2363,6 +2365,9 @@ export class ThreadSessionRuntime {
2363
2365
  async enqueueIncoming(input) {
2364
2366
  threadState.setSessionUsername(this.threadId, input.username);
2365
2367
  threadState.setSessionUserId(this.threadId, input.userId);
2368
+ await this.ensureParentSessionId({
2369
+ parentSessionId: input.parentSessionId,
2370
+ });
2366
2371
  // When a preprocessor is provided, we must resolve it inside
2367
2372
  // dispatchAction before we know the final mode for routing.
2368
2373
  if (input.preprocess) {
@@ -2383,6 +2388,33 @@ export class ThreadSessionRuntime {
2383
2388
  }
2384
2389
  return this.submitViaOpencodeQueue(input);
2385
2390
  }
2391
+ /**
2392
+ * Resolve parent session ID for child system prompts.
2393
+ * Prefer in-memory state, then SQLite, then the ingress marker.
2394
+ * Persist once so multi-turn child sessions keep the parent after restart.
2395
+ */
2396
+ async ensureParentSessionId({ parentSessionId, }) {
2397
+ if (this.state?.parentSessionId) {
2398
+ return;
2399
+ }
2400
+ const storedParentSessionId = await getThreadParentSessionId(this.threadId);
2401
+ if (storedParentSessionId) {
2402
+ threadState.setParentSessionId(this.threadId, storedParentSessionId);
2403
+ return;
2404
+ }
2405
+ if (!parentSessionId) {
2406
+ return;
2407
+ }
2408
+ threadState.setParentSessionId(this.threadId, parentSessionId);
2409
+ // Row may not exist yet on first ingress before ensureSession creates it.
2410
+ // Best-effort write; ensureSession path also persists after setThreadSession.
2411
+ await setThreadParentSessionId({
2412
+ threadId: this.threadId,
2413
+ parentSessionId,
2414
+ }).catch((error) => {
2415
+ logger.warn(`[PARENT SESSION] Failed to persist parent session for thread ${this.threadId}: ${error instanceof Error ? error.message : String(error)}`);
2416
+ });
2417
+ }
2386
2418
  /**
2387
2419
  * Serialize the preprocess callback via a lightweight promise chain, then
2388
2420
  * route the resolved input through the normal enqueue paths.
@@ -2586,6 +2618,10 @@ export class ThreadSessionRuntime {
2586
2618
  return { found: true, removed: true };
2587
2619
  return { found: true, removed: false };
2588
2620
  }
2621
+ /** Remove a queued message identified by its Discord source message ID. */
2622
+ removeQueuedMessage(sourceMessageId) {
2623
+ return threadState.updateQueueItemBySourceMessageId(this.threadId, sourceMessageId, () => null);
2624
+ }
2589
2625
  // ── Queue Drain ─────────────────────────────────────────────
2590
2626
  /**
2591
2627
  * Check if we can dispatch the next queued message. If so, dequeue and
@@ -2964,6 +3000,7 @@ export class ThreadSessionRuntime {
2964
3000
  agents: earlyAvailableAgents,
2965
3001
  username: this.state?.sessionUsername || input.username,
2966
3002
  userId: this.state?.sessionUserId || input.userId,
3003
+ parentSessionId: this.state?.parentSessionId || input.parentSessionId,
2967
3004
  }),
2968
3005
  model: earlyModelParam,
2969
3006
  agent: earlyAgentPreference,
@@ -3103,6 +3140,17 @@ export class ThreadSessionRuntime {
3103
3140
  // Store session in DB and thread state
3104
3141
  await setThreadSession(this.thread.id, session.id);
3105
3142
  threadState.setSessionId(this.threadId, session.id);
3143
+ // Parent may have been set on ingress before the thread_sessions row
3144
+ // existed; write it now that the row is guaranteed.
3145
+ const parentSessionId = this.state?.parentSessionId;
3146
+ if (parentSessionId) {
3147
+ await setThreadParentSessionId({
3148
+ threadId: this.thread.id,
3149
+ parentSessionId,
3150
+ }).catch((error) => {
3151
+ logger.warn(`[PARENT SESSION] Failed to persist parent session for thread ${this.threadId}: ${error instanceof Error ? error.message : String(error)}`);
3152
+ });
3153
+ }
3106
3154
  await this.hydrateSessionEventsFromDatabase({ sessionId: session.id });
3107
3155
  // Store session start source for scheduled tasks
3108
3156
  if (createdNewSession && sessionStartScheduleKind) {