kimaki 0.26.0 → 0.28.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.
- package/dist/agent-model.e2e.test.js +250 -164
- package/dist/anthropic-account-identity.js +9 -1
- package/dist/anthropic-auth-plugin.js +10 -13
- package/dist/anthropic-auth-plugin.test.js +17 -0
- package/dist/anthropic-auth-state.js +1 -0
- package/dist/bash-tool-schema-plugin.js +32 -0
- package/dist/bash-tool-schema-plugin.test.js +49 -0
- package/dist/bin.js +73 -19
- package/dist/cli-commands/maintenance.js +46 -7
- package/dist/cli-commands/project.js +11 -27
- package/dist/cli-commands/send.js +30 -1
- package/dist/cli-commands/session.js +322 -90
- package/dist/cli-commands/task.js +34 -15
- package/dist/cli-parsing.test.js +83 -4
- package/dist/cli-runner.js +44 -8
- package/dist/cli-runner.test.js +27 -0
- package/dist/cli-send-thread.e2e.test.js +9 -9
- package/dist/cli.js +32 -0
- package/dist/commands/abort.js +5 -1
- package/dist/commands/action-buttons.js +2 -1
- package/dist/commands/add-project.js +4 -7
- package/dist/commands/agent.js +164 -32
- package/dist/commands/ask-question.js +23 -33
- package/dist/commands/ask-question.test.js +43 -1
- package/dist/commands/btw.js +28 -6
- package/dist/commands/file-upload.js +2 -1
- package/dist/commands/fork-subagent.js +2 -4
- package/dist/commands/fork.js +4 -4
- package/dist/commands/last-sessions.js +17 -10
- package/dist/commands/login.js +6 -0
- package/dist/commands/merge-worktree.js +47 -14
- package/dist/commands/model-variant.js +8 -1
- package/dist/commands/model.js +16 -9
- package/dist/commands/multioauth.js +1 -0
- package/dist/commands/new-worktree.js +170 -102
- package/dist/commands/queue.js +3 -2
- package/dist/commands/resume.js +4 -4
- package/dist/commands/session.js +78 -63
- package/dist/commands/user-command.js +6 -8
- package/dist/commands/worktrees.js +5 -13
- package/dist/config.js +16 -1
- package/dist/cpu-profiler.js +211 -0
- package/dist/cpu-profiler.test.js +160 -0
- package/dist/database.js +152 -3
- package/dist/db.js +33 -0
- package/dist/db.test.js +267 -1
- package/dist/default-channel-provisioning.test.js +148 -0
- package/dist/discord-bot.js +517 -488
- package/dist/discord-command-registration.js +25 -10
- package/dist/discord-command-registration.test.js +17 -0
- package/dist/discord-utils.js +97 -5
- package/dist/discord-utils.test.js +151 -1
- package/dist/errors.js +10 -0
- package/dist/exec-async.js +17 -5
- package/dist/external-directory-permissions.e2e.test.js +11 -7
- package/dist/external-directory-project-deny.e2e.test.js +5 -3
- package/dist/external-opencode-sync.js +94 -17
- package/dist/external-opencode-sync.test.js +206 -0
- package/dist/extract-sdk-error-message.test.js +13 -0
- package/dist/file-edit-log.js +197 -0
- package/dist/file-edit-log.test.js +258 -0
- package/dist/format-tables.js +52 -0
- package/dist/format-tables.test.js +93 -0
- package/dist/gateway-proxy-reconnect.e2e.test.js +28 -8
- package/dist/gateway-proxy.e2e.test.js +18 -18
- package/dist/git-worktree-core.js +182 -17
- package/dist/interaction-handler.js +416 -398
- package/dist/ipc-tools-plugin.js +68 -3
- package/dist/kimaki-opencode-plugin-loading.e2e.test.js +122 -58
- package/dist/kimaki-opencode-plugin.js +9 -3
- package/dist/kimaki-workspace-adaptor.js +33 -10
- package/dist/logger.js +11 -1
- package/dist/markdown.js +3 -1
- package/dist/markdown.test.js +11 -3
- package/dist/message-formatting.js +181 -51
- package/dist/message-formatting.test.js +529 -22
- package/dist/message-preprocessing.js +105 -1
- package/dist/oauth-rotation-shared.js +10 -0
- package/dist/openai-auth-plugin.js +1 -0
- package/dist/openai-auth-state.js +1 -0
- package/dist/opencode-command.js +28 -0
- package/dist/opencode-command.test.js +109 -1
- package/dist/opencode-interrupt-plugin.js +4 -0
- package/dist/opencode-interrupt-plugin.test.js +24 -0
- package/dist/opencode.js +82 -15
- package/dist/project-add.test.js +62 -0
- package/dist/question-abort-clears-dropdown.e2e.test.js +89 -0
- package/dist/question-abort-retry-text-before-dropdown.e2e.test.js +150 -0
- package/dist/question-text-before-dropdown.e2e.test.js +52 -0
- package/dist/queue-advanced-abort.e2e.test.js +8 -10
- package/dist/queue-advanced-action-buttons.e2e.test.js +8 -8
- package/dist/queue-advanced-e2e-setup.js +78 -5
- package/dist/queue-advanced-footer.e2e.test.js +60 -269
- package/dist/queue-advanced-model-switch.e2e.test.js +9 -11
- package/dist/queue-advanced-permissions-typing.e2e.test.js +15 -15
- package/dist/queue-advanced-question.e2e.test.js +32 -27
- package/dist/queue-advanced-typing-interrupt.e2e.test.js +10 -12
- package/dist/queue-advanced-typing.e2e.test.js +7 -9
- package/dist/queue-delete-message.e2e.test.js +3 -3
- package/dist/queue-drain-after-interactive-ui.e2e.test.js +5 -5
- package/dist/queue-interrupt-drain.e2e.test.js +122 -12
- package/dist/queue-question-select-drain.e2e.test.js +34 -132
- package/dist/runtime-lifecycle.e2e.test.js +21 -51
- package/dist/schema.js +35 -1
- package/dist/session-handler/event-stream-state.js +391 -50
- package/dist/session-handler/event-stream-state.test.js +736 -8
- package/dist/session-handler/model-utils.js +172 -5
- package/dist/session-handler/model-utils.test.js +445 -0
- package/dist/session-handler/opencode-session-event-log.js +30 -1
- package/dist/session-handler/thread-ingress.test.js +41 -0
- package/dist/session-handler/thread-runtime-state.js +2 -0
- package/dist/session-handler/thread-session-runtime-summary.test.js +121 -0
- package/dist/session-handler/thread-session-runtime.js +574 -299
- package/dist/session-search.js +123 -4
- package/dist/session-search.test.js +248 -1
- package/dist/session-sleep.test.js +224 -0
- package/dist/session-title-rename.test.js +37 -39
- package/dist/sleep.e2e.test.js +494 -0
- package/dist/startup-time.e2e.test.js +2 -1
- package/dist/store.js +3 -0
- package/dist/system-message.js +102 -16
- package/dist/system-message.test.js +192 -15
- package/dist/task-runner.js +96 -5
- package/dist/task-schedule.js +87 -1
- package/dist/task-schedule.test.js +149 -1
- package/dist/test-utils.js +17 -9
- package/dist/thinking-utils.js +10 -0
- package/dist/thinking-utils.test.js +35 -0
- package/dist/thread-message-queue.e2e.test.js +65 -213
- package/dist/undo-redo.e2e.test.js +17 -9
- package/dist/voice-attachment.js +23 -1
- package/dist/voice-handler.js +21 -4
- package/dist/voice-message.e2e.test.js +144 -30
- package/dist/voice-routing-input.e2e.test.js +195 -0
- package/dist/voice-routing-question.e2e.test.js +125 -0
- package/dist/voice.js +28 -8
- package/dist/voice.test.js +96 -30
- package/dist/worktree-clone-isolation.e2e.test.js +188 -0
- package/dist/worktree-lifecycle.e2e.test.js +55 -36
- package/dist/worktrees.js +226 -443
- package/dist/worktrees.test.js +435 -58
- package/dist/xai-auth-plugin.js +1 -0
- package/dist/xai-auth-state.js +1 -0
- package/package.json +8 -6
- package/skills/bump-holocron-consumers/SKILL.md +137 -0
- package/skills/critique/SKILL.md +4 -4
- package/skills/errore/SKILL.md +60 -218
- package/skills/goke/SKILL.md +150 -10
- package/skills/holocron/SKILL.md +154 -17
- package/skills/npm-package/SKILL.md +55 -35
- package/skills/profano/SKILL.md +17 -2
- package/skills/spiceflow/SKILL.md +39 -3
- package/skills/strada/SKILL.md +39 -0
- package/skills/zele/SKILL.md +19 -0
- package/src/agent-model.e2e.test.ts +287 -186
- package/src/anthropic-account-identity.ts +20 -1
- package/src/anthropic-auth-plugin.test.ts +19 -0
- package/src/anthropic-auth-plugin.ts +12 -16
- package/src/anthropic-auth-state.ts +1 -0
- package/src/bash-tool-schema-plugin.test.ts +55 -0
- package/src/bash-tool-schema-plugin.ts +46 -0
- package/src/bin.ts +78 -26
- package/src/cli-commands/maintenance.ts +59 -13
- package/src/cli-commands/project.ts +14 -35
- package/src/cli-commands/send.ts +31 -1
- package/src/cli-commands/session.ts +374 -114
- package/src/cli-commands/task.ts +34 -15
- package/src/cli-parsing.test.ts +103 -4
- package/src/cli-runner.test.ts +49 -0
- package/src/cli-runner.ts +44 -7
- package/src/cli-send-thread.e2e.test.ts +9 -11
- package/src/cli.ts +53 -0
- package/src/commands/abort.ts +6 -1
- package/src/commands/action-buttons.ts +2 -1
- package/src/commands/add-project.ts +4 -8
- package/src/commands/agent.ts +205 -29
- package/src/commands/ask-question.test.ts +48 -1
- package/src/commands/ask-question.ts +23 -33
- package/src/commands/btw.ts +40 -5
- package/src/commands/file-upload.ts +2 -1
- package/src/commands/fork-subagent.ts +2 -3
- package/src/commands/fork.ts +4 -3
- package/src/commands/last-sessions.ts +10 -4
- package/src/commands/login.ts +6 -0
- package/src/commands/merge-worktree.ts +54 -16
- package/src/commands/model-variant.ts +9 -1
- package/src/commands/model.ts +20 -9
- package/src/commands/multioauth.ts +1 -0
- package/src/commands/new-worktree.ts +138 -55
- package/src/commands/queue.ts +3 -2
- package/src/commands/resume.ts +4 -3
- package/src/commands/session.ts +94 -70
- package/src/commands/user-command.ts +6 -6
- package/src/commands/worktrees.ts +5 -12
- package/src/config.ts +18 -1
- package/src/cpu-profiler.test.ts +183 -0
- package/src/cpu-profiler.ts +293 -0
- package/src/database.ts +235 -3
- package/src/db.test.ts +293 -0
- package/src/db.ts +38 -0
- package/src/default-channel-provisioning.test.ts +155 -0
- package/src/discord-bot.ts +78 -36
- package/src/discord-command-registration.test.ts +20 -0
- package/src/discord-command-registration.ts +42 -12
- package/src/discord-utils.test.ts +176 -0
- package/src/discord-utils.ts +152 -7
- package/src/errors.ts +12 -0
- package/src/exec-async.ts +20 -11
- package/src/external-directory-permissions.e2e.test.ts +11 -7
- package/src/external-directory-project-deny.e2e.test.ts +5 -3
- package/src/external-opencode-sync.test.ts +248 -0
- package/src/external-opencode-sync.ts +122 -19
- package/src/extract-sdk-error-message.test.ts +18 -0
- package/src/file-edit-log.test.ts +287 -0
- package/src/file-edit-log.ts +283 -0
- package/src/format-tables.test.ts +101 -0
- package/src/format-tables.ts +75 -0
- package/src/gateway-proxy-reconnect.e2e.test.ts +38 -8
- package/src/gateway-proxy.e2e.test.ts +18 -17
- package/src/git-worktree-core.ts +247 -20
- package/src/interaction-handler.ts +36 -13
- package/src/ipc-tools-plugin.ts +82 -5
- package/src/kimaki-opencode-plugin-loading.e2e.test.ts +188 -97
- package/src/kimaki-opencode-plugin.ts +9 -3
- package/src/kimaki-workspace-adaptor.ts +40 -11
- package/src/logger.ts +14 -4
- package/src/markdown.test.ts +13 -3
- package/src/markdown.ts +4 -1
- package/src/message-formatting.test.ts +672 -25
- package/src/message-formatting.ts +242 -53
- package/src/message-preprocessing.ts +116 -1
- package/src/oauth-rotation-shared.ts +10 -0
- package/src/openai-auth-plugin.ts +1 -0
- package/src/openai-auth-state.ts +1 -0
- package/src/opencode-command.test.ts +140 -0
- package/src/opencode-command.ts +38 -0
- package/src/opencode-interrupt-plugin.test.ts +32 -0
- package/src/opencode-interrupt-plugin.ts +3 -0
- package/src/opencode.ts +115 -15
- package/src/project-add.test.ts +80 -0
- package/src/question-abort-clears-dropdown.e2e.test.ts +109 -0
- package/src/question-abort-retry-text-before-dropdown.e2e.test.ts +172 -0
- package/src/question-text-before-dropdown.e2e.test.ts +67 -0
- package/src/queue-advanced-abort.e2e.test.ts +8 -9
- package/src/queue-advanced-action-buttons.e2e.test.ts +8 -8
- package/src/queue-advanced-e2e-setup.ts +83 -4
- package/src/queue-advanced-footer.e2e.test.ts +67 -315
- package/src/queue-advanced-model-switch.e2e.test.ts +9 -10
- package/src/queue-advanced-permissions-typing.e2e.test.ts +15 -15
- package/src/queue-advanced-question.e2e.test.ts +36 -30
- package/src/queue-advanced-typing-interrupt.e2e.test.ts +11 -11
- package/src/queue-advanced-typing.e2e.test.ts +8 -8
- package/src/queue-delete-message.e2e.test.ts +3 -3
- package/src/queue-drain-after-interactive-ui.e2e.test.ts +5 -5
- package/src/queue-interrupt-drain.e2e.test.ts +133 -12
- package/src/queue-question-select-drain.e2e.test.ts +36 -161
- package/src/runtime-lifecycle.e2e.test.ts +21 -54
- package/src/schema.sql +15 -2
- package/src/schema.ts +37 -1
- package/src/session-handler/event-stream-state.test.ts +790 -7
- package/src/session-handler/event-stream-state.ts +568 -53
- package/src/session-handler/model-utils.test.ts +537 -0
- package/src/session-handler/model-utils.ts +281 -5
- package/src/session-handler/opencode-session-event-log.ts +31 -1
- package/src/session-handler/thread-ingress.test.ts +54 -0
- package/src/session-handler/thread-runtime-state.ts +3 -0
- package/src/session-handler/thread-session-runtime-summary.test.ts +169 -0
- package/src/session-handler/thread-session-runtime.ts +712 -358
- package/src/session-search.test.ts +281 -0
- package/src/session-search.ts +241 -7
- package/src/session-sleep.test.ts +294 -0
- package/src/session-title-rename.test.ts +54 -48
- package/src/sleep.e2e.test.ts +565 -0
- package/src/startup-time.e2e.test.ts +5 -1
- package/src/store.ts +22 -0
- package/src/system-message.test.ts +226 -15
- package/src/system-message.ts +130 -16
- package/src/task-runner.ts +138 -3
- package/src/task-schedule.test.ts +170 -0
- package/src/task-schedule.ts +141 -1
- package/src/test-utils.ts +21 -10
- package/src/thinking-utils.test.ts +46 -0
- package/src/thinking-utils.ts +21 -0
- package/src/thread-message-queue.e2e.test.ts +65 -245
- package/src/undo-redo.e2e.test.ts +17 -9
- package/src/voice-attachment.ts +29 -1
- package/src/voice-handler.ts +23 -4
- package/src/voice-message.e2e.test.ts +149 -29
- package/src/voice-routing-input.e2e.test.ts +203 -0
- package/src/voice-routing-question.e2e.test.ts +131 -0
- package/src/voice.test.ts +107 -29
- package/src/voice.ts +34 -6
- package/src/worktree-clone-isolation.e2e.test.ts +213 -0
- package/src/worktree-lifecycle.e2e.test.ts +55 -35
- package/src/worktrees.test.ts +505 -64
- package/src/worktrees.ts +257 -603
- package/src/xai-auth-plugin.ts +1 -0
- package/src/xai-auth-state.ts +1 -0
- package/dist/message-finish-field.e2e.test.js +0 -169
- package/dist/subagent-rate-limit-plugin.js +0 -184
- package/src/message-finish-field.e2e.test.ts +0 -196
- package/src/subagent-rate-limit-plugin.ts +0 -228
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// run internals.
|
|
8
8
|
|
|
9
9
|
import crypto from 'node:crypto'
|
|
10
|
+
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
10
11
|
import { ChannelType, type ThreadChannel } from 'discord.js'
|
|
11
12
|
import type {
|
|
12
13
|
Event as OpenCodeEvent,
|
|
@@ -38,11 +39,27 @@ import {
|
|
|
38
39
|
import { createLogger, LogPrefix } from '../logger.js'
|
|
39
40
|
import {
|
|
40
41
|
sendThreadMessage,
|
|
42
|
+
sendSessionPartMessage,
|
|
41
43
|
SILENT_MESSAGE_FLAGS,
|
|
42
44
|
NOTIFY_MESSAGE_FLAGS,
|
|
45
|
+
raceDiscordRename,
|
|
46
|
+
DISCORD_THREAD_RENAME_TIMEOUT_MS,
|
|
47
|
+
resolveThreadFooterMentionUserId,
|
|
43
48
|
} from '../discord-utils.js'
|
|
44
|
-
import type { DiscordFileAttachment } from '../message-formatting.js'
|
|
45
|
-
import {
|
|
49
|
+
import type { DiscordFileAttachment, SessionPartKind } from '../message-formatting.js'
|
|
50
|
+
import {
|
|
51
|
+
asDiscordQuote,
|
|
52
|
+
formatPart,
|
|
53
|
+
formatTaskToolTitle,
|
|
54
|
+
planAssistantTurnFlush,
|
|
55
|
+
QUEUE_PREFIX,
|
|
56
|
+
sessionPartKind,
|
|
57
|
+
shouldLeadWithBlankLine,
|
|
58
|
+
shouldQuoteIntermediateTextPart,
|
|
59
|
+
STATUS_PREFIX,
|
|
60
|
+
WORKTREE_PREFIX,
|
|
61
|
+
type AssistantTurnFlushMode,
|
|
62
|
+
} from '../message-formatting.js'
|
|
46
63
|
import {
|
|
47
64
|
getChannelVerbosity,
|
|
48
65
|
getPartMessageIds,
|
|
@@ -54,14 +71,18 @@ import {
|
|
|
54
71
|
setThreadParentSessionId,
|
|
55
72
|
getThreadWorktreeOrWorkspace,
|
|
56
73
|
setSessionAgent,
|
|
74
|
+
setSessionModel,
|
|
57
75
|
clearSessionModel,
|
|
58
76
|
getVariantCascade,
|
|
59
77
|
setSessionStartSource,
|
|
78
|
+
getSessionStartSource,
|
|
79
|
+
getScheduledTask,
|
|
60
80
|
completeScheduledTaskRunsForSession,
|
|
61
81
|
failScheduledTaskRunsForSession,
|
|
62
82
|
startScheduledTaskRunSession,
|
|
63
83
|
appendSessionEventsSinceLastTimestamp,
|
|
64
84
|
getSessionEventSnapshot,
|
|
85
|
+
cancelSessionSleepForThread,
|
|
65
86
|
} from '../database.js'
|
|
66
87
|
import * as orm from 'drizzle-orm'
|
|
67
88
|
import * as schema from '../schema.js'
|
|
@@ -75,6 +96,7 @@ import {
|
|
|
75
96
|
showAskUserQuestionDropdowns,
|
|
76
97
|
pendingQuestionContexts,
|
|
77
98
|
cancelPendingQuestion,
|
|
99
|
+
findPendingQuestionContextForRequest,
|
|
78
100
|
} from '../commands/ask-question.js'
|
|
79
101
|
import {
|
|
80
102
|
showActionButtons,
|
|
@@ -90,6 +112,11 @@ import {
|
|
|
90
112
|
getCurrentModelInfo,
|
|
91
113
|
ensureSessionPreferencesSnapshot,
|
|
92
114
|
} from '../commands/model.js'
|
|
115
|
+
import {
|
|
116
|
+
displayedModelLabel,
|
|
117
|
+
resolveDisplayedModelName,
|
|
118
|
+
validateModelId,
|
|
119
|
+
} from './model-utils.js'
|
|
93
120
|
import {
|
|
94
121
|
getOpencodePromptContext,
|
|
95
122
|
getOpencodeSystemMessage,
|
|
@@ -97,8 +124,10 @@ import {
|
|
|
97
124
|
type AgentInfo,
|
|
98
125
|
type RepliedMessageContext,
|
|
99
126
|
type WorktreeInfo,
|
|
127
|
+
type ScheduledTaskSystemContext,
|
|
100
128
|
} from '../system-message.js'
|
|
101
129
|
import { getDataDir } from '../config.js'
|
|
130
|
+
import { store } from '../store.js'
|
|
102
131
|
import {
|
|
103
132
|
trackEvent,
|
|
104
133
|
type AnalyticsIngressMode,
|
|
@@ -114,7 +143,9 @@ import {
|
|
|
114
143
|
} from './opencode-session-event-log.js'
|
|
115
144
|
import {
|
|
116
145
|
doesLatestUserTurnHaveNaturalCompletion,
|
|
146
|
+
didLatestUserTurnUseSleepTool,
|
|
117
147
|
didQuestionQueueHandoffSinceLatestQuestionAsked,
|
|
148
|
+
deriveLatestUnansweredQuestion,
|
|
118
149
|
getAssistantMessageIdsForLatestUserTurn,
|
|
119
150
|
getCurrentTurnStartTime,
|
|
120
151
|
isSessionBusy,
|
|
@@ -122,10 +153,14 @@ import {
|
|
|
122
153
|
getIdleTokenUsageDelta,
|
|
123
154
|
getDerivedSubtaskIndex,
|
|
124
155
|
getDerivedSubtaskAgentType,
|
|
156
|
+
getTokenUsageSessionIdsForIdle,
|
|
157
|
+
isDerivedChildSession,
|
|
125
158
|
getLatestAssistantMessageIdForLatestUserTurn,
|
|
159
|
+
getAssistantMessageKind,
|
|
126
160
|
hasAssistantMessageCompletedBefore,
|
|
127
161
|
isAssistantMessageInLatestUserTurn,
|
|
128
162
|
isAssistantMessageNaturalCompletion,
|
|
163
|
+
shouldBufferSessionEvent,
|
|
129
164
|
type EventBufferEvent,
|
|
130
165
|
type EventBufferEntry,
|
|
131
166
|
} from './event-stream-state.js'
|
|
@@ -188,6 +223,63 @@ const shouldLogSessionEvents =
|
|
|
188
223
|
|
|
189
224
|
const runtimes = new Map<string, ThreadSessionRuntime>()
|
|
190
225
|
|
|
226
|
+
// Per-thread FIFO for Discord arrival order of one-shot slash calls vs messages.
|
|
227
|
+
// Covers /plan-agent (no prompt) and /foo-cmd /foo-skill. OpenCode already
|
|
228
|
+
// queues promptAsync. /model, /agent, and /compact are not on this queue.
|
|
229
|
+
const threadIngressChains = new Map<string, Promise<void>>()
|
|
230
|
+
const threadIngressSlotAls = new AsyncLocalStorage<ThreadIngressSlot | undefined>()
|
|
231
|
+
|
|
232
|
+
export type ThreadIngressSlot = {
|
|
233
|
+
wait: Promise<void>
|
|
234
|
+
release: () => void
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function reserveThreadIngress(threadId: string): ThreadIngressSlot {
|
|
238
|
+
const previous = threadIngressChains.get(threadId) ?? Promise.resolve()
|
|
239
|
+
let released = false
|
|
240
|
+
let releaseHeld = () => {}
|
|
241
|
+
const held = new Promise<void>((resolve) => {
|
|
242
|
+
releaseHeld = resolve
|
|
243
|
+
})
|
|
244
|
+
threadIngressChains.set(
|
|
245
|
+
threadId,
|
|
246
|
+
previous.then(() => held),
|
|
247
|
+
)
|
|
248
|
+
return {
|
|
249
|
+
wait: previous,
|
|
250
|
+
release: () => {
|
|
251
|
+
if (released) {
|
|
252
|
+
return
|
|
253
|
+
}
|
|
254
|
+
released = true
|
|
255
|
+
releaseHeld()
|
|
256
|
+
},
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export async function runInThreadIngressSlot<T>(
|
|
261
|
+
slot: ThreadIngressSlot | undefined,
|
|
262
|
+
run: () => Promise<T>,
|
|
263
|
+
): Promise<T> {
|
|
264
|
+
try {
|
|
265
|
+
return await threadIngressSlotAls.run(slot, run)
|
|
266
|
+
} finally {
|
|
267
|
+
slot?.release()
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export async function waitForCurrentThreadIngress(): Promise<void> {
|
|
272
|
+
const slot = threadIngressSlotAls.getStore()
|
|
273
|
+
if (!slot) {
|
|
274
|
+
return
|
|
275
|
+
}
|
|
276
|
+
await slot.wait
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export function releaseCurrentThreadIngress(): void {
|
|
280
|
+
threadIngressSlotAls.getStore()?.release()
|
|
281
|
+
}
|
|
282
|
+
|
|
191
283
|
export function getRuntime(
|
|
192
284
|
threadId: string,
|
|
193
285
|
): ThreadSessionRuntime | undefined {
|
|
@@ -201,6 +293,7 @@ export type RuntimeOptions = {
|
|
|
201
293
|
sdkDirectory: string
|
|
202
294
|
channelId?: string
|
|
203
295
|
appId?: string
|
|
296
|
+
sessionId?: string
|
|
204
297
|
}
|
|
205
298
|
|
|
206
299
|
export function getOrCreateRuntime(
|
|
@@ -213,9 +306,15 @@ export function getOrCreateRuntime(
|
|
|
213
306
|
`[RUNTIME] Ignoring sdkDirectory change for existing thread ${opts.threadId}: ${existing.sdkDirectory} → ${opts.sdkDirectory}`,
|
|
214
307
|
)
|
|
215
308
|
}
|
|
309
|
+
if (opts.sessionId && !existing.state?.sessionId) {
|
|
310
|
+
threadState.setSessionId(opts.threadId, opts.sessionId)
|
|
311
|
+
}
|
|
216
312
|
return existing
|
|
217
313
|
}
|
|
218
314
|
threadState.ensureThread(opts.threadId) // add to global store
|
|
315
|
+
if (opts.sessionId) {
|
|
316
|
+
threadState.setSessionId(opts.threadId, opts.sessionId)
|
|
317
|
+
}
|
|
219
318
|
const runtime = new ThreadSessionRuntime(opts)
|
|
220
319
|
runtimes.set(opts.threadId, runtime)
|
|
221
320
|
return runtime
|
|
@@ -229,6 +328,7 @@ export function disposeRuntime(threadId: string): void {
|
|
|
229
328
|
runtime.dispose()
|
|
230
329
|
runtimes.delete(threadId)
|
|
231
330
|
threadState.removeThread(threadId) // remove from global store
|
|
331
|
+
threadIngressChains.delete(threadId)
|
|
232
332
|
}
|
|
233
333
|
|
|
234
334
|
export function disposeRuntimesForDirectory({
|
|
@@ -249,6 +349,7 @@ export function disposeRuntimesForDirectory({
|
|
|
249
349
|
runtime.dispose()
|
|
250
350
|
runtimes.delete(threadId)
|
|
251
351
|
threadState.removeThread(threadId)
|
|
352
|
+
threadIngressChains.delete(threadId)
|
|
252
353
|
count++
|
|
253
354
|
}
|
|
254
355
|
return count
|
|
@@ -279,6 +380,7 @@ export function disposeInactiveRuntimes({
|
|
|
279
380
|
runtime.dispose()
|
|
280
381
|
runtimes.delete(threadId)
|
|
281
382
|
threadState.removeThread(threadId)
|
|
383
|
+
threadIngressChains.delete(threadId)
|
|
282
384
|
disposedThreadIds.push(threadId)
|
|
283
385
|
disposedDirectories.add(runtime.projectDirectory)
|
|
284
386
|
}
|
|
@@ -419,16 +521,20 @@ export function isEssentialToolPart(part: Part): boolean {
|
|
|
419
521
|
// ── Thread title derivation ──────────────────────────────────────
|
|
420
522
|
|
|
421
523
|
const DISCORD_THREAD_NAME_MAX = 100
|
|
422
|
-
const WORKTREE_THREAD_PREFIX = '⬦ '
|
|
423
|
-
|
|
424
|
-
// Prefixes that should survive OpenCode session title renames.
|
|
425
|
-
// When a thread starts with one of these, the rename preserves it.
|
|
426
524
|
const PRESERVED_THREAD_PREFIXES: string[] = [
|
|
427
|
-
|
|
525
|
+
WORKTREE_PREFIX,
|
|
428
526
|
'btw: ',
|
|
429
527
|
'Fork: ',
|
|
430
528
|
]
|
|
431
529
|
|
|
530
|
+
function stripPreservedThreadPrefix(name: string) {
|
|
531
|
+
const matchedPrefix = PRESERVED_THREAD_PREFIXES.find((prefix) => {
|
|
532
|
+
return name.startsWith(prefix)
|
|
533
|
+
})
|
|
534
|
+
if (!matchedPrefix) return name
|
|
535
|
+
return name.slice(matchedPrefix.length).trim()
|
|
536
|
+
}
|
|
537
|
+
|
|
432
538
|
function getThreadNameCandidateFromSessionTitle({
|
|
433
539
|
sessionTitle,
|
|
434
540
|
currentName,
|
|
@@ -436,18 +542,24 @@ function getThreadNameCandidateFromSessionTitle({
|
|
|
436
542
|
sessionTitle: string | undefined | null
|
|
437
543
|
currentName: string
|
|
438
544
|
}) {
|
|
439
|
-
const trimmed = sessionTitle
|
|
545
|
+
const trimmed = sessionTitle
|
|
546
|
+
?.replace(/<\/?callout\b[^>]*>/gi, '')
|
|
547
|
+
.trim()
|
|
440
548
|
if (!trimmed) {
|
|
441
549
|
return null
|
|
442
550
|
}
|
|
443
|
-
|
|
551
|
+
const withoutCopiedPrefix = stripPreservedThreadPrefix(trimmed)
|
|
552
|
+
if (!withoutCopiedPrefix) {
|
|
553
|
+
return null
|
|
554
|
+
}
|
|
555
|
+
if (/^new session\s*-/i.test(withoutCopiedPrefix)) {
|
|
444
556
|
return null
|
|
445
557
|
}
|
|
446
558
|
const matchedPrefix =
|
|
447
559
|
PRESERVED_THREAD_PREFIXES.find((p) => {
|
|
448
560
|
return currentName.startsWith(p)
|
|
449
561
|
}) ?? ''
|
|
450
|
-
return `${matchedPrefix}${
|
|
562
|
+
return `${matchedPrefix}${withoutCopiedPrefix}`.slice(0, DISCORD_THREAD_NAME_MAX)
|
|
451
563
|
}
|
|
452
564
|
|
|
453
565
|
export function deriveThreadNameFromSessionTitle({
|
|
@@ -470,45 +582,6 @@ export function deriveThreadNameFromSessionTitle({
|
|
|
470
582
|
return candidate
|
|
471
583
|
}
|
|
472
584
|
|
|
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
585
|
// ── Ingress input type ───────────────────────────────────────────
|
|
513
586
|
|
|
514
587
|
export type EnqueueResult = {
|
|
@@ -563,6 +636,11 @@ export type IngressInput = {
|
|
|
563
636
|
// First-dispatch-only overrides (used when creating a new session)
|
|
564
637
|
agent?: string
|
|
565
638
|
model?: string
|
|
639
|
+
/**
|
|
640
|
+
* Thinking-level variant from `/xxx-agent variant:`. Applied after
|
|
641
|
+
* agent/model snapshot so it wins over cascade for this turn.
|
|
642
|
+
*/
|
|
643
|
+
variant?: string
|
|
566
644
|
/**
|
|
567
645
|
* Raw permission rule strings from --permission flag ("tool:action" or
|
|
568
646
|
* "tool:pattern:action"). Parsed into PermissionRuleset entries by
|
|
@@ -588,6 +666,12 @@ export type IngressInput = {
|
|
|
588
666
|
* (e.g. user-to-user replies in a thread).
|
|
589
667
|
*/
|
|
590
668
|
noReply?: boolean
|
|
669
|
+
/**
|
|
670
|
+
* True only for the wake prompt posted by the kimaki_sleep task runner.
|
|
671
|
+
* Every other ingress cancels a pending sleep; this one must not, because it
|
|
672
|
+
* is delivering that sleep rather than superseding it.
|
|
673
|
+
*/
|
|
674
|
+
isSleepWake?: boolean
|
|
591
675
|
/**
|
|
592
676
|
* Product-analytics turn source. Defaults to discord. Set retry/cli/scheduled
|
|
593
677
|
* at the ingress site so DAU queries can exclude non-user activity.
|
|
@@ -694,32 +778,28 @@ export class ThreadSessionRuntime {
|
|
|
694
778
|
// message and showing multiple back-to-back POSTs is wasteful.
|
|
695
779
|
private typingKeepaliveTimeout: ReturnType<typeof setTimeout> | null = null
|
|
696
780
|
private readonly typingRepulseDebounce: ReturnType<typeof createDebouncedTimeout>
|
|
781
|
+
private readonly deferredQuestionShow: ReturnType<typeof createDebouncedTimeout>
|
|
697
782
|
|
|
698
783
|
private static TYPING_REPULSE_DEBOUNCE_MS = 500
|
|
784
|
+
private static DEFERRED_QUESTION_SHOW_MS = 1000
|
|
699
785
|
|
|
700
786
|
// Notification throttles for retry/context notices.
|
|
701
787
|
private lastDisplayedContextPercentage = 0
|
|
702
788
|
private lastRateLimitDisplayTime = 0
|
|
703
789
|
|
|
704
|
-
// Last OpenCode
|
|
705
|
-
//
|
|
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).
|
|
790
|
+
// Last OpenCode session title we applied to Discord. Dedupes session.updated
|
|
791
|
+
// so we only call setName once per distinct title. Not persisted.
|
|
710
792
|
private appliedOpencodeTitle: string | undefined
|
|
711
793
|
|
|
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
794
|
// Part output buffering (write-side cache, not domain state)
|
|
717
795
|
private partBuffer = new Map<string, Map<string, Part>>()
|
|
796
|
+
private shownQuestionRequestIds = new Set<string>()
|
|
718
797
|
|
|
719
798
|
// Derivable cache (perf optimization for provider.list API call)
|
|
720
799
|
private modelContextLimit: number | undefined
|
|
721
800
|
private modelContextLimitKey: string | undefined
|
|
722
801
|
private lastPromptWorktreeKey: string | null | undefined
|
|
802
|
+
private lastSentPartKind: SessionPartKind | undefined
|
|
723
803
|
|
|
724
804
|
// Bounded buffer of recent SSE events with timestamps.
|
|
725
805
|
// Used by waitForEvent() to scan for specific events that arrived
|
|
@@ -792,6 +872,17 @@ export class ThreadSessionRuntime {
|
|
|
792
872
|
this.restartTypingKeepalive({ sendNow: true })
|
|
793
873
|
},
|
|
794
874
|
})
|
|
875
|
+
this.deferredQuestionShow = createDebouncedTimeout({
|
|
876
|
+
delayMs: ThreadSessionRuntime.DEFERRED_QUESTION_SHOW_MS,
|
|
877
|
+
callback: () => {
|
|
878
|
+
if (this.disposed) {
|
|
879
|
+
return
|
|
880
|
+
}
|
|
881
|
+
void this.dispatchAction(async () => {
|
|
882
|
+
await this.tryShowPendingQuestion({ ignoreUnfinishedText: true })
|
|
883
|
+
})
|
|
884
|
+
},
|
|
885
|
+
})
|
|
795
886
|
}
|
|
796
887
|
|
|
797
888
|
private consumeWorktreePromptChange(
|
|
@@ -989,6 +1080,52 @@ export class ThreadSessionRuntime {
|
|
|
989
1080
|
return isSessionBusy({ events: this.eventBuffer, sessionId })
|
|
990
1081
|
}
|
|
991
1082
|
|
|
1083
|
+
private async persistIngressVariant({
|
|
1084
|
+
sessionId,
|
|
1085
|
+
channelId,
|
|
1086
|
+
appId,
|
|
1087
|
+
agentPreference,
|
|
1088
|
+
getClient,
|
|
1089
|
+
variant,
|
|
1090
|
+
}: {
|
|
1091
|
+
sessionId: string
|
|
1092
|
+
channelId?: string
|
|
1093
|
+
appId?: string
|
|
1094
|
+
agentPreference?: string
|
|
1095
|
+
getClient: Awaited<ReturnType<typeof initializeOpencodeForDirectory>>
|
|
1096
|
+
variant?: string
|
|
1097
|
+
}) {
|
|
1098
|
+
if (!variant) return
|
|
1099
|
+
if (getClient instanceof Error) return
|
|
1100
|
+
const variantModelInfo = await getCurrentModelInfo({
|
|
1101
|
+
sessionId,
|
|
1102
|
+
channelId,
|
|
1103
|
+
appId,
|
|
1104
|
+
agentPreference,
|
|
1105
|
+
getClient,
|
|
1106
|
+
directory: this.sdkDirectory,
|
|
1107
|
+
})
|
|
1108
|
+
if (variantModelInfo.type === 'none') return
|
|
1109
|
+
const providersResponse = await getClient()
|
|
1110
|
+
.provider.list({ directory: this.sdkDirectory })
|
|
1111
|
+
.catch((e) => new OpenCodeSdkError({ operation: 'provider.list', cause: e }))
|
|
1112
|
+
if (providersResponse instanceof Error || !providersResponse.data) return
|
|
1113
|
+
const matchedVariant = matchThinkingValue({
|
|
1114
|
+
requestedValue: variant,
|
|
1115
|
+
availableValues: getThinkingValuesForModel({
|
|
1116
|
+
providers: providersResponse.data.all,
|
|
1117
|
+
providerId: variantModelInfo.providerID,
|
|
1118
|
+
modelId: variantModelInfo.modelID,
|
|
1119
|
+
}),
|
|
1120
|
+
})
|
|
1121
|
+
if (!matchedVariant) return
|
|
1122
|
+
await setSessionModel({
|
|
1123
|
+
sessionId,
|
|
1124
|
+
modelId: variantModelInfo.model,
|
|
1125
|
+
variant: matchedVariant,
|
|
1126
|
+
})
|
|
1127
|
+
}
|
|
1128
|
+
|
|
992
1129
|
private getAssistantMessageIdsForCurrentTurn({
|
|
993
1130
|
sessionId,
|
|
994
1131
|
upToIndex,
|
|
@@ -1026,21 +1163,25 @@ export class ThreadSessionRuntime {
|
|
|
1026
1163
|
if (!mainSessionId || candidateSessionId === mainSessionId) {
|
|
1027
1164
|
return undefined
|
|
1028
1165
|
}
|
|
1029
|
-
|
|
1166
|
+
if (!isDerivedChildSession({
|
|
1030
1167
|
events: this.eventBuffer,
|
|
1031
1168
|
mainSessionId,
|
|
1032
1169
|
candidateSessionId,
|
|
1033
|
-
})
|
|
1034
|
-
if (!subtaskIndex) {
|
|
1170
|
+
})) {
|
|
1035
1171
|
return undefined
|
|
1036
1172
|
}
|
|
1037
1173
|
|
|
1174
|
+
const subtaskIndex = getDerivedSubtaskIndex({
|
|
1175
|
+
events: this.eventBuffer,
|
|
1176
|
+
mainSessionId,
|
|
1177
|
+
candidateSessionId,
|
|
1178
|
+
})
|
|
1038
1179
|
const agentType = getDerivedSubtaskAgentType({
|
|
1039
1180
|
events: this.eventBuffer,
|
|
1040
1181
|
mainSessionId,
|
|
1041
1182
|
candidateSessionId,
|
|
1042
1183
|
})
|
|
1043
|
-
const label = `${agentType || 'task'}-${subtaskIndex}`
|
|
1184
|
+
const label = `${agentType || 'task'}-${subtaskIndex || 1}`
|
|
1044
1185
|
const assistantMessageId = this.getLatestAssistantMessageIdForCurrentTurn({
|
|
1045
1186
|
sessionId: candidateSessionId,
|
|
1046
1187
|
})
|
|
@@ -1053,6 +1194,7 @@ export class ThreadSessionRuntime {
|
|
|
1053
1194
|
this.disposed = true
|
|
1054
1195
|
unregisterEventListener(this.threadId)
|
|
1055
1196
|
void this.persistEventBufferDebounced.dispose()
|
|
1197
|
+
this.deferredQuestionShow.clear()
|
|
1056
1198
|
this.stopTyping()
|
|
1057
1199
|
|
|
1058
1200
|
// Release large internal buffers so GC can reclaim memory immediately
|
|
@@ -1060,6 +1202,7 @@ export class ThreadSessionRuntime {
|
|
|
1060
1202
|
this.eventBuffer = []
|
|
1061
1203
|
this.nextEventIndex = 0
|
|
1062
1204
|
this.partBuffer.clear()
|
|
1205
|
+
this.shownQuestionRequestIds.clear()
|
|
1063
1206
|
this.preprocessChain = Promise.resolve()
|
|
1064
1207
|
|
|
1065
1208
|
// Don't clear actionQueue here — queued closures own resolve/reject for
|
|
@@ -1170,7 +1313,6 @@ export class ThreadSessionRuntime {
|
|
|
1170
1313
|
})
|
|
1171
1314
|
: []
|
|
1172
1315
|
delete info.system
|
|
1173
|
-
delete info.summary
|
|
1174
1316
|
delete info.tools
|
|
1175
1317
|
delete info.parts
|
|
1176
1318
|
if (partsSummary.length > 0) {
|
|
@@ -1353,10 +1495,14 @@ export class ThreadSessionRuntime {
|
|
|
1353
1495
|
// Subtask sessions also bypass — they're tracked in subtaskSessions.
|
|
1354
1496
|
|
|
1355
1497
|
private async handleEvent(event: OpenCodeEvent): Promise<void> {
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1498
|
+
const sessionId = this.state?.sessionId
|
|
1499
|
+
if (!shouldBufferSessionEvent({
|
|
1500
|
+
event,
|
|
1501
|
+
mainSessionId: sessionId,
|
|
1502
|
+
isKnownChildSession: (candidateSessionId) => {
|
|
1503
|
+
return Boolean(this.getSubtaskInfoForSession(candidateSessionId))
|
|
1504
|
+
},
|
|
1505
|
+
})) {
|
|
1360
1506
|
return
|
|
1361
1507
|
}
|
|
1362
1508
|
|
|
@@ -1371,8 +1517,6 @@ export class ThreadSessionRuntime {
|
|
|
1371
1517
|
this.appendEventToBuffer(event)
|
|
1372
1518
|
}
|
|
1373
1519
|
|
|
1374
|
-
const sessionId = this.state?.sessionId
|
|
1375
|
-
|
|
1376
1520
|
const eventSessionId = getOpencodeEventSessionId(event)
|
|
1377
1521
|
const toastSessionId = event.type === 'tui.toast.show'
|
|
1378
1522
|
? extractToastSessionId({ message: event.properties.message })
|
|
@@ -1712,12 +1856,12 @@ export class ThreadSessionRuntime {
|
|
|
1712
1856
|
})
|
|
1713
1857
|
}
|
|
1714
1858
|
|
|
1715
|
-
private
|
|
1859
|
+
private shouldSendPlannedPart({
|
|
1716
1860
|
part,
|
|
1717
|
-
|
|
1861
|
+
mode,
|
|
1718
1862
|
}: {
|
|
1719
1863
|
part: Part
|
|
1720
|
-
|
|
1864
|
+
mode: AssistantTurnFlushMode
|
|
1721
1865
|
}): boolean {
|
|
1722
1866
|
if (part.type === 'step-start' || part.type === 'step-finish') {
|
|
1723
1867
|
return false
|
|
@@ -1725,21 +1869,72 @@ export class ThreadSessionRuntime {
|
|
|
1725
1869
|
if (part.type === 'tool' && part.state.status === 'pending') {
|
|
1726
1870
|
return false
|
|
1727
1871
|
}
|
|
1728
|
-
if (
|
|
1729
|
-
return false
|
|
1730
|
-
}
|
|
1731
|
-
if (!force && part.type === 'tool' && part.state.status === 'completed') {
|
|
1872
|
+
if (part.type === 'text' && !part.time?.end && mode === 'progress') {
|
|
1732
1873
|
return false
|
|
1733
1874
|
}
|
|
1734
1875
|
return true
|
|
1735
1876
|
}
|
|
1736
1877
|
|
|
1878
|
+
private getCurrentTurnParts(): Part[] {
|
|
1879
|
+
const sessionId = this.state?.sessionId
|
|
1880
|
+
const messageIds = sessionId
|
|
1881
|
+
? [...this.getAssistantMessageIdsForCurrentTurn({ sessionId })]
|
|
1882
|
+
: []
|
|
1883
|
+
if (messageIds.length > 0) {
|
|
1884
|
+
return messageIds.flatMap((id) => this.getBufferedParts(id))
|
|
1885
|
+
}
|
|
1886
|
+
return [...this.partBuffer.keys()].flatMap((id) => this.getBufferedParts(id))
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1889
|
+
private async flushCurrentTurnParts({
|
|
1890
|
+
mode,
|
|
1891
|
+
throughPartId,
|
|
1892
|
+
skipPartId,
|
|
1893
|
+
repulseTyping = true,
|
|
1894
|
+
}: {
|
|
1895
|
+
mode: AssistantTurnFlushMode
|
|
1896
|
+
throughPartId?: string
|
|
1897
|
+
skipPartId?: string
|
|
1898
|
+
repulseTyping?: boolean
|
|
1899
|
+
}): Promise<void> {
|
|
1900
|
+
const parts = this.getCurrentTurnParts().filter((part) => {
|
|
1901
|
+
return !this.state?.sentPartIds.has(part.id)
|
|
1902
|
+
})
|
|
1903
|
+
const planned = planAssistantTurnFlush({
|
|
1904
|
+
parts,
|
|
1905
|
+
mode,
|
|
1906
|
+
throughPartId,
|
|
1907
|
+
})
|
|
1908
|
+
for (const { part, quoteText } of planned.sendParts) {
|
|
1909
|
+
if (skipPartId && part.id === skipPartId) {
|
|
1910
|
+
continue
|
|
1911
|
+
}
|
|
1912
|
+
if (!this.shouldSendPlannedPart({ part, mode })) {
|
|
1913
|
+
continue
|
|
1914
|
+
}
|
|
1915
|
+
if (part.type === 'tool' && part.tool === 'task') {
|
|
1916
|
+
continue
|
|
1917
|
+
}
|
|
1918
|
+
const pulseTyping =
|
|
1919
|
+
part.type === 'text' && part.ignored === true
|
|
1920
|
+
? false
|
|
1921
|
+
: repulseTyping
|
|
1922
|
+
await this.sendPartMessage({
|
|
1923
|
+
part,
|
|
1924
|
+
quoteText,
|
|
1925
|
+
repulseTyping: pulseTyping,
|
|
1926
|
+
})
|
|
1927
|
+
}
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1737
1930
|
private async sendPartMessage({
|
|
1738
1931
|
part,
|
|
1739
1932
|
repulseTyping = true,
|
|
1933
|
+
quoteText = false,
|
|
1740
1934
|
}: {
|
|
1741
1935
|
part: Part
|
|
1742
1936
|
repulseTyping?: boolean
|
|
1937
|
+
quoteText?: boolean
|
|
1743
1938
|
}): Promise<void> {
|
|
1744
1939
|
const verbosity = await this.getVerbosity()
|
|
1745
1940
|
if (verbosity === 'text_only' && part.type !== 'text') {
|
|
@@ -1751,7 +1946,14 @@ export class ThreadSessionRuntime {
|
|
|
1751
1946
|
}
|
|
1752
1947
|
}
|
|
1753
1948
|
|
|
1754
|
-
const
|
|
1949
|
+
const formatted = formatPart(part)
|
|
1950
|
+
const quote =
|
|
1951
|
+
quoteText
|
|
1952
|
+
&& shouldQuoteIntermediateTextPart({
|
|
1953
|
+
part,
|
|
1954
|
+
isLastInTurn: false,
|
|
1955
|
+
})
|
|
1956
|
+
const content = quote ? asDiscordQuote(formatted) : formatted
|
|
1755
1957
|
if (!content.trim() || content.length === 0) {
|
|
1756
1958
|
return
|
|
1757
1959
|
}
|
|
@@ -1766,7 +1968,13 @@ export class ThreadSessionRuntime {
|
|
|
1766
1968
|
return { ...t, sentPartIds: newIds }
|
|
1767
1969
|
})
|
|
1768
1970
|
|
|
1769
|
-
const
|
|
1971
|
+
const kind = sessionPartKind(part)
|
|
1972
|
+
const sendResult = await sendSessionPartMessage(this.thread, content, {
|
|
1973
|
+
leadWithBlankLine: shouldLeadWithBlankLine({
|
|
1974
|
+
previousKind: this.lastSentPartKind,
|
|
1975
|
+
nextKind: kind,
|
|
1976
|
+
}),
|
|
1977
|
+
})
|
|
1770
1978
|
.catch((e) => new DiscordOperationError({ operation: 'sendMessage', cause: e }))
|
|
1771
1979
|
if (sendResult instanceof Error) {
|
|
1772
1980
|
threadState.updateThread(this.threadId, (t) => {
|
|
@@ -1780,63 +1988,15 @@ export class ThreadSessionRuntime {
|
|
|
1780
1988
|
)
|
|
1781
1989
|
return
|
|
1782
1990
|
}
|
|
1991
|
+
this.lastSentPartKind = kind
|
|
1783
1992
|
await setPartMessage({ partId: part.id, messageId: sendResult.id, threadId: this.thread.id })
|
|
1784
1993
|
if (repulseTyping) {
|
|
1785
1994
|
this.requestTypingRepulse()
|
|
1786
1995
|
}
|
|
1787
1996
|
}
|
|
1788
1997
|
|
|
1789
|
-
private async flushBufferedParts({
|
|
1790
|
-
messageID,
|
|
1791
|
-
force,
|
|
1792
|
-
skipPartId,
|
|
1793
|
-
repulseTyping = true,
|
|
1794
|
-
}: {
|
|
1795
|
-
messageID: string | undefined
|
|
1796
|
-
force: boolean
|
|
1797
|
-
skipPartId?: string
|
|
1798
|
-
repulseTyping?: boolean
|
|
1799
|
-
}): Promise<void> {
|
|
1800
|
-
if (!messageID) {
|
|
1801
|
-
return
|
|
1802
|
-
}
|
|
1803
|
-
const parts = this.getBufferedParts(messageID)
|
|
1804
|
-
for (const part of parts) {
|
|
1805
|
-
if (skipPartId && part.id === skipPartId) {
|
|
1806
|
-
continue
|
|
1807
|
-
}
|
|
1808
|
-
if (!this.shouldSendPart({ part, force })) {
|
|
1809
|
-
continue
|
|
1810
|
-
}
|
|
1811
|
-
await this.sendPartMessage({ part, repulseTyping })
|
|
1812
|
-
}
|
|
1813
|
-
}
|
|
1814
|
-
|
|
1815
|
-
private async flushBufferedPartsForMessages({
|
|
1816
|
-
messageIDs,
|
|
1817
|
-
force,
|
|
1818
|
-
skipPartId,
|
|
1819
|
-
repulseTyping = true,
|
|
1820
|
-
}: {
|
|
1821
|
-
messageIDs: ReadonlyArray<string>
|
|
1822
|
-
force: boolean
|
|
1823
|
-
skipPartId?: string
|
|
1824
|
-
repulseTyping?: boolean
|
|
1825
|
-
}): Promise<void> {
|
|
1826
|
-
const uniqueMessageIDs = [...new Set(messageIDs)]
|
|
1827
|
-
for (const messageID of uniqueMessageIDs) {
|
|
1828
|
-
await this.flushBufferedParts({
|
|
1829
|
-
messageID,
|
|
1830
|
-
force,
|
|
1831
|
-
skipPartId,
|
|
1832
|
-
repulseTyping,
|
|
1833
|
-
})
|
|
1834
|
-
}
|
|
1835
|
-
}
|
|
1836
|
-
|
|
1837
1998
|
private async showInteractiveUi({
|
|
1838
1999
|
skipPartId,
|
|
1839
|
-
flushMessageId,
|
|
1840
2000
|
show,
|
|
1841
2001
|
}: {
|
|
1842
2002
|
skipPartId?: string
|
|
@@ -1844,32 +2004,11 @@ export class ThreadSessionRuntime {
|
|
|
1844
2004
|
show: () => Promise<void>
|
|
1845
2005
|
}): Promise<void> {
|
|
1846
2006
|
this.stopTyping()
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
if (!sessionId) {
|
|
1853
|
-
return undefined
|
|
1854
|
-
}
|
|
1855
|
-
return this.getLatestAssistantMessageIdForCurrentTurn({ sessionId })
|
|
1856
|
-
})()
|
|
1857
|
-
if (targetMessageId) {
|
|
1858
|
-
await this.flushBufferedParts({
|
|
1859
|
-
messageID: targetMessageId,
|
|
1860
|
-
force: true,
|
|
1861
|
-
skipPartId,
|
|
1862
|
-
})
|
|
1863
|
-
} else {
|
|
1864
|
-
const assistantMessageIds = sessionId
|
|
1865
|
-
? [...this.getAssistantMessageIdsForCurrentTurn({ sessionId })]
|
|
1866
|
-
: []
|
|
1867
|
-
await this.flushBufferedPartsForMessages({
|
|
1868
|
-
messageIDs: assistantMessageIds,
|
|
1869
|
-
force: true,
|
|
1870
|
-
skipPartId,
|
|
1871
|
-
})
|
|
1872
|
-
}
|
|
2007
|
+
await this.flushCurrentTurnParts({
|
|
2008
|
+
mode: 'interactive',
|
|
2009
|
+
throughPartId: skipPartId,
|
|
2010
|
+
skipPartId,
|
|
2011
|
+
})
|
|
1873
2012
|
await show()
|
|
1874
2013
|
}
|
|
1875
2014
|
|
|
@@ -1920,10 +2059,21 @@ export class ThreadSessionRuntime {
|
|
|
1920
2059
|
private async handleMessageUpdated(msg: OpenCodeMessage): Promise<void> {
|
|
1921
2060
|
const sessionId = this.state?.sessionId
|
|
1922
2061
|
|
|
1923
|
-
if (msg.
|
|
2062
|
+
if (msg.role !== 'assistant') {
|
|
1924
2063
|
return
|
|
1925
2064
|
}
|
|
1926
|
-
if (msg.
|
|
2065
|
+
if (msg.summary === true) {
|
|
2066
|
+
this.clearBufferedPartsForMessages([msg.id])
|
|
2067
|
+
logger.info(`[SKIP] message.updated for compaction summary ${msg.id}`)
|
|
2068
|
+
return
|
|
2069
|
+
}
|
|
2070
|
+
if (msg.sessionID !== sessionId) {
|
|
2071
|
+
const subtaskInfo = this.getSubtaskInfoForSession(msg.sessionID)
|
|
2072
|
+
if (subtaskInfo) {
|
|
2073
|
+
for (const part of this.getBufferedParts(msg.id)) {
|
|
2074
|
+
await this.handleSubtaskPart(part, subtaskInfo)
|
|
2075
|
+
}
|
|
2076
|
+
}
|
|
1927
2077
|
return
|
|
1928
2078
|
}
|
|
1929
2079
|
if (!sessionId) {
|
|
@@ -1934,6 +2084,7 @@ export class ThreadSessionRuntime {
|
|
|
1934
2084
|
sessionId,
|
|
1935
2085
|
messageId: msg.id,
|
|
1936
2086
|
})) {
|
|
2087
|
+
this.clearBufferedPartsForMessages([msg.id])
|
|
1937
2088
|
logger.info(`[SKIP] message.updated for old assistant message ${msg.id}, not in latest user turn`)
|
|
1938
2089
|
return
|
|
1939
2090
|
}
|
|
@@ -1970,9 +2121,8 @@ export class ThreadSessionRuntime {
|
|
|
1970
2121
|
})
|
|
1971
2122
|
}
|
|
1972
2123
|
|
|
1973
|
-
await this.
|
|
1974
|
-
|
|
1975
|
-
force: false,
|
|
2124
|
+
await this.flushCurrentTurnParts({
|
|
2125
|
+
mode: 'progress',
|
|
1976
2126
|
})
|
|
1977
2127
|
|
|
1978
2128
|
const wasAlreadyCompleted = hasAssistantMessageCompletedBefore({
|
|
@@ -2031,7 +2181,7 @@ export class ThreadSessionRuntime {
|
|
|
2031
2181
|
return
|
|
2032
2182
|
}
|
|
2033
2183
|
this.lastDisplayedContextPercentage = thresholdCrossed
|
|
2034
|
-
const chunk =
|
|
2184
|
+
const chunk = `${STATUS_PREFIX}context usage ${currentPercentage}%`
|
|
2035
2185
|
const sendResult = await this.thread.send({ content: chunk, flags: SILENT_MESSAGE_FLAGS })
|
|
2036
2186
|
.catch((e) => new DiscordOperationError({ operation: 'sendMessage', cause: e }))
|
|
2037
2187
|
if (sendResult instanceof Error) {
|
|
@@ -2040,8 +2190,24 @@ export class ThreadSessionRuntime {
|
|
|
2040
2190
|
}
|
|
2041
2191
|
|
|
2042
2192
|
private async handlePartUpdated(part: Part): Promise<void> {
|
|
2043
|
-
this.storePart(part)
|
|
2044
2193
|
const sessionId = this.state?.sessionId
|
|
2194
|
+
const messageKind = getAssistantMessageKind({
|
|
2195
|
+
events: this.eventBuffer,
|
|
2196
|
+
sessionId: part.sessionID,
|
|
2197
|
+
messageId: part.messageID,
|
|
2198
|
+
})
|
|
2199
|
+
|
|
2200
|
+
if (messageKind === 'summary') {
|
|
2201
|
+
this.clearBufferedPartsForMessages([part.messageID])
|
|
2202
|
+
logger.info(`[SKIP] message.part.updated for compaction summary ${part.messageID}`)
|
|
2203
|
+
return
|
|
2204
|
+
}
|
|
2205
|
+
|
|
2206
|
+
this.storePart(part)
|
|
2207
|
+
|
|
2208
|
+
if (messageKind === 'unknown') {
|
|
2209
|
+
return
|
|
2210
|
+
}
|
|
2045
2211
|
|
|
2046
2212
|
const subtaskInfo = this.getSubtaskInfoForSession(part.sessionID)
|
|
2047
2213
|
const isSubtaskEvent = Boolean(subtaskInfo)
|
|
@@ -2066,48 +2232,55 @@ export class ThreadSessionRuntime {
|
|
|
2066
2232
|
return
|
|
2067
2233
|
}
|
|
2068
2234
|
|
|
2069
|
-
if (
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2235
|
+
if (part.type === 'tool' && part.state.status === 'running') {
|
|
2236
|
+
await this.flushCurrentTurnParts({
|
|
2237
|
+
mode: 'progress',
|
|
2238
|
+
})
|
|
2239
|
+
const unsent = this.getCurrentTurnParts().filter((candidate) => {
|
|
2240
|
+
return !this.state?.sentPartIds.has(candidate.id)
|
|
2241
|
+
})
|
|
2242
|
+
const held = planAssistantTurnFlush({
|
|
2243
|
+
parts: unsent,
|
|
2244
|
+
mode: 'progress',
|
|
2245
|
+
}).hold.some((entry) => entry.id === part.id)
|
|
2246
|
+
if (held) {
|
|
2247
|
+
return
|
|
2248
|
+
}
|
|
2249
|
+
if (!this.state?.sentPartIds.has(part.id) && part.tool !== 'task') {
|
|
2250
|
+
await this.sendPartMessage({ part })
|
|
2251
|
+
}
|
|
2252
|
+
|
|
2253
|
+
if (part.tool === 'task' && !this.state?.sentPartIds.has(part.id)) {
|
|
2254
|
+
const taskDisplay = formatTaskToolTitle(part)
|
|
2255
|
+
if (taskDisplay && (await this.getVerbosity()) !== 'text_only') {
|
|
2089
2256
|
threadState.updateThread(this.threadId, (t) => {
|
|
2090
2257
|
const newIds = new Set(t.sentPartIds)
|
|
2091
|
-
newIds.
|
|
2258
|
+
newIds.add(part.id)
|
|
2092
2259
|
return { ...t, sentPartIds: newIds }
|
|
2093
2260
|
})
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2261
|
+
const sendResult = await sendSessionPartMessage(this.thread, taskDisplay, {
|
|
2262
|
+
leadWithBlankLine: shouldLeadWithBlankLine({
|
|
2263
|
+
previousKind: this.lastSentPartKind,
|
|
2264
|
+
nextKind: 'tool',
|
|
2265
|
+
}),
|
|
2266
|
+
})
|
|
2267
|
+
.catch((e) => new DiscordOperationError({ operation: 'sendMessage', cause: e }))
|
|
2268
|
+
if (sendResult instanceof Error) {
|
|
2269
|
+
threadState.updateThread(this.threadId, (t) => {
|
|
2270
|
+
const newIds = new Set(t.sentPartIds)
|
|
2271
|
+
newIds.delete(part.id)
|
|
2272
|
+
return { ...t, sentPartIds: newIds }
|
|
2273
|
+
})
|
|
2274
|
+
discordLogger.error(
|
|
2275
|
+
`ERROR: Failed to send task part ${part.id}:`,
|
|
2276
|
+
sendResult,
|
|
2277
|
+
)
|
|
2278
|
+
return
|
|
2279
|
+
}
|
|
2280
|
+
this.lastSentPartKind = 'tool'
|
|
2281
|
+
await setPartMessage({ partId: part.id, messageId: sendResult.id, threadId: this.thread.id })
|
|
2099
2282
|
}
|
|
2100
|
-
await setPartMessage({ partId: part.id, messageId: sendResult.id, threadId: this.thread.id })
|
|
2101
2283
|
}
|
|
2102
|
-
}
|
|
2103
|
-
|
|
2104
|
-
if (part.type === 'tool' && part.state.status === 'running') {
|
|
2105
|
-
await this.flushBufferedParts({
|
|
2106
|
-
messageID: part.messageID,
|
|
2107
|
-
force: true,
|
|
2108
|
-
skipPartId: part.id,
|
|
2109
|
-
})
|
|
2110
|
-
await this.sendPartMessage({ part })
|
|
2111
2284
|
return
|
|
2112
2285
|
}
|
|
2113
2286
|
|
|
@@ -2219,7 +2392,7 @@ export class ThreadSessionRuntime {
|
|
|
2219
2392
|
}
|
|
2220
2393
|
return ` (${pct.toFixed(1)}%)`
|
|
2221
2394
|
})()
|
|
2222
|
-
const chunk =
|
|
2395
|
+
const chunk = `${STATUS_PREFIX}${part.tool} returned ${formattedTokens} tokens${percentageSuffix}`
|
|
2223
2396
|
const largeOutputResult = await this.thread.send({
|
|
2224
2397
|
content: chunk,
|
|
2225
2398
|
flags: SILENT_MESSAGE_FLAGS,
|
|
@@ -2232,20 +2405,19 @@ export class ThreadSessionRuntime {
|
|
|
2232
2405
|
}
|
|
2233
2406
|
|
|
2234
2407
|
if (part.type === 'reasoning') {
|
|
2235
|
-
await this.
|
|
2408
|
+
await this.flushCurrentTurnParts({ mode: 'progress' })
|
|
2236
2409
|
return
|
|
2237
2410
|
}
|
|
2238
2411
|
|
|
2239
|
-
if (part.type === 'text'
|
|
2240
|
-
await this.
|
|
2412
|
+
if (part.type === 'text') {
|
|
2413
|
+
await this.flushCurrentTurnParts({ mode: 'progress' })
|
|
2414
|
+
if (part.time?.end) {
|
|
2415
|
+
await this.tryShowPendingQuestion()
|
|
2416
|
+
}
|
|
2241
2417
|
return
|
|
2242
2418
|
}
|
|
2243
2419
|
|
|
2244
2420
|
if (part.type === 'step-finish') {
|
|
2245
|
-
await this.flushBufferedParts({
|
|
2246
|
-
messageID: part.messageID,
|
|
2247
|
-
force: true,
|
|
2248
|
-
})
|
|
2249
2421
|
this.ensureTypingKeepalive()
|
|
2250
2422
|
}
|
|
2251
2423
|
}
|
|
@@ -2283,7 +2455,13 @@ export class ThreadSessionRuntime {
|
|
|
2283
2455
|
if (!content.trim() || this.state?.sentPartIds.has(part.id)) {
|
|
2284
2456
|
return
|
|
2285
2457
|
}
|
|
2286
|
-
const
|
|
2458
|
+
const kind = sessionPartKind(part)
|
|
2459
|
+
const sendResult = await sendSessionPartMessage(this.thread, content, {
|
|
2460
|
+
leadWithBlankLine: shouldLeadWithBlankLine({
|
|
2461
|
+
previousKind: this.lastSentPartKind,
|
|
2462
|
+
nextKind: kind,
|
|
2463
|
+
}),
|
|
2464
|
+
})
|
|
2287
2465
|
.catch((e) => new DiscordOperationError({ operation: 'sendMessage', cause: e }))
|
|
2288
2466
|
if (sendResult instanceof Error) {
|
|
2289
2467
|
discordLogger.error(
|
|
@@ -2292,6 +2470,7 @@ export class ThreadSessionRuntime {
|
|
|
2292
2470
|
)
|
|
2293
2471
|
return
|
|
2294
2472
|
}
|
|
2473
|
+
this.lastSentPartKind = kind
|
|
2295
2474
|
threadState.updateThread(this.threadId, (t) => {
|
|
2296
2475
|
const newIds = new Set(t.sentPartIds)
|
|
2297
2476
|
newIds.add(part.id)
|
|
@@ -2301,21 +2480,16 @@ export class ThreadSessionRuntime {
|
|
|
2301
2480
|
this.requestTypingRepulse()
|
|
2302
2481
|
}
|
|
2303
2482
|
|
|
2304
|
-
private trackIdleTokenUsage(
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
}
|
|
2312
|
-
}
|
|
2313
|
-
if (idleEventIndex === undefined) {
|
|
2314
|
-
return
|
|
2315
|
-
}
|
|
2483
|
+
private trackIdleTokenUsage({
|
|
2484
|
+
sessionId,
|
|
2485
|
+
idleEventIndex,
|
|
2486
|
+
}: {
|
|
2487
|
+
sessionId: string
|
|
2488
|
+
idleEventIndex: number
|
|
2489
|
+
}): void {
|
|
2316
2490
|
const usage = getIdleTokenUsageDelta({
|
|
2317
2491
|
events: this.eventBuffer,
|
|
2318
|
-
sessionId
|
|
2492
|
+
sessionId,
|
|
2319
2493
|
idleEventIndex,
|
|
2320
2494
|
})
|
|
2321
2495
|
if (!usage) {
|
|
@@ -2331,7 +2505,7 @@ export class ThreadSessionRuntime {
|
|
|
2331
2505
|
tokens_total: usage.total,
|
|
2332
2506
|
cost: usage.cost,
|
|
2333
2507
|
assistant_message_count: usage.assistantMessageCount,
|
|
2334
|
-
is_subagent: Boolean(this.getSubtaskInfoForSession(
|
|
2508
|
+
is_subagent: Boolean(this.getSubtaskInfoForSession(sessionId)),
|
|
2335
2509
|
}
|
|
2336
2510
|
if (usage.model) {
|
|
2337
2511
|
properties.model = usage.model
|
|
@@ -2342,8 +2516,37 @@ export class ThreadSessionRuntime {
|
|
|
2342
2516
|
trackEvent('tokens_used', properties)
|
|
2343
2517
|
}
|
|
2344
2518
|
|
|
2519
|
+
private trackIdleTokenUsageForSessionTree(idleSessionId: string): void {
|
|
2520
|
+
let idleEventIndex: number | undefined
|
|
2521
|
+
for (let i = this.eventBuffer.length - 1; i >= 0; i--) {
|
|
2522
|
+
const event = this.eventBuffer[i]?.event
|
|
2523
|
+
if (event?.type === 'session.idle' && event.properties.sessionID === idleSessionId) {
|
|
2524
|
+
idleEventIndex = i
|
|
2525
|
+
break
|
|
2526
|
+
}
|
|
2527
|
+
}
|
|
2528
|
+
if (idleEventIndex === undefined) {
|
|
2529
|
+
return
|
|
2530
|
+
}
|
|
2531
|
+
const mainSessionId = this.state?.sessionId
|
|
2532
|
+
const sessionIds = mainSessionId
|
|
2533
|
+
? getTokenUsageSessionIdsForIdle({
|
|
2534
|
+
events: this.eventBuffer,
|
|
2535
|
+
mainSessionId,
|
|
2536
|
+
idleSessionId,
|
|
2537
|
+
upToIndex: idleEventIndex,
|
|
2538
|
+
})
|
|
2539
|
+
: [idleSessionId]
|
|
2540
|
+
for (const sessionId of sessionIds) {
|
|
2541
|
+
this.trackIdleTokenUsage({
|
|
2542
|
+
sessionId,
|
|
2543
|
+
idleEventIndex,
|
|
2544
|
+
})
|
|
2545
|
+
}
|
|
2546
|
+
}
|
|
2547
|
+
|
|
2345
2548
|
private async handleSessionIdle(idleSessionId: string): Promise<void> {
|
|
2346
|
-
this.
|
|
2549
|
+
this.trackIdleTokenUsageForSessionTree(idleSessionId)
|
|
2347
2550
|
|
|
2348
2551
|
const sessionId = this.state?.sessionId
|
|
2349
2552
|
|
|
@@ -2400,9 +2603,8 @@ export class ThreadSessionRuntime {
|
|
|
2400
2603
|
return
|
|
2401
2604
|
}
|
|
2402
2605
|
|
|
2403
|
-
await this.
|
|
2404
|
-
|
|
2405
|
-
force: true,
|
|
2606
|
+
await this.flushCurrentTurnParts({
|
|
2607
|
+
mode: 'final',
|
|
2406
2608
|
repulseTyping: false,
|
|
2407
2609
|
})
|
|
2408
2610
|
|
|
@@ -2629,41 +2831,89 @@ export class ThreadSessionRuntime {
|
|
|
2629
2831
|
this.onInteractiveUiStateChanged()
|
|
2630
2832
|
}
|
|
2631
2833
|
|
|
2632
|
-
private
|
|
2633
|
-
|
|
2634
|
-
|
|
2834
|
+
private hasUnfinishedTextPart(messageID: string): boolean {
|
|
2835
|
+
return this.getBufferedParts(messageID).some((part) => {
|
|
2836
|
+
return part.type === 'text' && !part.time?.end
|
|
2837
|
+
})
|
|
2838
|
+
}
|
|
2839
|
+
|
|
2840
|
+
// OpenCode emits question.asked when the tool starts, often before the
|
|
2841
|
+
// preceding text part gets time.end. Showing the dropdown on that event
|
|
2842
|
+
// holds the action queue while Discord posts, so the later text-end cannot
|
|
2843
|
+
// send and dumps after the queued » user: indicator. Wait for text-end.
|
|
2844
|
+
private async tryShowPendingQuestion({
|
|
2845
|
+
ignoreUnfinishedText = false,
|
|
2846
|
+
} = {}): Promise<boolean> {
|
|
2635
2847
|
const sessionId = this.state?.sessionId
|
|
2636
|
-
if (
|
|
2637
|
-
|
|
2638
|
-
`[QUESTION IGNORED] Question for different session (expected: ${sessionId}, got: ${questionRequest.sessionID})`,
|
|
2639
|
-
)
|
|
2640
|
-
return
|
|
2848
|
+
if (!sessionId) {
|
|
2849
|
+
return false
|
|
2641
2850
|
}
|
|
2642
2851
|
|
|
2643
|
-
|
|
2644
|
-
|
|
2645
|
-
|
|
2852
|
+
const request = deriveLatestUnansweredQuestion({
|
|
2853
|
+
events: this.eventBuffer,
|
|
2854
|
+
sessionId,
|
|
2855
|
+
})
|
|
2856
|
+
if (!request) {
|
|
2857
|
+
this.deferredQuestionShow.clear()
|
|
2858
|
+
return false
|
|
2859
|
+
}
|
|
2860
|
+
if (
|
|
2861
|
+
this.shownQuestionRequestIds.has(request.id)
|
|
2862
|
+
|| findPendingQuestionContextForRequest({
|
|
2863
|
+
threadId: this.thread.id,
|
|
2864
|
+
requestId: request.id,
|
|
2865
|
+
})
|
|
2866
|
+
) {
|
|
2867
|
+
this.deferredQuestionShow.clear()
|
|
2868
|
+
return true
|
|
2869
|
+
}
|
|
2646
2870
|
|
|
2871
|
+
const messageId = request.tool?.messageID
|
|
2872
|
+
if (!ignoreUnfinishedText && messageId && this.hasUnfinishedTextPart(messageId)) {
|
|
2873
|
+
return false
|
|
2874
|
+
}
|
|
2875
|
+
|
|
2876
|
+
this.shownQuestionRequestIds.add(request.id)
|
|
2647
2877
|
await this.showInteractiveUi({
|
|
2878
|
+
flushMessageId: messageId,
|
|
2648
2879
|
show: async () => {
|
|
2649
|
-
if (!sessionId) {
|
|
2650
|
-
return
|
|
2651
|
-
}
|
|
2652
2880
|
await showAskUserQuestionDropdowns({
|
|
2653
2881
|
thread: this.thread,
|
|
2654
2882
|
sessionId,
|
|
2655
2883
|
directory: this.sdkDirectory,
|
|
2656
|
-
requestId:
|
|
2657
|
-
input: { questions:
|
|
2884
|
+
requestId: request.id,
|
|
2885
|
+
input: { questions: request.questions },
|
|
2658
2886
|
silent: this.getQueueLength() > 0,
|
|
2659
2887
|
})
|
|
2660
2888
|
},
|
|
2661
2889
|
})
|
|
2662
|
-
|
|
2890
|
+
this.deferredQuestionShow.clear()
|
|
2663
2891
|
this.maybeHandoffQueuedItemForPendingQuestion({
|
|
2664
2892
|
sessionId,
|
|
2665
2893
|
reason: 'question-shown',
|
|
2666
2894
|
})
|
|
2895
|
+
return true
|
|
2896
|
+
}
|
|
2897
|
+
|
|
2898
|
+
private async handleQuestionAsked(
|
|
2899
|
+
questionRequest: QuestionRequest,
|
|
2900
|
+
): Promise<void> {
|
|
2901
|
+
const sessionId = this.state?.sessionId
|
|
2902
|
+
if (questionRequest.sessionID !== sessionId) {
|
|
2903
|
+
logger.log(
|
|
2904
|
+
`[QUESTION IGNORED] Question for different session (expected: ${sessionId}, got: ${questionRequest.sessionID})`,
|
|
2905
|
+
)
|
|
2906
|
+
return
|
|
2907
|
+
}
|
|
2908
|
+
|
|
2909
|
+
logger.log(
|
|
2910
|
+
`Question requested: id=${questionRequest.id}, questions=${questionRequest.questions.length}`,
|
|
2911
|
+
)
|
|
2912
|
+
|
|
2913
|
+
const shown = await this.tryShowPendingQuestion()
|
|
2914
|
+
if (!shown) {
|
|
2915
|
+
this.deferredQuestionShow.trigger()
|
|
2916
|
+
}
|
|
2667
2917
|
}
|
|
2668
2918
|
|
|
2669
2919
|
private handleQuestionReplied(properties: { sessionID: string }): void {
|
|
@@ -2671,6 +2921,7 @@ export class ThreadSessionRuntime {
|
|
|
2671
2921
|
if (properties.sessionID !== sessionId) {
|
|
2672
2922
|
return
|
|
2673
2923
|
}
|
|
2924
|
+
this.deferredQuestionShow.clear()
|
|
2674
2925
|
this.onInteractiveUiStateChanged()
|
|
2675
2926
|
|
|
2676
2927
|
// When a question is answered and the local queue has items, the model may
|
|
@@ -2752,7 +3003,7 @@ export class ThreadSessionRuntime {
|
|
|
2752
3003
|
if (displayText.trim()) {
|
|
2753
3004
|
await sendThreadMessage(
|
|
2754
3005
|
this.thread,
|
|
2755
|
-
|
|
3006
|
+
`${QUEUE_PREFIX}**${next.username}:** ${displayText}`,
|
|
2756
3007
|
)
|
|
2757
3008
|
}
|
|
2758
3009
|
|
|
@@ -2805,7 +3056,7 @@ export class ThreadSessionRuntime {
|
|
|
2805
3056
|
return secs > 0 ? `${mins}m ${secs}s` : `${mins}m`
|
|
2806
3057
|
})()
|
|
2807
3058
|
|
|
2808
|
-
const chunk =
|
|
3059
|
+
const chunk = `${STATUS_PREFIX}${message} - retrying in ${duration} (attempt #${attempt})`
|
|
2809
3060
|
const retryResult = await this.thread.send({ content: chunk, flags: SILENT_MESSAGE_FLAGS })
|
|
2810
3061
|
.catch((e) => new DiscordOperationError({ operation: 'sendMessage', cause: e }))
|
|
2811
3062
|
if (retryResult instanceof Error) {
|
|
@@ -2830,61 +3081,33 @@ export class ThreadSessionRuntime {
|
|
|
2830
3081
|
if (info.id !== this.state?.sessionId) {
|
|
2831
3082
|
return
|
|
2832
3083
|
}
|
|
2833
|
-
|
|
2834
|
-
|
|
2835
|
-
|
|
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
|
|
3084
|
+
const normalizedTitle = info.title.trim()
|
|
3085
|
+
if (this.appliedOpencodeTitle === normalizedTitle) {
|
|
3086
|
+
return
|
|
2843
3087
|
}
|
|
2844
|
-
|
|
2845
|
-
const renameDecision = deriveThreadRenameFromSessionUpdate({
|
|
3088
|
+
const desiredName = deriveThreadNameFromSessionTitle({
|
|
2846
3089
|
sessionTitle: info.title,
|
|
2847
3090
|
currentName: this.thread.name,
|
|
2848
|
-
lastSyncedName: this.lastSyncedThreadName,
|
|
2849
3091
|
})
|
|
2850
|
-
|
|
2851
|
-
|
|
2852
|
-
|
|
2853
|
-
|
|
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) {
|
|
3092
|
+
// Mark before setName so concurrent session.updated events don't stack
|
|
3093
|
+
// renames. Keep the mark on failure — retry is almost always a rate limit.
|
|
3094
|
+
this.appliedOpencodeTitle = normalizedTitle
|
|
3095
|
+
if (!desiredName) {
|
|
2862
3096
|
return
|
|
2863
3097
|
}
|
|
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
3098
|
|
|
2869
|
-
const
|
|
2870
|
-
|
|
2871
|
-
const renameResult = await Promise.race([
|
|
2872
|
-
this.thread.setName(desiredName)
|
|
3099
|
+
const renameResult = await raceDiscordRename({
|
|
3100
|
+
rename: this.thread.setName(desiredName)
|
|
2873
3101
|
.catch((e) =>
|
|
2874
3102
|
new Error('Failed to rename thread from OpenCode title', {
|
|
2875
3103
|
cause: e,
|
|
2876
3104
|
}),
|
|
2877
3105
|
),
|
|
2878
|
-
|
|
2879
|
-
timeoutSignal.addEventListener('abort', () => {
|
|
2880
|
-
resolve('timeout')
|
|
2881
|
-
})
|
|
2882
|
-
}),
|
|
2883
|
-
])
|
|
3106
|
+
})
|
|
2884
3107
|
|
|
2885
3108
|
if (renameResult === 'timeout') {
|
|
2886
3109
|
logger.warn(
|
|
2887
|
-
`[TITLE] setName timed out after ${
|
|
3110
|
+
`[TITLE] setName timed out after ${DISCORD_THREAD_RENAME_TIMEOUT_MS}ms for thread ${this.threadId} (likely rate-limited)`,
|
|
2888
3111
|
)
|
|
2889
3112
|
return
|
|
2890
3113
|
}
|
|
@@ -2894,38 +3117,11 @@ export class ThreadSessionRuntime {
|
|
|
2894
3117
|
)
|
|
2895
3118
|
return
|
|
2896
3119
|
}
|
|
2897
|
-
await this.persistLastSyncedThreadName(desiredName)
|
|
2898
3120
|
logger.log(
|
|
2899
3121
|
`[TITLE] Renamed thread ${this.threadId} to "${desiredName}" from OpenCode session title`,
|
|
2900
3122
|
)
|
|
2901
3123
|
}
|
|
2902
3124
|
|
|
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
3125
|
private async handleTuiToast(properties: {
|
|
2930
3126
|
title?: string
|
|
2931
3127
|
message: string
|
|
@@ -2946,7 +3142,7 @@ export class ThreadSessionRuntime {
|
|
|
2946
3142
|
const titlePrefix = properties.title
|
|
2947
3143
|
? `${properties.title.trim()}: `
|
|
2948
3144
|
: ''
|
|
2949
|
-
const chunk =
|
|
3145
|
+
const chunk = `${STATUS_PREFIX}${properties.variant}: ${titlePrefix}${toastMessage}`
|
|
2950
3146
|
const toastResult = await this.thread.send({ content: chunk, flags: SILENT_MESSAGE_FLAGS })
|
|
2951
3147
|
.catch((e) => new DiscordOperationError({ operation: 'sendMessage', cause: e }))
|
|
2952
3148
|
if (toastResult instanceof Error) {
|
|
@@ -2965,6 +3161,7 @@ export class ThreadSessionRuntime {
|
|
|
2965
3161
|
* fields that the local-queue path provides.
|
|
2966
3162
|
*/
|
|
2967
3163
|
private async submitViaOpencodeQueue(input: IngressInput): Promise<EnqueueResult> {
|
|
3164
|
+
await this.supersedePendingSleep(input)
|
|
2968
3165
|
let skippedBySessionGuard = false
|
|
2969
3166
|
|
|
2970
3167
|
await this.dispatchAction(async () => {
|
|
@@ -3039,6 +3236,18 @@ export class ThreadSessionRuntime {
|
|
|
3039
3236
|
await clearSessionModel(session.id)
|
|
3040
3237
|
}
|
|
3041
3238
|
|
|
3239
|
+
if (input.model) {
|
|
3240
|
+
const validatedModel = await validateModelId({
|
|
3241
|
+
model: input.model,
|
|
3242
|
+
getClient,
|
|
3243
|
+
directory: this.sdkDirectory,
|
|
3244
|
+
})
|
|
3245
|
+
if (validatedModel instanceof Error) {
|
|
3246
|
+
await cleanupOnError(`Failed to resolve model: ${validatedModel.message}`)
|
|
3247
|
+
return
|
|
3248
|
+
}
|
|
3249
|
+
}
|
|
3250
|
+
|
|
3042
3251
|
await ensureSessionPreferencesSnapshot({
|
|
3043
3252
|
sessionId: session.id,
|
|
3044
3253
|
channelId,
|
|
@@ -3063,15 +3272,25 @@ export class ThreadSessionRuntime {
|
|
|
3063
3272
|
}
|
|
3064
3273
|
const resolvedAgent = agentResult.agentPreference
|
|
3065
3274
|
const availableAgents = agentResult.agents
|
|
3275
|
+
releaseCurrentThreadIngress()
|
|
3276
|
+
|
|
3277
|
+
await this.persistIngressVariant({
|
|
3278
|
+
sessionId: session.id,
|
|
3279
|
+
channelId,
|
|
3280
|
+
appId: resolvedAppId,
|
|
3281
|
+
agentPreference: resolvedAgent,
|
|
3282
|
+
getClient,
|
|
3283
|
+
variant: input.variant,
|
|
3284
|
+
})
|
|
3066
3285
|
|
|
3067
3286
|
const [modelResult, preferredVariant] = await Promise.all([
|
|
3068
3287
|
(async () => {
|
|
3069
3288
|
if (input.model) {
|
|
3070
|
-
|
|
3071
|
-
|
|
3072
|
-
|
|
3073
|
-
|
|
3074
|
-
}
|
|
3289
|
+
return validateModelId({
|
|
3290
|
+
model: input.model,
|
|
3291
|
+
getClient,
|
|
3292
|
+
directory: this.sdkDirectory,
|
|
3293
|
+
})
|
|
3075
3294
|
}
|
|
3076
3295
|
const modelInfo = await getCurrentModelInfo({
|
|
3077
3296
|
sessionId: session.id,
|
|
@@ -3186,6 +3405,7 @@ export class ThreadSessionRuntime {
|
|
|
3186
3405
|
userId: input.userId,
|
|
3187
3406
|
sourceMessageId: input.sourceMessageId,
|
|
3188
3407
|
sourceThreadId: input.sourceThreadId,
|
|
3408
|
+
threadName: this.thread.name || undefined,
|
|
3189
3409
|
repliedMessage: input.repliedMessage,
|
|
3190
3410
|
worktree,
|
|
3191
3411
|
currentAgent: resolvedAgent,
|
|
@@ -3211,6 +3431,7 @@ export class ThreadSessionRuntime {
|
|
|
3211
3431
|
username: this.state?.sessionUsername || input.username,
|
|
3212
3432
|
userId: this.state?.sessionUserId || input.userId,
|
|
3213
3433
|
parentSessionId: this.state?.parentSessionId || input.parentSessionId,
|
|
3434
|
+
scheduledTask: await this.resolveScheduledTaskContext(session.id),
|
|
3214
3435
|
}),
|
|
3215
3436
|
...(resolvedAgent ? { agent: resolvedAgent } : {}),
|
|
3216
3437
|
...(modelField ? { model: modelField } : {}),
|
|
@@ -3269,7 +3490,28 @@ export class ThreadSessionRuntime {
|
|
|
3269
3490
|
* Enqueue in kimaki's local per-thread queue.
|
|
3270
3491
|
* Used for explicit queue workflows (/queue, queueMessage=true).
|
|
3271
3492
|
*/
|
|
3493
|
+
/**
|
|
3494
|
+
* A new turn supersedes a pending sleep.
|
|
3495
|
+
*
|
|
3496
|
+
* Called from the two terminal routers rather than from the top of
|
|
3497
|
+
* enqueueIncoming: arrival order is only fixed once a message reaches the
|
|
3498
|
+
* preprocessChain link, so awaiting anything before that lets two rapid
|
|
3499
|
+
* messages swap places. By here the order is already committed.
|
|
3500
|
+
*
|
|
3501
|
+
* Awaited rather than fire-and-forget so it cannot race the task runner and
|
|
3502
|
+
* let a stale wake land after the user took the conversation back.
|
|
3503
|
+
*/
|
|
3504
|
+
private async supersedePendingSleep(input: IngressInput): Promise<void> {
|
|
3505
|
+
if (input.isSleepWake) return
|
|
3506
|
+
await cancelSessionSleepForThread({ threadId: this.threadId }).catch(
|
|
3507
|
+
(error) => {
|
|
3508
|
+
logger.error('[SLEEP] failed to cancel pending sleep:', error)
|
|
3509
|
+
},
|
|
3510
|
+
)
|
|
3511
|
+
}
|
|
3512
|
+
|
|
3272
3513
|
private async enqueueViaLocalQueue(input: IngressInput): Promise<EnqueueResult> {
|
|
3514
|
+
await this.supersedePendingSleep(input)
|
|
3273
3515
|
const queueId = crypto.randomBytes(8).toString('hex')
|
|
3274
3516
|
const queuedMessage: QueuedMessage = {
|
|
3275
3517
|
queueId,
|
|
@@ -3281,6 +3523,7 @@ export class ThreadSessionRuntime {
|
|
|
3281
3523
|
command: input.command,
|
|
3282
3524
|
agent: input.agent,
|
|
3283
3525
|
model: input.model,
|
|
3526
|
+
variant: input.variant,
|
|
3284
3527
|
permissions: input.permissions,
|
|
3285
3528
|
injectionGuardPatterns: input.injectionGuardPatterns,
|
|
3286
3529
|
parentSessionId: input.parentSessionId,
|
|
@@ -3333,8 +3576,12 @@ export class ThreadSessionRuntime {
|
|
|
3333
3576
|
* discord-bot.ts.
|
|
3334
3577
|
*/
|
|
3335
3578
|
async enqueueIncoming(input: IngressInput): Promise<EnqueueResult> {
|
|
3579
|
+
await waitForCurrentThreadIngress()
|
|
3336
3580
|
threadState.setSessionUsername(this.threadId, input.username)
|
|
3337
|
-
|
|
3581
|
+
const botUserId = this.thread.client.user?.id
|
|
3582
|
+
if (input.userId && input.userId !== botUserId) {
|
|
3583
|
+
threadState.setSessionUserId(this.threadId, input.userId)
|
|
3584
|
+
}
|
|
3338
3585
|
await this.ensureParentSessionId({
|
|
3339
3586
|
parentSessionId: input.parentSessionId,
|
|
3340
3587
|
})
|
|
@@ -3540,6 +3787,11 @@ export class ThreadSessionRuntime {
|
|
|
3540
3787
|
)
|
|
3541
3788
|
|
|
3542
3789
|
this.stopTyping()
|
|
3790
|
+
this.deferredQuestionShow.clear()
|
|
3791
|
+
|
|
3792
|
+
// The aborted run owns the question request, so the dropdown dies with it.
|
|
3793
|
+
// Questions have no TTL, so this is the only thing that clears them here.
|
|
3794
|
+
void cancelPendingQuestion(this.threadId)
|
|
3543
3795
|
|
|
3544
3796
|
const apiAbortPromise = sessionId
|
|
3545
3797
|
? this.abortSessionViaApi({ abortId, reason, sessionId })
|
|
@@ -3613,14 +3865,6 @@ export class ThreadSessionRuntime {
|
|
|
3613
3865
|
return this.state?.queueItems.length ?? 0
|
|
3614
3866
|
}
|
|
3615
3867
|
|
|
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
3868
|
/** Clear all queued messages. Returns the removed items. */
|
|
3625
3869
|
clearQueue(): threadState.QueuedMessage[] {
|
|
3626
3870
|
return threadState.clearQueueItems(this.threadId)
|
|
@@ -3721,7 +3965,7 @@ export class ThreadSessionRuntime {
|
|
|
3721
3965
|
if (displayText.trim()) {
|
|
3722
3966
|
await sendThreadMessage(
|
|
3723
3967
|
this.thread,
|
|
3724
|
-
|
|
3968
|
+
`${QUEUE_PREFIX}**${next.username}:** ${displayText}`,
|
|
3725
3969
|
)
|
|
3726
3970
|
}
|
|
3727
3971
|
}
|
|
@@ -3756,6 +4000,7 @@ export class ThreadSessionRuntime {
|
|
|
3756
4000
|
private async dispatchPrompt(input: QueuedMessage): Promise<void> {
|
|
3757
4001
|
this.lastDisplayedContextPercentage = 0
|
|
3758
4002
|
this.lastRateLimitDisplayTime = 0
|
|
4003
|
+
this.lastSentPartKind = undefined
|
|
3759
4004
|
|
|
3760
4005
|
// ── Ensure session ────────────────────────────────────────
|
|
3761
4006
|
const sessionResult = await this.ensureSession({
|
|
@@ -3809,6 +4054,24 @@ export class ThreadSessionRuntime {
|
|
|
3809
4054
|
await clearSessionModel(session.id)
|
|
3810
4055
|
}
|
|
3811
4056
|
|
|
4057
|
+
if (input.model) {
|
|
4058
|
+
const validatedModel = await validateModelId({
|
|
4059
|
+
model: input.model,
|
|
4060
|
+
getClient,
|
|
4061
|
+
directory: this.sdkDirectory,
|
|
4062
|
+
})
|
|
4063
|
+
if (validatedModel instanceof Error) {
|
|
4064
|
+
this.stopTyping()
|
|
4065
|
+
await sendThreadMessage(
|
|
4066
|
+
this.thread,
|
|
4067
|
+
`Failed to resolve model: ${validatedModel.message}`,
|
|
4068
|
+
{ flags: NOTIFY_MESSAGE_FLAGS },
|
|
4069
|
+
)
|
|
4070
|
+
await this.tryDrainQueue({ showIndicator: true })
|
|
4071
|
+
return
|
|
4072
|
+
}
|
|
4073
|
+
}
|
|
4074
|
+
|
|
3812
4075
|
await ensureSessionPreferencesSnapshot({
|
|
3813
4076
|
sessionId: session.id,
|
|
3814
4077
|
channelId,
|
|
@@ -3841,14 +4104,23 @@ export class ThreadSessionRuntime {
|
|
|
3841
4104
|
const earlyAgentPreference = earlyAgentResult.agentPreference
|
|
3842
4105
|
const earlyAvailableAgents = earlyAgentResult.agents
|
|
3843
4106
|
|
|
4107
|
+
await this.persistIngressVariant({
|
|
4108
|
+
sessionId: session.id,
|
|
4109
|
+
channelId,
|
|
4110
|
+
appId: resolvedAppId,
|
|
4111
|
+
agentPreference: earlyAgentPreference,
|
|
4112
|
+
getClient,
|
|
4113
|
+
variant: input.variant,
|
|
4114
|
+
})
|
|
4115
|
+
|
|
3844
4116
|
const [earlyModelResult, preferredVariant] = await Promise.all([
|
|
3845
4117
|
(async () => {
|
|
3846
4118
|
if (input.model) {
|
|
3847
|
-
|
|
3848
|
-
|
|
3849
|
-
|
|
3850
|
-
|
|
3851
|
-
}
|
|
4119
|
+
return validateModelId({
|
|
4120
|
+
model: input.model,
|
|
4121
|
+
getClient,
|
|
4122
|
+
directory: this.sdkDirectory,
|
|
4123
|
+
})
|
|
3852
4124
|
}
|
|
3853
4125
|
const modelInfo = await getCurrentModelInfo({
|
|
3854
4126
|
sessionId: session.id,
|
|
@@ -3975,6 +4247,7 @@ export class ThreadSessionRuntime {
|
|
|
3975
4247
|
userId: input.userId,
|
|
3976
4248
|
sourceMessageId: input.sourceMessageId,
|
|
3977
4249
|
sourceThreadId: input.sourceThreadId,
|
|
4250
|
+
threadName: this.thread.name || undefined,
|
|
3978
4251
|
repliedMessage: input.repliedMessage,
|
|
3979
4252
|
worktree,
|
|
3980
4253
|
currentAgent: earlyAgentPreference,
|
|
@@ -4025,6 +4298,7 @@ export class ThreadSessionRuntime {
|
|
|
4025
4298
|
userId: input.userId,
|
|
4026
4299
|
sourceMessageId: input.sourceMessageId,
|
|
4027
4300
|
sourceThreadId: input.sourceThreadId,
|
|
4301
|
+
threadName: this.thread.name || undefined,
|
|
4028
4302
|
repliedMessage: input.repliedMessage,
|
|
4029
4303
|
})
|
|
4030
4304
|
// OpenCode's session.command API has no `system` field. Persist the
|
|
@@ -4042,6 +4316,7 @@ export class ThreadSessionRuntime {
|
|
|
4042
4316
|
username: this.state?.sessionUsername || input.username,
|
|
4043
4317
|
userId: this.state?.sessionUserId || input.userId,
|
|
4044
4318
|
parentSessionId: this.state?.parentSessionId || input.parentSessionId,
|
|
4319
|
+
scheduledTask: await this.resolveScheduledTaskContext(session.id),
|
|
4045
4320
|
})
|
|
4046
4321
|
const systemWriteResult = await writeSessionSystemPrompt({
|
|
4047
4322
|
sessionId: session.id,
|
|
@@ -4179,6 +4454,7 @@ export class ThreadSessionRuntime {
|
|
|
4179
4454
|
username: this.state?.sessionUsername || input.username,
|
|
4180
4455
|
userId: this.state?.sessionUserId || input.userId,
|
|
4181
4456
|
parentSessionId: this.state?.parentSessionId || input.parentSessionId,
|
|
4457
|
+
scheduledTask: await this.resolveScheduledTaskContext(session.id),
|
|
4182
4458
|
}),
|
|
4183
4459
|
model: earlyModelParam,
|
|
4184
4460
|
agent: earlyAgentPreference,
|
|
@@ -4219,6 +4495,52 @@ export class ThreadSessionRuntime {
|
|
|
4219
4495
|
// ── Session Ensure ──────────────────────────────────────────
|
|
4220
4496
|
// Creates or reuses the OpenCode session for this thread.
|
|
4221
4497
|
|
|
4498
|
+
/** Cached per-session scheduled task info for the system message. */
|
|
4499
|
+
private scheduledTaskContextCache = new Map<
|
|
4500
|
+
string,
|
|
4501
|
+
ScheduledTaskSystemContext | undefined
|
|
4502
|
+
>()
|
|
4503
|
+
|
|
4504
|
+
/**
|
|
4505
|
+
* Resolve the scheduled-task context for the system message, once per
|
|
4506
|
+
* session. The row in session_start_sources is immutable, so caching the
|
|
4507
|
+
* result keeps the system prompt identical across turns (prompt-cache safe).
|
|
4508
|
+
* One-shot 'at' tasks are deleted after their run, so only schedule_kind
|
|
4509
|
+
* survives for them.
|
|
4510
|
+
*/
|
|
4511
|
+
private async resolveScheduledTaskContext(
|
|
4512
|
+
sessionId: string,
|
|
4513
|
+
): Promise<ScheduledTaskSystemContext | undefined> {
|
|
4514
|
+
const cache = this.scheduledTaskContextCache
|
|
4515
|
+
if (cache.has(sessionId)) {
|
|
4516
|
+
return cache.get(sessionId)
|
|
4517
|
+
}
|
|
4518
|
+
const context = await (async (): Promise<
|
|
4519
|
+
ScheduledTaskSystemContext | undefined
|
|
4520
|
+
> => {
|
|
4521
|
+
const source = await getSessionStartSource({ sessionId })
|
|
4522
|
+
if (!source) {
|
|
4523
|
+
return undefined
|
|
4524
|
+
}
|
|
4525
|
+
const task = source.scheduled_task_id
|
|
4526
|
+
? await getScheduledTask(source.scheduled_task_id)
|
|
4527
|
+
: null
|
|
4528
|
+
return {
|
|
4529
|
+
taskId: source.scheduled_task_id ?? undefined,
|
|
4530
|
+
scheduleKind: source.schedule_kind,
|
|
4531
|
+
cronExpr: task?.cron_expr,
|
|
4532
|
+
timezone: task?.timezone,
|
|
4533
|
+
}
|
|
4534
|
+
})().catch((error) => {
|
|
4535
|
+
logger.warn(
|
|
4536
|
+
`[SCHEDULED TASK CONTEXT] Failed to resolve for session ${sessionId}: ${error instanceof Error ? error.message : String(error)}`,
|
|
4537
|
+
)
|
|
4538
|
+
return undefined
|
|
4539
|
+
})
|
|
4540
|
+
cache.set(sessionId, context)
|
|
4541
|
+
return context
|
|
4542
|
+
}
|
|
4543
|
+
|
|
4222
4544
|
private async updateExistingSessionPermissions({
|
|
4223
4545
|
client,
|
|
4224
4546
|
sessionId,
|
|
@@ -4314,8 +4636,9 @@ export class ThreadSessionRuntime {
|
|
|
4314
4636
|
} else if (sessionResponse.data) {
|
|
4315
4637
|
session = sessionResponse.data
|
|
4316
4638
|
} else {
|
|
4639
|
+
const sdkMessage = extractSdkErrorMessage(sessionResponse.error)
|
|
4317
4640
|
logger.warn(
|
|
4318
|
-
`[ENSURE SESSION] session.get returned no data for ${sessionId}, response=${JSON.stringify(sessionResponse)}`,
|
|
4641
|
+
`[ENSURE SESSION] session.get returned no data for ${sessionId}: ${sdkMessage}, response=${JSON.stringify(sessionResponse)}`,
|
|
4319
4642
|
)
|
|
4320
4643
|
}
|
|
4321
4644
|
}
|
|
@@ -4346,35 +4669,37 @@ export class ThreadSessionRuntime {
|
|
|
4346
4669
|
{ cause: createResult },
|
|
4347
4670
|
)
|
|
4348
4671
|
}
|
|
4349
|
-
|
|
4350
|
-
|
|
4351
|
-
logger.
|
|
4352
|
-
`[ENSURE SESSION] session.create
|
|
4672
|
+
if (createResult.error || !createResult.data) {
|
|
4673
|
+
const errorMessage = extractSdkErrorMessage(createResult.error)
|
|
4674
|
+
logger.error(
|
|
4675
|
+
`[ENSURE SESSION] session.create failed: ${errorMessage}, threadId=${this.thread.id}, directory=${this.sdkDirectory}, response=${JSON.stringify(createResult)}`,
|
|
4676
|
+
)
|
|
4677
|
+
return new Error(
|
|
4678
|
+
`Failed to create session: ${errorMessage}, threadId=${this.thread.id}, directory=${this.sdkDirectory}`,
|
|
4353
4679
|
)
|
|
4354
4680
|
}
|
|
4681
|
+
session = createResult.data
|
|
4355
4682
|
// Insert DB row immediately so the external-sync poller sees
|
|
4356
4683
|
// source='kimaki' before the next poll tick and skips this session.
|
|
4357
4684
|
// The upsert at the end of ensureSession is kept for the reuse path.
|
|
4358
|
-
|
|
4359
|
-
|
|
4360
|
-
|
|
4361
|
-
|
|
4362
|
-
|
|
4363
|
-
scanPatterns: injectionGuardPatterns,
|
|
4364
|
-
})
|
|
4365
|
-
}
|
|
4366
|
-
const worktree = await getThreadWorktreeOrWorkspace(this.thread.id)
|
|
4367
|
-
trackEvent('session_created', {
|
|
4368
|
-
has_worktree: Boolean(worktree),
|
|
4369
|
-
source: sessionStartScheduleKind ? 'scheduled' : 'discord',
|
|
4685
|
+
await setThreadSession(this.thread.id, session.id)
|
|
4686
|
+
if (injectionGuardPatterns?.length) {
|
|
4687
|
+
writeInjectionGuardConfig({
|
|
4688
|
+
sessionId: session.id,
|
|
4689
|
+
scanPatterns: injectionGuardPatterns,
|
|
4370
4690
|
})
|
|
4371
4691
|
}
|
|
4692
|
+
const worktree = await getThreadWorktreeOrWorkspace(this.thread.id)
|
|
4693
|
+
trackEvent('session_created', {
|
|
4694
|
+
has_worktree: Boolean(worktree),
|
|
4695
|
+
source: sessionStartScheduleKind ? 'scheduled' : 'discord',
|
|
4696
|
+
})
|
|
4372
4697
|
createdNewSession = true
|
|
4373
4698
|
}
|
|
4374
4699
|
|
|
4375
4700
|
if (!session) {
|
|
4376
4701
|
return new Error(
|
|
4377
|
-
`Failed to create or get session: threadId=${this.thread.id}, channelId=${this.channelId}, directory=${directory}, sdkDirectory=${this.sdkDirectory}, existingSessionId=${sessionId ?? 'none'}, createdNewSession=${createdNewSession}
|
|
4702
|
+
`Failed to create or get session: threadId=${this.thread.id}, channelId=${this.channelId}, directory=${directory}, sdkDirectory=${this.sdkDirectory}, existingSessionId=${sessionId ?? 'none'}, createdNewSession=${createdNewSession}`,
|
|
4378
4703
|
)
|
|
4379
4704
|
}
|
|
4380
4705
|
|
|
@@ -4443,7 +4768,7 @@ export class ThreadSessionRuntime {
|
|
|
4443
4768
|
: ''
|
|
4444
4769
|
const result = await sendThreadMessage(
|
|
4445
4770
|
this.thread,
|
|
4446
|
-
`*using ${modelLabel}${agentLabel}
|
|
4771
|
+
asDiscordQuote(`*using ${modelLabel}${agentLabel}*`),
|
|
4447
4772
|
{ flags: SILENT_MESSAGE_FLAGS },
|
|
4448
4773
|
).catch((e) => new DiscordOperationError({ operation: 'sendMessage', cause: e }))
|
|
4449
4774
|
if (result instanceof Error) {
|
|
@@ -4477,7 +4802,6 @@ export class ThreadSessionRuntime {
|
|
|
4477
4802
|
elapsedMs < 1000
|
|
4478
4803
|
? '<1s'
|
|
4479
4804
|
: prettyMilliseconds(elapsedMs, { secondsDecimalDigits: 0 })
|
|
4480
|
-
const modelInfo = runInfo.model ? ` ⋅ ${runInfo.model}` : ''
|
|
4481
4805
|
const agentInfo =
|
|
4482
4806
|
runInfo.agent && runInfo.agent.toLowerCase() !== 'build'
|
|
4483
4807
|
? ` ⋅ **${runInfo.agent}**`
|
|
@@ -4494,7 +4818,7 @@ export class ThreadSessionRuntime {
|
|
|
4494
4818
|
}).catch((e) => new FilesystemOperationError({ operation: 'gitBranch', cause: e })),
|
|
4495
4819
|
(async () => {
|
|
4496
4820
|
if (!client || !sessionId) {
|
|
4497
|
-
return
|
|
4821
|
+
return []
|
|
4498
4822
|
}
|
|
4499
4823
|
let tokensUsed = runInfo.tokensUsed
|
|
4500
4824
|
// Fetch final token count from API
|
|
@@ -4534,9 +4858,12 @@ export class ThreadSessionRuntime {
|
|
|
4534
4858
|
})
|
|
4535
4859
|
: undefined
|
|
4536
4860
|
|
|
4861
|
+
const providers = providersResult && !(providersResult instanceof Error)
|
|
4862
|
+
? providersResult.data?.all ?? []
|
|
4863
|
+
: []
|
|
4537
4864
|
let contextLimit = fallbackLimit
|
|
4538
|
-
if (
|
|
4539
|
-
const provider =
|
|
4865
|
+
if (providers.length > 0) {
|
|
4866
|
+
const provider = providers.find((p) => {
|
|
4540
4867
|
return p.id === runInfo.providerID
|
|
4541
4868
|
})
|
|
4542
4869
|
const model = provider?.models?.[runInfo.model || '']
|
|
@@ -4549,6 +4876,7 @@ export class ThreadSessionRuntime {
|
|
|
4549
4876
|
)
|
|
4550
4877
|
contextInfo = ` ⋅ ${percentage}%`
|
|
4551
4878
|
}
|
|
4879
|
+
return providers
|
|
4552
4880
|
})().catch((e) => new OpenCodeSdkError({ operation: 'resolveModelPreference', cause: e })),
|
|
4553
4881
|
])
|
|
4554
4882
|
const branchName =
|
|
@@ -4559,6 +4887,19 @@ export class ThreadSessionRuntime {
|
|
|
4559
4887
|
contextResult,
|
|
4560
4888
|
)
|
|
4561
4889
|
}
|
|
4890
|
+
const providers = contextResult instanceof Error ? [] : (contextResult ?? [])
|
|
4891
|
+
const modelLabel = runInfo.model
|
|
4892
|
+
? displayedModelLabel({
|
|
4893
|
+
modelID: runInfo.model,
|
|
4894
|
+
name: await resolveDisplayedModelName({
|
|
4895
|
+
providers,
|
|
4896
|
+
providerID: runInfo.providerID,
|
|
4897
|
+
modelID: runInfo.model,
|
|
4898
|
+
sessionID: sessionId,
|
|
4899
|
+
}),
|
|
4900
|
+
})
|
|
4901
|
+
: undefined
|
|
4902
|
+
const modelInfo = modelLabel ? ` ⋅ ${modelLabel}` : ''
|
|
4562
4903
|
|
|
4563
4904
|
const truncate = (s: string, max: number) => {
|
|
4564
4905
|
return s.length > max ? s.slice(0, max - 1) + '\u2026' : s
|
|
@@ -4568,13 +4909,25 @@ export class ThreadSessionRuntime {
|
|
|
4568
4909
|
const projectInfo = truncatedBranch
|
|
4569
4910
|
? `${truncatedFolder} ⋅ ${truncatedBranch} ⋅ `
|
|
4570
4911
|
: `${truncatedFolder} ⋅ `
|
|
4571
|
-
const
|
|
4912
|
+
const hasQueuedMessage = this.getQueueLength() > 0
|
|
4913
|
+
const didUseSleepTool = sessionId
|
|
4914
|
+
? didLatestUserTurnUseSleepTool({ events: this.eventBuffer, sessionId })
|
|
4915
|
+
: false
|
|
4916
|
+
const shouldNotifyUser = !hasQueuedMessage && !didUseSleepTool
|
|
4917
|
+
const mentionUserId = store.getState().footerMentionsEnabled && shouldNotifyUser
|
|
4918
|
+
? await resolveThreadFooterMentionUserId({
|
|
4919
|
+
sessionUserId: this.state?.sessionUserId,
|
|
4920
|
+
thread: this.thread,
|
|
4921
|
+
})
|
|
4922
|
+
: undefined
|
|
4923
|
+
const mention = mentionUserId ? ` <@${mentionUserId}>` : ''
|
|
4924
|
+
const footerText = asDiscordQuote(
|
|
4925
|
+
`*${projectInfo}${sessionDuration}${contextInfo}${modelInfo}${agentInfo}*${mention}`,
|
|
4926
|
+
)
|
|
4572
4927
|
this.stopTyping()
|
|
4573
4928
|
|
|
4574
|
-
// Skip notification if there's a queued message next — the user only
|
|
4575
|
-
// needs to be notified when the entire queue finishes.
|
|
4576
4929
|
await sendThreadMessage(this.thread, footerText, {
|
|
4577
|
-
flags:
|
|
4930
|
+
flags: shouldNotifyUser ? NOTIFY_MESSAGE_FLAGS : SILENT_MESSAGE_FLAGS,
|
|
4578
4931
|
})
|
|
4579
4932
|
logger.log(
|
|
4580
4933
|
`DURATION: Session completed in ${sessionDuration}, model ${runInfo.model}, tokens ${runInfo.tokensUsed}`,
|
|
@@ -4587,6 +4940,7 @@ export class ThreadSessionRuntime {
|
|
|
4587
4940
|
this.modelContextLimitKey = undefined
|
|
4588
4941
|
this.lastDisplayedContextPercentage = 0
|
|
4589
4942
|
this.lastRateLimitDisplayTime = 0
|
|
4943
|
+
this.lastSentPartKind = undefined
|
|
4590
4944
|
}
|
|
4591
4945
|
|
|
4592
4946
|
// ── Retry Last User Prompt (for model-change flow) ──────────
|