kimaki 0.26.0 → 0.27.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 (214) hide show
  1. package/dist/agent-model.e2e.test.js +25 -122
  2. package/dist/anthropic-account-identity.js +1 -0
  3. package/dist/anthropic-auth-plugin.js +1 -0
  4. package/dist/anthropic-auth-state.js +1 -0
  5. package/dist/bin.js +73 -19
  6. package/dist/cli-commands/maintenance.js +46 -7
  7. package/dist/cli-commands/send.js +24 -0
  8. package/dist/cli-commands/session.js +183 -8
  9. package/dist/cli-commands/task.js +13 -1
  10. package/dist/cli-parsing.test.js +59 -3
  11. package/dist/cli-runner.js +28 -4
  12. package/dist/cli-runner.test.js +27 -0
  13. package/dist/cli-send-thread.e2e.test.js +1 -1
  14. package/dist/cli.js +32 -0
  15. package/dist/commands/abort.js +5 -1
  16. package/dist/commands/agent.js +6 -8
  17. package/dist/commands/ask-question.js +21 -32
  18. package/dist/commands/ask-question.test.js +43 -1
  19. package/dist/commands/login.js +6 -0
  20. package/dist/commands/merge-worktree.js +36 -10
  21. package/dist/commands/model-variant.js +7 -1
  22. package/dist/commands/model.js +14 -8
  23. package/dist/commands/multioauth.js +1 -0
  24. package/dist/commands/new-worktree.js +170 -102
  25. package/dist/commands/user-command.js +6 -8
  26. package/dist/commands/worktrees.js +5 -13
  27. package/dist/config.js +16 -1
  28. package/dist/database.js +124 -3
  29. package/dist/db.js +22 -0
  30. package/dist/db.test.js +167 -1
  31. package/dist/discord-bot.js +34 -32
  32. package/dist/discord-command-registration.js +9 -1
  33. package/dist/discord-utils.js +13 -5
  34. package/dist/discord-utils.test.js +17 -1
  35. package/dist/errors.js +5 -0
  36. package/dist/exec-async.js +17 -5
  37. package/dist/external-directory-permissions.e2e.test.js +2 -2
  38. package/dist/file-edit-log.js +197 -0
  39. package/dist/file-edit-log.test.js +258 -0
  40. package/dist/format-tables.js +52 -0
  41. package/dist/format-tables.test.js +93 -0
  42. package/dist/gateway-proxy-reconnect.e2e.test.js +28 -8
  43. package/dist/gateway-proxy.e2e.test.js +5 -5
  44. package/dist/git-worktree-core.js +182 -17
  45. package/dist/ipc-tools-plugin.js +63 -3
  46. package/dist/kimaki-opencode-plugin-loading.e2e.test.js +122 -58
  47. package/dist/kimaki-opencode-plugin.js +7 -3
  48. package/dist/kimaki-workspace-adaptor.js +33 -10
  49. package/dist/logger.js +10 -1
  50. package/dist/markdown.js +3 -1
  51. package/dist/markdown.test.js +11 -3
  52. package/dist/message-formatting.js +18 -7
  53. package/dist/message-formatting.test.js +56 -9
  54. package/dist/oauth-rotation-shared.js +10 -0
  55. package/dist/openai-auth-plugin.js +1 -0
  56. package/dist/openai-auth-state.js +1 -0
  57. package/dist/opencode-command.test.js +60 -0
  58. package/dist/opencode.js +53 -12
  59. package/dist/question-abort-clears-dropdown.e2e.test.js +89 -0
  60. package/dist/question-text-before-dropdown.e2e.test.js +52 -0
  61. package/dist/queue-advanced-abort.e2e.test.js +1 -1
  62. package/dist/queue-advanced-action-buttons.e2e.test.js +3 -3
  63. package/dist/queue-advanced-e2e-setup.js +78 -5
  64. package/dist/queue-advanced-footer.e2e.test.js +40 -246
  65. package/dist/queue-advanced-model-switch.e2e.test.js +2 -2
  66. package/dist/queue-advanced-permissions-typing.e2e.test.js +7 -9
  67. package/dist/queue-advanced-question.e2e.test.js +31 -26
  68. package/dist/queue-advanced-typing-interrupt.e2e.test.js +2 -2
  69. package/dist/queue-delete-message.e2e.test.js +1 -1
  70. package/dist/queue-drain-after-interactive-ui.e2e.test.js +2 -2
  71. package/dist/queue-interrupt-drain.e2e.test.js +112 -2
  72. package/dist/queue-question-select-drain.e2e.test.js +30 -128
  73. package/dist/runtime-lifecycle.e2e.test.js +10 -37
  74. package/dist/schema.js +34 -0
  75. package/dist/session-handler/event-stream-state.js +267 -50
  76. package/dist/session-handler/event-stream-state.test.js +415 -8
  77. package/dist/session-handler/model-utils.js +159 -5
  78. package/dist/session-handler/model-utils.test.js +345 -0
  79. package/dist/session-handler/thread-session-runtime.js +252 -182
  80. package/dist/session-sleep.test.js +224 -0
  81. package/dist/session-title-rename.test.js +7 -39
  82. package/dist/sleep.e2e.test.js +491 -0
  83. package/dist/startup-time.e2e.test.js +2 -1
  84. package/dist/store.js +3 -0
  85. package/dist/system-message.js +59 -6
  86. package/dist/system-message.test.js +76 -6
  87. package/dist/task-runner.js +94 -4
  88. package/dist/task-schedule.js +56 -0
  89. package/dist/task-schedule.test.js +71 -1
  90. package/dist/thread-message-queue.e2e.test.js +10 -169
  91. package/dist/undo-redo.e2e.test.js +2 -2
  92. package/dist/voice-attachment.js +23 -1
  93. package/dist/voice-message.e2e.test.js +10 -10
  94. package/dist/voice.test.js +55 -29
  95. package/dist/worktree-clone-isolation.e2e.test.js +188 -0
  96. package/dist/worktree-lifecycle.e2e.test.js +29 -10
  97. package/dist/worktrees.js +226 -443
  98. package/dist/worktrees.test.js +435 -58
  99. package/dist/xai-auth-plugin.js +1 -0
  100. package/dist/xai-auth-state.js +1 -0
  101. package/package.json +8 -6
  102. package/skills/errore/SKILL.md +18 -0
  103. package/skills/goke/SKILL.md +150 -10
  104. package/skills/holocron/SKILL.md +37 -0
  105. package/skills/npm-package/SKILL.md +55 -35
  106. package/skills/profano/SKILL.md +17 -2
  107. package/skills/spiceflow/SKILL.md +39 -3
  108. package/skills/strada/SKILL.md +39 -0
  109. package/skills/zele/SKILL.md +19 -0
  110. package/src/agent-model.e2e.test.ts +25 -151
  111. package/src/anthropic-account-identity.ts +1 -0
  112. package/src/anthropic-auth-plugin.ts +1 -0
  113. package/src/anthropic-auth-state.ts +1 -0
  114. package/src/bin.ts +78 -26
  115. package/src/cli-commands/maintenance.ts +59 -13
  116. package/src/cli-commands/send.ts +25 -0
  117. package/src/cli-commands/session.ts +203 -9
  118. package/src/cli-commands/task.ts +13 -1
  119. package/src/cli-parsing.test.ts +69 -3
  120. package/src/cli-runner.test.ts +49 -0
  121. package/src/cli-runner.ts +28 -4
  122. package/src/cli-send-thread.e2e.test.ts +1 -1
  123. package/src/cli.ts +53 -0
  124. package/src/commands/abort.ts +6 -1
  125. package/src/commands/agent.ts +6 -6
  126. package/src/commands/ask-question.test.ts +48 -1
  127. package/src/commands/ask-question.ts +21 -32
  128. package/src/commands/login.ts +6 -0
  129. package/src/commands/merge-worktree.ts +43 -12
  130. package/src/commands/model-variant.ts +8 -1
  131. package/src/commands/model.ts +18 -8
  132. package/src/commands/multioauth.ts +1 -0
  133. package/src/commands/new-worktree.ts +138 -55
  134. package/src/commands/user-command.ts +6 -6
  135. package/src/commands/worktrees.ts +5 -12
  136. package/src/config.ts +18 -1
  137. package/src/database.ts +195 -3
  138. package/src/db.test.ts +188 -0
  139. package/src/db.ts +26 -0
  140. package/src/discord-bot.ts +35 -29
  141. package/src/discord-command-registration.ts +15 -1
  142. package/src/discord-utils.test.ts +19 -0
  143. package/src/discord-utils.ts +21 -6
  144. package/src/errors.ts +5 -0
  145. package/src/exec-async.ts +20 -11
  146. package/src/external-directory-permissions.e2e.test.ts +2 -2
  147. package/src/file-edit-log.test.ts +287 -0
  148. package/src/file-edit-log.ts +283 -0
  149. package/src/format-tables.test.ts +101 -0
  150. package/src/format-tables.ts +75 -0
  151. package/src/gateway-proxy-reconnect.e2e.test.ts +38 -8
  152. package/src/gateway-proxy.e2e.test.ts +5 -5
  153. package/src/git-worktree-core.ts +247 -20
  154. package/src/ipc-tools-plugin.ts +75 -5
  155. package/src/kimaki-opencode-plugin-loading.e2e.test.ts +188 -97
  156. package/src/kimaki-opencode-plugin.ts +7 -3
  157. package/src/kimaki-workspace-adaptor.ts +40 -11
  158. package/src/logger.ts +13 -4
  159. package/src/markdown.test.ts +13 -3
  160. package/src/markdown.ts +4 -1
  161. package/src/message-formatting.test.ts +86 -12
  162. package/src/message-formatting.ts +19 -7
  163. package/src/oauth-rotation-shared.ts +10 -0
  164. package/src/openai-auth-plugin.ts +1 -0
  165. package/src/openai-auth-state.ts +1 -0
  166. package/src/opencode-command.test.ts +81 -0
  167. package/src/opencode.ts +71 -11
  168. package/src/question-abort-clears-dropdown.e2e.test.ts +109 -0
  169. package/src/question-text-before-dropdown.e2e.test.ts +67 -0
  170. package/src/queue-advanced-abort.e2e.test.ts +1 -1
  171. package/src/queue-advanced-action-buttons.e2e.test.ts +3 -3
  172. package/src/queue-advanced-e2e-setup.ts +83 -4
  173. package/src/queue-advanced-footer.e2e.test.ts +47 -294
  174. package/src/queue-advanced-model-switch.e2e.test.ts +2 -2
  175. package/src/queue-advanced-permissions-typing.e2e.test.ts +7 -9
  176. package/src/queue-advanced-question.e2e.test.ts +35 -29
  177. package/src/queue-advanced-typing-interrupt.e2e.test.ts +2 -2
  178. package/src/queue-delete-message.e2e.test.ts +1 -1
  179. package/src/queue-drain-after-interactive-ui.e2e.test.ts +2 -2
  180. package/src/queue-interrupt-drain.e2e.test.ts +123 -2
  181. package/src/queue-question-select-drain.e2e.test.ts +32 -157
  182. package/src/runtime-lifecycle.e2e.test.ts +10 -41
  183. package/src/schema.sql +14 -1
  184. package/src/schema.ts +36 -0
  185. package/src/session-handler/event-stream-state.test.ts +437 -7
  186. package/src/session-handler/event-stream-state.ts +389 -53
  187. package/src/session-handler/model-utils.test.ts +415 -0
  188. package/src/session-handler/model-utils.ts +249 -5
  189. package/src/session-handler/thread-session-runtime.ts +300 -215
  190. package/src/session-sleep.test.ts +294 -0
  191. package/src/session-title-rename.test.ts +9 -48
  192. package/src/sleep.e2e.test.ts +562 -0
  193. package/src/startup-time.e2e.test.ts +5 -1
  194. package/src/store.ts +21 -0
  195. package/src/system-message.test.ts +86 -6
  196. package/src/system-message.ts +66 -7
  197. package/src/task-runner.ts +136 -2
  198. package/src/task-schedule.test.ts +76 -0
  199. package/src/task-schedule.ts +95 -0
  200. package/src/thread-message-queue.e2e.test.ts +10 -202
  201. package/src/undo-redo.e2e.test.ts +2 -2
  202. package/src/voice-attachment.ts +29 -1
  203. package/src/voice-message.e2e.test.ts +10 -10
  204. package/src/voice.test.ts +63 -29
  205. package/src/worktree-clone-isolation.e2e.test.ts +213 -0
  206. package/src/worktree-lifecycle.e2e.test.ts +29 -9
  207. package/src/worktrees.test.ts +505 -64
  208. package/src/worktrees.ts +257 -603
  209. package/src/xai-auth-plugin.ts +1 -0
  210. package/src/xai-auth-state.ts +1 -0
  211. package/dist/message-finish-field.e2e.test.js +0 -169
  212. package/dist/subagent-rate-limit-plugin.js +0 -184
  213. package/src/message-finish-field.e2e.test.ts +0 -196
  214. package/src/subagent-rate-limit-plugin.ts +0 -228
