kimaki 0.30.0 → 0.31.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 (124) hide show
  1. package/dist/agent-model.e2e.test.js +27 -19
  2. package/dist/analytics.js +3 -1
  3. package/dist/btw-prefix-detection.js +9 -0
  4. package/dist/btw-prefix-detection.test.js +38 -1
  5. package/dist/cli-commands/send.js +5 -11
  6. package/dist/cli-runner.js +22 -10
  7. package/dist/cli-runner.test.js +34 -1
  8. package/dist/cli-send-thread.e2e.test.js +1 -1
  9. package/dist/commands/abort.js +16 -4
  10. package/dist/commands/btw.js +147 -39
  11. package/dist/commands/context-usage.js +47 -0
  12. package/dist/commands/context-usage.test.js +49 -0
  13. package/dist/commands/fork.js +32 -1
  14. package/dist/commands/model.js +69 -14
  15. package/dist/commands/new-worktree.js +5 -2
  16. package/dist/commands/queue.js +37 -30
  17. package/dist/commands/session.js +7 -2
  18. package/dist/db.test.js +17 -7
  19. package/dist/discord-bot.js +70 -47
  20. package/dist/discord-utils.js +15 -1
  21. package/dist/errors.js +10 -5
  22. package/dist/external-directory-permissions.e2e.test.js +4 -4
  23. package/dist/external-directory-project-deny.e2e.test.js +2 -2
  24. package/dist/gateway-proxy.e2e.test.js +5 -5
  25. package/dist/message-formatting.js +50 -15
  26. package/dist/message-formatting.test.js +12 -11
  27. package/dist/message-preprocessing.js +53 -75
  28. package/dist/question-abort-clears-dropdown.e2e.test.js +1 -1
  29. package/dist/question-abort-retry-text-before-dropdown.e2e.test.js +1 -1
  30. package/dist/queue-advanced-abort.e2e.test.js +62 -6
  31. package/dist/queue-advanced-action-buttons.e2e.test.js +3 -3
  32. package/dist/queue-advanced-footer.e2e.test.js +8 -8
  33. package/dist/queue-advanced-model-switch.e2e.test.js +2 -2
  34. package/dist/queue-advanced-permissions-typing.e2e.test.js +2 -2
  35. package/dist/queue-advanced-typing-interrupt.e2e.test.js +3 -3
  36. package/dist/queue-delete-message.e2e.test.js +146 -5
  37. package/dist/queue-drain-after-interactive-ui.e2e.test.js +2 -2
  38. package/dist/queue-interrupt-drain.e2e.test.js +24 -14
  39. package/dist/queue-question-select-drain.e2e.test.js +27 -27
  40. package/dist/runtime-lifecycle.e2e.test.js +6 -6
  41. package/dist/session-handler/agent-utils.js +16 -17
  42. package/dist/session-handler/event-stream-state.js +24 -16
  43. package/dist/session-handler/event-stream-state.test.js +281 -14
  44. package/dist/session-handler/thread-session-runtime.js +209 -128
  45. package/dist/sleep.e2e.test.js +6 -6
  46. package/dist/system-message.js +214 -392
  47. package/dist/system-message.test.js +204 -380
  48. package/dist/task-runner.js +3 -3
  49. package/dist/task-schedule.js +2 -2
  50. package/dist/test-utils.js +32 -6
  51. package/dist/thread-message-queue.e2e.test.js +132 -60
  52. package/dist/undo-redo.e2e.test.js +3 -3
  53. package/dist/voice-handler.js +2 -1
  54. package/dist/voice-message.e2e.test.js +22 -31
  55. package/dist/voice-routing-input.e2e.test.js +2 -2
  56. package/dist/voice-routing-question.e2e.test.js +59 -2
  57. package/dist/voice.js +306 -206
  58. package/dist/voice.test.js +161 -13
  59. package/dist/worktree-lifecycle.e2e.test.js +7 -7
  60. package/package.json +6 -9
  61. package/skills/critique/SKILL.md +4 -0
  62. package/skills/holocron/SKILL.md +0 -28
  63. package/skills/opensrc/SKILL.md +20 -13
  64. package/skills/playwriter/SKILL.md +1 -1
  65. package/src/agent-model.e2e.test.ts +28 -19
  66. package/src/analytics.ts +3 -1
  67. package/src/btw-prefix-detection.test.ts +39 -1
  68. package/src/btw-prefix-detection.ts +11 -0
  69. package/src/cli-commands/send.ts +11 -11
  70. package/src/cli-runner.test.ts +37 -0
  71. package/src/cli-runner.ts +26 -10
  72. package/src/cli-send-thread.e2e.test.ts +1 -1
  73. package/src/commands/abort.ts +20 -4
  74. package/src/commands/btw.ts +151 -41
  75. package/src/commands/context-usage.test.ts +57 -0
  76. package/src/commands/context-usage.ts +59 -0
  77. package/src/commands/fork.ts +34 -1
  78. package/src/commands/model.ts +81 -14
  79. package/src/commands/new-worktree.ts +5 -2
  80. package/src/commands/queue.ts +46 -30
  81. package/src/commands/session.ts +10 -2
  82. package/src/db.test.ts +18 -6
  83. package/src/discord-bot.ts +72 -56
  84. package/src/discord-utils.ts +17 -1
  85. package/src/errors.ts +12 -6
  86. package/src/external-directory-permissions.e2e.test.ts +4 -4
  87. package/src/external-directory-project-deny.e2e.test.ts +2 -2
  88. package/src/gateway-proxy.e2e.test.ts +5 -5
  89. package/src/message-formatting.test.ts +12 -11
  90. package/src/message-formatting.ts +52 -16
  91. package/src/message-preprocessing.ts +69 -91
  92. package/src/question-abort-clears-dropdown.e2e.test.ts +1 -1
  93. package/src/question-abort-retry-text-before-dropdown.e2e.test.ts +1 -1
  94. package/src/queue-advanced-abort.e2e.test.ts +73 -6
  95. package/src/queue-advanced-action-buttons.e2e.test.ts +3 -3
  96. package/src/queue-advanced-footer.e2e.test.ts +8 -8
  97. package/src/queue-advanced-model-switch.e2e.test.ts +2 -2
  98. package/src/queue-advanced-permissions-typing.e2e.test.ts +2 -2
  99. package/src/queue-advanced-typing-interrupt.e2e.test.ts +3 -3
  100. package/src/queue-delete-message.e2e.test.ts +164 -4
  101. package/src/queue-drain-after-interactive-ui.e2e.test.ts +2 -2
  102. package/src/queue-interrupt-drain.e2e.test.ts +24 -13
  103. package/src/queue-question-select-drain.e2e.test.ts +31 -28
  104. package/src/runtime-lifecycle.e2e.test.ts +6 -6
  105. package/src/session-handler/agent-utils.ts +25 -20
  106. package/src/session-handler/event-stream-state.test.ts +284 -13
  107. package/src/session-handler/event-stream-state.ts +37 -21
  108. package/src/session-handler/thread-runtime-state.ts +7 -0
  109. package/src/session-handler/thread-session-runtime.ts +255 -155
  110. package/src/sleep.e2e.test.ts +6 -6
  111. package/src/system-message.test.ts +216 -379
  112. package/src/system-message.ts +253 -389
  113. package/src/task-runner.ts +3 -3
  114. package/src/task-schedule.ts +2 -2
  115. package/src/test-utils.ts +55 -12
  116. package/src/thread-message-queue.e2e.test.ts +136 -63
  117. package/src/undo-redo.e2e.test.ts +3 -3
  118. package/src/voice-handler.ts +2 -1
  119. package/src/voice-message.e2e.test.ts +22 -33
  120. package/src/voice-routing-input.e2e.test.ts +2 -2
  121. package/src/voice-routing-question.e2e.test.ts +59 -2
  122. package/src/voice.test.ts +164 -14
  123. package/src/voice.ts +400 -279
  124. package/src/worktree-lifecycle.e2e.test.ts +7 -7
