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
@@ -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, 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';
@@ -27,14 +27,14 @@ import { showActionButtons, waitForQueuedActionButtonsRequest, pendingActionButt
27
27
  import { pendingFileUploadContexts, cancelPendingFileUpload, } from '../commands/file-upload.js';
28
28
  import { getCurrentModelInfo, ensureSessionPreferencesSnapshot, } from '../commands/model.js';
29
29
  import { displayedModelLabel, resolveDisplayedModelName, validateModelId, } from './model-utils.js';
30
- import { getOpencodePromptContext, getOpencodeSystemMessage, writeSessionSystemPrompt, } from '../system-message.js';
30
+ import { getOpencodePromptContext, getOpencodeSystemMessage, isSystemPromptForSession, resolveSessionSystemPrompt, systemPromptHasParentSession, } from '../system-message.js';
31
31
  import { getDataDir } from '../config.js';
32
32
  import { countSystemPromptDiffLines } from '../cache-rewrite.js';
33
33
  import { store } from '../store.js';
34
34
  import { trackEvent, } from '../analytics.js';
35
35
  import { resolveValidatedAgentPreference } from './agent-utils.js';
36
36
  import { appendOpencodeSessionEventLog, getOpencodeEventSessionId, isOpencodeSessionEventLogEnabled, } from './opencode-session-event-log.js';
37
- import { doesLatestUserTurnHaveNaturalCompletion, didLatestUserTurnUseSleepTool, didQuestionQueueHandoffSinceLatestQuestionAsked, deriveLatestUnansweredQuestion, getAssistantMessageIdsForLatestUserTurn, getCurrentTurnStartTime, isSessionBusy, getLatestRunInfo, getPromptCacheClear, formatPromptCacheClearMessage, getIdleTokenUsageDelta, getDerivedSubtaskIndex, getDerivedSubtaskAgentType, getTokenUsageSessionIdsForIdle, isDerivedChildSession, getLatestAssistantMessageIdForLatestUserTurn, getAssistantMessageKind, hasAssistantMessageCompletedBefore, isAssistantMessageInLatestUserTurn, isAssistantMessageNaturalCompletion, shouldBufferSessionEvent, shouldRetainSessionEvent, trimEventBuffer, } from './event-stream-state.js';
37
+ import { doesLatestUserTurnHaveNaturalCompletion, didLatestUserTurnUseSleepTool, didQuestionQueueHandoffSinceLatestQuestionAsked, deriveLatestUnansweredQuestion, getAssistantMessageIdsForLatestUserTurn, getCurrentTurnStartTime, isSessionBusy, getLatestRunInfo, getPromptCacheClear, formatPromptCacheClearMessage, getIdleTokenUsageDelta, getDerivedSubtaskLabel, getTokenUsageSessionIdsForIdle, isDerivedChildSession, getLatestAssistantMessageIdForLatestUserTurn, getAssistantMessageKind, hasAssistantMessageCompletedBefore, isAssistantMessageInLatestUserTurn, isAssistantMessageNaturalCompletion, shouldBufferSessionEvent, shouldRetainSessionEvent, trimEventBuffer, } from './event-stream-state.js';
38
38
  // Track multiple pending permissions per thread (keyed by permission ID).
39
39
  // OpenCode handles blocking/sequencing — we just need to track all pending
40
40
  // permissions to avoid duplicates and properly clean up on reply/teardown.
@@ -789,17 +789,13 @@ export class ThreadSessionRuntime {
789
789
  })) {
790
790
  return undefined;
791
791
  }
792
- const subtaskIndex = getDerivedSubtaskIndex({
792
+ const label = getDerivedSubtaskLabel({
793
793
  events: this.eventBuffer,
794
794
  mainSessionId,
795
795
  candidateSessionId,
796
796
  });
797
- const agentType = getDerivedSubtaskAgentType({
798
- events: this.eventBuffer,
799
- mainSessionId,
800
- candidateSessionId,
801
- });
802
- const label = `${agentType || 'task'}-${subtaskIndex || 1}`;
797
+ if (!label)
798
+ return undefined;
803
799
  const assistantMessageId = this.getLatestAssistantMessageIdForCurrentTurn({
804
800
  sessionId: candidateSessionId,
805
801
  });
@@ -1005,6 +1001,15 @@ export class ThreadSessionRuntime {
1005
1001
  });
1006
1002
  this.persistEventBufferDebounced.trigger();
1007
1003
  }
