kimaki 0.30.0 → 0.30.1

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 (46) hide show
  1. package/dist/cli-commands/send.js +3 -9
  2. package/dist/commands/abort.js +16 -4
  3. package/dist/commands/queue.js +7 -7
  4. package/dist/discord-bot.js +5 -5
  5. package/dist/discord-utils.js +15 -1
  6. package/dist/external-directory-permissions.e2e.test.js +2 -2
  7. package/dist/external-directory-project-deny.e2e.test.js +1 -1
  8. package/dist/message-formatting.js +16 -6
  9. package/dist/message-formatting.test.js +10 -9
  10. package/dist/question-abort-clears-dropdown.e2e.test.js +1 -1
  11. package/dist/question-abort-retry-text-before-dropdown.e2e.test.js +1 -1
  12. package/dist/queue-advanced-abort.e2e.test.js +61 -5
  13. package/dist/queue-advanced-footer.e2e.test.js +1 -1
  14. package/dist/queue-advanced-permissions-typing.e2e.test.js +1 -1
  15. package/dist/session-handler/event-stream-state.js +15 -15
  16. package/dist/session-handler/event-stream-state.test.js +152 -11
  17. package/dist/session-handler/thread-session-runtime.js +41 -28
  18. package/dist/sleep.e2e.test.js +2 -2
  19. package/dist/task-runner.js +3 -3
  20. package/dist/task-schedule.js +2 -2
  21. package/dist/thread-message-queue.e2e.test.js +1 -1
  22. package/dist/undo-redo.e2e.test.js +1 -1
  23. package/package.json +6 -6
  24. package/skills/holocron/SKILL.md +0 -28
  25. package/src/cli-commands/send.ts +3 -9
  26. package/src/commands/abort.ts +20 -4
  27. package/src/commands/queue.ts +7 -7
  28. package/src/discord-bot.ts +5 -5
  29. package/src/discord-utils.ts +17 -1
  30. package/src/external-directory-permissions.e2e.test.ts +2 -2
  31. package/src/external-directory-project-deny.e2e.test.ts +1 -1
  32. package/src/message-formatting.test.ts +10 -9
  33. package/src/message-formatting.ts +15 -6
  34. package/src/question-abort-clears-dropdown.e2e.test.ts +1 -1
  35. package/src/question-abort-retry-text-before-dropdown.e2e.test.ts +1 -1
  36. package/src/queue-advanced-abort.e2e.test.ts +72 -5
  37. package/src/queue-advanced-footer.e2e.test.ts +1 -1
  38. package/src/queue-advanced-permissions-typing.e2e.test.ts +1 -1
  39. package/src/session-handler/event-stream-state.test.ts +155 -11
  40. package/src/session-handler/event-stream-state.ts +17 -20
  41. package/src/session-handler/thread-session-runtime.ts +44 -32
  42. package/src/sleep.e2e.test.ts +2 -2
  43. package/src/task-runner.ts +3 -3
  44. package/src/task-schedule.ts +2 -2
  45. package/src/thread-message-queue.e2e.test.ts +1 -1
  46. package/src/undo-redo.e2e.test.ts +1 -1
@@ -1542,12 +1542,107 @@ describe('getPromptCacheClear', () => {
1542
1542
  sessionId,
1543
1543
  currentMessageId: 'msg_asst_2',
1544
1544
  })).toEqual({
1545
- previousCacheRead: 20000,
1545
+ expectedCacheRead: 20000,
1546
1546
  currentCacheRead: 0,
1547
1547
  previousMessageId: 'msg_asst_1',
1548
1548
  currentMessageId: 'msg_asst_2',
1549
1549
  });
1550
1550
  });