@@ -400,7 +400,7 @@ describe('agent model resolution', () => {
400
400
  Reply with exactly: agent-model-check
401
401
  --- from: assistant (TestBot)
402
402
  -# *using deterministic-provider/agent-model-v2 ⋅ test-agent*
403
- > ok
403
+ ok
404
404
  -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent***"
405
405
  `);
406
406
  expect(footerMessage).toBeDefined();
@@ -444,7 +444,7 @@ describe('agent model resolution', () => {
444
444
  Reply with exactly: system-context-check
445
445
  --- from: assistant (TestBot)
446
446
  -# *using deterministic-provider/agent-model-v2 ⋅ test-agent*
447
- > system-context-ok
447
+ system-context-ok
448
448
  -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent***"
449
449
  `);
450
450
  }, 15_000);
@@ -550,7 +550,7 @@ describe('agent model resolution', () => {
550
550
  Reply with exactly: channel-model-check
551
551
  --- from: assistant (TestBot)
552
552
  -# *using deterministic-provider/channel-model-v2*
553
- > ok
553
+ ok
554
554
  -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ channel-model-v2*"
555
555
  `);
556
556
  expect(footerMessage).toBeDefined();
@@ -594,7 +594,7 @@ describe('agent model resolution', () => {
594
594
  Reply with exactly: variant-check
595
595
  --- from: assistant (TestBot)
596
596
  -# *using deterministic-provider/channel-model-v2*
597
- > ok
597
+ ok
598
598
  -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ channel-model-v2*"
599
599
  `);
600
600
  expect(footer?.content).toContain(CHANNEL_MODEL);
@@ -655,13 +655,21 @@ describe('agent model resolution', () => {
655
655
  const forkedSessionModel = forkedSessionId
656
656
  ? await getSessionModel(forkedSessionId)
657
657
  : undefined;
658
+ // The immediate btw ack is edited in place with the fork link.
659
+ const sourceThreadText = (await discord.thread(sourceThread.id).text())
660
+ .replace(`<#${forkedThread.id}>`, '<#FORK_THREAD>');
661
+ expect(sourceThreadText.split('\n').slice(-3).join('\n')).toMatchInlineSnapshot(`
662
+ "Reply with exactly: btw-model-check. btw
663
+ --- from: assistant (TestBot)
664
+ Session forked! Continue in <#FORK_THREAD>"
665
+ `);
658
666
  const forkedThreadText = (await discord.thread(forkedThread.id).text())
659
667
  .replace(`<#${sourceThread.id}>`, '<#SOURCE_THREAD>');
660
668
  expect(forkedThreadText).toMatchInlineSnapshot(`
661
669
  "--- from: assistant (TestBot)
662
670
  Reusing context from <#SOURCE_THREAD> to answer prompt...
663
671
  Reply with exactly: btw-model-check
664
- > ok
672
+ ok
665
673
  -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ plan-model-v2*"
666
674
  `);
667
675
  expect(forkedSessionModel).toMatchInlineSnapshot(`
@@ -726,12 +734,12 @@ describe('agent model resolution', () => {
726
734
  Reply with exactly: first-thread-msg
727
735
  --- from: assistant (TestBot)
728
736
  -# *using deterministic-provider/agent-model-v2 ⋅ test-agent*
729
- > ok
737
+ ok
730
738
  -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent***
731
739
  --- from: user (agent-model-tester)
732
740
  Reply with exactly: second-thread-msg
733
741
  --- from: assistant (TestBot)
734
- > ok
742
+ ok
735
743
  -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent***"
736
744
  `);
737
745
  const secondMessages = await discord.thread(thread.id).getMessages();
@@ -780,7 +788,7 @@ describe('agent model resolution', () => {
780
788
  "--- from: assistant (TestBot)
781
789
  » **agent-model-tester** (plan): Reply with exactly: inline-plan-agent-msg
782
790
  -# *using deterministic-provider/plan-model-v2 ⋅ plan*
783
- > ok
791
+ ok
784
792
  -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ plan-model-v2 ⋅ **plan***"
785
793
  `);
786
794
  }, 20_000);
@@ -816,7 +824,7 @@ describe('agent model resolution', () => {
816
824
  "--- from: assistant (TestBot)
817
825
  » **agent-model-tester** (plan): Reply with exactly: inline-plan-agent-variant-msg
818
826
  -# *using deterministic-provider/plan-model-v2 ⋅ plan*
819
- > ok
827
+ ok
820
828
  -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ plan-model-v2 ⋅ **plan***"
821
829
  `);
822
830
  expect(sessionId ? await getSessionModel(sessionId) : undefined).toMatchInlineSnapshot(`
@@ -909,10 +917,10 @@ describe('agent model resolution', () => {
909
917
  Reply with exactly: inline-existing-first-msg
910
918
  --- from: assistant (TestBot)
911
919
  -# *using deterministic-provider/agent-model-v2 ⋅ test-agent*
912
- > ok
920
+ ok
913
921
  -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent***
914
922
  » **agent-model-tester** (plan): Reply with exactly: inline-existing-plan-msg
915
- > ok
923
+ ok
916
924
  -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ plan-model-v2 ⋅ **plan***"
917
925
  `);
918
926
  }, 20_000);
@@ -963,7 +971,7 @@ describe('agent model resolution', () => {
963
971
  Reply with exactly: switch-in-thread-msg
964
972
  --- from: assistant (TestBot)
965
973
  -# *using deterministic-provider/agent-model-v2 ⋅ test-agent*
966
- > ok
974
+ ok
967
975
  -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent***
968
976
  Switched to **plan** agent for this session (was **test-agent**)
969
977
  Model: *deterministic-provider/plan-model-v2* (agent "plan")
@@ -971,7 +979,7 @@ describe('agent model resolution', () => {
971
979
  --- from: user (agent-model-tester)
972
980
  Reply with exactly: after-switch-msg
973
981
  --- from: assistant (TestBot)
974
- > ok
982
+ ok
975
983
  -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ plan-model-v2 ⋅ **plan***"
976
984
  `);
977
985
  const secondFooter = [...(await discord.thread(thread.id).getMessages())]
@@ -1022,7 +1030,7 @@ describe('agent model resolution', () => {
1022
1030
  Reply with exactly: race-switch-first-msg
1023
1031
  --- from: assistant (TestBot)
1024
1032
  -# *using deterministic-provider/agent-model-v2 ⋅ test-agent*
1025
- > ok
1033
+ ok
1026
1034
  -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent***
1027
1035
  --- from: user (agent-model-tester)
1028
1036
  Reply with exactly: race-switch-follow-up
@@ -1030,7 +1038,7 @@ describe('agent model resolution', () => {
1030
1038
  Switched to **plan** agent for this session (was **test-agent**)
1031
1039
  Model: *deterministic-provider/plan-model-v2* (agent "plan")
1032
1040
  The agent will change on the next message.
1033
- > ok
1041
+ ok
1034
1042
  -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ plan-model-v2 ⋅ **plan***"
1035
1043
  `);
1036
1044
  const secondFooter = [...(await discord.thread(thread.id).getMessages())]
@@ -1072,7 +1080,7 @@ describe('agent model resolution', () => {
1072
1080
  Reply with exactly: race-channel-follow-up
1073
1081
  --- from: assistant (TestBot)
1074
1082
  -# *using deterministic-provider/plan-model-v2 ⋅ plan*
1075
- > ok
1083
+ ok
1076
1084
  -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ plan-model-v2 ⋅ **plan***"
1077
1085
  `);
1078
1086
  const footer = [...(await discord.thread(thread.id).getMessages())]
@@ -1120,7 +1128,7 @@ describe('agent model resolution', () => {
1120
1128
  Reply with exactly: refresh-agent-model-msg
1121
1129
  --- from: assistant (TestBot)
1122
1130
  -# *using deterministic-provider/plan-model-v2 ⋅ plan*
1123
- > ok
1131
+ ok
1124
1132
  -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ plan-model-v2 ⋅ **plan***
1125
1133
  Using **plan** agent for this session
1126
1134
  Model: *deterministic-provider/plan-model-v2* (agent "plan")
@@ -1162,7 +1170,7 @@ describe('agent model resolution', () => {
1162
1170
  Reply with exactly: channel-vs-agent-msg
1163
1171
  --- from: assistant (TestBot)
1164
1172
  -# *using deterministic-provider/agent-model-v2 ⋅ test-agent*
1165
- > ok
1173
+ ok
1166
1174
  -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent***
1167
1175
  Switched to **plan** agent for this session (was **test-agent**)
1168
1176
  Model: *deterministic-provider/plan-model-v2* (agent "plan")
@@ -1204,7 +1212,7 @@ describe('agent model resolution', () => {
1204
1212
  Reply with exactly: plain-agent-override-msg
1205
1213
  --- from: assistant (TestBot)
1206
1214
  -# *using deterministic-provider/channel-model-v2*
1207
- > ok
1215
+ ok
1208
1216
  -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ channel-model-v2*
1209
1217
  Switched to **plain** agent for this session
1210
1218
  Model: *deterministic-provider/channel-model-v2* (channel override)
package/dist/analytics.js CHANGED
@@ -1,4 +1,4 @@
1
- // Anonymous product analytics via Strada (OpenTelemetry).
1
+ // Anonymous product analytics via Strada (@strada.sh/sdk, no dependencies).
2
2
  // Tracks install-level usage only: no Discord IDs, paths, prompts, or secrets.
3
3
  // A random install id is stored in {dataDir}/install-id for DAU-style queries.
4
4
  //
@@ -158,6 +158,8 @@ export function initAnalytics() {
158
158
  environment,
159
159
  version: getKimakiVersion(),
160
160
  userId: installId,
161
+ // Kimaki owns crash handling in discord-bot.ts; the SDK must not exit the process.
162
+ captureUncaughtErrors: false,
161
163
  });
162
164
  initialized = true;
163
165
  }
@@ -6,10 +6,19 @@
6
6
  // - punctuation + btw: ". btw", "! btw", ". btw.", "!btw."
7
7
  // - btw as its own final line: "text\nbtw"
8
8
  // Non-matches: "btw fix this" (start only), "hello btw" (no punctuation)
9
+ import { extractQueueSuffix } from './message-formatting.js';
9
10
  const BTW_SUFFIX_RE = /(?:[.!?,;:])\s*btw\.?\s*$|\n\s*btw\.?\s*$/i;
11
+ const BTW_QUEUE_SUFFIX_RE = /(?:[.!?,;:])\s*btw\s+queue\.?\s*$|\n\s*btw\s+queue\.?\s*$/i;
10
12
  export function extractBtwSuffix(content) {
11
13
  if (!BTW_SUFFIX_RE.test(content)) {
12
14
  return { prompt: content, forceBtw: false };
13
15
  }
14
16
  return { prompt: content.replace(BTW_SUFFIX_RE, '').trimEnd(), forceBtw: true };
15
17
  }
18
+ export function extractBtwQueueSuffix(content) {
19
+ const queued = BTW_QUEUE_SUFFIX_RE.test(content)
20
+ ? { prompt: content.replace(/\s+queue\.?\s*$/i, ''), forceQueue: true }
21
+ : extractQueueSuffix(content);
22
+ const btw = extractBtwSuffix(queued.prompt);
23
+ return { prompt: btw.prompt, forceQueue: queued.forceQueue, forceBtw: btw.forceBtw };
24
+ }
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test } from 'vitest';
2
- import { extractBtwSuffix } from './btw-prefix-detection.js';
2
+ import { extractBtwSuffix, extractBtwQueueSuffix } from './btw-prefix-detection.js';
3
3
  describe('extractBtwSuffix', () => {
4
4
  test('matches after period', () => {
5
5
  expect(extractBtwSuffix('fix the bug. btw')).toMatchInlineSnapshot(`
@@ -91,3 +91,40 @@ describe('extractBtwSuffix', () => {
91
91
  `);
92
92
  });
93
93
  });
94
+ test('btw queue suffix keeps both actions in order', () => {
95
+ expect([
96
+ 'Explain this. btw queue',
97
+ 'Explain this. btw. queue',
98
+ 'Explain this\nbtw queue',
99
+ 'Explain this. btw',
100
+ 'Explain this. queue',
101
+ ].map(extractBtwQueueSuffix)).toMatchInlineSnapshot(`
102
+ [
103
+ {
104
+ "forceBtw": true,
105
+ "forceQueue": true,
106
+ "prompt": "Explain this",
107
+ },
108
+ {
109
+ "forceBtw": true,
110
+ "forceQueue": true,
111
+ "prompt": "Explain this",
112
+ },
113
+ {
114
+ "forceBtw": true,
115
+ "forceQueue": true,
116
+ "prompt": "Explain this",
117
+ },
118
+ {
119
+ "forceBtw": true,
120
+ "forceQueue": false,
121
+ "prompt": "Explain this",
122
+ },
123
+ {
124
+ "forceBtw": false,
125
+ "forceQueue": true,
126
+ "prompt": "Explain this",
127
+ },
128
+ ]
129
+ `);
130
+ });
@@ -6,7 +6,6 @@
6
6
  import { goke } from 'goke';
7
7
  import { z } from 'zod';
8
8
  import { note } from '@clack/prompts';
9
- import YAML from 'yaml';
10
9
  import * as errore from 'errore';
11
10
  import { Events, ActivityType, Routes } from 'discord.js';
12
11
  import path from 'node:path';
@@ -24,7 +23,7 @@ import { WORKTREE_PREFIX } from '../commands/merge-worktree.js';
24
23
  import { QUEUE_PREFIX } from '../message-formatting.js';
25
24
  import { buildOpencodeEventLogLine } from '../session-handler/opencode-session-event-log.js';
26
25
  import { createDiscordRest } from '../discord-urls.js';
27
- import { archiveThread, ensureThreadMember, uploadFilesToDiscord, stripMentions } from '../discord-utils.js';
26
+ import { archiveThread, buildThreadStartEmbeds, ensureThreadMember, uploadFilesToDiscord, stripMentions } from '../discord-utils.js';
28
27
  import { setDataDir, setProjectsDir, getDataDir, getProjectsDir } from '../config.js';
29
28
  import { execAsync, resolveSessionWorkingDirectory, isGitRepositoryRoot } from '../worktrees.js';
30
29
  import { upgrade, getCurrentVersion } from '../upgrade.js';
@@ -38,7 +37,7 @@ cli
38
37
  .alias('start-session') // backwards compatibility
39
38
  .option('-c, --channel <channelId>', 'Discord channel ID')
40
39
  .option('-d, --project <path>', 'Project directory (alternative to --channel)')
41
- .option('-p, --prompt <prompt>', 'Message content')
40
+ .option('-p, --prompt <prompt>', 'Message content. With --thread/--session, end with ". queue" to wait for the current run instead of interrupting it, ". btw" to fork a side session now, or ". btw queue" to fork after the current run')
42
41
  .option('-n, --name [name]', 'Thread name (optional, defaults to prompt preview)')
43
42
  .option('-a, --app-id [appId]', 'Bot application ID (required if no local database)')
44
43
  .option('--notify-only', 'Create notification thread without starting AI session')
@@ -56,7 +55,7 @@ cli
56
55
  .option('--send-at <schedule>', 'Schedule send for future (UTC ISO date/time ending in Z, or cron expression)')
57
56
  .option('--pre-run <command>', 'Run a shell command in the project before starting a scheduled task')
58
57
  .option('--allow-concurrency', 'Allow concurrent sessions from the same scheduled task')
59
- .option('--thread <threadId>', 'Post prompt to an existing thread')
58
+ .option('--thread <threadId>', 'Post prompt to an existing thread. Interrupts a busy session unless the prompt ends with ". queue"')
60
59
  .option('--session <sessionId>', 'Post prompt to thread mapped to an existing session')
61
60
  .option('--parent-session <sessionId>', 'Parent OpenCode session ID for newly created child sessions')
62
61
  .option('--wait', 'Wait for session to complete, then print session text to stdout')
@@ -405,12 +404,7 @@ cli
405
404
  ...(options.injectionGuard?.length ? { injectionGuardPatterns: options.injectionGuard } : {}),
406
405
  ...(options.parentSession && { parentSessionId: options.parentSession }),
407
406
  };
408
- const promptEmbed = [
409
- {
410
- color: 0x2b2d31,
411
- footer: { text: YAML.stringify(threadPromptMarker) },
412
- },
413
- ];
407
+ const promptEmbed = await buildThreadStartEmbeds(threadPromptMarker);
414
408
  // Prefix the prompt so it's clear who sent it (matches /queue format).
415
409
  // Use a newline between prefix and prompt so leading /command
416
410
  // detection can find the command on its own line.
@@ -584,7 +578,7 @@ cli
584
578
  ...(options.parentSession && { parentSessionId: options.parentSession }),
585
579
  };
586
580
  const autoStartEmbed = embedMarker
587
- ? [{ color: 0x2b2d31, footer: { text: YAML.stringify(embedMarker) } }]
581
+ ? await buildThreadStartEmbeds(embedMarker)
588
582
  : undefined;
589
583
  const starterMessage = await sendDiscordMessageWithOptionalAttachment({
590
584
  channelId,
@@ -9,6 +9,7 @@ import * as dbSchema from './schema.js';
9
9
  import { selectResolvedCommand } from './opencode-command.js';
10
10
  import { Events, ChannelType, Routes, AttachmentBuilder, } from 'discord.js';
11
11
  import { discordApiUrl, getDiscordRestApiUrl, getGatewayProxyRestBaseUrl, getInternetReachableBaseUrl } from './discord-urls.js';
12
+ import { extractBtwQueueSuffix } from './btw-prefix-detection.js';
12
13
  import crypto from 'node:crypto';
13
14
  import path from 'node:path';
14
15
  import fs from 'node:fs';
@@ -160,6 +161,18 @@ function wrapPromptAttachmentText(prompt) {
160
161
  })
161
162
  .join('\n');
162
163
  }
164
+ /**
165
+ * Visible message content + prompt.md text for prompts over Discord's limit.
166
+ * The bot reads queue/btw suffixes only from message content, so the suffix
167
+ * moves from the file to the visible message and the send still queues or forks.
168
+ */
169
+ export function buildLongPromptMessage(prompt) {
170
+ const { prompt: fileText, forceQueue, forceBtw } = extractBtwQueueSuffix(prompt);
171
+ const preview = fileText.slice(0, 100).replace(/\n/g, ' ');
172
+ const summary = `Prompt attached as file (${fileText.length} chars)\n\n> ${preview}…`;
173
+ const suffix = [forceBtw && 'btw', forceQueue && 'queue'].filter(Boolean).join(' ');
174
+ return { content: suffix ? `${summary}\n\n${suffix}` : summary, fileText };
175
+ }
163
176
  function promptAttachmentBlob(text) {
164
177
  return new Blob([new Uint8Array(Buffer.from(text, 'utf8'))], {
165
178
  type: 'text/markdown',
@@ -182,18 +195,18 @@ export async function sendDiscordMessageWithOptionalAttachment({ channelId, prom
182
195
  // When prompt exceeds Discord's limit, attach it as prompt.md alongside
183
196
  // user files so nothing is silently lost. Build prompt.md from memory so
184
197
  // parallel kimaki send processes never share or unlink a temp path.
185
- const isLongPrompt = prompt.length > discordMaxLength;
186
- const content = isLongPrompt
187
- ? `Prompt attached as file (${prompt.length} chars)\n\n> ${prompt.slice(0, 100).replace(/\n/g, ' ')}...`
188
- : prompt;
198
+ const longPrompt = prompt.length > discordMaxLength
199
+ ? buildLongPromptMessage(prompt)
200
+ : undefined;
201
+ const content = longPrompt ? longPrompt.content : prompt;
189
202
  const allFiles = files.map((file) => ({
190
203
  data: new Uint8Array(fs.readFileSync(file)),
191
204
  filename: path.basename(file),
192
205
  mimeType: mime.getType(file) || 'application/octet-stream',
193
206
  }));
194
- if (isLongPrompt) {
207
+ if (longPrompt) {
195
208
  allFiles.push({
196
- data: new Uint8Array(Buffer.from(prompt, 'utf8')),
209
+ data: new Uint8Array(Buffer.from(longPrompt.fileText, 'utf8')),
197
210
  filename: 'prompt.md',
198
211
  mimeType: 'text/markdown',
199
212
  });
@@ -259,17 +272,16 @@ export async function sendDiscordMessageWithOptionalAttachment({ channelId, prom
259
272
  }
260
273
  return firstMessage;
261
274
  }
262
- const preview = prompt.slice(0, 100).replace(/\n/g, ' ');
263
- const summaryContent = `Prompt attached as file (${prompt.length} chars)\n\n> ${preview}...`;
275
+ const longPrompt = buildLongPromptMessage(prompt);
264
276
  // In-memory Blob only — no temp file. Parallel send must never share a path.
265
277
  const formData = new FormData();
266
278
  formData.append('payload_json', JSON.stringify({
267
- content: summaryContent,
279
+ content: longPrompt.content,
268
280
  attachments: [{ id: 0, filename: 'prompt.md' }],
269
281
  embeds,
270
282
  allowed_mentions: { parse: store.getState().allowedMentions },
271
283
  }));
272
- formData.append('files[0]', promptAttachmentBlob(wrapPromptAttachmentText(prompt)), 'prompt.md');
284
+ formData.append('files[0]', promptAttachmentBlob(wrapPromptAttachmentText(longPrompt.fileText)), 'prompt.md');
273
285
  const starterMessageResponse = await fetch(discordApiUrl(`/channels/${channelId}/messages`), {
274
286
  method: 'POST',
275
287
  headers: {
@@ -1,6 +1,39 @@
1
1
  import { describe, expect, test } from 'vitest';
2
2
  import { REST } from 'discord.js';
3
- import { getOpenUrlCommand, isTransientNetworkError, resolveDiscordUserOption, } from './cli-runner.js';
3
+ import { buildLongPromptMessage, getOpenUrlCommand, isTransientNetworkError, resolveDiscordUserOption, } from './cli-runner.js';
4
+ import { extractBtwQueueSuffix } from './btw-prefix-detection.js';
5
+ // Regression: a long `. btw` prompt lost its suffix and interrupted the source session.
6
+ test('long prompts keep queue and btw suffixes in the visible message', () => {
7
+ const body = 'x'.repeat(2100);
8
+ expect(['queue', 'btw', 'btw queue', ''].map((suffix) => {
9
+ const { content, fileText } = buildLongPromptMessage(suffix ? `${body}. ${suffix}` : body);
10
+ const parsed = extractBtwQueueSuffix(content);
11
+ return { fileText: fileText === body, forceQueue: parsed.forceQueue, forceBtw: parsed.forceBtw };
12
+ })).toMatchInlineSnapshot(`
13
+ [
14
+ {
15
+ "fileText": true,
16
+ "forceBtw": false,
17
+ "forceQueue": true,
18
+ },
19
+ {
20
+ "fileText": true,
21
+ "forceBtw": true,
22
+ "forceQueue": false,
23
+ },
24
+ {
25
+ "fileText": true,
26
+ "forceBtw": true,
27
+ "forceQueue": true,
28
+ },
29
+ {
30
+ "fileText": true,
31
+ "forceBtw": false,
32
+ "forceQueue": false,
33
+ },
34
+ ]
35
+ `);
36
+ });
4
37
  test('raw Discord user ID does not invent a username', async () => {
5
38
  const user = await resolveDiscordUserOption({
6
39
  user: '535922349652836367',
@@ -320,7 +320,7 @@ describe('kimaki send --channel thread creation', () => {
320
320
  thread empty content seed
321
321
  --- from: assistant (TestBot)
322
322
  -# *using deterministic-provider/deterministic-v2*
323
- > caught-by-model
323
+ caught-by-model
324
324
  -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
325
325
  --- from: user (cli-send-tester)
326
326
  --- from: assistant (TestBot)
@@ -1,6 +1,6 @@
1
1
  // /abort command - Abort the current OpenCode request in this thread.
2
2
  import { ChannelType, MessageFlags, } from 'discord.js';
3
- import { cancelSessionSleepForThread, getThreadSession } from '../database.js';
3
+ import { cancelSessionSleepForThread, deleteThreadQueueItems, getThreadSession, } from '../database.js';
4
4
  import { getOpencodeClient, initializeOpencodeForDirectory } from '../opencode.js';
5
5
  import { resolveWorkingDirectory, SILENT_MESSAGE_FLAGS, } from '../discord-utils.js';
6
6
  import { getRuntime } from '../session-handler/thread-session-runtime.js';
@@ -45,12 +45,21 @@ export async function handleAbortCommand({ command, }) {
45
45
  // through enqueueIncoming, so cancel any pending sleep here too. Otherwise the
46
46
  // wake would still fire later and restart a session the user just stopped.
47
47
  await cancelSessionSleepForThread({ threadId: channel.id });
48
- // abortActiveRun delegates to session.abort(), run settlement stays event-driven.
48
+ // abortActiveRun delegates to session.abort() and clears the /queue, so
49
+ // queued messages are not sent after abort or restored after a restart.
49
50
  const runtime = getRuntime(channel.id);
51
+ let clearedCount = 0;
50
52
  if (runtime) {
51
- runtime.abortActiveRun('user-requested');
53
+ const cleared = await runtime.abortActiveRun('user-requested');
54
+ clearedCount = cleared.length;
52
55
  }
53
56
  else {
57
+ const deleteResult = await deleteThreadQueueItems(channel.id).catch((error) => {
58
+ return new Error('Failed to clear persisted queue', { cause: error });
59
+ });
60
+ if (deleteResult instanceof Error) {
61
+ logger.error(`[ABORT] ${deleteResult.message}:`, deleteResult.cause);
62
+ }
54
63
  // No runtime but session exists — fall back to direct API abort
55
64
  const serverResult = await initializeOpencodeForDirectory(projectDirectory);
56
65
  if (serverResult instanceof Error) {
@@ -67,6 +76,9 @@ export async function handleAbortCommand({ command, }) {
67
76
  logger.error('[ABORT] API abort failed:', error);
68
77
  }
69
78
  }
70
- await command.editReply('Request **aborted**');
79
+ const queueNote = clearedCount > 0
80
+ ? `, cleared ${clearedCount} queued message${clearedCount > 1 ? 's' : ''}`
81
+ : '';
82
+ await command.editReply(`Request **aborted**${queueNote}`);
71
83
  logger.log(`Session ${sessionId} aborted by user`);
72
84
  }