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
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// call runtime APIs (enqueueIncoming, abortActiveRun, etc.) without inspecting
|
|
7
7
|
// run internals.
|
|
8
8
|
import crypto from 'node:crypto';
|
|
9
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
9
10
|
import { ChannelType } from 'discord.js';
|
|
10
11
|
import path from 'node:path';
|
|
11
12
|
import prettyMilliseconds from 'pretty-ms';
|
|
@@ -15,22 +16,24 @@ import { getOpencodeClient, initializeOpencodeForDirectory, buildSessionPermissi
|
|
|
15
16
|
import { isAbortError } from '../utils.js';
|
|
16
17
|
import { registerEventListener, unregisterEventListener, waitForGlobalEventListener, } from './global-event-listener.js';
|
|
17
18
|
import { createLogger, LogPrefix } from '../logger.js';
|
|
18
|
-
import { sendThreadMessage, SILENT_MESSAGE_FLAGS, NOTIFY_MESSAGE_FLAGS, } from '../discord-utils.js';
|
|
19
|
-
import { formatPart, formatTaskToolTitle } from '../message-formatting.js';
|
|
20
|
-
import { getChannelVerbosity, getPartMessageIds, getDb, setPartMessage, getThreadSession, setThreadSession, getThreadParentSessionId, setThreadParentSessionId, getThreadWorktreeOrWorkspace, setSessionAgent, clearSessionModel, getVariantCascade, setSessionStartSource, completeScheduledTaskRunsForSession, failScheduledTaskRunsForSession, startScheduledTaskRunSession, appendSessionEventsSinceLastTimestamp, getSessionEventSnapshot, } from '../database.js';
|
|
19
|
+
import { sendThreadMessage, sendSessionPartMessage, SILENT_MESSAGE_FLAGS, NOTIFY_MESSAGE_FLAGS, raceDiscordRename, DISCORD_THREAD_RENAME_TIMEOUT_MS, resolveThreadFooterMentionUserId, } from '../discord-utils.js';
|
|
20
|
+
import { asDiscordQuote, formatPart, formatTaskToolTitle, planAssistantTurnFlush, QUEUE_PREFIX, sessionPartKind, shouldLeadWithBlankLine, shouldQuoteIntermediateTextPart, STATUS_PREFIX, WORKTREE_PREFIX, } from '../message-formatting.js';
|
|
21
|
+
import { getChannelVerbosity, getPartMessageIds, getDb, setPartMessage, getThreadSession, setThreadSession, getThreadParentSessionId, setThreadParentSessionId, getThreadWorktreeOrWorkspace, setSessionAgent, setSessionModel, clearSessionModel, getVariantCascade, setSessionStartSource, getSessionStartSource, getScheduledTask, completeScheduledTaskRunsForSession, failScheduledTaskRunsForSession, startScheduledTaskRunSession, appendSessionEventsSinceLastTimestamp, getSessionEventSnapshot, cancelSessionSleepForThread, } from '../database.js';
|
|
21
22
|
import * as orm from 'drizzle-orm';
|
|
22
23
|
import * as schema from '../schema.js';
|
|
23
24
|
import { showPermissionButtons, addPermissionRequestToContext, arePatternsCoveredBy, pendingPermissionContexts, } from '../commands/permissions.js';
|
|
24
|
-
import { showAskUserQuestionDropdowns, pendingQuestionContexts, cancelPendingQuestion, } from '../commands/ask-question.js';
|
|
25
|
+
import { showAskUserQuestionDropdowns, pendingQuestionContexts, cancelPendingQuestion, findPendingQuestionContextForRequest, } from '../commands/ask-question.js';
|
|
25
26
|
import { showActionButtons, waitForQueuedActionButtonsRequest, pendingActionButtonContexts, cancelPendingActionButtons, } from '../commands/action-buttons.js';
|
|
26
27
|
import { pendingFileUploadContexts, cancelPendingFileUpload, } from '../commands/file-upload.js';
|
|
27
28
|
import { getCurrentModelInfo, ensureSessionPreferencesSnapshot, } from '../commands/model.js';
|
|
29
|
+
import { displayedModelLabel, resolveDisplayedModelName, validateModelId, } from './model-utils.js';
|
|
28
30
|
import { getOpencodePromptContext, getOpencodeSystemMessage, writeSessionSystemPrompt, } from '../system-message.js';
|
|
29
31
|
import { getDataDir } from '../config.js';
|
|
32
|
+
import { store } from '../store.js';
|
|
30
33
|
import { trackEvent, } from '../analytics.js';
|
|
31
34
|
import { resolveValidatedAgentPreference } from './agent-utils.js';
|
|
32
35
|
import { appendOpencodeSessionEventLog, getOpencodeEventSessionId, isOpencodeSessionEventLogEnabled, } from './opencode-session-event-log.js';
|
|
33
|
-
import { doesLatestUserTurnHaveNaturalCompletion, didQuestionQueueHandoffSinceLatestQuestionAsked, getAssistantMessageIdsForLatestUserTurn, getCurrentTurnStartTime, isSessionBusy, getLatestRunInfo, getIdleTokenUsageDelta, getDerivedSubtaskIndex, getDerivedSubtaskAgentType, getLatestAssistantMessageIdForLatestUserTurn, hasAssistantMessageCompletedBefore, isAssistantMessageInLatestUserTurn, isAssistantMessageNaturalCompletion, } from './event-stream-state.js';
|
|
36
|
+
import { doesLatestUserTurnHaveNaturalCompletion, didLatestUserTurnUseSleepTool, didQuestionQueueHandoffSinceLatestQuestionAsked, deriveLatestUnansweredQuestion, getAssistantMessageIdsForLatestUserTurn, getCurrentTurnStartTime, isSessionBusy, getLatestRunInfo, getIdleTokenUsageDelta, getDerivedSubtaskIndex, getDerivedSubtaskAgentType, getTokenUsageSessionIdsForIdle, isDerivedChildSession, getLatestAssistantMessageIdForLatestUserTurn, getAssistantMessageKind, hasAssistantMessageCompletedBefore, isAssistantMessageInLatestUserTurn, isAssistantMessageNaturalCompletion, shouldBufferSessionEvent, } from './event-stream-state.js';
|
|
34
37
|
// Track multiple pending permissions per thread (keyed by permission ID).
|
|
35
38
|
// OpenCode handles blocking/sequencing — we just need to track all pending
|
|
36
39
|
// permissions to avoid duplicates and properly clean up on reply/teardown.
|
|
@@ -61,6 +64,48 @@ const shouldLogSessionEvents = process.env['KIMAKI_LOG_SESSION_EVENTS'] === '1'
|
|
|
61
64
|
// Runtime instances are kept in a plain Map (not Zustand — the Map
|
|
62
65
|
// is not reactive state, just a lookup for resource handles).
|
|
63
66
|
const runtimes = new Map();
|
|
67
|
+
// Per-thread FIFO for Discord arrival order of one-shot slash calls vs messages.
|
|
68
|
+
// Covers /plan-agent (no prompt) and /foo-cmd /foo-skill. OpenCode already
|
|
69
|
+
// queues promptAsync. /model, /agent, and /compact are not on this queue.
|
|
70
|
+
const threadIngressChains = new Map();
|
|
71
|
+
const threadIngressSlotAls = new AsyncLocalStorage();
|
|
72
|
+
export function reserveThreadIngress(threadId) {
|
|
73
|
+
const previous = threadIngressChains.get(threadId) ?? Promise.resolve();
|
|
74
|
+
let released = false;
|
|
75
|
+
let releaseHeld = () => { };
|
|
76
|
+
const held = new Promise((resolve) => {
|
|
77
|
+
releaseHeld = resolve;
|
|
78
|
+
});
|
|
79
|
+
threadIngressChains.set(threadId, previous.then(() => held));
|
|
80
|
+
return {
|
|
81
|
+
wait: previous,
|
|
82
|
+
release: () => {
|
|
83
|
+
if (released) {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
released = true;
|
|
87
|
+
releaseHeld();
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
export async function runInThreadIngressSlot(slot, run) {
|
|
92
|
+
try {
|
|
93
|
+
return await threadIngressSlotAls.run(slot, run);
|
|
94
|
+
}
|
|
95
|
+
finally {
|
|
96
|
+
slot?.release();
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
export async function waitForCurrentThreadIngress() {
|
|
100
|
+
const slot = threadIngressSlotAls.getStore();
|
|
101
|
+
if (!slot) {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
await slot.wait;
|
|
105
|
+
}
|
|
106
|
+
export function releaseCurrentThreadIngress() {
|
|
107
|
+
threadIngressSlotAls.getStore()?.release();
|
|
108
|
+
}
|
|
64
109
|
export function getRuntime(threadId) {
|
|
65
110
|
return runtimes.get(threadId);
|
|
66
111
|
}
|
|
@@ -70,9 +115,15 @@ export function getOrCreateRuntime(opts) {
|
|
|
70
115
|
if (existing.sdkDirectory !== opts.sdkDirectory) {
|
|
71
116
|
logger.warn(`[RUNTIME] Ignoring sdkDirectory change for existing thread ${opts.threadId}: ${existing.sdkDirectory} → ${opts.sdkDirectory}`);
|
|
72
117
|
}
|
|
118
|
+
if (opts.sessionId && !existing.state?.sessionId) {
|
|
119
|
+
threadState.setSessionId(opts.threadId, opts.sessionId);
|
|
120
|
+
}
|
|
73
121
|
return existing;
|
|
74
122
|
}
|
|
75
123
|
threadState.ensureThread(opts.threadId); // add to global store
|
|
124
|
+
if (opts.sessionId) {
|
|
125
|
+
threadState.setSessionId(opts.threadId, opts.sessionId);
|
|
126
|
+
}
|
|
76
127
|
const runtime = new ThreadSessionRuntime(opts);
|
|
77
128
|
runtimes.set(opts.threadId, runtime);
|
|
78
129
|
return runtime;
|
|
@@ -85,6 +136,7 @@ export function disposeRuntime(threadId) {
|
|
|
85
136
|
runtime.dispose();
|
|
86
137
|
runtimes.delete(threadId);
|
|
87
138
|
threadState.removeThread(threadId); // remove from global store
|
|
139
|
+
threadIngressChains.delete(threadId);
|
|
88
140
|
}
|
|
89
141
|
export function disposeRuntimesForDirectory({ directory, channelId, }) {
|
|
90
142
|
let count = 0;
|
|
@@ -98,6 +150,7 @@ export function disposeRuntimesForDirectory({ directory, channelId, }) {
|
|
|
98
150
|
runtime.dispose();
|
|
99
151
|
runtimes.delete(threadId);
|
|
100
152
|
threadState.removeThread(threadId);
|
|
153
|
+
threadIngressChains.delete(threadId);
|
|
101
154
|
count++;
|
|
102
155
|
}
|
|
103
156
|
return count;
|
|
@@ -116,6 +169,7 @@ export function disposeInactiveRuntimes({ idleMs, nowMs = Date.now(), }) {
|
|
|
116
169
|
runtime.dispose();
|
|
117
170
|
runtimes.delete(threadId);
|
|
118
171
|
threadState.removeThread(threadId);
|
|
172
|
+
threadIngressChains.delete(threadId);
|
|
119
173
|
disposedThreadIds.push(threadId);
|
|
120
174
|
disposedDirectories.add(runtime.projectDirectory);
|
|
121
175
|
}
|
|
@@ -227,26 +281,37 @@ export function isEssentialToolPart(part) {
|
|
|
227
281
|
}
|
|
228
282
|
// ── Thread title derivation ──────────────────────────────────────
|
|
229
283
|
const DISCORD_THREAD_NAME_MAX = 100;
|
|
230
|
-
const WORKTREE_THREAD_PREFIX = '⬦ ';
|
|
231
|
-
// Prefixes that should survive OpenCode session title renames.
|
|
232
|
-
// When a thread starts with one of these, the rename preserves it.
|
|
233
284
|
const PRESERVED_THREAD_PREFIXES = [
|
|
234
|
-
|
|
285
|
+
WORKTREE_PREFIX,
|
|
235
286
|
'btw: ',
|
|
236
287
|
'Fork: ',
|
|
237
288
|
];
|
|
289
|
+
function stripPreservedThreadPrefix(name) {
|
|
290
|
+
const matchedPrefix = PRESERVED_THREAD_PREFIXES.find((prefix) => {
|
|
291
|
+
return name.startsWith(prefix);
|
|
292
|
+
});
|
|
293
|
+
if (!matchedPrefix)
|
|
294
|
+
return name;
|
|
295
|
+
return name.slice(matchedPrefix.length).trim();
|
|
296
|
+
}
|
|
238
297
|
function getThreadNameCandidateFromSessionTitle({ sessionTitle, currentName, }) {
|
|
239
|
-
const trimmed = sessionTitle
|
|
298
|
+
const trimmed = sessionTitle
|
|
299
|
+
?.replace(/<\/?callout\b[^>]*>/gi, '')
|
|
300
|
+
.trim();
|
|
240
301
|
if (!trimmed) {
|
|
241
302
|
return null;
|
|
242
303
|
}
|
|
243
|
-
|
|
304
|
+
const withoutCopiedPrefix = stripPreservedThreadPrefix(trimmed);
|
|
305
|
+
if (!withoutCopiedPrefix) {
|
|
306
|
+
return null;
|
|
307
|
+
}
|
|
308
|
+
if (/^new session\s*-/i.test(withoutCopiedPrefix)) {
|
|
244
309
|
return null;
|
|
245
310
|
}
|
|
246
311
|
const matchedPrefix = PRESERVED_THREAD_PREFIXES.find((p) => {
|
|
247
312
|
return currentName.startsWith(p);
|
|
248
313
|
}) ?? '';
|
|
249
|
-
return `${matchedPrefix}${
|
|
314
|
+
return `${matchedPrefix}${withoutCopiedPrefix}`.slice(0, DISCORD_THREAD_NAME_MAX);
|
|
250
315
|
}
|
|
251
316
|
export function deriveThreadNameFromSessionTitle({ sessionTitle, currentName, }) {
|
|
252
317
|
const candidate = getThreadNameCandidateFromSessionTitle({
|
|
@@ -261,34 +326,6 @@ export function deriveThreadNameFromSessionTitle({ sessionTitle, currentName, })
|
|
|
261
326
|
}
|
|
262
327
|
return candidate;
|
|
263
328
|
}
|
|
264
|
-
export function deriveThreadRenameFromSessionUpdate({ sessionTitle, currentName, lastSyncedName, }) {
|
|
265
|
-
if (lastSyncedName !== null && currentName !== lastSyncedName) {
|
|
266
|
-
return {
|
|
267
|
-
desiredName: null,
|
|
268
|
-
nextSyncedName: lastSyncedName,
|
|
269
|
-
};
|
|
270
|
-
}
|
|
271
|
-
const candidate = getThreadNameCandidateFromSessionTitle({
|
|
272
|
-
sessionTitle,
|
|
273
|
-
currentName,
|
|
274
|
-
});
|
|
275
|
-
if (candidate === null) {
|
|
276
|
-
return {
|
|
277
|
-
desiredName: null,
|
|
278
|
-
nextSyncedName: lastSyncedName,
|
|
279
|
-
};
|
|
280
|
-
}
|
|
281
|
-
if (candidate === currentName) {
|
|
282
|
-
return {
|
|
283
|
-
desiredName: null,
|
|
284
|
-
nextSyncedName: currentName,
|
|
285
|
-
};
|
|
286
|
-
}
|
|
287
|
-
return {
|
|
288
|
-
desiredName: candidate,
|
|
289
|
-
nextSyncedName: candidate,
|
|
290
|
-
};
|
|
291
|
-
}
|
|
292
329
|
function resolveTurnSource(input) {
|
|
293
330
|
if (input.analyticsSource)
|
|
294
331
|
return input.analyticsSource;
|
|
@@ -351,26 +388,23 @@ export class ThreadSessionRuntime {
|
|
|
351
388
|
// message and showing multiple back-to-back POSTs is wasteful.
|
|
352
389
|
typingKeepaliveTimeout = null;
|
|
353
390
|
typingRepulseDebounce;
|
|
391
|
+
deferredQuestionShow;
|
|
354
392
|
static TYPING_REPULSE_DEBOUNCE_MS = 500;
|
|
393
|
+
static DEFERRED_QUESTION_SHOW_MS = 1000;
|
|
355
394
|
// Notification throttles for retry/context notices.
|
|
356
395
|
lastDisplayedContextPercentage = 0;
|
|
357
396
|
lastRateLimitDisplayTime = 0;
|
|
358
|
-
// Last OpenCode
|
|
359
|
-
//
|
|
360
|
-
// we only call thread.setName() once per distinct title. Discord rate-limits
|
|
361
|
-
// channel/thread renames to ~2 per 10 minutes per thread, so we must avoid
|
|
362
|
-
// retrying. Not persisted — worst case on restart we re-apply the same title
|
|
363
|
-
// once (which is a no-op via deriveThreadNameFromSessionTitle).
|
|
397
|
+
// Last OpenCode session title we applied to Discord. Dedupes session.updated
|
|
398
|
+
// so we only call setName once per distinct title. Not persisted.
|
|
364
399
|
appliedOpencodeTitle;
|
|
365
|
-
// Last Discord thread name known to match the OpenCode title. Persisted so a
|
|
366
|
-
// user rename is still respected after Kimaki restarts.
|
|
367
|
-
lastSyncedThreadName;
|
|
368
400
|
// Part output buffering (write-side cache, not domain state)
|
|
369
401
|
partBuffer = new Map();
|
|
402
|
+
shownQuestionRequestIds = new Set();
|
|
370
403
|
// Derivable cache (perf optimization for provider.list API call)
|
|
371
404
|
modelContextLimit;
|
|
372
405
|
modelContextLimitKey;
|
|
373
406
|
lastPromptWorktreeKey;
|
|
407
|
+
lastSentPartKind;
|
|
374
408
|
// Bounded buffer of recent SSE events with timestamps.
|
|
375
409
|
// Used by waitForEvent() to scan for specific events that arrived
|
|
376
410
|
// after a given point in time (e.g. wait for session.idle after abort).
|
|
@@ -432,6 +466,17 @@ export class ThreadSessionRuntime {
|
|
|
432
466
|
this.restartTypingKeepalive({ sendNow: true });
|
|
433
467
|
},
|
|
434
468
|
});
|
|
469
|
+
this.deferredQuestionShow = createDebouncedTimeout({
|
|
470
|
+
delayMs: ThreadSessionRuntime.DEFERRED_QUESTION_SHOW_MS,
|
|
471
|
+
callback: () => {
|
|
472
|
+
if (this.disposed) {
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
void this.dispatchAction(async () => {
|
|
476
|
+
await this.tryShowPendingQuestion({ ignoreUnfinishedText: true });
|
|
477
|
+
});
|
|
478
|
+
},
|
|
479
|
+
});
|
|
435
480
|
}
|
|
436
481
|
consumeWorktreePromptChange(worktree) {
|
|
437
482
|
const nextKey = getWorktreePromptKey(worktree);
|
|
@@ -579,6 +624,42 @@ export class ThreadSessionRuntime {
|
|
|
579
624
|
}
|
|
580
625
|
return isSessionBusy({ events: this.eventBuffer, sessionId });
|
|
581
626
|
}
|
|
627
|
+
async persistIngressVariant({ sessionId, channelId, appId, agentPreference, getClient, variant, }) {
|
|
628
|
+
if (!variant)
|
|
629
|
+
return;
|
|
630
|
+
if (getClient instanceof Error)
|
|
631
|
+
return;
|
|
632
|
+
const variantModelInfo = await getCurrentModelInfo({
|
|
633
|
+
sessionId,
|
|
634
|
+
channelId,
|
|
635
|
+
appId,
|
|
636
|
+
agentPreference,
|
|
637
|
+
getClient,
|
|
638
|
+
directory: this.sdkDirectory,
|
|
639
|
+
});
|
|
640
|
+
if (variantModelInfo.type === 'none')
|
|
641
|
+
return;
|
|
642
|
+
const providersResponse = await getClient()
|
|
643
|
+
.provider.list({ directory: this.sdkDirectory })
|
|
644
|
+
.catch((e) => new OpenCodeSdkError({ operation: 'provider.list', cause: e }));
|
|
645
|
+
if (providersResponse instanceof Error || !providersResponse.data)
|
|
646
|
+
return;
|
|
647
|
+
const matchedVariant = matchThinkingValue({
|
|
648
|
+
requestedValue: variant,
|
|
649
|
+
availableValues: getThinkingValuesForModel({
|
|
650
|
+
providers: providersResponse.data.all,
|
|
651
|
+
providerId: variantModelInfo.providerID,
|
|
652
|
+
modelId: variantModelInfo.modelID,
|
|
653
|
+
}),
|
|
654
|
+
});
|
|
655
|
+
if (!matchedVariant)
|
|
656
|
+
return;
|
|
657
|
+
await setSessionModel({
|
|
658
|
+
sessionId,
|
|
659
|
+
modelId: variantModelInfo.model,
|
|
660
|
+
variant: matchedVariant,
|
|
661
|
+
});
|
|
662
|
+
}
|
|
582
663
|
getAssistantMessageIdsForCurrentTurn({ sessionId, upToIndex, }) {
|
|
583
664
|
const normalizedIndex = upToIndex === undefined ? undefined : upToIndex - 1;
|
|
584
665
|
return getAssistantMessageIdsForLatestUserTurn({
|
|
@@ -600,20 +681,24 @@ export class ThreadSessionRuntime {
|
|
|
600
681
|
if (!mainSessionId || candidateSessionId === mainSessionId) {
|
|
601
682
|
return undefined;
|
|
602
683
|
}
|
|
603
|
-
|
|
684
|
+
if (!isDerivedChildSession({
|
|
604
685
|
events: this.eventBuffer,
|
|
605
686
|
mainSessionId,
|
|
606
687
|
candidateSessionId,
|
|
607
|
-
})
|
|
608
|
-
if (!subtaskIndex) {
|
|
688
|
+
})) {
|
|
609
689
|
return undefined;
|
|
610
690
|
}
|
|
691
|
+
const subtaskIndex = getDerivedSubtaskIndex({
|
|
692
|
+
events: this.eventBuffer,
|
|
693
|
+
mainSessionId,
|
|
694
|
+
candidateSessionId,
|
|
695
|
+
});
|
|
611
696
|
const agentType = getDerivedSubtaskAgentType({
|
|
612
697
|
events: this.eventBuffer,
|
|
613
698
|
mainSessionId,
|
|
614
699
|
candidateSessionId,
|
|
615
700
|
});
|
|
616
|
-
const label = `${agentType || 'task'}-${subtaskIndex}`;
|
|
701
|
+
const label = `${agentType || 'task'}-${subtaskIndex || 1}`;
|
|
617
702
|
const assistantMessageId = this.getLatestAssistantMessageIdForCurrentTurn({
|
|
618
703
|
sessionId: candidateSessionId,
|
|
619
704
|
});
|
|
@@ -624,12 +709,14 @@ export class ThreadSessionRuntime {
|
|
|
624
709
|
this.disposed = true;
|
|
625
710
|
unregisterEventListener(this.threadId);
|
|
626
711
|
void this.persistEventBufferDebounced.dispose();
|
|
712
|
+
this.deferredQuestionShow.clear();
|
|
627
713
|
this.stopTyping();
|
|
628
714
|
// Release large internal buffers so GC can reclaim memory immediately
|
|
629
715
|
// instead of waiting for the runtime object itself to become unreachable.
|
|
630
716
|
this.eventBuffer = [];
|
|
631
717
|
this.nextEventIndex = 0;
|
|
632
718
|
this.partBuffer.clear();
|
|
719
|
+
this.shownQuestionRequestIds.clear();
|
|
633
720
|
this.preprocessChain = Promise.resolve();
|
|
634
721
|
// Don't clear actionQueue here — queued closures own resolve/reject for
|
|
635
722
|
// dispatchAction() promises. Dropping them would leave awaiting callers
|
|
@@ -719,7 +806,6 @@ export class ThreadSessionRuntime {
|
|
|
719
806
|
})
|
|
720
807
|
: [];
|
|
721
808
|
delete info.system;
|
|
722
|
-
delete info.summary;
|
|
723
809
|
delete info.tools;
|
|
724
810
|
delete info.parts;
|
|
725
811
|
if (partsSummary.length > 0) {
|
|
@@ -870,10 +956,14 @@ export class ThreadSessionRuntime {
|
|
|
870
956
|
// Global events (tui.toast.show) bypass the guard.
|
|
871
957
|
// Subtask sessions also bypass — they're tracked in subtaskSessions.
|
|
872
958
|
async handleEvent(event) {
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
959
|
+
const sessionId = this.state?.sessionId;
|
|
960
|
+
if (!shouldBufferSessionEvent({
|
|
961
|
+
event,
|
|
962
|
+
mainSessionId: sessionId,
|
|
963
|
+
isKnownChildSession: (candidateSessionId) => {
|
|
964
|
+
return Boolean(this.getSubtaskInfoForSession(candidateSessionId));
|
|
965
|
+
},
|
|
966
|
+
})) {
|
|
877
967
|
return;
|
|
878
968
|
}
|
|
879
969
|
// Skip message.part.delta from the event buffer — no derivation function
|
|
@@ -886,7 +976,6 @@ export class ThreadSessionRuntime {
|
|
|
886
976
|
if (event.type !== 'message.part.delta') {
|
|
887
977
|
this.appendEventToBuffer(event);
|
|
888
978
|
}
|
|
889
|
-
const sessionId = this.state?.sessionId;
|
|
890
979
|
const eventSessionId = getOpencodeEventSessionId(event);
|
|
891
980
|
const toastSessionId = event.type === 'tui.toast.show'
|
|
892
981
|
? extractToastSessionId({ message: event.properties.message })
|
|
@@ -1180,22 +1269,58 @@ export class ThreadSessionRuntime {
|
|
|
1180
1269
|
return part.type === 'step-finish';
|
|
1181
1270
|
});
|
|
1182
1271
|
}
|
|
1183
|
-
|
|
1272
|
+
shouldSendPlannedPart({ part, mode, }) {
|
|
1184
1273
|
if (part.type === 'step-start' || part.type === 'step-finish') {
|
|
1185
1274
|
return false;
|
|
1186
1275
|
}
|
|
1187
1276
|
if (part.type === 'tool' && part.state.status === 'pending') {
|
|
1188
1277
|
return false;
|
|
1189
1278
|
}
|
|
1190
|
-
if (
|
|
1191
|
-
return false;
|
|
1192
|
-
}
|
|
1193
|
-
if (!force && part.type === 'tool' && part.state.status === 'completed') {
|
|
1279
|
+
if (part.type === 'text' && !part.time?.end && mode === 'progress') {
|
|
1194
1280
|
return false;
|
|
1195
1281
|
}
|
|
1196
1282
|
return true;
|
|
1197
1283
|
}
|
|
1198
|
-
|
|
1284
|
+
getCurrentTurnParts() {
|
|
1285
|
+
const sessionId = this.state?.sessionId;
|
|
1286
|
+
const messageIds = sessionId
|
|
1287
|
+
? [...this.getAssistantMessageIdsForCurrentTurn({ sessionId })]
|
|
1288
|
+
: [];
|
|
1289
|
+
if (messageIds.length > 0) {
|
|
1290
|
+
return messageIds.flatMap((id) => this.getBufferedParts(id));
|
|
1291
|
+
}
|
|
1292
|
+
return [...this.partBuffer.keys()].flatMap((id) => this.getBufferedParts(id));
|
|
1293
|
+
}
|
|
1294
|
+
async flushCurrentTurnParts({ mode, throughPartId, skipPartId, repulseTyping = true, }) {
|
|
1295
|
+
const parts = this.getCurrentTurnParts().filter((part) => {
|
|
1296
|
+
return !this.state?.sentPartIds.has(part.id);
|
|
1297
|
+
});
|
|
1298
|
+
const planned = planAssistantTurnFlush({
|
|
1299
|
+
parts,
|
|
1300
|
+
mode,
|
|
1301
|
+
throughPartId,
|
|
1302
|
+
});
|
|
1303
|
+
for (const { part, quoteText } of planned.sendParts) {
|
|
1304
|
+
if (skipPartId && part.id === skipPartId) {
|
|
1305
|
+
continue;
|
|
1306
|
+
}
|
|
1307
|
+
if (!this.shouldSendPlannedPart({ part, mode })) {
|
|
1308
|
+
continue;
|
|
1309
|
+
}
|
|
1310
|
+
if (part.type === 'tool' && part.tool === 'task') {
|
|
1311
|
+
continue;
|
|
1312
|
+
}
|
|
1313
|
+
const pulseTyping = part.type === 'text' && part.ignored === true
|
|
1314
|
+
? false
|
|
1315
|
+
: repulseTyping;
|
|
1316
|
+
await this.sendPartMessage({
|
|
1317
|
+
part,
|
|
1318
|
+
quoteText,
|
|
1319
|
+
repulseTyping: pulseTyping,
|
|
1320
|
+
});
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
async sendPartMessage({ part, repulseTyping = true, quoteText = false, }) {
|
|
1199
1324
|
const verbosity = await this.getVerbosity();
|
|
1200
1325
|
if (verbosity === 'text_only' && part.type !== 'text') {
|
|
1201
1326
|
return;
|
|
@@ -1205,7 +1330,13 @@ export class ThreadSessionRuntime {
|
|
|
1205
1330
|
return;
|
|
1206
1331
|
}
|
|
1207
1332
|
}
|
|
1208
|
-
const
|
|
1333
|
+
const formatted = formatPart(part);
|
|
1334
|
+
const quote = quoteText
|
|
1335
|
+
&& shouldQuoteIntermediateTextPart({
|
|
1336
|
+
part,
|
|
1337
|
+
isLastInTurn: false,
|
|
1338
|
+
});
|
|
1339
|
+
const content = quote ? asDiscordQuote(formatted) : formatted;
|
|
1209
1340
|
if (!content.trim() || content.length === 0) {
|
|
1210
1341
|
return;
|
|
1211
1342
|
}
|
|
@@ -1219,7 +1350,13 @@ export class ThreadSessionRuntime {
|
|
|
1219
1350
|
newIds.add(part.id);
|
|
1220
1351
|
return { ...t, sentPartIds: newIds };
|
|
1221
1352
|
});
|
|
1222
|
-
const
|
|
1353
|
+
const kind = sessionPartKind(part);
|
|
1354
|
+
const sendResult = await sendSessionPartMessage(this.thread, content, {
|
|
1355
|
+
leadWithBlankLine: shouldLeadWithBlankLine({
|
|
1356
|
+
previousKind: this.lastSentPartKind,
|
|
1357
|
+
nextKind: kind,
|
|
1358
|
+
}),
|
|
1359
|
+
})
|
|
1223
1360
|
.catch((e) => new DiscordOperationError({ operation: 'sendMessage', cause: e }));
|
|
1224
1361
|
if (sendResult instanceof Error) {
|
|
1225
1362
|
threadState.updateThread(this.threadId, (t) => {
|
|
@@ -1230,66 +1367,19 @@ export class ThreadSessionRuntime {
|
|
|
1230
1367
|
discordLogger.error(`ERROR: Failed to send part ${part.id}:`, sendResult);
|
|
1231
1368
|
return;
|
|
1232
1369
|
}
|
|
1370
|
+
this.lastSentPartKind = kind;
|
|
1233
1371
|
await setPartMessage({ partId: part.id, messageId: sendResult.id, threadId: this.thread.id });
|
|
1234
1372
|
if (repulseTyping) {
|
|
1235
1373
|
this.requestTypingRepulse();
|
|
1236
1374
|
}
|
|
1237
1375
|
}
|
|
1238
|
-
async
|
|
1239
|
-
if (!messageID) {
|
|
1240
|
-
return;
|
|
1241
|
-
}
|
|
1242
|
-
const parts = this.getBufferedParts(messageID);
|
|
1243
|
-
for (const part of parts) {
|
|
1244
|
-
if (skipPartId && part.id === skipPartId) {
|
|
1245
|
-
continue;
|
|
1246
|
-
}
|
|
1247
|
-
if (!this.shouldSendPart({ part, force })) {
|
|
1248
|
-
continue;
|
|
1249
|
-
}
|
|
1250
|
-
await this.sendPartMessage({ part, repulseTyping });
|
|
1251
|
-
}
|
|
1252
|
-
}
|
|
1253
|
-
async flushBufferedPartsForMessages({ messageIDs, force, skipPartId, repulseTyping = true, }) {
|
|
1254
|
-
const uniqueMessageIDs = [...new Set(messageIDs)];
|
|
1255
|
-
for (const messageID of uniqueMessageIDs) {
|
|
1256
|
-
await this.flushBufferedParts({
|
|
1257
|
-
messageID,
|
|
1258
|
-
force,
|
|
1259
|
-
skipPartId,
|
|
1260
|
-
repulseTyping,
|
|
1261
|
-
});
|
|
1262
|
-
}
|
|
1263
|
-
}
|
|
1264
|
-
async showInteractiveUi({ skipPartId, flushMessageId, show, }) {
|
|
1376
|
+
async showInteractiveUi({ skipPartId, show, }) {
|
|
1265
1377
|
this.stopTyping();
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
if (!sessionId) {
|
|
1272
|
-
return undefined;
|
|
1273
|
-
}
|
|
1274
|
-
return this.getLatestAssistantMessageIdForCurrentTurn({ sessionId });
|
|
1275
|
-
})();
|
|
1276
|
-
if (targetMessageId) {
|
|
1277
|
-
await this.flushBufferedParts({
|
|
1278
|
-
messageID: targetMessageId,
|
|
1279
|
-
force: true,
|
|
1280
|
-
skipPartId,
|
|
1281
|
-
});
|
|
1282
|
-
}
|
|
1283
|
-
else {
|
|
1284
|
-
const assistantMessageIds = sessionId
|
|
1285
|
-
? [...this.getAssistantMessageIdsForCurrentTurn({ sessionId })]
|
|
1286
|
-
: [];
|
|
1287
|
-
await this.flushBufferedPartsForMessages({
|
|
1288
|
-
messageIDs: assistantMessageIds,
|
|
1289
|
-
force: true,
|
|
1290
|
-
skipPartId,
|
|
1291
|
-
});
|
|
1292
|
-
}
|
|
1378
|
+
await this.flushCurrentTurnParts({
|
|
1379
|
+
mode: 'interactive',
|
|
1380
|
+
throughPartId: skipPartId,
|
|
1381
|
+
skipPartId,
|
|
1382
|
+
});
|
|
1293
1383
|
await show();
|
|
1294
1384
|
}
|
|
1295
1385
|
async ensureModelContextLimit({ providerID, modelID, }) {
|
|
@@ -1325,10 +1415,21 @@ export class ThreadSessionRuntime {
|
|
|
1325
1415
|
// These operate on runtime instance state + global store transitions.
|
|
1326
1416
|
async handleMessageUpdated(msg) {
|
|
1327
1417
|
const sessionId = this.state?.sessionId;
|
|
1328
|
-
if (msg.
|
|
1418
|
+
if (msg.role !== 'assistant') {
|
|
1329
1419
|
return;
|
|
1330
1420
|
}
|
|
1331
|
-
if (msg.
|
|
1421
|
+
if (msg.summary === true) {
|
|
1422
|
+
this.clearBufferedPartsForMessages([msg.id]);
|
|
1423
|
+
logger.info(`[SKIP] message.updated for compaction summary ${msg.id}`);
|
|
1424
|
+
return;
|
|
1425
|
+
}
|
|
1426
|
+
if (msg.sessionID !== sessionId) {
|
|
1427
|
+
const subtaskInfo = this.getSubtaskInfoForSession(msg.sessionID);
|
|
1428
|
+
if (subtaskInfo) {
|
|
1429
|
+
for (const part of this.getBufferedParts(msg.id)) {
|
|
1430
|
+
await this.handleSubtaskPart(part, subtaskInfo);
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1332
1433
|
return;
|
|
1333
1434
|
}
|
|
1334
1435
|
if (!sessionId) {
|
|
@@ -1339,6 +1440,7 @@ export class ThreadSessionRuntime {
|
|
|
1339
1440
|
sessionId,
|
|
1340
1441
|
messageId: msg.id,
|
|
1341
1442
|
})) {
|
|
1443
|
+
this.clearBufferedPartsForMessages([msg.id]);
|
|
1342
1444
|
logger.info(`[SKIP] message.updated for old assistant message ${msg.id}, not in latest user turn`);
|
|
1343
1445
|
return;
|
|
1344
1446
|
}
|
|
@@ -1366,9 +1468,8 @@ export class ThreadSessionRuntime {
|
|
|
1366
1468
|
this.storePart(part);
|
|
1367
1469
|
});
|
|
1368
1470
|
}
|
|
1369
|
-
await this.
|
|
1370
|
-
|
|
1371
|
-
force: false,
|
|
1471
|
+
await this.flushCurrentTurnParts({
|
|
1472
|
+
mode: 'progress',
|
|
1372
1473
|
});
|
|
1373
1474
|
const wasAlreadyCompleted = hasAssistantMessageCompletedBefore({
|
|
1374
1475
|
events: this.eventBuffer,
|
|
@@ -1417,7 +1518,7 @@ export class ThreadSessionRuntime {
|
|
|
1417
1518
|
return;
|
|
1418
1519
|
}
|
|
1419
1520
|
this.lastDisplayedContextPercentage = thresholdCrossed;
|
|
1420
|
-
const chunk =
|
|
1521
|
+
const chunk = `${STATUS_PREFIX}context usage ${currentPercentage}%`;
|
|
1421
1522
|
const sendResult = await this.thread.send({ content: chunk, flags: SILENT_MESSAGE_FLAGS })
|
|
1422
1523
|
.catch((e) => new DiscordOperationError({ operation: 'sendMessage', cause: e }));
|
|
1423
1524
|
if (sendResult instanceof Error) {
|
|
@@ -1425,8 +1526,21 @@ export class ThreadSessionRuntime {
|
|
|
1425
1526
|
}
|
|
1426
1527
|
}
|
|
1427
1528
|
async handlePartUpdated(part) {
|
|
1428
|
-
this.storePart(part);
|
|
1429
1529
|
const sessionId = this.state?.sessionId;
|
|
1530
|
+
const messageKind = getAssistantMessageKind({
|
|
1531
|
+
events: this.eventBuffer,
|
|
1532
|
+
sessionId: part.sessionID,
|
|
1533
|
+
messageId: part.messageID,
|
|
1534
|
+
});
|
|
1535
|
+
if (messageKind === 'summary') {
|
|
1536
|
+
this.clearBufferedPartsForMessages([part.messageID]);
|
|
1537
|
+
logger.info(`[SKIP] message.part.updated for compaction summary ${part.messageID}`);
|
|
1538
|
+
return;
|
|
1539
|
+
}
|
|
1540
|
+
this.storePart(part);
|
|
1541
|
+
if (messageKind === 'unknown') {
|
|
1542
|
+
return;
|
|
1543
|
+
}
|
|
1430
1544
|
const subtaskInfo = this.getSubtaskInfoForSession(part.sessionID);
|
|
1431
1545
|
const isSubtaskEvent = Boolean(subtaskInfo);
|
|
1432
1546
|
if (part.sessionID !== sessionId && !isSubtaskEvent) {
|
|
@@ -1444,42 +1558,51 @@ export class ThreadSessionRuntime {
|
|
|
1444
1558
|
this.ensureTypingNow();
|
|
1445
1559
|
return;
|
|
1446
1560
|
}
|
|
1447
|
-
if (part.type === 'tool' &&
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1561
|
+
if (part.type === 'tool' && part.state.status === 'running') {
|
|
1562
|
+
await this.flushCurrentTurnParts({
|
|
1563
|
+
mode: 'progress',
|
|
1564
|
+
});
|
|
1565
|
+
const unsent = this.getCurrentTurnParts().filter((candidate) => {
|
|
1566
|
+
return !this.state?.sentPartIds.has(candidate.id);
|
|
1567
|
+
});
|
|
1568
|
+
const held = planAssistantTurnFlush({
|
|
1569
|
+
parts: unsent,
|
|
1570
|
+
mode: 'progress',
|
|
1571
|
+
}).hold.some((entry) => entry.id === part.id);
|
|
1572
|
+
if (held) {
|
|
1573
|
+
return;
|
|
1574
|
+
}
|
|
1575
|
+
if (!this.state?.sentPartIds.has(part.id) && part.tool !== 'task') {
|
|
1576
|
+
await this.sendPartMessage({ part });
|
|
1577
|
+
}
|
|
1578
|
+
if (part.tool === 'task' && !this.state?.sentPartIds.has(part.id)) {
|
|
1579
|
+
const taskDisplay = formatTaskToolTitle(part);
|
|
1580
|
+
if (taskDisplay && (await this.getVerbosity()) !== 'text_only') {
|
|
1465
1581
|
threadState.updateThread(this.threadId, (t) => {
|
|
1466
1582
|
const newIds = new Set(t.sentPartIds);
|
|
1467
|
-
newIds.
|
|
1583
|
+
newIds.add(part.id);
|
|
1468
1584
|
return { ...t, sentPartIds: newIds };
|
|
1469
1585
|
});
|
|
1470
|
-
|
|
1471
|
-
|
|
1586
|
+
const sendResult = await sendSessionPartMessage(this.thread, taskDisplay, {
|
|
1587
|
+
leadWithBlankLine: shouldLeadWithBlankLine({
|
|
1588
|
+
previousKind: this.lastSentPartKind,
|
|
1589
|
+
nextKind: 'tool',
|
|
1590
|
+
}),
|
|
1591
|
+
})
|
|
1592
|
+
.catch((e) => new DiscordOperationError({ operation: 'sendMessage', cause: e }));
|
|
1593
|
+
if (sendResult instanceof Error) {
|
|
1594
|
+
threadState.updateThread(this.threadId, (t) => {
|
|
1595
|
+
const newIds = new Set(t.sentPartIds);
|
|
1596
|
+
newIds.delete(part.id);
|
|
1597
|
+
return { ...t, sentPartIds: newIds };
|
|
1598
|
+
});
|
|
1599
|
+
discordLogger.error(`ERROR: Failed to send task part ${part.id}:`, sendResult);
|
|
1600
|
+
return;
|
|
1601
|
+
}
|
|
1602
|
+
this.lastSentPartKind = 'tool';
|
|
1603
|
+
await setPartMessage({ partId: part.id, messageId: sendResult.id, threadId: this.thread.id });
|
|
1472
1604
|
}
|
|
1473
|
-
await setPartMessage({ partId: part.id, messageId: sendResult.id, threadId: this.thread.id });
|
|
1474
1605
|
}
|
|
1475
|
-
}
|
|
1476
|
-
if (part.type === 'tool' && part.state.status === 'running') {
|
|
1477
|
-
await this.flushBufferedParts({
|
|
1478
|
-
messageID: part.messageID,
|
|
1479
|
-
force: true,
|
|
1480
|
-
skipPartId: part.id,
|
|
1481
|
-
});
|
|
1482
|
-
await this.sendPartMessage({ part });
|
|
1483
1606
|
return;
|
|
1484
1607
|
}
|
|
1485
1608
|
// Action buttons tool handler
|
|
@@ -1575,7 +1698,7 @@ export class ThreadSessionRuntime {
|
|
|
1575
1698
|
}
|
|
1576
1699
|
return ` (${pct.toFixed(1)}%)`;
|
|
1577
1700
|
})();
|
|
1578
|
-
const chunk =
|
|
1701
|
+
const chunk = `${STATUS_PREFIX}${part.tool} returned ${formattedTokens} tokens${percentageSuffix}`;
|
|
1579
1702
|
const largeOutputResult = await this.thread.send({
|
|
1580
1703
|
content: chunk,
|
|
1581
1704
|
flags: SILENT_MESSAGE_FLAGS,
|
|
@@ -1587,18 +1710,17 @@ export class ThreadSessionRuntime {
|
|
|
1587
1710
|
}
|
|
1588
1711
|
}
|
|
1589
1712
|
if (part.type === 'reasoning') {
|
|
1590
|
-
await this.
|
|
1713
|
+
await this.flushCurrentTurnParts({ mode: 'progress' });
|
|
1591
1714
|
return;
|
|
1592
1715
|
}
|
|
1593
|
-
if (part.type === 'text'
|
|
1594
|
-
await this.
|
|
1716
|
+
if (part.type === 'text') {
|
|
1717
|
+
await this.flushCurrentTurnParts({ mode: 'progress' });
|
|
1718
|
+
if (part.time?.end) {
|
|
1719
|
+
await this.tryShowPendingQuestion();
|
|
1720
|
+
}
|
|
1595
1721
|
return;
|
|
1596
1722
|
}
|
|
1597
1723
|
if (part.type === 'step-finish') {
|
|
1598
|
-
await this.flushBufferedParts({
|
|
1599
|
-
messageID: part.messageID,
|
|
1600
|
-
force: true,
|
|
1601
|
-
});
|
|
1602
1724
|
this.ensureTypingKeepalive();
|
|
1603
1725
|
}
|
|
1604
1726
|
}
|
|
@@ -1629,12 +1751,19 @@ export class ThreadSessionRuntime {
|
|
|
1629
1751
|
if (!content.trim() || this.state?.sentPartIds.has(part.id)) {
|
|
1630
1752
|
return;
|
|
1631
1753
|
}
|
|
1632
|
-
const
|
|
1754
|
+
const kind = sessionPartKind(part);
|
|
1755
|
+
const sendResult = await sendSessionPartMessage(this.thread, content, {
|
|
1756
|
+
leadWithBlankLine: shouldLeadWithBlankLine({
|
|
1757
|
+
previousKind: this.lastSentPartKind,
|
|
1758
|
+
nextKind: kind,
|
|
1759
|
+
}),
|
|
1760
|
+
})
|
|
1633
1761
|
.catch((e) => new DiscordOperationError({ operation: 'sendMessage', cause: e }));
|
|
1634
1762
|
if (sendResult instanceof Error) {
|
|
1635
1763
|
discordLogger.error(`ERROR: Failed to send subtask part ${part.id}:`, sendResult);
|
|
1636
1764
|
return;
|
|
1637
1765
|
}
|
|
1766
|
+
this.lastSentPartKind = kind;
|
|
1638
1767
|
threadState.updateThread(this.threadId, (t) => {
|
|
1639
1768
|
const newIds = new Set(t.sentPartIds);
|
|
1640
1769
|
newIds.add(part.id);
|
|
@@ -1643,21 +1772,10 @@ export class ThreadSessionRuntime {
|
|
|
1643
1772
|
await setPartMessage({ partId: part.id, messageId: sendResult.id, threadId: this.thread.id });
|
|
1644
1773
|
this.requestTypingRepulse();
|
|
1645
1774
|
}
|
|
1646
|
-
trackIdleTokenUsage(
|
|
1647
|
-
let idleEventIndex;
|
|
1648
|
-
for (let i = this.eventBuffer.length - 1; i >= 0; i--) {
|
|
1649
|
-
const event = this.eventBuffer[i]?.event;
|
|
1650
|
-
if (event?.type === 'session.idle' && event.properties.sessionID === idleSessionId) {
|
|
1651
|
-
idleEventIndex = i;
|
|
1652
|
-
break;
|
|
1653
|
-
}
|
|
1654
|
-
}
|
|
1655
|
-
if (idleEventIndex === undefined) {
|
|
1656
|
-
return;
|
|
1657
|
-
}
|
|
1775
|
+
trackIdleTokenUsage({ sessionId, idleEventIndex, }) {
|
|
1658
1776
|
const usage = getIdleTokenUsageDelta({
|
|
1659
1777
|
events: this.eventBuffer,
|
|
1660
|
-
sessionId
|
|
1778
|
+
sessionId,
|
|
1661
1779
|
idleEventIndex,
|
|
1662
1780
|
});
|
|
1663
1781
|
if (!usage) {
|
|
@@ -1672,7 +1790,7 @@ export class ThreadSessionRuntime {
|
|
|
1672
1790
|
tokens_total: usage.total,
|
|
1673
1791
|
cost: usage.cost,
|
|
1674
1792
|
assistant_message_count: usage.assistantMessageCount,
|
|
1675
|
-
is_subagent: Boolean(this.getSubtaskInfoForSession(
|
|
1793
|
+
is_subagent: Boolean(this.getSubtaskInfoForSession(sessionId)),
|
|
1676
1794
|
};
|
|
1677
1795
|
if (usage.model) {
|
|
1678
1796
|
properties.model = usage.model;
|
|
@@ -1682,8 +1800,36 @@ export class ThreadSessionRuntime {
|
|
|
1682
1800
|
}
|
|
1683
1801
|
trackEvent('tokens_used', properties);
|
|
1684
1802
|
}
|
|
1803
|
+
trackIdleTokenUsageForSessionTree(idleSessionId) {
|
|
1804
|
+
let idleEventIndex;
|
|
1805
|
+
for (let i = this.eventBuffer.length - 1; i >= 0; i--) {
|
|
1806
|
+
const event = this.eventBuffer[i]?.event;
|
|
1807
|
+
if (event?.type === 'session.idle' && event.properties.sessionID === idleSessionId) {
|
|
1808
|
+
idleEventIndex = i;
|
|
1809
|
+
break;
|
|
1810
|
+
}
|
|
1811
|
+
}
|
|
1812
|
+
if (idleEventIndex === undefined) {
|
|
1813
|
+
return;
|
|
1814
|
+
}
|
|
1815
|
+
const mainSessionId = this.state?.sessionId;
|
|
1816
|
+
const sessionIds = mainSessionId
|
|
1817
|
+
? getTokenUsageSessionIdsForIdle({
|
|
1818
|
+
events: this.eventBuffer,
|
|
1819
|
+
mainSessionId,
|
|
1820
|
+
idleSessionId,
|
|
1821
|
+
upToIndex: idleEventIndex,
|
|
1822
|
+
})
|
|
1823
|
+
: [idleSessionId];
|
|
1824
|
+
for (const sessionId of sessionIds) {
|
|
1825
|
+
this.trackIdleTokenUsage({
|
|
1826
|
+
sessionId,
|
|
1827
|
+
idleEventIndex,
|
|
1828
|
+
});
|
|
1829
|
+
}
|
|
1830
|
+
}
|
|
1685
1831
|
async handleSessionIdle(idleSessionId) {
|
|
1686
|
-
this.
|
|
1832
|
+
this.trackIdleTokenUsageForSessionTree(idleSessionId);
|
|
1687
1833
|
const sessionId = this.state?.sessionId;
|
|
1688
1834
|
// ── Subtask idle ──────────────────────────────────────────
|
|
1689
1835
|
const subtask = this.getSubtaskInfoForSession(idleSessionId);
|
|
@@ -1722,9 +1868,8 @@ export class ThreadSessionRuntime {
|
|
|
1722
1868
|
if (assistantMessageIds.length === 0) {
|
|
1723
1869
|
return;
|
|
1724
1870
|
}
|
|
1725
|
-
await this.
|
|
1726
|
-
|
|
1727
|
-
force: true,
|
|
1871
|
+
await this.flushCurrentTurnParts({
|
|
1872
|
+
mode: 'final',
|
|
1728
1873
|
repulseTyping: false,
|
|
1729
1874
|
});
|
|
1730
1875
|
// Skip footer if model produced no visible output (no text, no tool calls,
|
|
@@ -1882,38 +2027,79 @@ export class ThreadSessionRuntime {
|
|
|
1882
2027
|
}
|
|
1883
2028
|
this.onInteractiveUiStateChanged();
|
|
1884
2029
|
}
|
|
1885
|
-
|
|
2030
|
+
hasUnfinishedTextPart(messageID) {
|
|
2031
|
+
return this.getBufferedParts(messageID).some((part) => {
|
|
2032
|
+
return part.type === 'text' && !part.time?.end;
|
|
2033
|
+
});
|
|
2034
|
+
}
|
|
2035
|
+
// OpenCode emits question.asked when the tool starts, often before the
|
|
2036
|
+
// preceding text part gets time.end. Showing the dropdown on that event
|
|
2037
|
+
// holds the action queue while Discord posts, so the later text-end cannot
|
|
2038
|
+
// send and dumps after the queued » user: indicator. Wait for text-end.
|
|
2039
|
+
async tryShowPendingQuestion({ ignoreUnfinishedText = false, } = {}) {
|
|
1886
2040
|
const sessionId = this.state?.sessionId;
|
|
1887
|
-
if (
|
|
1888
|
-
|
|
1889
|
-
return;
|
|
2041
|
+
if (!sessionId) {
|
|
2042
|
+
return false;
|
|
1890
2043
|
}
|
|
1891
|
-
|
|
2044
|
+
const request = deriveLatestUnansweredQuestion({
|
|
2045
|
+
events: this.eventBuffer,
|
|
2046
|
+
sessionId,
|
|
2047
|
+
});
|
|
2048
|
+
if (!request) {
|
|
2049
|
+
this.deferredQuestionShow.clear();
|
|
2050
|
+
return false;
|
|
2051
|
+
}
|
|
2052
|
+
if (this.shownQuestionRequestIds.has(request.id)
|
|
2053
|
+
|| findPendingQuestionContextForRequest({
|
|
2054
|
+
threadId: this.thread.id,
|
|
2055
|
+
requestId: request.id,
|
|
2056
|
+
})) {
|
|
2057
|
+
this.deferredQuestionShow.clear();
|
|
2058
|
+
return true;
|
|
2059
|
+
}
|
|
2060
|
+
const messageId = request.tool?.messageID;
|
|
2061
|
+
if (!ignoreUnfinishedText && messageId && this.hasUnfinishedTextPart(messageId)) {
|
|
2062
|
+
return false;
|
|
2063
|
+
}
|
|
2064
|
+
this.shownQuestionRequestIds.add(request.id);
|
|
1892
2065
|
await this.showInteractiveUi({
|
|
2066
|
+
flushMessageId: messageId,
|
|
1893
2067
|
show: async () => {
|
|
1894
|
-
if (!sessionId) {
|
|
1895
|
-
return;
|
|
1896
|
-
}
|
|
1897
2068
|
await showAskUserQuestionDropdowns({
|
|
1898
2069
|
thread: this.thread,
|
|
1899
2070
|
sessionId,
|
|
1900
2071
|
directory: this.sdkDirectory,
|
|
1901
|
-
requestId:
|
|
1902
|
-
input: { questions:
|
|
2072
|
+
requestId: request.id,
|
|
2073
|
+
input: { questions: request.questions },
|
|
1903
2074
|
silent: this.getQueueLength() > 0,
|
|
1904
2075
|
});
|
|
1905
2076
|
},
|
|
1906
2077
|
});
|
|
2078
|
+
this.deferredQuestionShow.clear();
|
|
1907
2079
|
this.maybeHandoffQueuedItemForPendingQuestion({
|
|
1908
2080
|
sessionId,
|
|
1909
2081
|
reason: 'question-shown',
|
|
1910
2082
|
});
|
|
2083
|
+
return true;
|
|
2084
|
+
}
|
|
2085
|
+
async handleQuestionAsked(questionRequest) {
|
|
2086
|
+
const sessionId = this.state?.sessionId;
|
|
2087
|
+
if (questionRequest.sessionID !== sessionId) {
|
|
2088
|
+
logger.log(`[QUESTION IGNORED] Question for different session (expected: ${sessionId}, got: ${questionRequest.sessionID})`);
|
|
2089
|
+
return;
|
|
2090
|
+
}
|
|
2091
|
+
logger.log(`Question requested: id=${questionRequest.id}, questions=${questionRequest.questions.length}`);
|
|
2092
|
+
const shown = await this.tryShowPendingQuestion();
|
|
2093
|
+
if (!shown) {
|
|
2094
|
+
this.deferredQuestionShow.trigger();
|
|
2095
|
+
}
|
|
1911
2096
|
}
|
|
1912
2097
|
handleQuestionReplied(properties) {
|
|
1913
2098
|
const sessionId = this.state?.sessionId;
|
|
1914
2099
|
if (properties.sessionID !== sessionId) {
|
|
1915
2100
|
return;
|
|
1916
2101
|
}
|
|
2102
|
+
this.deferredQuestionShow.clear();
|
|
1917
2103
|
this.onInteractiveUiStateChanged();
|
|
1918
2104
|
// When a question is answered and the local queue has items, the model may
|
|
1919
2105
|
// continue the same run without ever reaching the local-queue idle gate.
|
|
@@ -1973,7 +2159,7 @@ export class ThreadSessionRuntime {
|
|
|
1973
2159
|
? `/${next.command.name}`
|
|
1974
2160
|
: `${next.prompt.slice(0, 150)}${next.prompt.length > 150 ? '...' : ''}`;
|
|
1975
2161
|
if (displayText.trim()) {
|
|
1976
|
-
await sendThreadMessage(this.thread,
|
|
2162
|
+
await sendThreadMessage(this.thread, `${QUEUE_PREFIX}**${next.username}:** ${displayText}`);
|
|
1977
2163
|
}
|
|
1978
2164
|
this.markQuestionQueueHandoffStarted(sessionId);
|
|
1979
2165
|
await this.submitViaOpencodeQueue(next);
|
|
@@ -2011,7 +2197,7 @@ export class ThreadSessionRuntime {
|
|
|
2011
2197
|
const secs = remainingSec % 60;
|
|
2012
2198
|
return secs > 0 ? `${mins}m ${secs}s` : `${mins}m`;
|
|
2013
2199
|
})();
|
|
2014
|
-
const chunk =
|
|
2200
|
+
const chunk = `${STATUS_PREFIX}${message} - retrying in ${duration} (attempt #${attempt})`;
|
|
2015
2201
|
const retryResult = await this.thread.send({ content: chunk, flags: SILENT_MESSAGE_FLAGS })
|
|
2016
2202
|
.catch((e) => new DiscordOperationError({ operation: 'sendMessage', cause: e }));
|
|
2017
2203
|
if (retryResult instanceof Error) {
|
|
@@ -2032,80 +2218,36 @@ export class ThreadSessionRuntime {
|
|
|
2032
2218
|
if (info.id !== this.state?.sessionId) {
|
|
2033
2219
|
return;
|
|
2034
2220
|
}
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
logger.warn(`[TITLE] ${persistedName.message} for thread ${this.threadId}`);
|
|
2039
|
-
return;
|
|
2040
|
-
}
|
|
2041
|
-
this.lastSyncedThreadName = persistedName;
|
|
2221
|
+
const normalizedTitle = info.title.trim();
|
|
2222
|
+
if (this.appliedOpencodeTitle === normalizedTitle) {
|
|
2223
|
+
return;
|
|
2042
2224
|
}
|
|
2043
|
-
const
|
|
2225
|
+
const desiredName = deriveThreadNameFromSessionTitle({
|
|
2044
2226
|
sessionTitle: info.title,
|
|
2045
2227
|
currentName: this.thread.name,
|
|
2046
|
-
lastSyncedName: this.lastSyncedThreadName,
|
|
2047
2228
|
});
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
}
|
|
2053
|
-
return;
|
|
2054
|
-
}
|
|
2055
|
-
const { desiredName } = renameDecision;
|
|
2056
|
-
const normalizedTitle = info.title.trim();
|
|
2057
|
-
if (this.appliedOpencodeTitle === normalizedTitle) {
|
|
2229
|
+
// Mark before setName so concurrent session.updated events don't stack
|
|
2230
|
+
// renames. Keep the mark on failure — retry is almost always a rate limit.
|
|
2231
|
+
this.appliedOpencodeTitle = normalizedTitle;
|
|
2232
|
+
if (!desiredName) {
|
|
2058
2233
|
return;
|
|
2059
2234
|
}
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
// because the failure is almost always a rate limit.
|
|
2063
|
-
this.appliedOpencodeTitle = normalizedTitle;
|
|
2064
|
-
const RENAME_TIMEOUT_MS = 3000;
|
|
2065
|
-
const timeoutSignal = AbortSignal.timeout(RENAME_TIMEOUT_MS);
|
|
2066
|
-
const renameResult = await Promise.race([
|
|
2067
|
-
this.thread.setName(desiredName)
|
|
2235
|
+
const renameResult = await raceDiscordRename({
|
|
2236
|
+
rename: this.thread.setName(desiredName)
|
|
2068
2237
|
.catch((e) => new Error('Failed to rename thread from OpenCode title', {
|
|
2069
2238
|
cause: e,
|
|
2070
2239
|
})),
|
|
2071
|
-
|
|
2072
|
-
timeoutSignal.addEventListener('abort', () => {
|
|
2073
|
-
resolve('timeout');
|
|
2074
|
-
});
|
|
2075
|
-
}),
|
|
2076
|
-
]);
|
|
2240
|
+
});
|
|
2077
2241
|
if (renameResult === 'timeout') {
|
|
2078
|
-
logger.warn(`[TITLE] setName timed out after ${
|
|
2242
|
+
logger.warn(`[TITLE] setName timed out after ${DISCORD_THREAD_RENAME_TIMEOUT_MS}ms for thread ${this.threadId} (likely rate-limited)`);
|
|
2079
2243
|
return;
|
|
2080
2244
|
}
|
|
2081
2245
|
if (renameResult instanceof Error) {
|
|
2082
2246
|
logger.warn(`[TITLE] Could not rename thread ${this.threadId}: ${renameResult.message}`);
|
|
2083
2247
|
return;
|
|
2084
2248
|
}
|
|
2085
|
-
await this.persistLastSyncedThreadName(desiredName);
|
|
2086
2249
|
logger.log(`[TITLE] Renamed thread ${this.threadId} to "${desiredName}" from OpenCode session title`);
|
|
2087
2250
|
}
|
|
2088
|
-
async loadLastSyncedThreadName() {
|
|
2089
|
-
const db = await getDb();
|
|
2090
|
-
const row = await db.query.thread_sessions.findFirst({
|
|
2091
|
-
where: { thread_id: this.threadId },
|
|
2092
|
-
columns: { last_synced_name: true },
|
|
2093
|
-
});
|
|
2094
|
-
return row?.last_synced_name ?? null;
|
|
2095
|
-
}
|
|
2096
|
-
async persistLastSyncedThreadName(name) {
|
|
2097
|
-
this.lastSyncedThreadName = name;
|
|
2098
|
-
const db = await getDb();
|
|
2099
|
-
const result = await db.update(schema.thread_sessions)
|
|
2100
|
-
.set({ last_synced_name: name })
|
|
2101
|
-
.where(orm.eq(schema.thread_sessions.thread_id, this.threadId))
|
|
2102
|
-
.catch((e) => new Error('Failed to persist thread rename state', {
|
|
2103
|
-
cause: e,
|
|
2104
|
-
}));
|
|
2105
|
-
if (result instanceof Error) {
|
|
2106
|
-
logger.warn(`[TITLE] ${result.message} for thread ${this.threadId}`);
|
|
2107
|
-
}
|
|
2108
|
-
}
|
|
2109
2251
|
async handleTuiToast(properties) {
|
|
2110
2252
|
if (properties.variant === 'warning') {
|
|
2111
2253
|
return;
|
|
@@ -2121,7 +2263,7 @@ export class ThreadSessionRuntime {
|
|
|
2121
2263
|
const titlePrefix = properties.title
|
|
2122
2264
|
? `${properties.title.trim()}: `
|
|
2123
2265
|
: '';
|
|
2124
|
-
const chunk =
|
|
2266
|
+
const chunk = `${STATUS_PREFIX}${properties.variant}: ${titlePrefix}${toastMessage}`;
|
|
2125
2267
|
const toastResult = await this.thread.send({ content: chunk, flags: SILENT_MESSAGE_FLAGS })
|
|
2126
2268
|
.catch((e) => new DiscordOperationError({ operation: 'sendMessage', cause: e }));
|
|
2127
2269
|
if (toastResult instanceof Error) {
|
|
@@ -2138,6 +2280,7 @@ export class ThreadSessionRuntime {
|
|
|
2138
2280
|
* fields that the local-queue path provides.
|
|
2139
2281
|
*/
|
|
2140
2282
|
async submitViaOpencodeQueue(input) {
|
|
2283
|
+
await this.supersedePendingSleep(input);
|
|
2141
2284
|
let skippedBySessionGuard = false;
|
|
2142
2285
|
await this.dispatchAction(async () => {
|
|
2143
2286
|
if (input.expectedSessionId &&
|
|
@@ -2197,6 +2340,17 @@ export class ThreadSessionRuntime {
|
|
|
2197
2340
|
await setSessionAgent(session.id, input.agent);
|
|
2198
2341
|
await clearSessionModel(session.id);
|
|
2199
2342
|
}
|
|
2343
|
+
if (input.model) {
|
|
2344
|
+
const validatedModel = await validateModelId({
|
|
2345
|
+
model: input.model,
|
|
2346
|
+
getClient,
|
|
2347
|
+
directory: this.sdkDirectory,
|
|
2348
|
+
});
|
|
2349
|
+
if (validatedModel instanceof Error) {
|
|
2350
|
+
await cleanupOnError(`Failed to resolve model: ${validatedModel.message}`);
|
|
2351
|
+
return;
|
|
2352
|
+
}
|
|
2353
|
+
}
|
|
2200
2354
|
await ensureSessionPreferencesSnapshot({
|
|
2201
2355
|
sessionId: session.id,
|
|
2202
2356
|
channelId,
|
|
@@ -2220,14 +2374,23 @@ export class ThreadSessionRuntime {
|
|
|
2220
2374
|
}
|
|
2221
2375
|
const resolvedAgent = agentResult.agentPreference;
|
|
2222
2376
|
const availableAgents = agentResult.agents;
|
|
2377
|
+
releaseCurrentThreadIngress();
|
|
2378
|
+
await this.persistIngressVariant({
|
|
2379
|
+
sessionId: session.id,
|
|
2380
|
+
channelId,
|
|
2381
|
+
appId: resolvedAppId,
|
|
2382
|
+
agentPreference: resolvedAgent,
|
|
2383
|
+
getClient,
|
|
2384
|
+
variant: input.variant,
|
|
2385
|
+
});
|
|
2223
2386
|
const [modelResult, preferredVariant] = await Promise.all([
|
|
2224
2387
|
(async () => {
|
|
2225
2388
|
if (input.model) {
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
}
|
|
2389
|
+
return validateModelId({
|
|
2390
|
+
model: input.model,
|
|
2391
|
+
getClient,
|
|
2392
|
+
directory: this.sdkDirectory,
|
|
2393
|
+
});
|
|
2231
2394
|
}
|
|
2232
2395
|
const modelInfo = await getCurrentModelInfo({
|
|
2233
2396
|
sessionId: session.id,
|
|
@@ -2333,6 +2496,7 @@ export class ThreadSessionRuntime {
|
|
|
2333
2496
|
userId: input.userId,
|
|
2334
2497
|
sourceMessageId: input.sourceMessageId,
|
|
2335
2498
|
sourceThreadId: input.sourceThreadId,
|
|
2499
|
+
threadName: this.thread.name || undefined,
|
|
2336
2500
|
repliedMessage: input.repliedMessage,
|
|
2337
2501
|
worktree,
|
|
2338
2502
|
currentAgent: resolvedAgent,
|
|
@@ -2357,6 +2521,7 @@ export class ThreadSessionRuntime {
|
|
|
2357
2521
|
username: this.state?.sessionUsername || input.username,
|
|
2358
2522
|
userId: this.state?.sessionUserId || input.userId,
|
|
2359
2523
|
parentSessionId: this.state?.parentSessionId || input.parentSessionId,
|
|
2524
|
+
scheduledTask: await this.resolveScheduledTaskContext(session.id),
|
|
2360
2525
|
}),
|
|
2361
2526
|
...(resolvedAgent ? { agent: resolvedAgent } : {}),
|
|
2362
2527
|
...(modelField ? { model: modelField } : {}),
|
|
@@ -2407,7 +2572,26 @@ export class ThreadSessionRuntime {
|
|
|
2407
2572
|
* Enqueue in kimaki's local per-thread queue.
|
|
2408
2573
|
* Used for explicit queue workflows (/queue, queueMessage=true).
|
|
2409
2574
|
*/
|
|
2575
|
+
/**
|
|
2576
|
+
* A new turn supersedes a pending sleep.
|
|
2577
|
+
*
|
|
2578
|
+
* Called from the two terminal routers rather than from the top of
|
|
2579
|
+
* enqueueIncoming: arrival order is only fixed once a message reaches the
|
|
2580
|
+
* preprocessChain link, so awaiting anything before that lets two rapid
|
|
2581
|
+
* messages swap places. By here the order is already committed.
|
|
2582
|
+
*
|
|
2583
|
+
* Awaited rather than fire-and-forget so it cannot race the task runner and
|
|
2584
|
+
* let a stale wake land after the user took the conversation back.
|
|
2585
|
+
*/
|
|
2586
|
+
async supersedePendingSleep(input) {
|
|
2587
|
+
if (input.isSleepWake)
|
|
2588
|
+
return;
|
|
2589
|
+
await cancelSessionSleepForThread({ threadId: this.threadId }).catch((error) => {
|
|
2590
|
+
logger.error('[SLEEP] failed to cancel pending sleep:', error);
|
|
2591
|
+
});
|
|
2592
|
+
}
|
|
2410
2593
|
async enqueueViaLocalQueue(input) {
|
|
2594
|
+
await this.supersedePendingSleep(input);
|
|
2411
2595
|
const queueId = crypto.randomBytes(8).toString('hex');
|
|
2412
2596
|
const queuedMessage = {
|
|
2413
2597
|
queueId,
|
|
@@ -2419,6 +2603,7 @@ export class ThreadSessionRuntime {
|
|
|
2419
2603
|
command: input.command,
|
|
2420
2604
|
agent: input.agent,
|
|
2421
2605
|
model: input.model,
|
|
2606
|
+
variant: input.variant,
|
|
2422
2607
|
permissions: input.permissions,
|
|
2423
2608
|
injectionGuardPatterns: input.injectionGuardPatterns,
|
|
2424
2609
|
parentSessionId: input.parentSessionId,
|
|
@@ -2463,8 +2648,12 @@ export class ThreadSessionRuntime {
|
|
|
2463
2648
|
* discord-bot.ts.
|
|
2464
2649
|
*/
|
|
2465
2650
|
async enqueueIncoming(input) {
|
|
2651
|
+
await waitForCurrentThreadIngress();
|
|
2466
2652
|
threadState.setSessionUsername(this.threadId, input.username);
|
|
2467
|
-
|
|
2653
|
+
const botUserId = this.thread.client.user?.id;
|
|
2654
|
+
if (input.userId && input.userId !== botUserId) {
|
|
2655
|
+
threadState.setSessionUserId(this.threadId, input.userId);
|
|
2656
|
+
}
|
|
2468
2657
|
await this.ensureParentSessionId({
|
|
2469
2658
|
parentSessionId: input.parentSessionId,
|
|
2470
2659
|
});
|
|
@@ -2623,6 +2812,10 @@ export class ThreadSessionRuntime {
|
|
|
2623
2812
|
const sessionIsBusy = this.isMainSessionBusy();
|
|
2624
2813
|
logger.log(`[ABORT] id=${abortId} reason=${reason} threadId=${this.threadId} sessionId=${sessionId || 'none'} queueLength=${state.queueItems.length} ${this.formatRunStateForLog()} sessionBusy=${sessionIsBusy}`);
|
|
2625
2814
|
this.stopTyping();
|
|
2815
|
+
this.deferredQuestionShow.clear();
|
|
2816
|
+
// The aborted run owns the question request, so the dropdown dies with it.
|
|
2817
|
+
// Questions have no TTL, so this is the only thing that clears them here.
|
|
2818
|
+
void cancelPendingQuestion(this.threadId);
|
|
2626
2819
|
const apiAbortPromise = sessionId
|
|
2627
2820
|
? this.abortSessionViaApi({ abortId, reason, sessionId })
|
|
2628
2821
|
: undefined;
|
|
@@ -2680,13 +2873,6 @@ export class ThreadSessionRuntime {
|
|
|
2680
2873
|
getQueueLength() {
|
|
2681
2874
|
return this.state?.queueItems.length ?? 0;
|
|
2682
2875
|
}
|
|
2683
|
-
/** NOTIFY_MESSAGE_FLAGS unless queue has a next item, then SILENT.
|
|
2684
|
-
* Permissions should NOT use this — they always notify. */
|
|
2685
|
-
getNotifyFlags() {
|
|
2686
|
-
return this.getQueueLength() > 0
|
|
2687
|
-
? SILENT_MESSAGE_FLAGS
|
|
2688
|
-
: NOTIFY_MESSAGE_FLAGS;
|
|
2689
|
-
}
|
|
2690
2876
|
/** Clear all queued messages. Returns the removed items. */
|
|
2691
2877
|
clearQueue() {
|
|
2692
2878
|
return threadState.clearQueueItems(this.threadId);
|
|
@@ -2763,7 +2949,7 @@ export class ThreadSessionRuntime {
|
|
|
2763
2949
|
? `/${next.command.name}`
|
|
2764
2950
|
: `${next.prompt.slice(0, 150)}${next.prompt.length > 150 ? '...' : ''}`;
|
|
2765
2951
|
if (displayText.trim()) {
|
|
2766
|
-
await sendThreadMessage(this.thread,
|
|
2952
|
+
await sendThreadMessage(this.thread, `${QUEUE_PREFIX}**${next.username}:** ${displayText}`);
|
|
2767
2953
|
}
|
|
2768
2954
|
}
|
|
2769
2955
|
// Start dispatch (detached — does not block the action queue).
|
|
@@ -2794,6 +2980,7 @@ export class ThreadSessionRuntime {
|
|
|
2794
2980
|
async dispatchPrompt(input) {
|
|
2795
2981
|
this.lastDisplayedContextPercentage = 0;
|
|
2796
2982
|
this.lastRateLimitDisplayTime = 0;
|
|
2983
|
+
this.lastSentPartKind = undefined;
|
|
2797
2984
|
// ── Ensure session ────────────────────────────────────────
|
|
2798
2985
|
const sessionResult = await this.ensureSession({
|
|
2799
2986
|
prompt: input.prompt,
|
|
@@ -2834,6 +3021,19 @@ export class ThreadSessionRuntime {
|
|
|
2834
3021
|
await setSessionAgent(session.id, input.agent);
|
|
2835
3022
|
await clearSessionModel(session.id);
|
|
2836
3023
|
}
|
|
3024
|
+
if (input.model) {
|
|
3025
|
+
const validatedModel = await validateModelId({
|
|
3026
|
+
model: input.model,
|
|
3027
|
+
getClient,
|
|
3028
|
+
directory: this.sdkDirectory,
|
|
3029
|
+
});
|
|
3030
|
+
if (validatedModel instanceof Error) {
|
|
3031
|
+
this.stopTyping();
|
|
3032
|
+
await sendThreadMessage(this.thread, `Failed to resolve model: ${validatedModel.message}`, { flags: NOTIFY_MESSAGE_FLAGS });
|
|
3033
|
+
await this.tryDrainQueue({ showIndicator: true });
|
|
3034
|
+
return;
|
|
3035
|
+
}
|
|
3036
|
+
}
|
|
2837
3037
|
await ensureSessionPreferencesSnapshot({
|
|
2838
3038
|
sessionId: session.id,
|
|
2839
3039
|
channelId,
|
|
@@ -2860,14 +3060,22 @@ export class ThreadSessionRuntime {
|
|
|
2860
3060
|
}
|
|
2861
3061
|
const earlyAgentPreference = earlyAgentResult.agentPreference;
|
|
2862
3062
|
const earlyAvailableAgents = earlyAgentResult.agents;
|
|
3063
|
+
await this.persistIngressVariant({
|
|
3064
|
+
sessionId: session.id,
|
|
3065
|
+
channelId,
|
|
3066
|
+
appId: resolvedAppId,
|
|
3067
|
+
agentPreference: earlyAgentPreference,
|
|
3068
|
+
getClient,
|
|
3069
|
+
variant: input.variant,
|
|
3070
|
+
});
|
|
2863
3071
|
const [earlyModelResult, preferredVariant] = await Promise.all([
|
|
2864
3072
|
(async () => {
|
|
2865
3073
|
if (input.model) {
|
|
2866
|
-
|
|
2867
|
-
|
|
2868
|
-
|
|
2869
|
-
|
|
2870
|
-
}
|
|
3074
|
+
return validateModelId({
|
|
3075
|
+
model: input.model,
|
|
3076
|
+
getClient,
|
|
3077
|
+
directory: this.sdkDirectory,
|
|
3078
|
+
});
|
|
2871
3079
|
}
|
|
2872
3080
|
const modelInfo = await getCurrentModelInfo({
|
|
2873
3081
|
sessionId: session.id,
|
|
@@ -2980,6 +3188,7 @@ export class ThreadSessionRuntime {
|
|
|
2980
3188
|
userId: input.userId,
|
|
2981
3189
|
sourceMessageId: input.sourceMessageId,
|
|
2982
3190
|
sourceThreadId: input.sourceThreadId,
|
|
3191
|
+
threadName: this.thread.name || undefined,
|
|
2983
3192
|
repliedMessage: input.repliedMessage,
|
|
2984
3193
|
worktree,
|
|
2985
3194
|
currentAgent: earlyAgentPreference,
|
|
@@ -3023,6 +3232,7 @@ export class ThreadSessionRuntime {
|
|
|
3023
3232
|
userId: input.userId,
|
|
3024
3233
|
sourceMessageId: input.sourceMessageId,
|
|
3025
3234
|
sourceThreadId: input.sourceThreadId,
|
|
3235
|
+
threadName: this.thread.name || undefined,
|
|
3026
3236
|
repliedMessage: input.repliedMessage,
|
|
3027
3237
|
});
|
|
3028
3238
|
// OpenCode's session.command API has no `system` field. Persist the
|
|
@@ -3040,6 +3250,7 @@ export class ThreadSessionRuntime {
|
|
|
3040
3250
|
username: this.state?.sessionUsername || input.username,
|
|
3041
3251
|
userId: this.state?.sessionUserId || input.userId,
|
|
3042
3252
|
parentSessionId: this.state?.parentSessionId || input.parentSessionId,
|
|
3253
|
+
scheduledTask: await this.resolveScheduledTaskContext(session.id),
|
|
3043
3254
|
});
|
|
3044
3255
|
const systemWriteResult = await writeSessionSystemPrompt({
|
|
3045
3256
|
sessionId: session.id,
|
|
@@ -3141,6 +3352,7 @@ export class ThreadSessionRuntime {
|
|
|
3141
3352
|
username: this.state?.sessionUsername || input.username,
|
|
3142
3353
|
userId: this.state?.sessionUserId || input.userId,
|
|
3143
3354
|
parentSessionId: this.state?.parentSessionId || input.parentSessionId,
|
|
3355
|
+
scheduledTask: await this.resolveScheduledTaskContext(session.id),
|
|
3144
3356
|
}),
|
|
3145
3357
|
model: earlyModelParam,
|
|
3146
3358
|
agent: earlyAgentPreference,
|
|
@@ -3176,6 +3388,41 @@ export class ThreadSessionRuntime {
|
|
|
3176
3388
|
}
|
|
3177
3389
|
// ── Session Ensure ──────────────────────────────────────────
|
|
3178
3390
|
// Creates or reuses the OpenCode session for this thread.
|
|
3391
|
+
/** Cached per-session scheduled task info for the system message. */
|
|
3392
|
+
scheduledTaskContextCache = new Map();
|
|
3393
|
+
/**
|
|
3394
|
+
* Resolve the scheduled-task context for the system message, once per
|
|
3395
|
+
* session. The row in session_start_sources is immutable, so caching the
|
|
3396
|
+
* result keeps the system prompt identical across turns (prompt-cache safe).
|
|
3397
|
+
* One-shot 'at' tasks are deleted after their run, so only schedule_kind
|
|
3398
|
+
* survives for them.
|
|
3399
|
+
*/
|
|
3400
|
+
async resolveScheduledTaskContext(sessionId) {
|
|
3401
|
+
const cache = this.scheduledTaskContextCache;
|
|
3402
|
+
if (cache.has(sessionId)) {
|
|
3403
|
+
return cache.get(sessionId);
|
|
3404
|
+
}
|
|
3405
|
+
const context = await (async () => {
|
|
3406
|
+
const source = await getSessionStartSource({ sessionId });
|
|
3407
|
+
if (!source) {
|
|
3408
|
+
return undefined;
|
|
3409
|
+
}
|
|
3410
|
+
const task = source.scheduled_task_id
|
|
3411
|
+
? await getScheduledTask(source.scheduled_task_id)
|
|
3412
|
+
: null;
|
|
3413
|
+
return {
|
|
3414
|
+
taskId: source.scheduled_task_id ?? undefined,
|
|
3415
|
+
scheduleKind: source.schedule_kind,
|
|
3416
|
+
cronExpr: task?.cron_expr,
|
|
3417
|
+
timezone: task?.timezone,
|
|
3418
|
+
};
|
|
3419
|
+
})().catch((error) => {
|
|
3420
|
+
logger.warn(`[SCHEDULED TASK CONTEXT] Failed to resolve for session ${sessionId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
3421
|
+
return undefined;
|
|
3422
|
+
});
|
|
3423
|
+
cache.set(sessionId, context);
|
|
3424
|
+
return context;
|
|
3425
|
+
}
|
|
3179
3426
|
async updateExistingSessionPermissions({ client, sessionId, createdNewSession, permissions, }) {
|
|
3180
3427
|
if (createdNewSession) {
|
|
3181
3428
|
return null;
|
|
@@ -3232,7 +3479,8 @@ export class ThreadSessionRuntime {
|
|
|
3232
3479
|
session = sessionResponse.data;
|
|
3233
3480
|
}
|
|
3234
3481
|
else {
|
|
3235
|
-
|
|
3482
|
+
const sdkMessage = extractSdkErrorMessage(sessionResponse.error);
|
|
3483
|
+
logger.warn(`[ENSURE SESSION] session.get returned no data for ${sessionId}: ${sdkMessage}, response=${JSON.stringify(sessionResponse)}`);
|
|
3236
3484
|
}
|
|
3237
3485
|
}
|
|
3238
3486
|
if (!session) {
|
|
@@ -3256,31 +3504,31 @@ export class ThreadSessionRuntime {
|
|
|
3256
3504
|
logger.error(`[ENSURE SESSION] session.create failed: ${createResult.message}`);
|
|
3257
3505
|
return new Error(`Failed to create session: ${createResult.message}, threadId=${this.thread.id}, directory=${this.sdkDirectory}`, { cause: createResult });
|
|
3258
3506
|
}
|
|
3259
|
-
|
|
3260
|
-
|
|
3261
|
-
logger.
|
|
3507
|
+
if (createResult.error || !createResult.data) {
|
|
3508
|
+
const errorMessage = extractSdkErrorMessage(createResult.error);
|
|
3509
|
+
logger.error(`[ENSURE SESSION] session.create failed: ${errorMessage}, threadId=${this.thread.id}, directory=${this.sdkDirectory}, response=${JSON.stringify(createResult)}`);
|
|
3510
|
+
return new Error(`Failed to create session: ${errorMessage}, threadId=${this.thread.id}, directory=${this.sdkDirectory}`);
|
|
3262
3511
|
}
|
|
3512
|
+
session = createResult.data;
|
|
3263
3513
|
// Insert DB row immediately so the external-sync poller sees
|
|
3264
3514
|
// source='kimaki' before the next poll tick and skips this session.
|
|
3265
3515
|
// The upsert at the end of ensureSession is kept for the reuse path.
|
|
3266
|
-
|
|
3267
|
-
|
|
3268
|
-
|
|
3269
|
-
|
|
3270
|
-
|
|
3271
|
-
scanPatterns: injectionGuardPatterns,
|
|
3272
|
-
});
|
|
3273
|
-
}
|
|
3274
|
-
const worktree = await getThreadWorktreeOrWorkspace(this.thread.id);
|
|
3275
|
-
trackEvent('session_created', {
|
|
3276
|
-
has_worktree: Boolean(worktree),
|
|
3277
|
-
source: sessionStartScheduleKind ? 'scheduled' : 'discord',
|
|
3516
|
+
await setThreadSession(this.thread.id, session.id);
|
|
3517
|
+
if (injectionGuardPatterns?.length) {
|
|
3518
|
+
writeInjectionGuardConfig({
|
|
3519
|
+
sessionId: session.id,
|
|
3520
|
+
scanPatterns: injectionGuardPatterns,
|
|
3278
3521
|
});
|
|
3279
3522
|
}
|
|
3523
|
+
const worktree = await getThreadWorktreeOrWorkspace(this.thread.id);
|
|
3524
|
+
trackEvent('session_created', {
|
|
3525
|
+
has_worktree: Boolean(worktree),
|
|
3526
|
+
source: sessionStartScheduleKind ? 'scheduled' : 'discord',
|
|
3527
|
+
});
|
|
3280
3528
|
createdNewSession = true;
|
|
3281
3529
|
}
|
|
3282
3530
|
if (!session) {
|
|
3283
|
-
return new Error(`Failed to create or get session: threadId=${this.thread.id}, channelId=${this.channelId}, directory=${directory}, sdkDirectory=${this.sdkDirectory}, existingSessionId=${sessionId ?? 'none'}, createdNewSession=${createdNewSession}
|
|
3531
|
+
return new Error(`Failed to create or get session: threadId=${this.thread.id}, channelId=${this.channelId}, directory=${directory}, sdkDirectory=${this.sdkDirectory}, existingSessionId=${sessionId ?? 'none'}, createdNewSession=${createdNewSession}`);
|
|
3284
3532
|
}
|
|
3285
3533
|
// Store session in DB and thread state
|
|
3286
3534
|
await setThreadSession(this.thread.id, session.id);
|
|
@@ -3326,7 +3574,7 @@ export class ThreadSessionRuntime {
|
|
|
3326
3574
|
const agentLabel = agent && agent.toLowerCase() !== 'build'
|
|
3327
3575
|
? ` ⋅ ${agent}`
|
|
3328
3576
|
: '';
|
|
3329
|
-
const result = await sendThreadMessage(this.thread, `*using ${modelLabel}${agentLabel}
|
|
3577
|
+
const result = await sendThreadMessage(this.thread, asDiscordQuote(`*using ${modelLabel}${agentLabel}*`), { flags: SILENT_MESSAGE_FLAGS }).catch((e) => new DiscordOperationError({ operation: 'sendMessage', cause: e }));
|
|
3330
3578
|
if (result instanceof Error) {
|
|
3331
3579
|
logger.warn(`[SESSION INFO] Failed to send model info: ${result.message}`);
|
|
3332
3580
|
}
|
|
@@ -3350,7 +3598,6 @@ export class ThreadSessionRuntime {
|
|
|
3350
3598
|
const sessionDuration = elapsedMs < 1000
|
|
3351
3599
|
? '<1s'
|
|
3352
3600
|
: prettyMilliseconds(elapsedMs, { secondsDecimalDigits: 0 });
|
|
3353
|
-
const modelInfo = runInfo.model ? ` ⋅ ${runInfo.model}` : '';
|
|
3354
3601
|
const agentInfo = runInfo.agent && runInfo.agent.toLowerCase() !== 'build'
|
|
3355
3602
|
? ` ⋅ **${runInfo.agent}**`
|
|
3356
3603
|
: '';
|
|
@@ -3364,7 +3611,7 @@ export class ThreadSessionRuntime {
|
|
|
3364
3611
|
}).catch((e) => new FilesystemOperationError({ operation: 'gitBranch', cause: e })),
|
|
3365
3612
|
(async () => {
|
|
3366
3613
|
if (!client || !sessionId) {
|
|
3367
|
-
return;
|
|
3614
|
+
return [];
|
|
3368
3615
|
}
|
|
3369
3616
|
let tokensUsed = runInfo.tokensUsed;
|
|
3370
3617
|
// Fetch final token count from API
|
|
@@ -3401,9 +3648,12 @@ export class ThreadSessionRuntime {
|
|
|
3401
3648
|
providerID: runInfo.providerID,
|
|
3402
3649
|
})
|
|
3403
3650
|
: undefined;
|
|
3651
|
+
const providers = providersResult && !(providersResult instanceof Error)
|
|
3652
|
+
? providersResult.data?.all ?? []
|
|
3653
|
+
: [];
|
|
3404
3654
|
let contextLimit = fallbackLimit;
|
|
3405
|
-
if (
|
|
3406
|
-
const provider =
|
|
3655
|
+
if (providers.length > 0) {
|
|
3656
|
+
const provider = providers.find((p) => {
|
|
3407
3657
|
return p.id === runInfo.providerID;
|
|
3408
3658
|
});
|
|
3409
3659
|
const model = provider?.models?.[runInfo.model || ''];
|
|
@@ -3413,12 +3663,26 @@ export class ThreadSessionRuntime {
|
|
|
3413
3663
|
const percentage = Math.round((tokensUsed / contextLimit) * 100);
|
|
3414
3664
|
contextInfo = ` ⋅ ${percentage}%`;
|
|
3415
3665
|
}
|
|
3666
|
+
return providers;
|
|
3416
3667
|
})().catch((e) => new OpenCodeSdkError({ operation: 'resolveModelPreference', cause: e })),
|
|
3417
3668
|
]);
|
|
3418
3669
|
const branchName = branchResult instanceof Error ? '' : branchResult.stdout.trim();
|
|
3419
3670
|
if (contextResult instanceof Error) {
|
|
3420
3671
|
logger.error('Failed to fetch provider info for context percentage:', contextResult);
|
|
3421
3672
|
}
|
|
3673
|
+
const providers = contextResult instanceof Error ? [] : (contextResult ?? []);
|
|
3674
|
+
const modelLabel = runInfo.model
|
|
3675
|
+
? displayedModelLabel({
|
|
3676
|
+
modelID: runInfo.model,
|
|
3677
|
+
name: await resolveDisplayedModelName({
|
|
3678
|
+
providers,
|
|
3679
|
+
providerID: runInfo.providerID,
|
|
3680
|
+
modelID: runInfo.model,
|
|
3681
|
+
sessionID: sessionId,
|
|
3682
|
+
}),
|
|
3683
|
+
})
|
|
3684
|
+
: undefined;
|
|
3685
|
+
const modelInfo = modelLabel ? ` ⋅ ${modelLabel}` : '';
|
|
3422
3686
|
const truncate = (s, max) => {
|
|
3423
3687
|
return s.length > max ? s.slice(0, max - 1) + '\u2026' : s;
|
|
3424
3688
|
};
|
|
@@ -3427,12 +3691,22 @@ export class ThreadSessionRuntime {
|
|
|
3427
3691
|
const projectInfo = truncatedBranch
|
|
3428
3692
|
? `${truncatedFolder} ⋅ ${truncatedBranch} ⋅ `
|
|
3429
3693
|
: `${truncatedFolder} ⋅ `;
|
|
3430
|
-
const
|
|
3694
|
+
const hasQueuedMessage = this.getQueueLength() > 0;
|
|
3695
|
+
const didUseSleepTool = sessionId
|
|
3696
|
+
? didLatestUserTurnUseSleepTool({ events: this.eventBuffer, sessionId })
|
|
3697
|
+
: false;
|
|
3698
|
+
const shouldNotifyUser = !hasQueuedMessage && !didUseSleepTool;
|
|
3699
|
+
const mentionUserId = store.getState().footerMentionsEnabled && shouldNotifyUser
|
|
3700
|
+
? await resolveThreadFooterMentionUserId({
|
|
3701
|
+
sessionUserId: this.state?.sessionUserId,
|
|
3702
|
+
thread: this.thread,
|
|
3703
|
+
})
|
|
3704
|
+
: undefined;
|
|
3705
|
+
const mention = mentionUserId ? ` <@${mentionUserId}>` : '';
|
|
3706
|
+
const footerText = asDiscordQuote(`*${projectInfo}${sessionDuration}${contextInfo}${modelInfo}${agentInfo}*${mention}`);
|
|
3431
3707
|
this.stopTyping();
|
|
3432
|
-
// Skip notification if there's a queued message next — the user only
|
|
3433
|
-
// needs to be notified when the entire queue finishes.
|
|
3434
3708
|
await sendThreadMessage(this.thread, footerText, {
|
|
3435
|
-
flags:
|
|
3709
|
+
flags: shouldNotifyUser ? NOTIFY_MESSAGE_FLAGS : SILENT_MESSAGE_FLAGS,
|
|
3436
3710
|
});
|
|
3437
3711
|
logger.log(`DURATION: Session completed in ${sessionDuration}, model ${runInfo.model}, tokens ${runInfo.tokensUsed}`);
|
|
3438
3712
|
}
|
|
@@ -3442,6 +3716,7 @@ export class ThreadSessionRuntime {
|
|
|
3442
3716
|
this.modelContextLimitKey = undefined;
|
|
3443
3717
|
this.lastDisplayedContextPercentage = 0;
|
|
3444
3718
|
this.lastRateLimitDisplayTime = 0;
|
|
3719
|
+
this.lastSentPartKind = undefined;
|
|
3445
3720
|
}
|
|
3446
3721
|
// ── Retry Last User Prompt (for model-change flow) ──────────
|
|
3447
3722
|
/**
|