1004
+ seedForkPromptCacheBaseline(message) {
1005
+ if (message.sessionID !== this.state?.sessionId || this.eventBuffer.length > 0)
1006
+ return;
1007
+ this.appendEventToBuffer({
1008
+ id: `fork-cache-baseline-${message.id}`,
1009
+ type: 'message.updated',
1010
+ properties: { sessionID: message.sessionID, info: message },
1011
+ });
1012
+ }
1008
1013
  // Queue-dispatch lifecycle markers are synthetic buffer-only events.
1009
1014
  // They are not fed into handleEvent(), so they do not emit Discord messages;
1010
1015
  // they only stabilize event-derived busy/idle gating for local queue drains.
@@ -1422,13 +1427,10 @@ export class ThreadSessionRuntime {
1422
1427
  }
1423
1428
  async unquoteFinalTextPart() {
1424
1429
  const parts = this.getCurrentTurnParts();
1425
- const finalPart = planAssistantTurnFlush({
1426
- parts,
1427
- mode: 'final',
1428
- }).sendParts.at(-1);
1429
- if (!finalPart || finalPart.quoteText || finalPart.part.type !== 'text')
1430
+ const finalPart = parts.findLast((part) => part.type === 'text' || part.type === 'tool');
1431
+ if (!finalPart || finalPart.type !== 'text')
1430
1432
  return;
1431
- const last = finalPart.part;
1433
+ const last = finalPart;
1432
1434
  const db = await getDb();
1433
1435
  const row = await db.query.part_messages.findFirst({
1434
1436
  where: { part_id: last.id },
@@ -1653,14 +1655,15 @@ export class ThreadSessionRuntime {
1653
1655
  upToIndex: this.eventBuffer.length - 2,
1654
1656
  });
1655
1657
  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;
1658
+ if (!wasAlreadyCompleted && typeof completedAt === 'number') {
1659
+ if (isAssistantMessageNaturalCompletion({ message: msg })) {
1660
+ await this.handleNaturalAssistantCompletion({
1661
+ completedMessageId: msg.id,
1662
+ completedAt,
1663
+ });
1664
+ return;
1665
+ }
1666
+ await this.maybeNotifyPromptCacheClear({ sessionId, messageId: msg.id });
1664
1667
  }
1665
1668
  // Context usage notice.
1666
1669
  // Skip the final assistant update for a run: by the time the last
@@ -1693,7 +1696,7 @@ export class ThreadSessionRuntime {
1693
1696
  return;
1694
1697
  }
1695
1698
  this.lastDisplayedContextPercentage = thresholdCrossed;
1696
- const chunk = `${STATUS_PREFIX}context usage ${currentPercentage}%`;
1699
+ const chunk = asSubtext(`context usage ${currentPercentage}%`);
1697
1700
  const sendResult = await this.thread.send({ content: chunk, flags: SILENT_MESSAGE_FLAGS })
1698
1701
  .catch((e) => new DiscordOperationError({ operation: 'sendMessage', cause: e }));
1699
1702
  if (sendResult instanceof Error) {
@@ -1877,7 +1880,7 @@ export class ThreadSessionRuntime {
1877
1880
  }
1878
1881
  return ` (${pct.toFixed(1)}%)`;
1879
1882
  })();
1880
- const chunk = `${STATUS_PREFIX}${part.tool} returned ${formattedTokens} tokens${percentageSuffix}`;
1883
+ const chunk = asSubtext(`${STATUS_PREFIX}${part.tool} returned ${formattedTokens} tokens${percentageSuffix}`);
1881
1884
  const largeOutputResult = await this.thread.send({
1882
1885
  content: chunk,
1883
1886
  flags: SILENT_MESSAGE_FLAGS,
@@ -2335,16 +2338,14 @@ export class ThreadSessionRuntime {
2335
2338
  logger.log(`[QUESTION QUEUE HANDOFF] Session changed before queue handoff for thread ${this.threadId}`);
2336
2339
  return;
2337
2340
  }
2341
+ // Only a prompt can answer a pending question; a fork must wait for idle.
2342
+ if (this.state?.queueItems[0]?.queuedAction === 'btw')
2343
+ return;
2338
2344
  const next = threadState.dequeueItem(this.threadId);
2339
2345
  if (!next) {
2340
2346
  return;
2341
2347
  }
2342
- const displayText = next.command
2343
- ? `/${next.command.name}`
2344
- : `${next.prompt.slice(0, 150)}${next.prompt.length > 150 ? '...' : ''}`;
2345
- if (displayText.trim()) {
2346
- await sendThreadMessage(this.thread, `${QUEUE_PREFIX}**${next.username}:** ${displayText}`);
2347
- }
2348
+ await this.sendQueueDrainIndicator(next);
2348
2349
  this.markQuestionQueueHandoffStarted(sessionId);
2349
2350
  await this.submitViaOpencodeQueue(next);
2350
2351
  }
@@ -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) {
@@ -2674,6 +2675,16 @@ export class ThreadSessionRuntime {
2674
2675
  }
2675
2676
  return fetched.topic?.trim() || undefined;
2676
2677
  })();