@@ -40,6 +40,8 @@ import {
40
40
  sendThreadMessage,
41
41
  SILENT_MESSAGE_FLAGS,
42
42
  NOTIFY_MESSAGE_FLAGS,
43
+ raceDiscordRename,
44
+ DISCORD_THREAD_RENAME_TIMEOUT_MS,
43
45
  } from '../discord-utils.js'
44
46
  import type { DiscordFileAttachment } from '../message-formatting.js'
45
47
  import { formatPart, formatTaskToolTitle } from '../message-formatting.js'
@@ -62,6 +64,7 @@ import {
62
64
  startScheduledTaskRunSession,
63
65
  appendSessionEventsSinceLastTimestamp,
64
66
  getSessionEventSnapshot,
67
+ cancelSessionSleepForThread,
65
68
  } from '../database.js'
66
69
  import * as orm from 'drizzle-orm'
67
70
  import * as schema from '../schema.js'
@@ -75,6 +78,7 @@ import {
75
78
  showAskUserQuestionDropdowns,
76
79
  pendingQuestionContexts,
77
80
  cancelPendingQuestion,
81
+ findPendingQuestionContextForRequest,
78
82
  } from '../commands/ask-question.js'
79
83
  import {
80
84
  showActionButtons,
@@ -90,6 +94,11 @@ import {
90
94
  getCurrentModelInfo,
91
95
  ensureSessionPreferencesSnapshot,
92
96
  } from '../commands/model.js'
97
+ import {
98
+ displayedModelLabel,
99
+ getProviderModelName,
100
+ validateModelId,
101
+ } from './model-utils.js'
93
102
  import {
94
103
  getOpencodePromptContext,
95
104
  getOpencodeSystemMessage,
@@ -99,6 +108,7 @@ import {
99
108
  type WorktreeInfo,
100
109
  } from '../system-message.js'
101
110
  import { getDataDir } from '../config.js'
111
+ import { store } from '../store.js'
102
112
  import {
103
113
  trackEvent,
104
114
  type AnalyticsIngressMode,
@@ -115,6 +125,7 @@ import {
115
125
  import {
116
126
  doesLatestUserTurnHaveNaturalCompletion,
117
127
  didQuestionQueueHandoffSinceLatestQuestionAsked,
128
+ deriveLatestUnansweredQuestion,
118
129
  getAssistantMessageIdsForLatestUserTurn,
119
130
  getCurrentTurnStartTime,
120
131
  isSessionBusy,
@@ -122,6 +133,8 @@ import {
122
133
  getIdleTokenUsageDelta,
123
134
  getDerivedSubtaskIndex,
124
135
  getDerivedSubtaskAgentType,
136
+ getTokenUsageSessionIdsForIdle,
137
+ isDerivedChildSession,
125
138
  getLatestAssistantMessageIdForLatestUserTurn,
126
139
  hasAssistantMessageCompletedBefore,
127
140
  isAssistantMessageInLatestUserTurn,
@@ -429,6 +442,14 @@ const PRESERVED_THREAD_PREFIXES: string[] = [
429
442
  'Fork: ',
430
443
  ]
431
444
 
445
+ function stripPreservedThreadPrefix(name: string) {
446
+ const matchedPrefix = PRESERVED_THREAD_PREFIXES.find((prefix) => {
447
+ return name.startsWith(prefix)
448
+ })
449
+ if (!matchedPrefix) return name
450
+ return name.slice(matchedPrefix.length).trim()
451
+ }
452
+
432
453
  function getThreadNameCandidateFromSessionTitle({
433
454
  sessionTitle,
434
455
  currentName,
@@ -440,14 +461,18 @@ function getThreadNameCandidateFromSessionTitle({
440
461
  if (!trimmed) {
441
462
  return null
442
463
  }
443
- if (/^new session\s*-/i.test(trimmed)) {
464
+ const withoutCopiedPrefix = stripPreservedThreadPrefix(trimmed)
465
+ if (!withoutCopiedPrefix) {
466
+ return null
467
+ }
468
+ if (/^new session\s*-/i.test(withoutCopiedPrefix)) {
444
469
  return null
445
470
  }
446
471
  const matchedPrefix =
447
472
  PRESERVED_THREAD_PREFIXES.find((p) => {
448
473
  return currentName.startsWith(p)
449
474
  }) ?? ''
450
- return `${matchedPrefix}${trimmed}`.slice(0, DISCORD_THREAD_NAME_MAX)
475
+ return `${matchedPrefix}${withoutCopiedPrefix}`.slice(0, DISCORD_THREAD_NAME_MAX)
451
476
  }
452
477
 
453
478
  export function deriveThreadNameFromSessionTitle({
@@ -470,45 +495,6 @@ export function deriveThreadNameFromSessionTitle({
470
495
  return candidate
471
496
  }
472
497
 
473
- export function deriveThreadRenameFromSessionUpdate({
474
- sessionTitle,
475
- currentName,
476
- lastSyncedName,
477
- }: {
478
- sessionTitle: string | undefined | null
479
- currentName: string
480
- lastSyncedName: string | null
481
- }) {
482
- if (lastSyncedName !== null && currentName !== lastSyncedName) {
483
- return {
484
- desiredName: null,
485
- nextSyncedName: lastSyncedName,
486
- }
487
- }
488
-
489
- const candidate = getThreadNameCandidateFromSessionTitle({
490
- sessionTitle,
491
- currentName,
492
- })
493
- if (candidate === null) {
494
- return {
495
- desiredName: null,
496
- nextSyncedName: lastSyncedName,
497
- }
498
- }
499
- if (candidate === currentName) {
500
- return {
501
- desiredName: null,
502
- nextSyncedName: currentName,
503
- }
504
- }
505
-
506
- return {
507
- desiredName: candidate,
508
- nextSyncedName: candidate,
509
- }
510
- }
511
-
512
498
  // ── Ingress input type ───────────────────────────────────────────
513
499
 
514
500
  export type EnqueueResult = {
@@ -588,6 +574,12 @@ export type IngressInput = {
588
574
  * (e.g. user-to-user replies in a thread).
589
575
  */
590
576
  noReply?: boolean
577
+ /**
578
+ * True only for the wake prompt posted by the kimaki_sleep task runner.
579
+ * Every other ingress cancels a pending sleep; this one must not, because it
580
+ * is delivering that sleep rather than superseding it.
581
+ */
582
+ isSleepWake?: boolean
591
583
  /**
592
584
  * Product-analytics turn source. Defaults to discord. Set retry/cli/scheduled
593
585
  * at the ingress site so DAU queries can exclude non-user activity.
@@ -694,27 +686,22 @@ export class ThreadSessionRuntime {
694
686
  // message and showing multiple back-to-back POSTs is wasteful.
695
687
  private typingKeepaliveTimeout: ReturnType<typeof setTimeout> | null = null
696
688
  private readonly typingRepulseDebounce: ReturnType<typeof createDebouncedTimeout>
689
+ private readonly deferredQuestionShow: ReturnType<typeof createDebouncedTimeout>
697
690
 
698
691
  private static TYPING_REPULSE_DEBOUNCE_MS = 500
692
+ private static DEFERRED_QUESTION_SHOW_MS = 1000
699
693
 
700
694
  // Notification throttles for retry/context notices.
701
695
  private lastDisplayedContextPercentage = 0
702
696
  private lastRateLimitDisplayTime = 0
703
697
 
704
- // Last OpenCode-generated session title we successfully applied to the
705
- // Discord thread name. Used to dedupe repeated session.updated events so
706
- // we only call thread.setName() once per distinct title. Discord rate-limits
707
- // channel/thread renames to ~2 per 10 minutes per thread, so we must avoid
708
- // retrying. Not persisted — worst case on restart we re-apply the same title
709
- // once (which is a no-op via deriveThreadNameFromSessionTitle).
698
+ // Last OpenCode session title we applied to Discord. Dedupes session.updated
699
+ // so we only call setName once per distinct title. Not persisted.
710
700
  private appliedOpencodeTitle: string | undefined
711
701
 
712
- // Last Discord thread name known to match the OpenCode title. Persisted so a
713
- // user rename is still respected after Kimaki restarts.
714
- private lastSyncedThreadName: string | null | undefined
715
-
716
702
  // Part output buffering (write-side cache, not domain state)
717
703
  private partBuffer = new Map<string, Map<string, Part>>()
704
+ private shownQuestionRequestIds = new Set<string>()
718
705
 
719
706
  // Derivable cache (perf optimization for provider.list API call)
720
707
  private modelContextLimit: number | undefined
@@ -792,6 +779,17 @@ export class ThreadSessionRuntime {
792
779
  this.restartTypingKeepalive({ sendNow: true })
793
780
  },
794
781
  })
782
+ this.deferredQuestionShow = createDebouncedTimeout({
783
+ delayMs: ThreadSessionRuntime.DEFERRED_QUESTION_SHOW_MS,
784
+ callback: () => {
785
+ if (this.disposed) {
786
+ return
787
+ }
788
+ void this.dispatchAction(async () => {
789
+ await this.tryShowPendingQuestion({ ignoreUnfinishedText: true })
790
+ })
791
+ },
792
+ })
795
793
  }
796
794
 
797
795
  private consumeWorktreePromptChange(
@@ -1026,21 +1024,25 @@ export class ThreadSessionRuntime {
1026
1024
  if (!mainSessionId || candidateSessionId === mainSessionId) {
1027
1025
  return undefined
1028
1026
  }
1029
- const subtaskIndex = getDerivedSubtaskIndex({
1027
+ if (!isDerivedChildSession({
1030
1028
  events: this.eventBuffer,
1031
1029
  mainSessionId,
1032
1030
  candidateSessionId,
1033
- })
1034
- if (!subtaskIndex) {
1031
+ })) {
1035
1032
  return undefined
1036
1033
  }
1037
1034
 
1035
+ const subtaskIndex = getDerivedSubtaskIndex({
1036
+ events: this.eventBuffer,
1037
+ mainSessionId,
1038
+ candidateSessionId,
1039
+ })
1038
1040
  const agentType = getDerivedSubtaskAgentType({
1039
1041
  events: this.eventBuffer,
1040
1042
  mainSessionId,
1041
1043
  candidateSessionId,
1042
1044
  })
1043
- const label = `${agentType || 'task'}-${subtaskIndex}`
1045
+ const label = `${agentType || 'task'}-${subtaskIndex || 1}`
1044
1046
  const assistantMessageId = this.getLatestAssistantMessageIdForCurrentTurn({
1045
1047
  sessionId: candidateSessionId,
1046
1048
  })
@@ -1053,6 +1055,7 @@ export class ThreadSessionRuntime {
1053
1055
  this.disposed = true
1054
1056
  unregisterEventListener(this.threadId)
1055
1057
  void this.persistEventBufferDebounced.dispose()
1058
+ this.deferredQuestionShow.clear()
1056
1059
  this.stopTyping()
1057
1060
 
1058
1061
  // Release large internal buffers so GC can reclaim memory immediately
@@ -1060,6 +1063,7 @@ export class ThreadSessionRuntime {
1060
1063
  this.eventBuffer = []
1061
1064
  this.nextEventIndex = 0
1062
1065
  this.partBuffer.clear()
1066
+ this.shownQuestionRequestIds.clear()
1063
1067
  this.preprocessChain = Promise.resolve()
1064
1068
 
1065
1069
  // Don't clear actionQueue here — queued closures own resolve/reject for
@@ -2066,41 +2070,6 @@ export class ThreadSessionRuntime {
2066
2070
  return
2067
2071
  }
2068
2072
 
2069
- if (
2070
- part.type === 'tool' &&
2071
- part.tool === 'task' &&
2072
- !this.state?.sentPartIds.has(part.id)
2073
- ) {
2074
- const taskDisplay = formatTaskToolTitle(part)
2075
- if (taskDisplay && (await this.getVerbosity()) !== 'text_only') {
2076
- await this.flushBufferedParts({
2077
- messageID: part.messageID,
2078
- force: true,
2079
- skipPartId: part.id,
2080
- })
2081
- threadState.updateThread(this.threadId, (t) => {
2082
- const newIds = new Set(t.sentPartIds)
2083
- newIds.add(part.id)
2084
- return { ...t, sentPartIds: newIds }
2085
- })
2086
- const sendResult = await sendThreadMessage(this.thread, taskDisplay + '\n\n')
2087
- .catch((e) => new DiscordOperationError({ operation: 'sendMessage', cause: e }))
2088
- if (sendResult instanceof Error) {
2089
- threadState.updateThread(this.threadId, (t) => {
2090
- const newIds = new Set(t.sentPartIds)
2091
- newIds.delete(part.id)
2092
- return { ...t, sentPartIds: newIds }
2093
- })
2094
- discordLogger.error(
2095
- `ERROR: Failed to send task part ${part.id}:`,
2096
- sendResult,
2097
- )
2098
- return
2099
- }
2100
- await setPartMessage({ partId: part.id, messageId: sendResult.id, threadId: this.thread.id })
2101
- }
2102
- }
2103
-
2104
2073
  if (part.type === 'tool' && part.state.status === 'running') {
2105
2074
  await this.flushBufferedParts({
2106
2075
  messageID: part.messageID,
@@ -2108,6 +2077,32 @@ export class ThreadSessionRuntime {
2108
2077
  skipPartId: part.id,
2109
2078
  })
2110
2079
  await this.sendPartMessage({ part })
2080
+
2081
+ if (part.tool === 'task' && !this.state?.sentPartIds.has(part.id)) {
2082
+ const taskDisplay = formatTaskToolTitle(part)
2083
+ if (taskDisplay && (await this.getVerbosity()) !== 'text_only') {
2084
+ threadState.updateThread(this.threadId, (t) => {
2085
+ const newIds = new Set(t.sentPartIds)
2086
+ newIds.add(part.id)
2087
+ return { ...t, sentPartIds: newIds }
2088
+ })
2089
+ const sendResult = await sendThreadMessage(this.thread, taskDisplay + '\n\n')
2090
+ .catch((e) => new DiscordOperationError({ operation: 'sendMessage', cause: e }))
2091
+ if (sendResult instanceof Error) {
2092
+ threadState.updateThread(this.threadId, (t) => {
2093
+ const newIds = new Set(t.sentPartIds)
2094
+ newIds.delete(part.id)
2095
+ return { ...t, sentPartIds: newIds }
2096
+ })
2097
+ discordLogger.error(
2098
+ `ERROR: Failed to send task part ${part.id}:`,
2099
+ sendResult,
2100
+ )
2101
+ return
2102
+ }
2103
+ await setPartMessage({ partId: part.id, messageId: sendResult.id, threadId: this.thread.id })
2104
+ }
2105
+ }
2111
2106
  return
2112
2107
  }
2113
2108
 
@@ -2238,6 +2233,7 @@ export class ThreadSessionRuntime {
2238
2233
 
2239
2234
  if (part.type === 'text' && part.time?.end) {
2240
2235
  await this.sendPartMessage({ part })
2236
+ await this.tryShowPendingQuestion()
2241
2237
  return
2242
2238
  }
2243
2239
 
@@ -2301,21 +2297,16 @@ export class ThreadSessionRuntime {
2301
2297
  this.requestTypingRepulse()
2302
2298
  }
2303
2299
 
2304
- private trackIdleTokenUsage(idleSessionId: string): void {
2305
- let idleEventIndex: number | undefined
2306
- for (let i = this.eventBuffer.length - 1; i >= 0; i--) {
2307
- const event = this.eventBuffer[i]?.event
2308
- if (event?.type === 'session.idle' && event.properties.sessionID === idleSessionId) {
2309
- idleEventIndex = i
2310
- break
2311
- }
2312
- }
2313
- if (idleEventIndex === undefined) {
2314
- return
2315
- }
2300
+ private trackIdleTokenUsage({
2301
+ sessionId,
2302
+ idleEventIndex,
2303
+ }: {
2304
+ sessionId: string
2305
+ idleEventIndex: number
2306
+ }): void {
2316
2307
  const usage = getIdleTokenUsageDelta({
2317
2308
  events: this.eventBuffer,
2318
- sessionId: idleSessionId,
2309
+ sessionId,
2319
2310
  idleEventIndex,
2320
2311
  })
2321
2312
  if (!usage) {
@@ -2331,7 +2322,7 @@ export class ThreadSessionRuntime {
2331
2322
  tokens_total: usage.total,
2332
2323
  cost: usage.cost,
2333
2324
  assistant_message_count: usage.assistantMessageCount,
2334
- is_subagent: Boolean(this.getSubtaskInfoForSession(idleSessionId)),
2325
+ is_subagent: Boolean(this.getSubtaskInfoForSession(sessionId)),
2335
2326
  }
2336
2327
  if (usage.model) {
2337
2328
  properties.model = usage.model
@@ -2342,8 +2333,37 @@ export class ThreadSessionRuntime {
2342
2333
  trackEvent('tokens_used', properties)
2343
2334
  }
2344
2335
 
2336
+ private trackIdleTokenUsageForSessionTree(idleSessionId: string): void {
2337
+ let idleEventIndex: number | undefined
2338
+ for (let i = this.eventBuffer.length - 1; i >= 0; i--) {
2339
+ const event = this.eventBuffer[i]?.event
2340
+ if (event?.type === 'session.idle' && event.properties.sessionID === idleSessionId) {
2341
+ idleEventIndex = i
2342
+ break
2343
+ }
2344
+ }
2345
+ if (idleEventIndex === undefined) {
2346
+ return
2347
+ }
2348
+ const mainSessionId = this.state?.sessionId
2349
+ const sessionIds = mainSessionId
2350
+ ? getTokenUsageSessionIdsForIdle({
2351
+ events: this.eventBuffer,
2352
+ mainSessionId,
2353
+ idleSessionId,
2354
+ upToIndex: idleEventIndex,
2355
+ })
2356
+ : [idleSessionId]
2357
+ for (const sessionId of sessionIds) {
2358
+ this.trackIdleTokenUsage({
2359
+ sessionId,
2360
+ idleEventIndex,
2361
+ })
2362
+ }
2363
+ }
2364
+
2345
2365
  private async handleSessionIdle(idleSessionId: string): Promise<void> {
2346
- this.trackIdleTokenUsage(idleSessionId)
2366
+ this.trackIdleTokenUsageForSessionTree(idleSessionId)
2347
2367
 
2348
2368
  const sessionId = this.state?.sessionId
2349
2369
 
@@ -2629,41 +2649,89 @@ export class ThreadSessionRuntime {
2629
2649
  this.onInteractiveUiStateChanged()
2630
2650
  }
2631
2651
 
2632
- private async handleQuestionAsked(
2633
- questionRequest: QuestionRequest,
2634
- ): Promise<void> {
2652
+ private hasUnfinishedTextPart(messageID: string): boolean {
2653
+ return this.getBufferedParts(messageID).some((part) => {
2654
+ return part.type === 'text' && !part.time?.end
2655
+ })
2656
+ }
2657
+
2658
+ // OpenCode emits question.asked when the tool starts, often before the
2659
+ // preceding text part gets time.end. Showing the dropdown on that event
2660
+ // holds the action queue while Discord posts, so the later text-end cannot
2661
+ // send and dumps after the queued » user: indicator. Wait for text-end.
2662
+ private async tryShowPendingQuestion({
2663
+ ignoreUnfinishedText = false,
2664
+ } = {}): Promise<boolean> {
2635
2665
  const sessionId = this.state?.sessionId
2636
- if (questionRequest.sessionID !== sessionId) {
2637
- logger.log(
2638
- `[QUESTION IGNORED] Question for different session (expected: ${sessionId}, got: ${questionRequest.sessionID})`,
2639
- )
2640
- return
2666
+ if (!sessionId) {
2667
+ return false
2641
2668
  }
2642
2669
 
2643
- logger.log(
2644
- `Question requested: id=${questionRequest.id}, questions=${questionRequest.questions.length}`,
2645
- )
2670
+ const request = deriveLatestUnansweredQuestion({
2671
+ events: this.eventBuffer,
2672
+ sessionId,
2673
+ })
2674
+ if (!request) {
2675
+ this.deferredQuestionShow.clear()
2676
+ return false
2677
+ }
2678
+ if (
2679
+ this.shownQuestionRequestIds.has(request.id)
2680
+ || findPendingQuestionContextForRequest({
2681
+ threadId: this.thread.id,
2682
+ requestId: request.id,
2683
+ })
2684
+ ) {
2685
+ this.deferredQuestionShow.clear()
2686
+ return true
2687
+ }
2646
2688
 
2689
+ const messageId = request.tool?.messageID
2690
+ if (!ignoreUnfinishedText && messageId && this.hasUnfinishedTextPart(messageId)) {
2691
+ return false
2692
+ }
2693
+
2694
+ this.shownQuestionRequestIds.add(request.id)
2647
2695
  await this.showInteractiveUi({
2696
+ flushMessageId: messageId,
2648
2697
  show: async () => {
2649
- if (!sessionId) {
2650
- return
2651
- }
2652
2698
  await showAskUserQuestionDropdowns({
2653
2699
  thread: this.thread,
2654
2700
  sessionId,
2655
2701
  directory: this.sdkDirectory,
2656
- requestId: questionRequest.id,
2657
- input: { questions: questionRequest.questions },
2702
+ requestId: request.id,
2703
+ input: { questions: request.questions },
2658
2704
  silent: this.getQueueLength() > 0,
2659
2705
  })
2660
2706
  },
2661
2707
  })
2662
-
2708
+ this.deferredQuestionShow.clear()
2663
2709
  this.maybeHandoffQueuedItemForPendingQuestion({
2664
2710
  sessionId,
2665
2711
  reason: 'question-shown',
2666
2712
  })
2713
+ return true
2714
+ }
2715
+
2716
+ private async handleQuestionAsked(
2717
+ questionRequest: QuestionRequest,
2718
+ ): Promise<void> {
2719
+ const sessionId = this.state?.sessionId
2720
+ if (questionRequest.sessionID !== sessionId) {
2721
+ logger.log(
2722
+ `[QUESTION IGNORED] Question for different session (expected: ${sessionId}, got: ${questionRequest.sessionID})`,
2723
+ )
2724
+ return
2725
+ }
2726
+
2727
+ logger.log(
2728
+ `Question requested: id=${questionRequest.id}, questions=${questionRequest.questions.length}`,
2729
+ )
2730
+
2731
+ const shown = await this.tryShowPendingQuestion()
2732
+ if (!shown) {
2733
+ this.deferredQuestionShow.trigger()
2734
+ }
2667
2735
  }
2668
2736
 
2669
2737
  private handleQuestionReplied(properties: { sessionID: string }): void {
@@ -2671,6 +2739,7 @@ export class ThreadSessionRuntime {
2671
2739
  if (properties.sessionID !== sessionId) {
2672
2740
  return
2673
2741
  }
2742
+ this.deferredQuestionShow.clear()
2674
2743
  this.onInteractiveUiStateChanged()
2675
2744
 
2676
2745
  // When a question is answered and the local queue has items, the model may
@@ -2830,61 +2899,33 @@ export class ThreadSessionRuntime {
2830
2899
  if (info.id !== this.state?.sessionId) {
2831
2900
  return
2832
2901
  }
2833
- if (this.lastSyncedThreadName === undefined) {
2834
- const persistedName = await this.loadLastSyncedThreadName().catch(
2835
- (e) =>
2836
- new Error('Failed to read persisted thread rename state', { cause: e }),
2837
- )
2838
- if (persistedName instanceof Error) {
2839
- logger.warn(`[TITLE] ${persistedName.message} for thread ${this.threadId}`)
2840
- return
2841
- }
2842
- this.lastSyncedThreadName = persistedName
2902
+ const normalizedTitle = info.title.trim()
2903
+ if (this.appliedOpencodeTitle === normalizedTitle) {
2904
+ return
2843
2905
  }
2844
-
2845
- const renameDecision = deriveThreadRenameFromSessionUpdate({
2906
+ const desiredName = deriveThreadNameFromSessionTitle({
2846
2907
  sessionTitle: info.title,
2847
2908
  currentName: this.thread.name,
2848
- lastSyncedName: this.lastSyncedThreadName,
2849
2909
  })
2850
- if (renameDecision.desiredName === null) {
2851
- if (
2852
- renameDecision.nextSyncedName !== null &&
2853
- renameDecision.nextSyncedName !== this.lastSyncedThreadName
2854
- ) {
2855
- await this.persistLastSyncedThreadName(renameDecision.nextSyncedName)
2856
- }
2857
- return
2858
- }
2859
- const { desiredName } = renameDecision
2860
- const normalizedTitle = info.title.trim()
2861
- if (this.appliedOpencodeTitle === normalizedTitle) {
2910
+ // Mark before setName so concurrent session.updated events don't stack
2911
+ // renames. Keep the mark on failure — retry is almost always a rate limit.
2912
+ this.appliedOpencodeTitle = normalizedTitle
2913
+ if (!desiredName) {
2862
2914
  return
2863
2915
  }
2864
- // Mark before the call so concurrent session.updated events don't stack
2865
- // rename attempts. On failure we keep the mark — a retry won't help
2866
- // because the failure is almost always a rate limit.
2867
- this.appliedOpencodeTitle = normalizedTitle
2868
2916
 
2869
- const RENAME_TIMEOUT_MS = 3000
2870
- const timeoutSignal = AbortSignal.timeout(RENAME_TIMEOUT_MS)
2871
- const renameResult = await Promise.race([
2872
- this.thread.setName(desiredName)
2917
+ const renameResult = await raceDiscordRename({
2918
+ rename: this.thread.setName(desiredName)
2873
2919
  .catch((e) =>
2874
2920
  new Error('Failed to rename thread from OpenCode title', {
2875
2921
  cause: e,
2876
2922
  }),
2877
2923
  ),
2878
- new Promise<'timeout'>((resolve) => {
2879
- timeoutSignal.addEventListener('abort', () => {
2880
- resolve('timeout')
2881
- })
2882
- }),
2883
- ])
2924
+ })
2884
2925
 
2885
2926
  if (renameResult === 'timeout') {
2886
2927
  logger.warn(
2887
- `[TITLE] setName timed out after ${RENAME_TIMEOUT_MS}ms for thread ${this.threadId} (likely rate-limited)`,
2928
+ `[TITLE] setName timed out after ${DISCORD_THREAD_RENAME_TIMEOUT_MS}ms for thread ${this.threadId} (likely rate-limited)`,
2888
2929
  )
2889
2930
  return
2890
2931
  }
@@ -2894,38 +2935,11 @@ export class ThreadSessionRuntime {
2894
2935
  )
2895
2936
  return
2896
2937
  }
2897
- await this.persistLastSyncedThreadName(desiredName)
2898
2938
  logger.log(
2899
2939
  `[TITLE] Renamed thread ${this.threadId} to "${desiredName}" from OpenCode session title`,
2900
2940
  )
2901
2941
  }
2902
2942
 
2903
- private async loadLastSyncedThreadName() {
2904
- const db = await getDb()
2905
- const row = await db.query.thread_sessions.findFirst({
2906
- where: { thread_id: this.threadId },
2907
- columns: { last_synced_name: true },
2908
- })
2909
- return row?.last_synced_name ?? null
2910
- }
2911
-
2912
- private async persistLastSyncedThreadName(name: string): Promise<void> {
2913
- this.lastSyncedThreadName = name
2914
- const db = await getDb()
2915
- const result = await db.update(schema.thread_sessions)
2916
- .set({ last_synced_name: name })
2917
- .where(orm.eq(schema.thread_sessions.thread_id, this.threadId))
2918
- .catch(
2919
- (e) =>
2920
- new Error('Failed to persist thread rename state', {
2921
- cause: e,
2922
- }),
2923
- )
2924
- if (result instanceof Error) {
2925
- logger.warn(`[TITLE] ${result.message} for thread ${this.threadId}`)
2926
- }
2927
- }
2928
-
2929
2943
  private async handleTuiToast(properties: {
2930
2944
  title?: string
2931
2945
  message: string
@@ -2965,6 +2979,7 @@ export class ThreadSessionRuntime {
2965
2979
  * fields that the local-queue path provides.
2966
2980
  */
2967
2981
  private async submitViaOpencodeQueue(input: IngressInput): Promise<EnqueueResult> {
2982
+ await this.supersedePendingSleep(input)
2968
2983
  let skippedBySessionGuard = false
2969
2984
 
2970
2985
  await this.dispatchAction(async () => {
@@ -3039,6 +3054,18 @@ export class ThreadSessionRuntime {
3039
3054
  await clearSessionModel(session.id)
3040
3055
  }
3041
3056
 
3057
+ if (input.model) {
3058
+ const validatedModel = await validateModelId({
3059
+ model: input.model,
3060
+ getClient,
3061
+ directory: this.sdkDirectory,
3062
+ })
3063
+ if (validatedModel instanceof Error) {
3064
+ await cleanupOnError(`Failed to resolve model: ${validatedModel.message}`)
3065
+ return
3066
+ }
3067
+ }
3068
+
3042
3069
  await ensureSessionPreferencesSnapshot({
3043
3070
  sessionId: session.id,
3044
3071
  channelId,
@@ -3067,11 +3094,11 @@ export class ThreadSessionRuntime {
3067
3094
  const [modelResult, preferredVariant] = await Promise.all([
3068
3095
  (async () => {
3069
3096
  if (input.model) {
3070
- const [providerID, ...modelParts] = input.model.split('/')
3071
- const modelID = modelParts.join('/')
3072
- if (providerID && modelID) {
3073
- return { providerID, modelID }
3074
- }
3097
+ return validateModelId({
3098
+ model: input.model,
3099
+ getClient,
3100
+ directory: this.sdkDirectory,
3101
+ })
3075
3102
  }
3076
3103
  const modelInfo = await getCurrentModelInfo({
3077
3104
  sessionId: session.id,
@@ -3186,6 +3213,7 @@ export class ThreadSessionRuntime {
3186
3213
  userId: input.userId,
3187
3214
  sourceMessageId: input.sourceMessageId,
3188
3215
  sourceThreadId: input.sourceThreadId,
3216
+ threadName: this.thread.name || undefined,
3189
3217
  repliedMessage: input.repliedMessage,
3190
3218
  worktree,
3191
3219
  currentAgent: resolvedAgent,
@@ -3269,7 +3297,28 @@ export class ThreadSessionRuntime {
3269
3297
  * Enqueue in kimaki's local per-thread queue.
3270
3298
  * Used for explicit queue workflows (/queue, queueMessage=true).
3271
3299
  */
3300
+ /**
3301
+ * A new turn supersedes a pending sleep.
3302
+ *
3303
+ * Called from the two terminal routers rather than from the top of
3304
+ * enqueueIncoming: arrival order is only fixed once a message reaches the
3305
+ * preprocessChain link, so awaiting anything before that lets two rapid
3306
+ * messages swap places. By here the order is already committed.
3307
+ *
3308
+ * Awaited rather than fire-and-forget so it cannot race the task runner and
3309
+ * let a stale wake land after the user took the conversation back.
3310
+ */
3311
+ private async supersedePendingSleep(input: IngressInput): Promise<void> {
3312
+ if (input.isSleepWake) return
3313
+ await cancelSessionSleepForThread({ threadId: this.threadId }).catch(
3314
+ (error) => {
3315
+ logger.error('[SLEEP] failed to cancel pending sleep:', error)
3316
+ },
3317
+ )
3318
+ }
3319
+
3272
3320
  private async enqueueViaLocalQueue(input: IngressInput): Promise<EnqueueResult> {
3321
+ await this.supersedePendingSleep(input)
3273
3322
  const queueId = crypto.randomBytes(8).toString('hex')
3274
3323
  const queuedMessage: QueuedMessage = {
3275
3324
  queueId,
@@ -3540,6 +3589,11 @@ export class ThreadSessionRuntime {
3540
3589
  )
3541
3590
 
3542
3591
  this.stopTyping()
3592
+ this.deferredQuestionShow.clear()
3593
+
3594
+ // The aborted run owns the question request, so the dropdown dies with it.
3595
+ // Questions have no TTL, so this is the only thing that clears them here.
3596
+ void cancelPendingQuestion(this.threadId)
3543
3597
 
3544
3598
  const apiAbortPromise = sessionId
3545
3599
  ? this.abortSessionViaApi({ abortId, reason, sessionId })
@@ -3613,14 +3667,6 @@ export class ThreadSessionRuntime {
3613
3667
  return this.state?.queueItems.length ?? 0
3614
3668
  }
3615
3669
 
3616
- /** NOTIFY_MESSAGE_FLAGS unless queue has a next item, then SILENT.
3617
- * Permissions should NOT use this — they always notify. */
3618
- private getNotifyFlags(): number {
3619
- return this.getQueueLength() > 0
3620
- ? SILENT_MESSAGE_FLAGS
3621
- : NOTIFY_MESSAGE_FLAGS
3622
- }
3623
-
3624
3670
  /** Clear all queued messages. Returns the removed items. */
3625
3671
  clearQueue(): threadState.QueuedMessage[] {
3626
3672
  return threadState.clearQueueItems(this.threadId)
@@ -3809,6 +3855,24 @@ export class ThreadSessionRuntime {
3809
3855
  await clearSessionModel(session.id)
3810
3856
  }
3811
3857
 
3858
+ if (input.model) {
3859
+ const validatedModel = await validateModelId({
3860
+ model: input.model,
3861
+ getClient,
3862
+ directory: this.sdkDirectory,
3863
+ })
3864
+ if (validatedModel instanceof Error) {
3865
+ this.stopTyping()
3866
+ await sendThreadMessage(
3867
+ this.thread,
3868
+ `Failed to resolve model: ${validatedModel.message}`,
3869
+ { flags: NOTIFY_MESSAGE_FLAGS },
3870
+ )
3871
+ await this.tryDrainQueue({ showIndicator: true })
3872
+ return
3873
+ }
3874
+ }
3875
+
3812
3876
  await ensureSessionPreferencesSnapshot({
3813
3877
  sessionId: session.id,
3814
3878
  channelId,
@@ -3844,11 +3908,11 @@ export class ThreadSessionRuntime {
3844
3908
  const [earlyModelResult, preferredVariant] = await Promise.all([
3845
3909
  (async () => {
3846
3910
  if (input.model) {
3847
- const [providerID, ...modelParts] = input.model.split('/')
3848
- const modelID = modelParts.join('/')
3849
- if (providerID && modelID) {
3850
- return { providerID, modelID }
3851
- }
3911
+ return validateModelId({
3912
+ model: input.model,
3913
+ getClient,
3914
+ directory: this.sdkDirectory,
3915
+ })
3852
3916
  }
3853
3917
  const modelInfo = await getCurrentModelInfo({
3854
3918
  sessionId: session.id,
@@ -3975,6 +4039,7 @@ export class ThreadSessionRuntime {
3975
4039
  userId: input.userId,
3976
4040
  sourceMessageId: input.sourceMessageId,
3977
4041
  sourceThreadId: input.sourceThreadId,
4042
+ threadName: this.thread.name || undefined,
3978
4043
  repliedMessage: input.repliedMessage,
3979
4044
  worktree,
3980
4045
  currentAgent: earlyAgentPreference,
@@ -4025,6 +4090,7 @@ export class ThreadSessionRuntime {
4025
4090
  userId: input.userId,
4026
4091
  sourceMessageId: input.sourceMessageId,
4027
4092
  sourceThreadId: input.sourceThreadId,
4093
+ threadName: this.thread.name || undefined,
4028
4094
  repliedMessage: input.repliedMessage,
4029
4095
  })
4030
4096
  // OpenCode's session.command API has no `system` field. Persist the
@@ -4477,7 +4543,6 @@ export class ThreadSessionRuntime {
4477
4543
  elapsedMs < 1000
4478
4544
  ? '<1s'
4479
4545
  : prettyMilliseconds(elapsedMs, { secondsDecimalDigits: 0 })
4480
- const modelInfo = runInfo.model ? ` ⋅ ${runInfo.model}` : ''
4481
4546
  const agentInfo =
4482
4547
  runInfo.agent && runInfo.agent.toLowerCase() !== 'build'
4483
4548
  ? ` ⋅ **${runInfo.agent}**`
@@ -4494,7 +4559,7 @@ export class ThreadSessionRuntime {
4494
4559
  }).catch((e) => new FilesystemOperationError({ operation: 'gitBranch', cause: e })),
4495
4560
  (async () => {
4496
4561
  if (!client || !sessionId) {
4497
- return
4562
+ return []
4498
4563
  }
4499
4564
  let tokensUsed = runInfo.tokensUsed
4500
4565
  // Fetch final token count from API
@@ -4534,9 +4599,12 @@ export class ThreadSessionRuntime {
4534
4599
  })
4535
4600
  : undefined
4536
4601
 
4602
+ const providers = providersResult && !(providersResult instanceof Error)
4603
+ ? providersResult.data?.all ?? []
4604
+ : []
4537
4605
  let contextLimit = fallbackLimit
4538
- if (providersResult && !(providersResult instanceof Error)) {
4539
- const provider = providersResult.data?.all?.find((p) => {
4606
+ if (providers.length > 0) {
4607
+ const provider = providers.find((p) => {
4540
4608
  return p.id === runInfo.providerID
4541
4609
  })
4542
4610
  const model = provider?.models?.[runInfo.model || '']
@@ -4549,6 +4617,7 @@ export class ThreadSessionRuntime {
4549
4617
  )
4550
4618
  contextInfo = ` ⋅ ${percentage}%`
4551
4619
  }
4620
+ return providers
4552
4621
  })().catch((e) => new OpenCodeSdkError({ operation: 'resolveModelPreference', cause: e })),
4553
4622
  ])
4554
4623
  const branchName =
@@ -4559,6 +4628,18 @@ export class ThreadSessionRuntime {
4559
4628
  contextResult,
4560
4629
  )
4561
4630
  }
4631
+ const providers = contextResult instanceof Error ? [] : (contextResult ?? [])
4632
+ const modelLabel = runInfo.model
4633
+ ? displayedModelLabel({
4634
+ modelID: runInfo.model,
4635
+ name: getProviderModelName({
4636
+ providers,
4637
+ providerID: runInfo.providerID,
4638
+ modelID: runInfo.model,
4639
+ }),
4640
+ })
4641
+ : undefined
4642
+ const modelInfo = modelLabel ? ` ⋅ ${modelLabel}` : ''
4562
4643
 
4563
4644
  const truncate = (s: string, max: number) => {
4564
4645
  return s.length > max ? s.slice(0, max - 1) + '\u2026' : s
@@ -4568,13 +4649,17 @@ export class ThreadSessionRuntime {
4568
4649
  const projectInfo = truncatedBranch
4569
4650
  ? `${truncatedFolder} ⋅ ${truncatedBranch} ⋅ `
4570
4651
  : `${truncatedFolder} ⋅ `
4571
- const footerText = `*${projectInfo}${sessionDuration}${contextInfo}${modelInfo}${agentInfo}*`
4652
+ const hasQueuedMessage = this.getQueueLength() > 0
4653
+ const mention = store.getState().footerMentionsEnabled
4654
+ && !hasQueuedMessage
4655
+ && this.state?.sessionUserId
4656
+ ? ` <@${this.state.sessionUserId}>`
4657
+ : ''
4658
+ const footerText = `*${projectInfo}${sessionDuration}${contextInfo}${modelInfo}${agentInfo}*${mention}`
4572
4659
  this.stopTyping()
4573
4660
 
4574
- // Skip notification if there's a queued message next — the user only
4575
- // needs to be notified when the entire queue finishes.
4576
4661
  await sendThreadMessage(this.thread, footerText, {
4577
- flags: this.getNotifyFlags(),
4662
+ flags: hasQueuedMessage ? SILENT_MESSAGE_FLAGS : NOTIFY_MESSAGE_FLAGS,
4578
4663
  })
4579
4664
  logger.log(
4580
4665
  `DURATION: Session completed in ${sessionDuration}, model ${runInfo.model}, tokens ${runInfo.tokensUsed}`,