1551
+ test('detects a miss on the second turn when the first turn only wrote cache', () => {
1552
+ const sessionId = 'ses_cache_second_turn';
1553
+ const events = [
1554
+ userEvent({ sessionId, messageId: 'msg_user_1', created: 1 }),
1555
+ assistantEvent({
1556
+ sessionId,
1557
+ messageId: 'msg_asst_1',
1558
+ parentID: 'msg_user_1',
1559
+ created: 2,
1560
+ tokens: {
1561
+ input: 100,
1562
+ output: 10,
1563
+ reasoning: 0,
1564
+ cache: { read: 0, write: 20000 },
1565
+ },
1566
+ }),
1567
+ userEvent({ sessionId, messageId: 'msg_user_2', created: 3 }),
1568
+ assistantEvent({
1569
+ sessionId,
1570
+ messageId: 'msg_asst_2',
1571
+ parentID: 'msg_user_2',
1572
+ created: 4,
1573
+ tokens: {
1574
+ input: 100,
1575
+ output: 12,
1576
+ reasoning: 0,
1577
+ cache: { read: 0, write: 20200 },
1578
+ },
1579
+ }),
1580
+ ];
1581
+ expect(getPromptCacheClear({
1582
+ events,
1583
+ sessionId,
1584
+ currentMessageId: 'msg_asst_2',
1585
+ })).toEqual({
1586
+ expectedCacheRead: 20000,
1587
+ currentCacheRead: 0,
1588
+ previousMessageId: 'msg_asst_1',
1589
+ currentMessageId: 'msg_asst_2',
1590
+ });
1591
+ });
1592
+ test('detects consecutive misses, not only every other turn', () => {
1593
+ const sessionId = 'ses_cache_consecutive';
1594
+ const events = [
1595
+ userEvent({ sessionId, messageId: 'msg_user_1', created: 1 }),
1596
+ assistantEvent({
1597
+ sessionId,
1598
+ messageId: 'msg_asst_1',
1599
+ parentID: 'msg_user_1',
1600
+ created: 2,
1601
+ tokens: {
1602
+ input: 100,
1603
+ output: 10,
1604
+ reasoning: 0,
1605
+ cache: { read: 20000, write: 100 },
1606
+ },
1607
+ }),
1608
+ userEvent({ sessionId, messageId: 'msg_user_2', created: 3 }),
1609
+ assistantEvent({
1610
+ sessionId,
1611
+ messageId: 'msg_asst_2',
1612
+ parentID: 'msg_user_2',
1613
+ created: 4,
1614
+ tokens: {
1615
+ input: 100,
1616
+ output: 10,
1617
+ reasoning: 0,
1618
+ cache: { read: 0, write: 20300 },
1619
+ },
1620
+ }),
1621
+ userEvent({ sessionId, messageId: 'msg_user_3', created: 5 }),
1622
+ assistantEvent({
1623
+ sessionId,
1624
+ messageId: 'msg_asst_3',
1625
+ parentID: 'msg_user_3',
1626
+ created: 6,
1627
+ tokens: {
1628
+ input: 100,
1629
+ output: 10,
1630
+ reasoning: 0,
1631
+ cache: { read: 0, write: 20500 },
1632
+ },
1633
+ }),
1634
+ ];
1635
+ expect(getPromptCacheClear({
1636
+ events,
1637
+ sessionId,
1638
+ currentMessageId: 'msg_asst_3',
1639
+ })).toEqual({
1640
+ expectedCacheRead: 20300,
1641
+ currentCacheRead: 0,
1642
+ previousMessageId: 'msg_asst_2',
1643
+ currentMessageId: 'msg_asst_3',
1644
+ });
1645
+ });
1551
1646
  test('detects a cache drop when prompt size stays similar', () => {
1552
1647
  const sessionId = 'ses_cache_drop';
1553
1648
  const events = [
@@ -1583,7 +1678,7 @@ describe('getPromptCacheClear', () => {
1583
1678
  sessionId,
1584
1679
  currentMessageId: 'msg_asst_2',
1585
1680
  })).toEqual({
1586
- previousCacheRead: 40000,
1681
+ expectedCacheRead: 40000,
1587
1682
  currentCacheRead: 8000,
1588
1683
  previousMessageId: 'msg_asst_1',
1589
1684
  currentMessageId: 'msg_asst_2',
@@ -1679,8 +1774,8 @@ describe('getPromptCacheClear', () => {
1679
1774
  currentMessageId: 'msg_asst_2',
1680
1775
  })).toBeUndefined();
1681
1776
  });
1682
- test('ignores a smaller prompt after context reduction', () => {
1683
- const sessionId = 'ses_cache_smaller';
1777
+ test('ignores a smaller prompt that is still read from cache after a revert', () => {
1778
+ const sessionId = 'ses_cache_revert';
1684
1779
  const events = [
1685
1780
  userEvent({ sessionId, messageId: 'msg_user_1', created: 1 }),
1686
1781
  assistantEvent({
@@ -1702,10 +1797,10 @@ describe('getPromptCacheClear', () => {
1702
1797
  parentID: 'msg_user_2',
1703
1798
  created: 4,
1704
1799
  tokens: {
1705
- input: 5000,
1800
+ input: 200,
1706
1801
  output: 10,
1707
1802
  reasoning: 0,
1708
- cache: { read: 8000, write: 5000 },
1803
+ cache: { read: 8000, write: 0 },
1709
1804
  },
1710
1805
  }),
1711
1806
  ];
@@ -1715,6 +1810,47 @@ describe('getPromptCacheClear', () => {
1715
1810
  currentMessageId: 'msg_asst_2',
1716
1811
  })).toBeUndefined();
1717
1812
  });
1813
+ test('detects a miss when the prompt shrank without pruning', () => {
1814
+ const sessionId = 'ses_cache_shrunk_miss';
1815
+ const events = [
1816
+ userEvent({ sessionId, messageId: 'msg_user_1', created: 1 }),
1817
+ assistantEvent({
1818
+ sessionId,
1819
+ messageId: 'msg_asst_1',
1820
+ parentID: 'msg_user_1',
1821
+ created: 2,
1822
+ tokens: {
1823
+ input: 100,
1824
+ output: 10,
1825
+ reasoning: 0,
1826
+ cache: { read: 187000, write: 0 },
1827
+ },
1828
+ }),
1829
+ userEvent({ sessionId, messageId: 'msg_user_2', created: 3 }),
1830
+ assistantEvent({
1831
+ sessionId,
1832
+ messageId: 'msg_asst_2',
1833
+ parentID: 'msg_user_2',
1834
+ created: 4,
1835
+ tokens: {
1836
+ input: 182000,
1837
+ output: 10,
1838
+ reasoning: 0,
1839
+ cache: { read: 0, write: 0 },
1840
+ },
1841
+ }),
1842
+ ];
1843
+ expect(getPromptCacheClear({
1844
+ events,
1845
+ sessionId,
1846
+ currentMessageId: 'msg_asst_2',
1847
+ })).toEqual({
1848
+ expectedCacheRead: 182000,
1849
+ currentCacheRead: 0,
1850
+ previousMessageId: 'msg_asst_1',
1851
+ currentMessageId: 'msg_asst_2',
1852
+ });
1853
+ });
1718
1854
  test('compares against the previous user turn, not a same-turn tool step', () => {
1719
1855
  const sessionId = 'ses_cache_same_turn';
1720
1856
  const events = [
@@ -1762,7 +1898,7 @@ describe('getPromptCacheClear', () => {
1762
1898
  sessionId,
1763
1899
  currentMessageId: 'msg_asst_2b',
1764
1900
  })).toEqual({
1765
- previousCacheRead: 20000,
1901
+ expectedCacheRead: 20000,
1766
1902
  currentCacheRead: 0,
1767
1903
  previousMessageId: 'msg_asst_1',
1768
1904
  currentMessageId: 'msg_asst_2b',
@@ -1804,7 +1940,7 @@ describe('getPromptCacheClear', () => {
1804
1940
  currentMessageId: 'msg_asst_2',
1805
1941
  })).toBeUndefined();
1806
1942
  });
1807
- test('does not compare across an aborted assistant', () => {
1943
+ test('compares across an aborted assistant with the last good reply', () => {
1808
1944
  const sessionId = 'ses_cache_abort_bridge';
1809
1945
  const events = [
1810
1946
  userEvent({ sessionId, messageId: 'msg_user_1', created: 1 }),
@@ -1852,7 +1988,12 @@ describe('getPromptCacheClear', () => {
1852
1988
  events,
1853
1989
  sessionId,
1854
1990
  currentMessageId: 'msg_asst_3',
1855
- })).toBeUndefined();
1991
+ })).toEqual({
1992
+ expectedCacheRead: 40000,
1993
+ currentCacheRead: 0,
1994
+ previousMessageId: 'msg_asst_1',
1995
+ currentMessageId: 'msg_asst_3',
1996
+ });
1856
1997
  });
1857
1998
  test('does not compare across a compaction summary', () => {
1858
1999
  const sessionId = 'ses_cache_compaction';
@@ -1993,13 +2134,13 @@ describe('getPromptCacheClear', () => {
1993
2134
  });
1994
2135
  test('formats a compact cache-miss notice', () => {
1995
2136
  expect(formatPromptCacheClearMessage({
1996
- previousCacheRead: 20000,
2137
+ expectedCacheRead: 20000,
1997
2138
  currentCacheRead: 0,
1998
2139
  previousMessageId: 'msg_asst_1',
1999
2140
  currentMessageId: 'msg_asst_2',
2000
2141
  })).toBe('prompt cache missed (20k → 0)');
2001
2142
  expect(formatPromptCacheClearMessage({
2002
- previousCacheRead: 20000,
2143
+ expectedCacheRead: 20000,
2003
2144
  currentCacheRead: 0,
2004
2145
  previousMessageId: 'msg_asst_1',
2005
2146
  currentMessageId: 'msg_asst_2',
@@ -17,7 +17,7 @@ import { isAbortError } from '../utils.js';
17
17
  import { registerEventListener, unregisterEventListener, waitForGlobalEventListener, } from './global-event-listener.js';
18
18
  import { createLogger, LogPrefix } from '../logger.js';
19
19
  import { sendThreadMessage, sendSessionPartMessage, SILENT_MESSAGE_FLAGS, NOTIFY_MESSAGE_FLAGS, raceDiscordRename, DISCORD_THREAD_RENAME_TIMEOUT_MS, resolveThreadFooterMentionUserId, resolveWorkingDirectory, } from '../discord-utils.js';
20
- import { asDiscordQuote, asSystemLine, formatPart, formatTaskToolTitle, planAssistantTurnFlush, QUEUE_PREFIX, sessionPartContent, sessionPartKind, shouldLeadWithBlankLine, shouldQuoteIntermediateTextPart, STATUS_PREFIX, WORKTREE_PREFIX, } from '../message-formatting.js';
20
+ import { asDiscordQuote, asSubtext, formatPart, formatTaskToolTitle, planAssistantTurnFlush, QUEUE_PREFIX, sessionPartContent, sessionPartKind, shouldLeadWithBlankLine, shouldQuoteIntermediateTextPart, STATUS_PREFIX, WORKTREE_PREFIX, } from '../message-formatting.js';
21
21
  import { getChannelVerbosity, getPartMessageIds, getDb, setPartMessage, getThreadSession, setThreadSession, getThreadParentSessionId, setThreadParentSessionId, getThreadWorktreeOrWorkspace, setSessionAgent, setSessionModel, clearSessionModel, getVariantCascade, setSessionStartSource, getSessionStartSource, getScheduledTask, completeScheduledTaskRunsForSession, failScheduledTaskRunsForSession, startScheduledTaskRunSession, appendSessionEventsSinceLastTimestamp, getSessionEventSnapshot, cancelSessionSleepForThread, insertThreadQueueItem, listAllThreadQueueItems, deleteThreadQueueItem, deleteThreadQueueItems, updateThreadQueueItemPayload, } from '../database.js';
22
22
  import * as orm from 'drizzle-orm';
23
23
  import * as schema from '../schema.js';
@@ -1653,14 +1653,15 @@ export class ThreadSessionRuntime {
1653
1653
  upToIndex: this.eventBuffer.length - 2,
1654
1654
  });
1655
1655
  const completedAt = msg.time.completed;
1656
- if (!wasAlreadyCompleted
1657
- && typeof completedAt === 'number'
1658
- && isAssistantMessageNaturalCompletion({ message: msg })) {
1659
- await this.handleNaturalAssistantCompletion({
1660
- completedMessageId: msg.id,
1661
- completedAt,
1662
- });
1663
- return;
1656
+ if (!wasAlreadyCompleted && typeof completedAt === 'number') {
1657
+ if (isAssistantMessageNaturalCompletion({ message: msg })) {
1658
+ await this.handleNaturalAssistantCompletion({
1659
+ completedMessageId: msg.id,
1660
+ completedAt,
1661
+ });
1662
+ return;
1663
+ }
1664
+ await this.maybeNotifyPromptCacheClear({ sessionId, messageId: msg.id });
1664
1665
  }
1665
1666
  // Context usage notice.
1666
1667
  // Skip the final assistant update for a run: by the time the last
@@ -1693,7 +1694,7 @@ export class ThreadSessionRuntime {
1693
1694
  return;
1694
1695
  }
1695
1696
  this.lastDisplayedContextPercentage = thresholdCrossed;
1696
- const chunk = `${STATUS_PREFIX}context usage ${currentPercentage}%`;
1697
+ const chunk = asSubtext(`context usage ${currentPercentage}%`);
1697
1698
  const sendResult = await this.thread.send({ content: chunk, flags: SILENT_MESSAGE_FLAGS })
1698
1699
  .catch((e) => new DiscordOperationError({ operation: 'sendMessage', cause: e }));
1699
1700
  if (sendResult instanceof Error) {
@@ -1877,7 +1878,7 @@ export class ThreadSessionRuntime {
1877
1878
  }
1878
1879
  return ` (${pct.toFixed(1)}%)`;
1879
1880
  })();
1880
- const chunk = `${STATUS_PREFIX}${part.tool} returned ${formattedTokens} tokens${percentageSuffix}`;
1881
+ const chunk = asSubtext(`${STATUS_PREFIX}${part.tool} returned ${formattedTokens} tokens${percentageSuffix}`);
1881
1882
  const largeOutputResult = await this.thread.send({
1882
1883
  content: chunk,
1883
1884
  flags: SILENT_MESSAGE_FLAGS,
@@ -2381,7 +2382,7 @@ export class ThreadSessionRuntime {
2381
2382
  const secs = remainingSec % 60;
2382
2383
  return secs > 0 ? `${mins}m ${secs}s` : `${mins}m`;
2383
2384
  })();
2384
- const chunk = `${STATUS_PREFIX}${message} - retrying in ${duration} (attempt #${attempt})`;
2385
+ const chunk = asSubtext(`${message} - retrying in ${duration} (attempt #${attempt})`);
2385
2386
  const retryResult = await this.thread.send({ content: chunk, flags: SILENT_MESSAGE_FLAGS })
2386
2387
  .catch((e) => new DiscordOperationError({ operation: 'sendMessage', cause: e }));
2387
2388
  if (retryResult instanceof Error) {
@@ -2447,7 +2448,7 @@ export class ThreadSessionRuntime {
2447
2448
  const titlePrefix = properties.title
2448
2449
  ? `${properties.title.trim()}: `
2449
2450
  : '';
2450
- const chunk = `${STATUS_PREFIX}${properties.variant}: ${titlePrefix}${toastMessage}`;
2451
+ const chunk = asSubtext(`${properties.variant}: ${titlePrefix}${toastMessage}`);
2451
2452
  const toastResult = await this.thread.send({ content: chunk, flags: SILENT_MESSAGE_FLAGS })
2452
2453
  .catch((e) => new DiscordOperationError({ operation: 'sendMessage', cause: e }));
2453
2454
  if (toastResult instanceof Error) {
@@ -3022,17 +3023,20 @@ export class ThreadSessionRuntime {
3022
3023
  apiAbortPromise,
3023
3024
  };
3024
3025
  }
3025
- abortActiveRun(reason) {
3026
+ /** Explicit user abort: stops the run and drops all queued messages. Returns the removed items. */
3027
+ async abortActiveRun(reason) {
3026
3028
  const outcome = this.abortActiveRunInternal({
3027
3029
  reason,
3028
3030
  });
3029
3031
  if (outcome.apiAbortPromise) {
3030
3032
  void outcome.apiAbortPromise;
3031
3033
  }
3032
- // Drain local queued messages after explicit abort.
3033
- void this.dispatchAction(() => {
3034
- return this.tryDrainQueue({ showIndicator: true });
3034
+ // Enqueued synchronously, so it runs before the abort's session.idle can drain the queue.
3035
+ let cleared = [];
3036
+ await this.dispatchAction(async () => {
3037
+ cleared = await this.clearQueueNow();
3035
3038
  });
3039
+ return cleared;
3036
3040
  }
3037
3041
  async abortActiveRunAndWait({ reason, timeoutMs = 2_000, }) {
3038
3042
  const state = this.state;
@@ -3073,17 +3077,21 @@ export class ThreadSessionRuntime {
3073
3077
  async clearQueue() {
3074
3078
  let cleared = [];
3075
3079
  await this.dispatchAction(async () => {
3076
- const persistResult = await deleteThreadQueueItems(this.threadId).catch((error) => {
3077
- return new Error('Failed to clear persisted queue', { cause: error });
3078
- });
3079
- if (persistResult instanceof Error) {
3080
- logger.error(`[QUEUE] Failed to clear persisted queue for thread ${this.threadId}: ${persistResult.message}`);
3081
- return;
3082
- }
3083
- cleared = threadState.clearQueueItems(this.threadId);
3080
+ cleared = await this.clearQueueNow();
3084
3081
  });
3085
3082
  return cleared;
3086
3083
  }
3084
+ // Must run inside dispatchAction.
3085
+ async clearQueueNow() {
3086
+ const persistResult = await deleteThreadQueueItems(this.threadId).catch((error) => {
3087
+ return new Error('Failed to clear persisted queue', { cause: error });
3088
+ });
3089
+ if (persistResult instanceof Error) {
3090
+ logger.error(`[QUEUE] Failed to clear persisted queue for thread ${this.threadId}: ${persistResult.message}`);
3091
+ return [];
3092
+ }
3093
+ return threadState.clearQueueItems(this.threadId);
3094
+ }
3087
3095
  /** Remove a queued message by its 1-based position. */
3088
3096
  async removeQueuePosition(position) {
3089
3097
  let removed;
@@ -3912,7 +3920,7 @@ export class ThreadSessionRuntime {
3912
3920
  const agentLabel = agent && agent.toLowerCase() !== 'build'
3913
3921
  ? ` ⋅ ${agent}`
3914
3922
  : '';
3915
- const result = await sendThreadMessage(this.thread, asSystemLine(`*using ${modelLabel}${agentLabel}*`), { flags: SILENT_MESSAGE_FLAGS }).catch((e) => new DiscordOperationError({ operation: 'sendMessage', cause: e }));
3923
+ const result = await sendThreadMessage(this.thread, asSubtext(`*using ${modelLabel}${agentLabel}*`), { flags: SILENT_MESSAGE_FLAGS }).catch((e) => new DiscordOperationError({ operation: 'sendMessage', cause: e }));
3916
3924
  if (result instanceof Error) {
3917
3925
  logger.warn(`[SESSION INFO] Failed to send model info: ${result.message}`);
3918
3926
  }
@@ -4041,7 +4049,7 @@ export class ThreadSessionRuntime {
4041
4049
  })
4042
4050
  : undefined;
4043
4051
  const mention = mentionUserId ? ` <@${mentionUserId}>` : '';
4044
- const footerText = asSystemLine(`*${projectInfo}${sessionDuration}${contextInfo}${modelInfo}${agentInfo}*${mention}`);
4052
+ const footerText = asSubtext(`*${projectInfo}${sessionDuration}${contextInfo}${modelInfo}${agentInfo}*${mention}`);
4045
4053
  this.stopTyping();
4046
4054
  await sendThreadMessage(this.thread, footerText, {
4047
4055
  flags: shouldNotifyUser ? NOTIFY_MESSAGE_FLAGS : SILENT_MESSAGE_FLAGS,
@@ -4057,6 +4065,11 @@ export class ThreadSessionRuntime {
4057
4065
  this.lastSentPartKind = undefined;
4058
4066
  }
4059
4067
  async maybeNotifyPromptCacheClear({ sessionId, messageId, }) {
4068
+ // Only the first reply after a user prompt can show a cold cache. Later steps re-read the turn's own writes.
4069
+ const [firstAssistantId] = this.getAssistantMessageIdsForCurrentTurn({ sessionId });
4070
+ if (firstAssistantId !== messageId) {
4071
+ return;
4072
+ }
4060
4073
  const cacheClear = getPromptCacheClear({
4061
4074
  events: this.eventBuffer,
4062
4075
  sessionId,
@@ -4070,7 +4083,7 @@ export class ThreadSessionRuntime {
4070
4083
  previousMessageId: cacheClear.previousMessageId,
4071
4084
  currentMessageId: cacheClear.currentMessageId,
4072
4085
  });
4073
- const chunk = `${STATUS_PREFIX}${formatPromptCacheClearMessage(cacheClear, systemDiff)}`;
4086
+ const chunk = asSubtext(formatPromptCacheClearMessage(cacheClear, systemDiff));
4074
4087
  const sendResult = await this.thread.send({ content: chunk, flags: SILENT_MESSAGE_FLAGS })
4075
4088
  .catch((e) => new DiscordOperationError({ operation: 'sendMessage', cause: e }));
4076
4089
  if (sendResult instanceof Error) {
@@ -313,7 +313,7 @@ describe('kimaki_sleep', () => {
313
313
  -# *using deterministic-provider/deterministic-v2*
314
314
  > waiting for the deploy
315
315
 
316
- ┣ kimaki_sleep until 2030-01-01T09:00:00Z _waiting for the deploy_
316
+ -# ┣ kimaki_sleep until 2030-01-01T09:00:00Z _waiting for the deploy_
317
317
 
318
318
  > sleep-started
319
319
  -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
@@ -412,7 +412,7 @@ describe('kimaki_sleep', () => {
412
412
  SLEEP_CANCEL_MARKER wait for something
413
413
  --- from: assistant (TestBot)
414
414
  -# *using deterministic-provider/deterministic-v2*
415
- ┣ kimaki_sleep for 2h _cancel me_
415
+ -# ┣ kimaki_sleep for 2h _cancel me_
416
416
 
417
417
  > cancel-sleep-started
418
418
  -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
@@ -1,7 +1,7 @@
1
1
  // Scheduled task runner for due `send --send-at` jobs and session sleep wakes.
2
2
  import { DiscordAPIError, Routes } from 'discord.js';
3
3
  import { createDiscordRest } from './discord-urls.js';
4
- import { ensureThreadMember } from './discord-utils.js';
4
+ import { buildThreadStartEmbeds, ensureThreadMember } from './discord-utils.js';
5
5
  import YAML from 'yaml';
6
6
  import { claimScheduledTaskRunning, createScheduledTaskRun, failScheduledTaskRun, finishScheduledTaskRun, getDuePlannedScheduledTasks, getScheduledTask, getActiveScheduledTaskRuns, markScheduledTaskCronRescheduled, markScheduledTaskCronRetry, markScheduledTaskFailed, deleteScheduledTask, recoverStaleRunningScheduledTasks, releaseScheduledTaskClaim, setScheduledTaskRunThread, claimSessionSleepAttempt, getDueSessionSleeps, getThreadIdBySessionId, markSessionSleepFailed, } from './database.js';
7
7
  import { execAsync } from './exec-async.js';
@@ -44,7 +44,7 @@ async function executeThreadScheduledTask({ rest, task, payload, prompt, runId,
44
44
  : {}),
45
45
  ...(payload.parentSessionId ? { parentSessionId: payload.parentSessionId } : {}),
46
46
  };
47
- const embed = [{ color: 0x2b2d31, footer: { text: YAML.stringify(marker) } }];
47
+ const embed = await buildThreadStartEmbeds(marker);
48
48
  // Newline between prefix and prompt so leading /command detection can
49
49
  // find the command on its own line.
50
50
  const prefixedPrompt = `${QUEUE_PREFIX}**kimaki-cli:**\n${prompt}`;
@@ -188,7 +188,7 @@ async function executeChannelScheduledTask({ rest, task, payload, prompt, runId,
188
188
  ...(payload.parentSessionId ? { parentSessionId: payload.parentSessionId } : {}),
189
189
  };
190
190
  const embeds = marker
191
- ? [{ color: 0x2b2d31, footer: { text: YAML.stringify(marker) } }]
191
+ ? await buildThreadStartEmbeds(marker)
192
192
  : undefined;
193
193
  const starterResult = await rest
194
194
  .post(Routes.channelMessages(payload.channelId), {
@@ -1,7 +1,7 @@
1
1
  // Scheduled task parsing for `send --send-at`, plus kimaki_sleep wake/result text.
2
2
  import { CronExpressionParser } from 'cron-parser';
3
3
  import * as errore from 'errore';
4
- import { STATUS_PREFIX } from './message-formatting.js';
4
+ import { asSubtext } from './message-formatting.js';
5
5
  const UTC_SEND_AT_DATE_REGEX = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d{1,3})?)?Z$/;
6
6
  export function getLocalTimeZone() {
7
7
  const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
@@ -88,7 +88,7 @@ export function formatSessionSleepWakeAt(wakeAt) {
88
88
  export function formatSessionSleepWakePrompt({ wakeAt, reason, }) {
89
89
  const until = formatSessionSleepWakeAt(wakeAt);
90
90
  const reasonLine = reason?.trim() ? `\nReason: ${reason.trim()}` : '';
91
- return `${STATUS_PREFIX}Woke after sleeping until ${until}${reasonLine}\nContinue the work you were waiting for.`;
91
+ return `${asSubtext(`Woke after sleeping until ${until}`)}${reasonLine}\nContinue the work you were waiting for.`;
92
92
  }
93
93
  // Tool result after persist. Keep "Sleeping until" first so e2e matchers still hit.
94
94
  // Do not say "woken" or "Woke after": models treat that as the wake turn.
@@ -583,7 +583,7 @@ e2eTest('thread message queue ordering', () => {
583
583
  > running create file
584
584
  > creating marker
585
585
 
586
- ┣ bash _Create marker file for e2e test_
586
+ -# ┣ bash _Create marker file for e2e test_
587
587
 
588
588
  > file created
589
589
  -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
@@ -156,7 +156,7 @@ e2eTest('/undo sets revert state and cleans up on next prompt', () => {
156
156
  -# *using deterministic-provider/deterministic-v2*
157
157
  > creating undo file
158
158
 
159
- ┣ bash _Create undo marker file_
159
+ -# ┣ bash _Create undo marker file_
160
160
 
161
161
  > undo file created
162
162
  -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "kimaki",
3
3
  "module": "index.ts",
4
4
  "type": "module",
5
- "version": "0.30.0",
5
+ "version": "0.30.1",
6
6
  "repository": "https://github.com/remorses/kimaki",
7
7
  "bin": "bin.js",
8
8
  "exports": {
@@ -33,8 +33,8 @@
33
33
  "undici": "^8.0.2",
34
34
  "discord-digital-twin": "^0.1.2",
35
35
  "opencode-cached-provider": "^0.0.1",
36
- "db": "^0.0.0",
37
- "opencode-deterministic-provider": "^0.0.1"
36
+ "opencode-deterministic-provider": "^0.0.1",
37
+ "db": "^0.0.0"
38
38
  },
39
39
  "dependencies": {
40
40
  "@ai-sdk/google": "^3.0.53",
@@ -71,11 +71,11 @@
71
71
  "zod": "^4.3.6",
72
72
  "zustand": "^5.0.11",
73
73
  "@subrouter/cli": "^0.6.1",
74
- "@subrouter/opencode": "^0.5.1",
75
74
  "errore": "^0.14.1",
76
- "libsqlproxy": "^0.1.0",
77
75
  "opencode-injection-guard": "^0.2.1",
78
- "traforo": "^0.7.2"
76
+ "libsqlproxy": "^0.1.0",
77
+ "traforo": "^0.7.2",
78
+ "@subrouter/opencode": "^0.5.1"
79
79
  },
80
80
  "optionalDependencies": {
81
81
  "@snazzah/davey": "^0.1.10",
@@ -746,34 +746,6 @@ npx -y "@holocron.so/cli" whoami
746
746
  If `whoami` succeeds and shows user, orgs, and projects, skip login. Only run
747
747
  `npx -y "@holocron.so/cli" login` if `whoami` fails.
748
748
 
749
- ## www hostname
750
-
751
- For every new site on a custom domain, **301 `www` to the apex in one hop**.
752
- Never leave `www` on another host (Vercel, Pages, a second worker). Never use
753
- 307. Chain `http://www` → `https://www` → apex is two hops; collapse it.
754
-
755
- On Cloudflare Workers:
756
-
757
- 1. Add `www.example.com` as a `custom_domain` on the **same** worker as the apex.
758
- 2. Redirect `www` to `https://example.com` + path + query with status **301**.
759
-
760
- ```ts
761
- .use(({ request }, next) => {
762
- const url = new URL(request.url)
763
- if (!url.hostname.startsWith('www.')) return next()
764
- url.hostname = url.hostname.slice('www.'.length)
765
- url.protocol = 'https:'
766
- throw redirect(url.toString(), { status: 301 })
767
- })
768
- ```
769
-
770
- ```jsonc
771
- "routes": [
772
- { "pattern": "example.com", "custom_domain": true },
773
- { "pattern": "www.example.com", "custom_domain": true }
774
- ]
775
- ```
776
-
777
749
  ## Deploy
778
750
 
779
751
  When the user asks to "deploy the site", they may mean deploying with their own
@@ -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 type { OpencodeClient, Event as OpenCodeEvent } from '@opencode-ai/sdk/v2'
12
11
  import { Events, ActivityType, type PresenceStatusData, type Guild, Routes } from 'discord.js'
@@ -30,7 +29,7 @@ import { QUEUE_PREFIX } from '../message-formatting.js'
30
29
  import type { ThreadStartMarker } from '../system-message.js'
31
30
  import { buildOpencodeEventLogLine } from '../session-handler/opencode-session-event-log.js'
32
31
  import { createDiscordRest } from '../discord-urls.js'
33
- import { archiveThread, ensureThreadMember, uploadFilesToDiscord, stripMentions } from '../discord-utils.js'
32
+ import { archiveThread, buildThreadStartEmbeds, ensureThreadMember, uploadFilesToDiscord, stripMentions } from '../discord-utils.js'
34
33
  import { setDataDir, setProjectsDir, getDataDir, getProjectsDir } from '../config.js'
35
34
  import { execAsync, resolveSessionWorkingDirectory, isGitRepositoryRoot } from '../worktrees.js'
36
35
  import { upgrade, getCurrentVersion } from '../upgrade.js'
@@ -565,12 +564,7 @@ cli
565
564
  ...(options.injectionGuard?.length ? { injectionGuardPatterns: options.injectionGuard } : {}),
566
565
  ...(options.parentSession && { parentSessionId: options.parentSession }),
567
566
  }
568
- const promptEmbed = [
569
- {
570
- color: 0x2b2d31,
571
- footer: { text: YAML.stringify(threadPromptMarker) },
572
- },
573
- ]
567
+ const promptEmbed = await buildThreadStartEmbeds(threadPromptMarker)
574
568
 
575
569
  // Prefix the prompt so it's clear who sent it (matches /queue format).
576
570
  // Use a newline between prefix and prompt so leading /command
@@ -779,7 +773,7 @@ cli
779
773
  ...(options.parentSession && { parentSessionId: options.parentSession }),
780
774
  }
781
775
  const autoStartEmbed = embedMarker
782
- ? [{ color: 0x2b2d31, footer: { text: YAML.stringify(embedMarker) } }]
776
+ ? await buildThreadStartEmbeds(embedMarker)
783
777
  : undefined
784
778
 
785
779
  const starterMessage = await sendDiscordMessageWithOptionalAttachment({
@@ -7,7 +7,11 @@ import {
7
7
  type ThreadChannel,
8
8
  } from 'discord.js'
9
9
  import type { CommandContext } from './types.js'
10
- import { cancelSessionSleepForThread, getThreadSession } from '../database.js'
10
+ import {
11
+ cancelSessionSleepForThread,
12
+ deleteThreadQueueItems,
13
+ getThreadSession,
14
+ } from '../database.js'
11
15
  import { getOpencodeClient, initializeOpencodeForDirectory } from '../opencode.js'
12
16
  import {
13
17
  resolveWorkingDirectory,
@@ -71,11 +75,20 @@ export async function handleAbortCommand({
71
75
  // wake would still fire later and restart a session the user just stopped.
72
76
  await cancelSessionSleepForThread({ threadId: channel.id })
73
77
 
74
- // abortActiveRun delegates to session.abort(), run settlement stays event-driven.
78
+ // abortActiveRun delegates to session.abort() and clears the /queue, so
79
+ // queued messages are not sent after abort or restored after a restart.
75
80
  const runtime = getRuntime(channel.id)
81
+ let clearedCount = 0
76
82
  if (runtime) {
77
- runtime.abortActiveRun('user-requested')
83
+ const cleared = await runtime.abortActiveRun('user-requested')
84
+ clearedCount = cleared.length
78
85
  } else {
86
+ const deleteResult = await deleteThreadQueueItems(channel.id).catch((error) => {
87
+ return new Error('Failed to clear persisted queue', { cause: error })
88
+ })
89
+ if (deleteResult instanceof Error) {
90
+ logger.error(`[ABORT] ${deleteResult.message}:`, deleteResult.cause)
91
+ }
79
92
  // No runtime but session exists — fall back to direct API abort
80
93
  const serverResult = await initializeOpencodeForDirectory(projectDirectory)
81
94
  if (serverResult instanceof Error) {
@@ -92,6 +105,9 @@ export async function handleAbortCommand({
92
105
  }
93
106
  }
94
107
 
95
- await command.editReply('Request **aborted**')
108
+ const queueNote = clearedCount > 0
109
+ ? `, cleared ${clearedCount} queued message${clearedCount > 1 ? 's' : ''}`
110
+ : ''
111
+ await command.editReply(`Request **aborted**${queueNote}`)
96
112
  logger.log(`Session ${sessionId} aborted by user`)
97
113
  }