2678
+ const system = await this.resolveTurnSystemPrompt({
2679
+ sessionId: session.id,
2680
+ channelTopic,
2681
+ agents: availableAgents,
2682
+ input,
2683
+ });
2684
+ if (system instanceof Error) {
2685
+ await cleanupOnError(`✗ Failed to prepare system prompt: ${system.message}`);
2686
+ return;
2687
+ }
2677
2688
  const worktreeChanged = this.consumeWorktreePromptChange(worktree);
2678
2689
  const syntheticContext = getOpencodePromptContext({
2679
2690
  sessionId: session.id,
@@ -2687,6 +2698,8 @@ export class ThreadSessionRuntime {
2687
2698
  worktree,
2688
2699
  currentAgent: resolvedAgent,
2689
2700
  worktreeChanged,
2701
+ systemPromptFromSourceSession: !isSystemPromptForSession({ system, sessionId: session.id }),
2702
+ parentSessionId: this.getParentSessionIdMissingFromSystem({ system, input }),
2690
2703
  });
2691
2704
  const parts = [
2692
2705
  { type: 'text', text: promptWithImagePaths },
@@ -2697,18 +2710,7 @@ export class ThreadSessionRuntime {
2697
2710
  sessionID: session.id,
2698
2711
  directory: this.sdkDirectory,
2699
2712
  parts,
2700
- system: getOpencodeSystemMessage({
2701
- sessionId: session.id,
2702
- channelId,
2703
- guildId: this.thread.guildId,
2704
- threadId: this.thread.id,
2705
- channelTopic,
2706
- agents: availableAgents,
2707
- username: this.state?.sessionUsername || input.username,
2708
- userId: this.state?.sessionUserId || input.userId,
2709
- parentSessionId: this.state?.parentSessionId || input.parentSessionId,
2710
- scheduledTask: await this.resolveScheduledTaskContext(session.id),
2711
- }),
2713
+ system,
2712
2714
  ...(resolvedAgent ? { agent: resolvedAgent } : {}),
2713
2715
  ...(modelField ? { model: modelField } : {}),
2714
2716
  ...variantField,
@@ -2782,6 +2784,7 @@ export class ThreadSessionRuntime {
2782
2784
  const queuedMessage = {
2783
2785
  queueId,
2784
2786
  prompt: input.prompt,
2787
+ queuedAction: input.queuedAction,
2785
2788
  userId: input.userId,
2786
2789
  username: input.username,
2787
2790
  images: input.images,
@@ -2795,6 +2798,7 @@ export class ThreadSessionRuntime {
2795
2798
  parentSessionId: input.parentSessionId,
2796
2799
  sourceMessageId: input.sourceMessageId,
2797
2800
  sourceThreadId: input.sourceThreadId,
2801
+ sourceChannelId: input.sourceChannelId,
2798
2802
  repliedMessage: input.repliedMessage,
2799
2803
  sessionStartScheduleKind: input.sessionStartSource?.scheduleKind,
2800
2804
  sessionStartScheduledTaskId: input.sessionStartSource?.scheduledTaskId,
@@ -2802,10 +2806,25 @@ export class ThreadSessionRuntime {
2802
2806
  };
2803
2807
  let result = { queued: false, queueId };
2804
2808
  await this.dispatchAction(async () => {
2809
+ // Determine if the message will genuinely wait in queue
2810
+ const position = (this.state?.queueItems.length ?? 0) + 1;
2811
+ result = this.isBusy()
2812
+ ? { queued: true, position, queueId }
2813
+ : { queued: false, queueId };
2814
+ // Post the ack before the item is visible to any drain path.
2815
+ const queueAckMessageId = result.queued && input.onLocalQueued
2816
+ ? await input.onLocalQueued({ queueId, position }).catch((error) => {
2817
+ logger.error(`[QUEUE] Failed to post queue ack for ${queueId}:`, error);
2818
+ return undefined;
2819
+ })
2820
+ : undefined;
2821
+ const item = queueAckMessageId
2822
+ ? { ...queuedMessage, queueAckMessageId }
2823
+ : queuedMessage;
2805
2824
  const persistResult = await insertThreadQueueItem({
2806
2825
  queueId,
2807
2826
  threadId: this.threadId,
2808
- payloadJson: JSON.stringify(queuedMessage),
2827
+ payloadJson: JSON.stringify(item),
2809
2828
  }).catch((error) => {
2810
2829
  return new Error('Failed to persist queued message', { cause: error });
2811
2830
  });
@@ -2813,17 +2832,8 @@ export class ThreadSessionRuntime {
2813
2832
  logger.error(`[QUEUE] Failed to persist queued message ${queueId} in thread ${this.threadId}: ${persistResult.message}`);
2814
2833
  throw persistResult;
2815
2834
  }
2816
- threadState.enqueueItem(this.threadId, queuedMessage);
2817
- // Determine if the message is genuinely waiting in queue
2835
+ threadState.enqueueItem(this.threadId, item);
2818
2836
  const stateAfterEnqueue = threadState.getThreadState(this.threadId);
2819
- const position = stateAfterEnqueue?.queueItems.length ?? 0;
2820
- const willDrainNow = stateAfterEnqueue
2821
- ? (stateAfterEnqueue.queueItems.length > 0
2822
- && !this.isBusy())
2823
- : false;
2824
- result = !willDrainNow && position > 0
2825
- ? { queued: true, position, queueId }
2826
- : { queued: false, queueId };
2827
2837
  if (this.hasPendingQuestionUi()) {
2828
2838
  this.maybeHandoffQueuedItemForPendingQuestion({
2829
2839
  sessionId: stateAfterEnqueue?.sessionId || this.state?.sessionId,
@@ -2934,6 +2944,7 @@ export class ThreadSessionRuntime {
2934
2944
  const resolvedInput = maybeConvertLeadingCommand({
2935
2945
  ...input,
2936
2946
  prompt: result.prompt,
2947
+ queuedAction: result.queuedAction,
2937
2948
  images: result.images,
2938
2949
  mode: result.mode,
2939
2950
  // Voice transcription can extract an agent name — apply it only if
@@ -3022,17 +3033,20 @@ export class ThreadSessionRuntime {
3022
3033
  apiAbortPromise,
3023
3034
  };
3024
3035
  }
3025
- abortActiveRun(reason) {
3036
+ /** Explicit user abort: stops the run and drops all queued messages. Returns the removed items. */
3037
+ async abortActiveRun(reason) {
3026
3038
  const outcome = this.abortActiveRunInternal({
3027
3039
  reason,
3028
3040
  });
3029
3041
  if (outcome.apiAbortPromise) {
3030
3042
  void outcome.apiAbortPromise;
3031
3043
  }
3032
- // Drain local queued messages after explicit abort.
3033
- void this.dispatchAction(() => {
3034
- return this.tryDrainQueue({ showIndicator: true });
3044
+ // Enqueued synchronously, so it runs before the abort's session.idle can drain the queue.
3045
+ let cleared = [];
3046
+ await this.dispatchAction(async () => {
3047
+ cleared = await this.clearQueueNow();
3035
3048
  });
3049
+ return cleared;
3036
3050
  }
3037
3051
  async abortActiveRunAndWait({ reason, timeoutMs = 2_000, }) {
3038
3052
  const state = this.state;
@@ -3073,17 +3087,21 @@ export class ThreadSessionRuntime {
3073
3087
  async clearQueue() {
3074
3088
  let cleared = [];
3075
3089
  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);
3090
+ cleared = await this.clearQueueNow();
3084
3091
  });
3085
3092
  return cleared;
3086
3093
  }
3094
+ // Must run inside dispatchAction.
3095
+ async clearQueueNow() {
3096
+ const persistResult = await deleteThreadQueueItems(this.threadId).catch((error) => {
3097
+ return new Error('Failed to clear persisted queue', { cause: error });
3098
+ });
3099
+ if (persistResult instanceof Error) {
3100
+ logger.error(`[QUEUE] Failed to clear persisted queue for thread ${this.threadId}: ${persistResult.message}`);
3101
+ return [];
3102
+ }
3103
+ return threadState.clearQueueItems(this.threadId);
3104
+ }
3087
3105
  /** Remove a queued message by its 1-based position. */
3088
3106
  async removeQueuePosition(position) {
3089
3107
  let removed;
@@ -3135,7 +3153,7 @@ export class ThreadSessionRuntime {
3135
3153
  * Returns { found: true, removed } if the item was in the queue,
3136
3154
  * or { found: false } if it was already dispatched or never queued.
3137
3155
  */
3138
- async updateQueuedMessage(sourceMessageId, newPrompt) {
3156
+ async updateQueuedMessage({ sourceMessageId, newPrompt, queuedAction, }) {
3139
3157
  let result = { found: false, removed: false };
3140
3158
  await this.dispatchAction(async () => {
3141
3159
  const trimmed = newPrompt.trim();
@@ -3151,7 +3169,7 @@ export class ThreadSessionRuntime {
3151
3169
  const persistResult = trimmed
3152
3170
  ? await updateThreadQueueItemPayload({
3153
3171
  queueId,
3154
- payloadJson: JSON.stringify({ ...original, prompt: trimmed }),
3172
+ payloadJson: JSON.stringify({ ...original, prompt: trimmed, queuedAction }),
3155
3173
  }).catch((error) => {
3156
3174
  return new Error('Failed to update persisted queue item', { cause: error });
3157
3175
  })
@@ -3167,7 +3185,7 @@ export class ThreadSessionRuntime {
3167
3185
  threadState.updateQueueItemBySourceMessageId(this.threadId, sourceMessageId, (item) => {
3168
3186
  if (!trimmed)
3169
3187
  return null;
3170
- return { ...item, prompt: trimmed };
3188
+ return { ...item, prompt: trimmed, queuedAction };
3171
3189
  });
3172
3190
  result = trimmed
3173
3191
  ? { found: true, removed: false }
@@ -3254,6 +3272,32 @@ export class ThreadSessionRuntime {
3254
3272
  threadState.removeQueueItemById(this.threadId, item.queueId);
3255
3273
  });
3256
3274
  }
3275
+ // Silent reply to the message that queued the item. Discord replies must
3276
+ // reference a message in the same channel.
3277
+ async sendQueueDrainIndicator(item) {
3278
+ const replyTarget = item.queueAckMessageId
3279
+ ?? (item.sourceChannelId === this.threadId ? item.sourceMessageId : undefined);
3280
+ const content = (() => {
3281
+ if (replyTarget) {
3282
+ return asSubtext('Executing queued prompt');
3283
+ }
3284
+ const preview = item.command
3285
+ ? `/${item.command.name}`
3286
+ : item.prompt.replace(/\s+/g, ' ').trim().slice(0, 150);
3287
+ return asSubtext(`Executing queued prompt from ${item.username}: ${preview}`);
3288
+ })();
3289
+ const sendResult = await this.thread.send({
3290
+ content,
3291
+ flags: SILENT_MESSAGE_FLAGS,
3292
+ allowedMentions: { parse: [], repliedUser: false },
3293
+ reply: replyTarget
3294
+ ? { messageReference: replyTarget, failIfNotExists: false }
3295
+ : undefined,
3296
+ }).catch((e) => new DiscordOperationError({ operation: 'sendMessage', cause: e }));
3297
+ if (sendResult instanceof Error) {
3298
+ discordLogger.error('Failed to send queue drain indicator:', sendResult);
3299
+ }
3300
+ }
3257
3301
  // ── Queue Drain ─────────────────────────────────────────────
3258
3302
  /**
3259
3303
  * Check if we can dispatch the next queued message. If so, dequeue and
@@ -3283,7 +3327,9 @@ export class ThreadSessionRuntime {
3283
3327
  if (sessionBusy) {
3284
3328
  return;
3285
3329
  }
3286
- const next = thread.queueItems.find((item) => item.queueId !== this.dispatchingQueueId);
3330
+ if (this.dispatchingQueueId)
3331
+ return;
3332
+ const next = thread.queueItems[0];
3287
3333
  if (!next) {
3288
3334
  return;
3289
3335
  }
@@ -3292,12 +3338,7 @@ export class ThreadSessionRuntime {
3292
3338
  // Show queued message indicator only for messages that actually waited
3293
3339
  // behind a running request — not for the first immediate dispatch.
3294
3340
  if (showIndicator) {
3295
- const displayText = next.command
3296
- ? `/${next.command.name}`
3297
- : `${next.prompt.slice(0, 150)}${next.prompt.length > 150 ? '...' : ''}`;
3298
- if (displayText.trim()) {
3299
- await sendThreadMessage(this.thread, `${QUEUE_PREFIX}**${next.username}:** ${displayText}`);
3300
- }
3341
+ await this.sendQueueDrainIndicator(next);
3301
3342
  }
3302
3343
  // Start dispatch (detached — does not block the action queue).
3303
3344
  // The prompt call is long-running. Events continue to flow through
@@ -3305,7 +3346,7 @@ export class ThreadSessionRuntime {
3305
3346
  // gating prevents concurrent local-queue dispatches. Mark busy now to
3306
3347
  // close the tiny window before the first session.status busy arrives.
3307
3348
  const dispatchSessionId = thread.sessionId;
3308
- if (dispatchSessionId) {
3349
+ if (dispatchSessionId && next.queuedAction !== 'btw') {
3309
3350
  this.markQueueDispatchBusy(dispatchSessionId);
3310
3351
  }
3311
3352
  let accepted = false;
@@ -3335,6 +3376,27 @@ export class ThreadSessionRuntime {
3335
3376
  // The listener is already running, so this only handles
3336
3377
  // session ensure + model/agent + SDK call + state.
3337
3378
  async dispatchPrompt(input) {
3379
+ if (input.queuedAction === 'btw') {
3380
+ const { forkSessionToBtwThread } = await import('../commands/btw.js');
3381
+ const result = await forkSessionToBtwThread({
3382
+ sourceThread: this.thread,
3383
+ projectDirectory: this.projectDirectory,
3384
+ sdkDirectory: this.sdkDirectory,
3385
+ prompt: input.prompt,
3386
+ userId: input.userId,
3387
+ username: input.username,
3388
+ appId: input.appId,
3389
+ images: input.images,
3390
+ agent: input.agent,
3391
+ });
3392
+ if (result instanceof Error) {
3393
+ logger.error('[QUEUE] Could not fork queued btw:', result);
3394
+ await sendThreadMessage(this.thread, `Could not fork queued btw: ${result.message}`);
3395
+ return true;
3396
+ }
3397
+ await sendThreadMessage(this.thread, `Session forked! Continue in ${result.thread.toString()}`);
3398
+ return true;
3399
+ }
3338
3400
  this.lastDisplayedContextPercentage = 0;
3339
3401
  this.lastRateLimitDisplayTime = 0;
3340
3402
  this.lastSentPartKind = undefined;
@@ -3530,6 +3592,23 @@ export class ThreadSessionRuntime {
3530
3592
  }
3531
3593
  return fetched.topic?.trim() || undefined;
3532
3594
  })();
3595
+ // Pinned before building parts so the fork notice can compare identities.
3596
+ // Also covers session.command: the context-awareness plugin reads the same
3597
+ // pinned file because the command API has no system field.
3598
+ const system = await this.resolveTurnSystemPrompt({
3599
+ sessionId: session.id,
3600
+ channelTopic,
3601
+ agents: earlyAvailableAgents,
3602
+ input,
3603
+ });
3604
+ if (system instanceof Error) {
3605
+ logger.error(`[DISPATCH] Failed to pin system prompt for session ${session.id}: ${system.message}`);
3606
+ void notifyError(system, 'Failed to pin session system prompt');
3607
+ this.stopTyping();
3608
+ await sendThreadMessage(this.thread, `✗ Failed to prepare system prompt: ${system.message}`, { flags: NOTIFY_MESSAGE_FLAGS });
3609
+ return false;
3610
+ }
3611
+ const systemPromptFromSourceSession = !isSystemPromptForSession({ system, sessionId: session.id });
3533
3612
  const worktreeChanged = this.consumeWorktreePromptChange(worktree);
3534
3613
  const syntheticContext = getOpencodePromptContext({
3535
3614
  sessionId: session.id,
@@ -3543,6 +3622,8 @@ export class ThreadSessionRuntime {
3543
3622
  worktree,
3544
3623
  currentAgent: earlyAgentPreference,
3545
3624
  worktreeChanged,
3625
+ systemPromptFromSourceSession,
3626
+ parentSessionId: this.getParentSessionIdMissingFromSystem({ system, input }),
3546
3627
  });
3547
3628
  const parts = [
3548
3629
  { type: 'text', text: promptWithImagePaths },
@@ -3586,40 +3667,9 @@ export class ThreadSessionRuntime {
3586
3667
  sourceThreadId: input.sourceThreadId || this.thread.id,
3587
3668
  threadName: this.thread.name || undefined,
3588
3669
  repliedMessage: input.repliedMessage,
3670
+ systemPromptFromSourceSession,
3671
+ parentSessionId: this.getParentSessionIdMissingFromSystem({ system, input }),
3589
3672
  });
3590
- // OpenCode's session.command API has no `system` field. Persist the
3591
- // kimaki system prompt so the context-awareness plugin can attach it
3592
- // on chat.message when the command user message is created.
3593
- // Fail the dispatch if persistence fails — otherwise the command runs
3594
- // without Discord context and silently restores the original bug.
3595
- const commandSystem = getOpencodeSystemMessage({
3596
- sessionId: session.id,
3597
- channelId,
3598
- guildId: this.thread.guildId,
3599
- threadId: this.thread.id,
3600
- channelTopic,
3601
- agents: earlyAvailableAgents,
3602
- username: this.state?.sessionUsername || input.username,
3603
- userId: this.state?.sessionUserId || input.userId,
3604
- parentSessionId: this.state?.parentSessionId || input.parentSessionId,
3605
- scheduledTask: await this.resolveScheduledTaskContext(session.id),
3606
- });
3607
- const systemWriteResult = await writeSessionSystemPrompt({
3608
- sessionId: session.id,
3609
- system: commandSystem,
3610
- dataDir: getDataDir(),
3611
- }).catch((e) => {
3612
- return e instanceof Error
3613
- ? e
3614
- : new Error(String(e), { cause: e });
3615
- });
3616
- if (systemWriteResult instanceof Error) {
3617
- logger.error(`[DISPATCH] Failed to persist system prompt for command session ${session.id}: ${systemWriteResult.message}`);
3618
- void notifyError(systemWriteResult, 'Failed to persist system prompt before session.command');
3619
- this.stopTyping();
3620
- await sendThreadMessage(this.thread, `✗ Failed to prepare command system prompt: ${systemWriteResult.message}`, { flags: NOTIFY_MESSAGE_FLAGS });
3621
- return false;
3622
- }
3623
3673
  const commandResponse = await getClient().session.command({
3624
3674
  sessionID: session.id,
3625
3675
  directory: this.sdkDirectory,
@@ -3682,18 +3732,7 @@ export class ThreadSessionRuntime {
3682
3732
  sessionID: session.id,
3683
3733
  directory: this.sdkDirectory,
3684
3734
  parts,
3685
- system: getOpencodeSystemMessage({
3686
- sessionId: session.id,
3687
- channelId,
3688
- guildId: this.thread.guildId,
3689
- threadId: this.thread.id,
3690
- channelTopic,
3691
- agents: earlyAvailableAgents,
3692
- username: this.state?.sessionUsername || input.username,
3693
- userId: this.state?.sessionUserId || input.userId,
3694
- parentSessionId: this.state?.parentSessionId || input.parentSessionId,
3695
- scheduledTask: await this.resolveScheduledTaskContext(session.id),
3696
- }),
3735
+ system,
3697
3736
  model: earlyModelParam,
3698
3737
  agent: earlyAgentPreference,
3699
3738
  ...variantField,
@@ -3761,6 +3800,38 @@ export class ThreadSessionRuntime {
3761
3800
  cache.set(sessionId, context);
3762
3801
  return context;
3763
3802
  }
3803
+ /**
3804
+ * Pinned system prompt for this turn. Generated only on the first turn of a
3805
+ * session; forks start with the source session's pinned prompt.
3806
+ */
3807
+ async resolveTurnSystemPrompt({ sessionId, channelTopic, agents, input, }) {
3808
+ return resolveSessionSystemPrompt({
3809
+ sessionId,
3810
+ generate: async () => {
3811
+ return getOpencodeSystemMessage({
3812
+ sessionId,
3813
+ channelId: this.channelId,
3814
+ guildId: this.thread.guildId,
3815
+ threadId: this.thread.id,
3816
+ channelTopic,
3817
+ agents,
3818
+ username: this.state?.sessionUsername || input.username,
3819
+ userId: this.state?.sessionUserId || input.userId,
3820
+ parentSessionId: this.state?.parentSessionId || input.parentSessionId,
3821
+ scheduledTask: await this.resolveScheduledTaskContext(sessionId),
3822
+ });
3823
+ },
3824
+ });
3825
+ }
3826
+ /** Parent set after the first turn is not in the pinned prompt; send it per turn. */
3827
+ getParentSessionIdMissingFromSystem({ system, input, }) {
3828
+ const parentSessionId = this.state?.parentSessionId || input.parentSessionId;
3829
+ if (!parentSessionId)
3830
+ return undefined;
3831
+ if (systemPromptHasParentSession({ system, parentSessionId }))
3832
+ return undefined;
3833
+ return parentSessionId;
3834
+ }
3764
3835
  async updateExistingSessionPermissions({ client, sessionId, createdNewSession, permissions, }) {
3765
3836
  if (createdNewSession) {
3766
3837
  return null;
@@ -3912,7 +3983,7 @@ export class ThreadSessionRuntime {
3912
3983
  const agentLabel = agent && agent.toLowerCase() !== 'build'
3913
3984
  ? ` ⋅ ${agent}`
3914
3985
  : '';
3915
- const result = await sendThreadMessage(this.thread, asSystemLine(`*using ${modelLabel}${agentLabel}*`), { flags: SILENT_MESSAGE_FLAGS }).catch((e) => new DiscordOperationError({ operation: 'sendMessage', cause: e }));
3986
+ const result = await sendThreadMessage(this.thread, asSubtext(`*using ${modelLabel}${agentLabel}*`), { flags: SILENT_MESSAGE_FLAGS }).catch((e) => new DiscordOperationError({ operation: 'sendMessage', cause: e }));
3916
3987
  if (result instanceof Error) {
3917
3988
  logger.warn(`[SESSION INFO] Failed to send model info: ${result.message}`);
3918
3989
  }
@@ -4041,7 +4112,7 @@ export class ThreadSessionRuntime {
4041
4112
  })
4042
4113
  : undefined;
4043
4114
  const mention = mentionUserId ? ` <@${mentionUserId}>` : '';
4044
- const footerText = asSystemLine(`*${projectInfo}${sessionDuration}${contextInfo}${modelInfo}${agentInfo}*${mention}`);
4115
+ const footerText = asSubtext(`*${projectInfo}${sessionDuration}${contextInfo}${modelInfo}${agentInfo}*${mention}`);
4045
4116
  this.stopTyping();
4046
4117
  await sendThreadMessage(this.thread, footerText, {
4047
4118
  flags: shouldNotifyUser ? NOTIFY_MESSAGE_FLAGS : SILENT_MESSAGE_FLAGS,
@@ -4057,6 +4128,16 @@ export class ThreadSessionRuntime {
4057
4128
  this.lastSentPartKind = undefined;
4058
4129
  }
4059
4130
  async maybeNotifyPromptCacheClear({ sessionId, messageId, }) {
4131
+ // Only the first reply after a user prompt can show a cold cache. Later steps re-read the turn's own writes.
4132
+ // TODO: send this before step 1 tools finish. Tokens arrive only with step-finish, after tools in v1 and v2.
4133
+ // OpenCode v2 publishes `session.step.streamed` at stream end (before tools settle) but without tokens, and
4134
+ // packages/ai drops Anthropic `message_start` usage (onMessageStart returns NO_EVENTS). Needs upstream:
4135
+ // add tokens to Step.Streamed, or emit a usage event on message_start for a notice right after the prompt.
4136
+ // https://github.com/anomalyco/opencode/blob/v2/packages/schema/src/session-event.ts
4137
+ const [firstAssistantId] = this.getAssistantMessageIdsForCurrentTurn({ sessionId });
4138
+ if (firstAssistantId !== messageId) {
4139
+ return;
4140
+ }
4060
4141
  const cacheClear = getPromptCacheClear({
4061
4142
  events: this.eventBuffer,
4062
4143
  sessionId,
@@ -4070,7 +4151,7 @@ export class ThreadSessionRuntime {
4070
4151
  previousMessageId: cacheClear.previousMessageId,
4071
4152
  currentMessageId: cacheClear.currentMessageId,
4072
4153
  });
4073
- const chunk = `${STATUS_PREFIX}${formatPromptCacheClearMessage(cacheClear, systemDiff)}`;
4154
+ const chunk = asSubtext(formatPromptCacheClearMessage(cacheClear, systemDiff));
4074
4155
  const sendResult = await this.thread.send({ content: chunk, flags: SILENT_MESSAGE_FLAGS })
4075
4156
  .catch((e) => new DiscordOperationError({ operation: 'sendMessage', cause: e }));
4076
4157
  if (sendResult instanceof Error) {
@@ -313,15 +313,15 @@ 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
- > sleep-started
318
+ sleep-started
319
319
  -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
320
320
  -# Woke after sleeping until 2030-01-01 09:00 UTC
321
321
  Reason: waiting for the deploy
322
322
  Continue the work you were waiting for.
323
323
  [embed]
324
- > sleep-wake-done
324
+ sleep-wake-done
325
325
  -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
326
326
  `);
327
327
  // The wake must continue the SAME OpenCode session, not start a new one:
@@ -412,14 +412,14 @@ 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
- > cancel-sleep-started
417
+ cancel-sleep-started
418
418
  -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
419
419
  --- from: user (sleep-tester)
420
420
  SLEEP_CANCEL_FOLLOWUP never mind, keep going
421
421
  --- from: assistant (TestBot)
422
- > cancel-followup-done
422
+ cancel-followup-done
423
423
  -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
424
424
  `);
425
425
  }, 20_000);