@otto-assistant/bridge 0.4.92
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/bin.js +2 -0
- package/dist/agent-model.e2e.test.js +755 -0
- package/dist/ai-tool-to-genai.js +233 -0
- package/dist/ai-tool-to-genai.test.js +267 -0
- package/dist/ai-tool.js +6 -0
- package/dist/anthropic-auth-plugin.js +728 -0
- package/dist/anthropic-auth-plugin.test.js +125 -0
- package/dist/anthropic-auth-state.js +231 -0
- package/dist/bin.js +90 -0
- package/dist/channel-management.js +227 -0
- package/dist/cli-parsing.test.js +137 -0
- package/dist/cli-send-thread.e2e.test.js +356 -0
- package/dist/cli.js +3276 -0
- package/dist/commands/abort.js +65 -0
- package/dist/commands/action-buttons.js +245 -0
- package/dist/commands/add-project.js +113 -0
- package/dist/commands/agent.js +335 -0
- package/dist/commands/ask-question.js +274 -0
- package/dist/commands/btw.js +116 -0
- package/dist/commands/compact.js +120 -0
- package/dist/commands/context-usage.js +140 -0
- package/dist/commands/create-new-project.js +130 -0
- package/dist/commands/diff.js +63 -0
- package/dist/commands/file-upload.js +275 -0
- package/dist/commands/fork.js +220 -0
- package/dist/commands/gemini-apikey.js +70 -0
- package/dist/commands/login.js +885 -0
- package/dist/commands/mcp.js +239 -0
- package/dist/commands/memory-snapshot.js +24 -0
- package/dist/commands/mention-mode.js +44 -0
- package/dist/commands/merge-worktree.js +159 -0
- package/dist/commands/model-variant.js +364 -0
- package/dist/commands/model.js +776 -0
- package/dist/commands/new-worktree.js +366 -0
- package/dist/commands/paginated-select.js +57 -0
- package/dist/commands/permissions.js +274 -0
- package/dist/commands/queue.js +206 -0
- package/dist/commands/remove-project.js +115 -0
- package/dist/commands/restart-opencode-server.js +127 -0
- package/dist/commands/resume.js +149 -0
- package/dist/commands/run-command.js +79 -0
- package/dist/commands/screenshare.js +303 -0
- package/dist/commands/screenshare.test.js +20 -0
- package/dist/commands/session-id.js +78 -0
- package/dist/commands/session.js +176 -0
- package/dist/commands/share.js +80 -0
- package/dist/commands/tasks.js +205 -0
- package/dist/commands/types.js +2 -0
- package/dist/commands/undo-redo.js +305 -0
- package/dist/commands/unset-model.js +138 -0
- package/dist/commands/upgrade.js +42 -0
- package/dist/commands/user-command.js +155 -0
- package/dist/commands/verbosity.js +125 -0
- package/dist/commands/worktree-settings.js +43 -0
- package/dist/commands/worktrees.js +410 -0
- package/dist/condense-memory.js +33 -0
- package/dist/config.js +94 -0
- package/dist/context-awareness-plugin.js +363 -0
- package/dist/context-awareness-plugin.test.js +124 -0
- package/dist/critique-utils.js +95 -0
- package/dist/database.js +1310 -0
- package/dist/db.js +251 -0
- package/dist/db.test.js +138 -0
- package/dist/debounce-timeout.js +28 -0
- package/dist/debounced-process-flush.js +77 -0
- package/dist/discord-bot.js +1008 -0
- package/dist/discord-command-registration.js +524 -0
- package/dist/discord-urls.js +81 -0
- package/dist/discord-utils.js +591 -0
- package/dist/discord-utils.test.js +134 -0
- package/dist/errors.js +157 -0
- package/dist/escape-backticks.test.js +429 -0
- package/dist/event-stream-real-capture.e2e.test.js +533 -0
- package/dist/eventsource-parser.test.js +327 -0
- package/dist/exec-async.js +26 -0
- package/dist/external-opencode-sync.js +480 -0
- package/dist/format-tables.js +302 -0
- package/dist/format-tables.test.js +308 -0
- package/dist/forum-sync/config.js +79 -0
- package/dist/forum-sync/discord-operations.js +154 -0
- package/dist/forum-sync/index.js +5 -0
- package/dist/forum-sync/markdown.js +113 -0
- package/dist/forum-sync/sync-to-discord.js +417 -0
- package/dist/forum-sync/sync-to-files.js +190 -0
- package/dist/forum-sync/types.js +53 -0
- package/dist/forum-sync/watchers.js +307 -0
- package/dist/gateway-proxy-reconnect.e2e.test.js +394 -0
- package/dist/gateway-proxy.e2e.test.js +483 -0
- package/dist/genai-worker-wrapper.js +111 -0
- package/dist/genai-worker.js +311 -0
- package/dist/genai.js +232 -0
- package/dist/generated/browser.js +17 -0
- package/dist/generated/client.js +37 -0
- package/dist/generated/commonInputTypes.js +10 -0
- package/dist/generated/enums.js +52 -0
- package/dist/generated/internal/class.js +49 -0
- package/dist/generated/internal/prismaNamespace.js +253 -0
- package/dist/generated/internal/prismaNamespaceBrowser.js +223 -0
- package/dist/generated/models/bot_api_keys.js +1 -0
- package/dist/generated/models/bot_tokens.js +1 -0
- package/dist/generated/models/channel_agents.js +1 -0
- package/dist/generated/models/channel_directories.js +1 -0
- package/dist/generated/models/channel_mention_mode.js +1 -0
- package/dist/generated/models/channel_models.js +1 -0
- package/dist/generated/models/channel_verbosity.js +1 -0
- package/dist/generated/models/channel_worktrees.js +1 -0
- package/dist/generated/models/forum_sync_configs.js +1 -0
- package/dist/generated/models/global_models.js +1 -0
- package/dist/generated/models/ipc_requests.js +1 -0
- package/dist/generated/models/part_messages.js +1 -0
- package/dist/generated/models/scheduled_tasks.js +1 -0
- package/dist/generated/models/session_agents.js +1 -0
- package/dist/generated/models/session_events.js +1 -0
- package/dist/generated/models/session_models.js +1 -0
- package/dist/generated/models/session_start_sources.js +1 -0
- package/dist/generated/models/thread_sessions.js +1 -0
- package/dist/generated/models/thread_worktrees.js +1 -0
- package/dist/generated/models.js +1 -0
- package/dist/heap-monitor.js +122 -0
- package/dist/hrana-server.js +263 -0
- package/dist/hrana-server.test.js +370 -0
- package/dist/html-actions.js +123 -0
- package/dist/html-actions.test.js +70 -0
- package/dist/html-components.js +117 -0
- package/dist/html-components.test.js +34 -0
- package/dist/image-optimizer-plugin.js +153 -0
- package/dist/image-utils.js +112 -0
- package/dist/interaction-handler.js +397 -0
- package/dist/ipc-polling.js +252 -0
- package/dist/ipc-tools-plugin.js +193 -0
- package/dist/kimaki-digital-twin.e2e.test.js +161 -0
- package/dist/kimaki-opencode-plugin-loading.e2e.test.js +87 -0
- package/dist/kimaki-opencode-plugin.js +17 -0
- package/dist/kimaki-opencode-plugin.test.js +98 -0
- package/dist/limit-heading-depth.js +25 -0
- package/dist/limit-heading-depth.test.js +105 -0
- package/dist/logger.js +165 -0
- package/dist/markdown.js +342 -0
- package/dist/markdown.test.js +257 -0
- package/dist/message-finish-field.e2e.test.js +165 -0
- package/dist/message-formatting.js +413 -0
- package/dist/message-formatting.test.js +73 -0
- package/dist/message-preprocessing.js +330 -0
- package/dist/onboarding-tutorial.js +172 -0
- package/dist/onboarding-welcome.js +37 -0
- package/dist/openai-realtime.js +224 -0
- package/dist/opencode-command-detection.js +65 -0
- package/dist/opencode-command-detection.test.js +240 -0
- package/dist/opencode-command.js +129 -0
- package/dist/opencode-command.test.js +48 -0
- package/dist/opencode-interrupt-plugin.js +361 -0
- package/dist/opencode-interrupt-plugin.test.js +458 -0
- package/dist/opencode.js +861 -0
- package/dist/otto/branding.js +22 -0
- package/dist/otto/index.js +21 -0
- package/dist/parse-permission-rules.test.js +117 -0
- package/dist/patch-text-parser.js +97 -0
- package/dist/plugin-logger.js +59 -0
- package/dist/privacy-sanitizer.js +105 -0
- package/dist/queue-advanced-abort.e2e.test.js +293 -0
- package/dist/queue-advanced-action-buttons.e2e.test.js +206 -0
- package/dist/queue-advanced-e2e-setup.js +786 -0
- package/dist/queue-advanced-footer.e2e.test.js +472 -0
- package/dist/queue-advanced-model-switch.e2e.test.js +299 -0
- package/dist/queue-advanced-permissions-typing.e2e.test.js +180 -0
- package/dist/queue-advanced-question.e2e.test.js +261 -0
- package/dist/queue-advanced-typing-interrupt.e2e.test.js +114 -0
- package/dist/queue-advanced-typing.e2e.test.js +153 -0
- package/dist/queue-drain-after-interactive-ui.e2e.test.js +119 -0
- package/dist/queue-interrupt-drain.e2e.test.js +135 -0
- package/dist/queue-question-select-drain.e2e.test.js +120 -0
- package/dist/runtime-idle-sweeper.js +52 -0
- package/dist/runtime-lifecycle.e2e.test.js +508 -0
- package/dist/sentry.js +23 -0
- package/dist/session-handler/agent-utils.js +67 -0
- package/dist/session-handler/event-stream-state.js +420 -0
- package/dist/session-handler/event-stream-state.test.js +563 -0
- package/dist/session-handler/model-utils.js +124 -0
- package/dist/session-handler/opencode-session-event-log.js +94 -0
- package/dist/session-handler/thread-runtime-state.js +104 -0
- package/dist/session-handler/thread-session-runtime.js +3258 -0
- package/dist/session-handler.js +9 -0
- package/dist/session-search.js +100 -0
- package/dist/session-search.test.js +40 -0
- package/dist/session-title-rename.test.js +80 -0
- package/dist/startup-service.js +153 -0
- package/dist/startup-time.e2e.test.js +296 -0
- package/dist/store.js +17 -0
- package/dist/system-message.js +613 -0
- package/dist/system-message.test.js +602 -0
- package/dist/task-runner.js +295 -0
- package/dist/task-schedule.js +209 -0
- package/dist/task-schedule.test.js +71 -0
- package/dist/test-utils.js +299 -0
- package/dist/thinking-utils.js +35 -0
- package/dist/thread-message-queue.e2e.test.js +999 -0
- package/dist/tools.js +357 -0
- package/dist/undo-redo.e2e.test.js +161 -0
- package/dist/unnest-code-blocks.js +146 -0
- package/dist/unnest-code-blocks.test.js +673 -0
- package/dist/upgrade.js +114 -0
- package/dist/utils.js +144 -0
- package/dist/voice-attachment.js +34 -0
- package/dist/voice-handler.js +646 -0
- package/dist/voice-message.e2e.test.js +1021 -0
- package/dist/voice.js +447 -0
- package/dist/voice.test.js +235 -0
- package/dist/wait-session.js +94 -0
- package/dist/websockify.js +69 -0
- package/dist/worker-types.js +4 -0
- package/dist/worktree-lifecycle.e2e.test.js +308 -0
- package/dist/worktree-utils.js +3 -0
- package/dist/worktrees.js +929 -0
- package/dist/worktrees.test.js +189 -0
- package/dist/xml.js +92 -0
- package/dist/xml.test.js +32 -0
- package/package.json +98 -0
- package/schema.prisma +295 -0
- package/skills/batch/SKILL.md +87 -0
- package/skills/critique/SKILL.md +112 -0
- package/skills/egaki/SKILL.md +100 -0
- package/skills/errore/SKILL.md +647 -0
- package/skills/event-sourcing-state/SKILL.md +252 -0
- package/skills/gitchamber/SKILL.md +93 -0
- package/skills/goke/SKILL.md +644 -0
- package/skills/jitter/EDITOR.md +219 -0
- package/skills/jitter/EXPORT-INTERNALS.md +309 -0
- package/skills/jitter/SKILL.md +158 -0
- package/skills/jitter/jitter-clipboard.json +1042 -0
- package/skills/jitter/package.json +14 -0
- package/skills/jitter/tsconfig.json +15 -0
- package/skills/jitter/utils/actions.ts +212 -0
- package/skills/jitter/utils/export.ts +114 -0
- package/skills/jitter/utils/index.ts +141 -0
- package/skills/jitter/utils/snapshot.ts +154 -0
- package/skills/jitter/utils/traverse.ts +246 -0
- package/skills/jitter/utils/types.ts +279 -0
- package/skills/jitter/utils/wait.ts +133 -0
- package/skills/lintcn/SKILL.md +873 -0
- package/skills/new-skill/SKILL.md +211 -0
- package/skills/npm-package/SKILL.md +239 -0
- package/skills/playwriter/SKILL.md +35 -0
- package/skills/proxyman/SKILL.md +215 -0
- package/skills/security-review/SKILL.md +208 -0
- package/skills/simplify/SKILL.md +58 -0
- package/skills/spiceflow/SKILL.md +14 -0
- package/skills/termcast/SKILL.md +945 -0
- package/skills/tuistory/SKILL.md +250 -0
- package/skills/usecomputer/SKILL.md +264 -0
- package/skills/x-articles/SKILL.md +554 -0
- package/skills/zele/SKILL.md +112 -0
- package/skills/zustand-centralized-state/SKILL.md +1004 -0
- package/src/agent-model.e2e.test.ts +976 -0
- package/src/ai-tool-to-genai.test.ts +296 -0
- package/src/ai-tool-to-genai.ts +283 -0
- package/src/ai-tool.ts +39 -0
- package/src/anthropic-auth-plugin.test.ts +159 -0
- package/src/anthropic-auth-plugin.ts +861 -0
- package/src/anthropic-auth-state.ts +282 -0
- package/src/bin.ts +111 -0
- package/src/channel-management.ts +334 -0
- package/src/cli-parsing.test.ts +195 -0
- package/src/cli-send-thread.e2e.test.ts +464 -0
- package/src/cli.ts +4581 -0
- package/src/commands/abort.ts +89 -0
- package/src/commands/action-buttons.ts +364 -0
- package/src/commands/add-project.ts +149 -0
- package/src/commands/agent.ts +473 -0
- package/src/commands/ask-question.ts +390 -0
- package/src/commands/btw.ts +164 -0
- package/src/commands/compact.ts +157 -0
- package/src/commands/context-usage.ts +199 -0
- package/src/commands/create-new-project.ts +190 -0
- package/src/commands/diff.ts +91 -0
- package/src/commands/file-upload.ts +389 -0
- package/src/commands/fork.ts +321 -0
- package/src/commands/gemini-apikey.ts +104 -0
- package/src/commands/login.ts +1173 -0
- package/src/commands/mcp.ts +307 -0
- package/src/commands/memory-snapshot.ts +30 -0
- package/src/commands/mention-mode.ts +68 -0
- package/src/commands/merge-worktree.ts +223 -0
- package/src/commands/model-variant.ts +483 -0
- package/src/commands/model.ts +1053 -0
- package/src/commands/new-worktree.ts +510 -0
- package/src/commands/paginated-select.ts +81 -0
- package/src/commands/permissions.ts +397 -0
- package/src/commands/queue.ts +271 -0
- package/src/commands/remove-project.ts +155 -0
- package/src/commands/restart-opencode-server.ts +162 -0
- package/src/commands/resume.ts +230 -0
- package/src/commands/run-command.ts +123 -0
- package/src/commands/screenshare.test.ts +30 -0
- package/src/commands/screenshare.ts +366 -0
- package/src/commands/session-id.ts +109 -0
- package/src/commands/session.ts +227 -0
- package/src/commands/share.ts +106 -0
- package/src/commands/tasks.ts +293 -0
- package/src/commands/types.ts +25 -0
- package/src/commands/undo-redo.ts +386 -0
- package/src/commands/unset-model.ts +173 -0
- package/src/commands/upgrade.ts +52 -0
- package/src/commands/user-command.ts +198 -0
- package/src/commands/verbosity.ts +173 -0
- package/src/commands/worktree-settings.ts +70 -0
- package/src/commands/worktrees.ts +552 -0
- package/src/condense-memory.ts +36 -0
- package/src/config.ts +111 -0
- package/src/context-awareness-plugin.test.ts +142 -0
- package/src/context-awareness-plugin.ts +510 -0
- package/src/critique-utils.ts +139 -0
- package/src/database.ts +1876 -0
- package/src/db.test.ts +162 -0
- package/src/db.ts +286 -0
- package/src/debounce-timeout.ts +43 -0
- package/src/debounced-process-flush.ts +104 -0
- package/src/discord-bot.ts +1330 -0
- package/src/discord-command-registration.ts +693 -0
- package/src/discord-urls.ts +88 -0
- package/src/discord-utils.test.ts +153 -0
- package/src/discord-utils.ts +800 -0
- package/src/errors.ts +201 -0
- package/src/escape-backticks.test.ts +469 -0
- package/src/event-stream-real-capture.e2e.test.ts +692 -0
- package/src/eventsource-parser.test.ts +351 -0
- package/src/exec-async.ts +35 -0
- package/src/external-opencode-sync.ts +685 -0
- package/src/format-tables.test.ts +335 -0
- package/src/format-tables.ts +445 -0
- package/src/forum-sync/config.ts +92 -0
- package/src/forum-sync/discord-operations.ts +241 -0
- package/src/forum-sync/index.ts +9 -0
- package/src/forum-sync/markdown.ts +172 -0
- package/src/forum-sync/sync-to-discord.ts +595 -0
- package/src/forum-sync/sync-to-files.ts +294 -0
- package/src/forum-sync/types.ts +175 -0
- package/src/forum-sync/watchers.ts +454 -0
- package/src/gateway-proxy-reconnect.e2e.test.ts +523 -0
- package/src/gateway-proxy.e2e.test.ts +640 -0
- package/src/genai-worker-wrapper.ts +164 -0
- package/src/genai-worker.ts +386 -0
- package/src/genai.ts +321 -0
- package/src/generated/browser.ts +114 -0
- package/src/generated/client.ts +138 -0
- package/src/generated/commonInputTypes.ts +736 -0
- package/src/generated/enums.ts +88 -0
- package/src/generated/internal/class.ts +384 -0
- package/src/generated/internal/prismaNamespace.ts +2386 -0
- package/src/generated/internal/prismaNamespaceBrowser.ts +326 -0
- package/src/generated/models/bot_api_keys.ts +1288 -0
- package/src/generated/models/bot_tokens.ts +1656 -0
- package/src/generated/models/channel_agents.ts +1256 -0
- package/src/generated/models/channel_directories.ts +1859 -0
- package/src/generated/models/channel_mention_mode.ts +1300 -0
- package/src/generated/models/channel_models.ts +1288 -0
- package/src/generated/models/channel_verbosity.ts +1228 -0
- package/src/generated/models/channel_worktrees.ts +1300 -0
- package/src/generated/models/forum_sync_configs.ts +1452 -0
- package/src/generated/models/global_models.ts +1288 -0
- package/src/generated/models/ipc_requests.ts +1485 -0
- package/src/generated/models/part_messages.ts +1302 -0
- package/src/generated/models/scheduled_tasks.ts +2320 -0
- package/src/generated/models/session_agents.ts +1086 -0
- package/src/generated/models/session_events.ts +1439 -0
- package/src/generated/models/session_models.ts +1114 -0
- package/src/generated/models/session_start_sources.ts +1408 -0
- package/src/generated/models/thread_sessions.ts +1781 -0
- package/src/generated/models/thread_worktrees.ts +1356 -0
- package/src/generated/models.ts +30 -0
- package/src/heap-monitor.ts +152 -0
- package/src/hrana-server.test.ts +434 -0
- package/src/hrana-server.ts +314 -0
- package/src/html-actions.test.ts +87 -0
- package/src/html-actions.ts +174 -0
- package/src/html-components.test.ts +38 -0
- package/src/html-components.ts +181 -0
- package/src/image-optimizer-plugin.ts +194 -0
- package/src/image-utils.ts +149 -0
- package/src/interaction-handler.ts +576 -0
- package/src/ipc-polling.ts +326 -0
- package/src/ipc-tools-plugin.ts +236 -0
- package/src/kimaki-digital-twin.e2e.test.ts +199 -0
- package/src/kimaki-opencode-plugin-loading.e2e.test.ts +109 -0
- package/src/kimaki-opencode-plugin.test.ts +108 -0
- package/src/kimaki-opencode-plugin.ts +18 -0
- package/src/limit-heading-depth.test.ts +116 -0
- package/src/limit-heading-depth.ts +26 -0
- package/src/logger.ts +208 -0
- package/src/markdown.test.ts +308 -0
- package/src/markdown.ts +410 -0
- package/src/message-finish-field.e2e.test.ts +192 -0
- package/src/message-formatting.test.ts +81 -0
- package/src/message-formatting.ts +533 -0
- package/src/message-preprocessing.ts +455 -0
- package/src/onboarding-tutorial.ts +176 -0
- package/src/onboarding-welcome.ts +49 -0
- package/src/openai-realtime.ts +358 -0
- package/src/opencode-command-detection.test.ts +307 -0
- package/src/opencode-command-detection.ts +76 -0
- package/src/opencode-command.test.ts +70 -0
- package/src/opencode-command.ts +188 -0
- package/src/opencode-interrupt-plugin.test.ts +677 -0
- package/src/opencode-interrupt-plugin.ts +477 -0
- package/src/opencode.ts +1110 -0
- package/src/otto/branding.ts +23 -0
- package/src/otto/index.ts +22 -0
- package/src/parse-permission-rules.test.ts +127 -0
- package/src/patch-text-parser.ts +107 -0
- package/src/plugin-logger.ts +68 -0
- package/src/privacy-sanitizer.ts +142 -0
- package/src/queue-advanced-abort.e2e.test.ts +382 -0
- package/src/queue-advanced-action-buttons.e2e.test.ts +268 -0
- package/src/queue-advanced-e2e-setup.ts +873 -0
- package/src/queue-advanced-footer.e2e.test.ts +576 -0
- package/src/queue-advanced-model-switch.e2e.test.ts +383 -0
- package/src/queue-advanced-permissions-typing.e2e.test.ts +245 -0
- package/src/queue-advanced-question.e2e.test.ts +316 -0
- package/src/queue-advanced-typing-interrupt.e2e.test.ts +146 -0
- package/src/queue-advanced-typing.e2e.test.ts +199 -0
- package/src/queue-drain-after-interactive-ui.e2e.test.ts +151 -0
- package/src/queue-interrupt-drain.e2e.test.ts +166 -0
- package/src/queue-question-select-drain.e2e.test.ts +152 -0
- package/src/runtime-idle-sweeper.ts +76 -0
- package/src/runtime-lifecycle.e2e.test.ts +641 -0
- package/src/schema.sql +173 -0
- package/src/sentry.ts +26 -0
- package/src/session-handler/agent-utils.ts +97 -0
- package/src/session-handler/event-stream-fixtures/real-session-action-buttons.jsonl +45 -0
- package/src/session-handler/event-stream-fixtures/real-session-footer-suppressed-on-pre-idle-interrupt.jsonl +40 -0
- package/src/session-handler/event-stream-fixtures/real-session-permission-external-file.jsonl +23 -0
- package/src/session-handler/event-stream-fixtures/real-session-task-normal.jsonl +22 -0
- package/src/session-handler/event-stream-fixtures/real-session-task-three-parallel-sleeps.jsonl +277 -0
- package/src/session-handler/event-stream-fixtures/real-session-task-user-interruption.jsonl +46 -0
- package/src/session-handler/event-stream-fixtures/session-abort-after-idle-race.jsonl +21 -0
- package/src/session-handler/event-stream-fixtures/session-concurrent-messages-serialized.jsonl +56 -0
- package/src/session-handler/event-stream-fixtures/session-explicit-abort.jsonl +44 -0
- package/src/session-handler/event-stream-fixtures/session-normal-completion.jsonl +29 -0
- package/src/session-handler/event-stream-fixtures/session-tool-call-noisy-stream.jsonl +29 -0
- package/src/session-handler/event-stream-fixtures/session-two-completions-same-session.jsonl +50 -0
- package/src/session-handler/event-stream-fixtures/session-user-interruption.jsonl +59 -0
- package/src/session-handler/event-stream-fixtures/session-voice-queued-followup.jsonl +52 -0
- package/src/session-handler/event-stream-state.test.ts +645 -0
- package/src/session-handler/event-stream-state.ts +608 -0
- package/src/session-handler/model-utils.ts +183 -0
- package/src/session-handler/opencode-session-event-log.ts +130 -0
- package/src/session-handler/thread-runtime-state.ts +212 -0
- package/src/session-handler/thread-session-runtime.ts +4281 -0
- package/src/session-handler.ts +15 -0
- package/src/session-search.test.ts +50 -0
- package/src/session-search.ts +148 -0
- package/src/session-title-rename.test.ts +112 -0
- package/src/startup-service.ts +200 -0
- package/src/startup-time.e2e.test.ts +373 -0
- package/src/store.ts +122 -0
- package/src/system-message.test.ts +612 -0
- package/src/system-message.ts +723 -0
- package/src/task-runner.ts +421 -0
- package/src/task-schedule.test.ts +84 -0
- package/src/task-schedule.ts +311 -0
- package/src/test-utils.ts +435 -0
- package/src/thinking-utils.ts +61 -0
- package/src/thread-message-queue.e2e.test.ts +1219 -0
- package/src/tools.ts +430 -0
- package/src/undici.d.ts +12 -0
- package/src/undo-redo.e2e.test.ts +209 -0
- package/src/unnest-code-blocks.test.ts +713 -0
- package/src/unnest-code-blocks.ts +185 -0
- package/src/upgrade.ts +127 -0
- package/src/utils.ts +212 -0
- package/src/voice-attachment.ts +51 -0
- package/src/voice-handler.ts +908 -0
- package/src/voice-message.e2e.test.ts +1255 -0
- package/src/voice.test.ts +281 -0
- package/src/voice.ts +627 -0
- package/src/wait-session.ts +147 -0
- package/src/websockify.ts +101 -0
- package/src/worker-types.ts +64 -0
- package/src/worktree-lifecycle.e2e.test.ts +391 -0
- package/src/worktree-utils.ts +4 -0
- package/src/worktrees.test.ts +223 -0
- package/src/worktrees.ts +1294 -0
- package/src/xml.test.ts +38 -0
- package/src/xml.ts +121 -0
|
@@ -0,0 +1,4281 @@
|
|
|
1
|
+
// ThreadSessionRuntime — one per active thread.
|
|
2
|
+
// Owns resource handles (listener controller, typing timers, part buffer).
|
|
3
|
+
// Delegates all state to the global store via thread-runtime-state.ts transitions.
|
|
4
|
+
//
|
|
5
|
+
// This is the sole session orchestrator. Discord handlers and slash commands
|
|
6
|
+
// call runtime APIs (enqueueIncoming, abortActiveRun, etc.) without inspecting
|
|
7
|
+
// run internals.
|
|
8
|
+
|
|
9
|
+
import { ChannelType, type ThreadChannel } from 'discord.js'
|
|
10
|
+
import type {
|
|
11
|
+
Event as OpenCodeEvent,
|
|
12
|
+
Part,
|
|
13
|
+
PermissionRequest,
|
|
14
|
+
QuestionRequest,
|
|
15
|
+
Message as OpenCodeMessage,
|
|
16
|
+
} from '@opencode-ai/sdk/v2'
|
|
17
|
+
import path from 'node:path'
|
|
18
|
+
import prettyMilliseconds from 'pretty-ms'
|
|
19
|
+
import * as errore from 'errore'
|
|
20
|
+
import * as threadState from './thread-runtime-state.js'
|
|
21
|
+
import type { QueuedMessage } from './thread-runtime-state.js'
|
|
22
|
+
import type { OpencodeClient } from '@opencode-ai/sdk/v2'
|
|
23
|
+
import {
|
|
24
|
+
getOpencodeClient,
|
|
25
|
+
initializeOpencodeForDirectory,
|
|
26
|
+
buildSessionPermissions,
|
|
27
|
+
parsePermissionRules,
|
|
28
|
+
subscribeOpencodeServerLifecycle,
|
|
29
|
+
writeInjectionGuardConfig,
|
|
30
|
+
} from '../opencode.js'
|
|
31
|
+
import { isAbortError } from '../utils.js'
|
|
32
|
+
import { createLogger, LogPrefix } from '../logger.js'
|
|
33
|
+
import {
|
|
34
|
+
sendThreadMessage,
|
|
35
|
+
SILENT_MESSAGE_FLAGS,
|
|
36
|
+
NOTIFY_MESSAGE_FLAGS,
|
|
37
|
+
} from '../discord-utils.js'
|
|
38
|
+
import type { DiscordFileAttachment } from '../message-formatting.js'
|
|
39
|
+
import { formatPart } from '../message-formatting.js'
|
|
40
|
+
import {
|
|
41
|
+
getChannelVerbosity,
|
|
42
|
+
getPartMessageIds,
|
|
43
|
+
setPartMessage,
|
|
44
|
+
getThreadSession,
|
|
45
|
+
setThreadSession,
|
|
46
|
+
getThreadWorktree,
|
|
47
|
+
setSessionAgent,
|
|
48
|
+
getVariantCascade,
|
|
49
|
+
setSessionStartSource,
|
|
50
|
+
appendSessionEventsSinceLastTimestamp,
|
|
51
|
+
getSessionEventSnapshot,
|
|
52
|
+
} from '../database.js'
|
|
53
|
+
import {
|
|
54
|
+
showPermissionButtons,
|
|
55
|
+
cleanupPermissionContext,
|
|
56
|
+
addPermissionRequestToContext,
|
|
57
|
+
arePatternsCoveredBy,
|
|
58
|
+
pendingPermissionContexts,
|
|
59
|
+
} from '../commands/permissions.js'
|
|
60
|
+
import {
|
|
61
|
+
showAskUserQuestionDropdowns,
|
|
62
|
+
pendingQuestionContexts,
|
|
63
|
+
cancelPendingQuestion,
|
|
64
|
+
} from '../commands/ask-question.js'
|
|
65
|
+
import {
|
|
66
|
+
showActionButtons,
|
|
67
|
+
waitForQueuedActionButtonsRequest,
|
|
68
|
+
pendingActionButtonContexts,
|
|
69
|
+
cancelPendingActionButtons,
|
|
70
|
+
} from '../commands/action-buttons.js'
|
|
71
|
+
import {
|
|
72
|
+
pendingFileUploadContexts,
|
|
73
|
+
cancelPendingFileUpload,
|
|
74
|
+
} from '../commands/file-upload.js'
|
|
75
|
+
import {
|
|
76
|
+
getCurrentModelInfo,
|
|
77
|
+
ensureSessionPreferencesSnapshot,
|
|
78
|
+
} from '../commands/model.js'
|
|
79
|
+
import {
|
|
80
|
+
getOpencodePromptContext,
|
|
81
|
+
getOpencodeSystemMessage,
|
|
82
|
+
type AgentInfo,
|
|
83
|
+
type RepliedMessageContext,
|
|
84
|
+
type WorktreeInfo,
|
|
85
|
+
} from '../system-message.js'
|
|
86
|
+
import { resolveValidatedAgentPreference } from './agent-utils.js'
|
|
87
|
+
import {
|
|
88
|
+
appendOpencodeSessionEventLog,
|
|
89
|
+
getOpencodeEventSessionId,
|
|
90
|
+
isOpencodeSessionEventLogEnabled,
|
|
91
|
+
} from './opencode-session-event-log.js'
|
|
92
|
+
import {
|
|
93
|
+
doesLatestUserTurnHaveNaturalCompletion,
|
|
94
|
+
getAssistantMessageIdsForLatestUserTurn,
|
|
95
|
+
getCurrentTurnStartTime,
|
|
96
|
+
isSessionBusy,
|
|
97
|
+
getLatestRunInfo,
|
|
98
|
+
getDerivedSubtaskIndex,
|
|
99
|
+
getDerivedSubtaskAgentType,
|
|
100
|
+
getLatestAssistantMessageIdForLatestUserTurn,
|
|
101
|
+
hasAssistantMessageCompletedBefore,
|
|
102
|
+
isAssistantMessageInLatestUserTurn,
|
|
103
|
+
isAssistantMessageNaturalCompletion,
|
|
104
|
+
type EventBufferEntry,
|
|
105
|
+
} from './event-stream-state.js'
|
|
106
|
+
|
|
107
|
+
// Track multiple pending permissions per thread (keyed by permission ID).
|
|
108
|
+
// OpenCode handles blocking/sequencing — we just need to track all pending
|
|
109
|
+
// permissions to avoid duplicates and properly clean up on reply/teardown.
|
|
110
|
+
// The runtime is the sole owner of pending permissions per thread.
|
|
111
|
+
export const pendingPermissions = new Map<
|
|
112
|
+
string, // threadId
|
|
113
|
+
Map<
|
|
114
|
+
string,
|
|
115
|
+
{
|
|
116
|
+
permission: PermissionRequest
|
|
117
|
+
messageId: string
|
|
118
|
+
directory: string
|
|
119
|
+
permissionDirectory: string
|
|
120
|
+
contextHash: string
|
|
121
|
+
dedupeKey: string
|
|
122
|
+
}
|
|
123
|
+
> // permissionId -> data
|
|
124
|
+
>()
|
|
125
|
+
import {
|
|
126
|
+
getThinkingValuesForModel,
|
|
127
|
+
matchThinkingValue,
|
|
128
|
+
} from '../thinking-utils.js'
|
|
129
|
+
import { execAsync } from '../worktrees.js'
|
|
130
|
+
|
|
131
|
+
import { notifyError } from '../sentry.js'
|
|
132
|
+
import { createDebouncedProcessFlush } from '../debounced-process-flush.js'
|
|
133
|
+
import { cancelHtmlActionsForThread } from '../html-actions.js'
|
|
134
|
+
import { createDebouncedTimeout } from '../debounce-timeout.js'
|
|
135
|
+
import { extractLeadingOpencodeCommand } from '../opencode-command-detection.js'
|
|
136
|
+
|
|
137
|
+
const logger = createLogger(LogPrefix.SESSION)
|
|
138
|
+
const discordLogger = createLogger(LogPrefix.DISCORD)
|
|
139
|
+
const DETERMINISTIC_CONTEXT_LIMIT = 100_000
|
|
140
|
+
const shouldLogSessionEvents =
|
|
141
|
+
process.env['KIMAKI_LOG_SESSION_EVENTS'] === '1' ||
|
|
142
|
+
process.env['KIMAKI_VITEST'] === '1'
|
|
143
|
+
|
|
144
|
+
// ── Registry ─────────────────────────────────────────────────────
|
|
145
|
+
// Runtime instances are kept in a plain Map (not Zustand — the Map
|
|
146
|
+
// is not reactive state, just a lookup for resource handles).
|
|
147
|
+
|
|
148
|
+
const runtimes = new Map<string, ThreadSessionRuntime>()
|
|
149
|
+
|
|
150
|
+
subscribeOpencodeServerLifecycle((event) => {
|
|
151
|
+
if (event.type !== 'started') {
|
|
152
|
+
return
|
|
153
|
+
}
|
|
154
|
+
for (const runtime of runtimes.values()) {
|
|
155
|
+
runtime.handleSharedServerStarted({ port: event.port })
|
|
156
|
+
}
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
export function getRuntime(
|
|
160
|
+
threadId: string,
|
|
161
|
+
): ThreadSessionRuntime | undefined {
|
|
162
|
+
return runtimes.get(threadId)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export type RuntimeOptions = {
|
|
166
|
+
threadId: string
|
|
167
|
+
thread: ThreadChannel
|
|
168
|
+
projectDirectory: string
|
|
169
|
+
sdkDirectory: string
|
|
170
|
+
channelId?: string
|
|
171
|
+
appId?: string
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function getOrCreateRuntime(
|
|
175
|
+
opts: RuntimeOptions,
|
|
176
|
+
): ThreadSessionRuntime {
|
|
177
|
+
const existing = runtimes.get(opts.threadId)
|
|
178
|
+
if (existing) {
|
|
179
|
+
// Reconcile sdkDirectory: worktree threads transition from pending
|
|
180
|
+
// (projectDirectory) to ready (worktree path) after runtime creation.
|
|
181
|
+
if (existing.sdkDirectory !== opts.sdkDirectory) {
|
|
182
|
+
existing.handleDirectoryChanged({
|
|
183
|
+
oldDirectory: existing.sdkDirectory,
|
|
184
|
+
newDirectory: opts.sdkDirectory,
|
|
185
|
+
})
|
|
186
|
+
}
|
|
187
|
+
return existing
|
|
188
|
+
}
|
|
189
|
+
threadState.ensureThread(opts.threadId) // add to global store
|
|
190
|
+
const runtime = new ThreadSessionRuntime(opts)
|
|
191
|
+
runtimes.set(opts.threadId, runtime)
|
|
192
|
+
return runtime
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function disposeRuntime(threadId: string): void {
|
|
196
|
+
const runtime = runtimes.get(threadId)
|
|
197
|
+
if (!runtime) {
|
|
198
|
+
return
|
|
199
|
+
}
|
|
200
|
+
runtime.dispose()
|
|
201
|
+
runtimes.delete(threadId)
|
|
202
|
+
threadState.removeThread(threadId) // remove from global store
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function disposeRuntimesForDirectory({
|
|
206
|
+
directory,
|
|
207
|
+
channelId,
|
|
208
|
+
}: {
|
|
209
|
+
directory: string
|
|
210
|
+
channelId?: string
|
|
211
|
+
}): number {
|
|
212
|
+
let count = 0
|
|
213
|
+
for (const [threadId, runtime] of runtimes) {
|
|
214
|
+
if (runtime.projectDirectory !== directory) {
|
|
215
|
+
continue
|
|
216
|
+
}
|
|
217
|
+
if (channelId && runtime.channelId !== channelId) {
|
|
218
|
+
continue
|
|
219
|
+
}
|
|
220
|
+
runtime.dispose()
|
|
221
|
+
runtimes.delete(threadId)
|
|
222
|
+
threadState.removeThread(threadId)
|
|
223
|
+
count++
|
|
224
|
+
}
|
|
225
|
+
return count
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Returns number of active runtimes (useful for diagnostics). */
|
|
229
|
+
export function getRuntimeCount(): number {
|
|
230
|
+
return runtimes.size
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export function disposeInactiveRuntimes({
|
|
234
|
+
idleMs,
|
|
235
|
+
nowMs = Date.now(),
|
|
236
|
+
}: {
|
|
237
|
+
idleMs: number
|
|
238
|
+
nowMs?: number
|
|
239
|
+
}): {
|
|
240
|
+
disposedThreadIds: string[]
|
|
241
|
+
disposedDirectories: string[]
|
|
242
|
+
} {
|
|
243
|
+
const candidates = [...runtimes.entries()].filter(([, runtime]) => {
|
|
244
|
+
return runtime.isIdleForInactivityTimeout({ idleMs, nowMs })
|
|
245
|
+
})
|
|
246
|
+
const disposedDirectories = new Set<string>()
|
|
247
|
+
const disposedThreadIds: string[] = []
|
|
248
|
+
|
|
249
|
+
for (const [threadId, runtime] of candidates) {
|
|
250
|
+
runtime.dispose()
|
|
251
|
+
runtimes.delete(threadId)
|
|
252
|
+
threadState.removeThread(threadId)
|
|
253
|
+
disposedThreadIds.push(threadId)
|
|
254
|
+
disposedDirectories.add(runtime.projectDirectory)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return {
|
|
258
|
+
disposedThreadIds,
|
|
259
|
+
disposedDirectories: [...disposedDirectories],
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// ── Pending UI cleanup ───────────────────────────────────────────
|
|
264
|
+
// Clears all pending interactive UI state for a thread on dispose/delete.
|
|
265
|
+
// Uses existing cancel functions which handle upstream replies (so OpenCode
|
|
266
|
+
// doesn't hang waiting for answers that will never come).
|
|
267
|
+
|
|
268
|
+
function cleanupPendingUiForThread(threadId: string): void {
|
|
269
|
+
// Permissions: reject each pending permission so OpenCode doesn't hang,
|
|
270
|
+
// then delete the per-thread tracking map.
|
|
271
|
+
const threadPerms = pendingPermissions.get(threadId)
|
|
272
|
+
if (threadPerms) {
|
|
273
|
+
for (const [, entry] of threadPerms) {
|
|
274
|
+
const ctx = pendingPermissionContexts.get(entry.contextHash)
|
|
275
|
+
if (ctx) {
|
|
276
|
+
const client = getOpencodeClient(ctx.directory)
|
|
277
|
+
if (client) {
|
|
278
|
+
const requestIds: string[] = ctx.requestIds.length > 0
|
|
279
|
+
? ctx.requestIds
|
|
280
|
+
: [ctx.permission.id]
|
|
281
|
+
void Promise.all(
|
|
282
|
+
requestIds.map((requestId) => {
|
|
283
|
+
return client.permission.reply({
|
|
284
|
+
requestID: requestId,
|
|
285
|
+
directory: ctx.permissionDirectory,
|
|
286
|
+
reply: 'reject',
|
|
287
|
+
})
|
|
288
|
+
}),
|
|
289
|
+
).catch(() => {})
|
|
290
|
+
}
|
|
291
|
+
pendingPermissionContexts.delete(entry.contextHash)
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
pendingPermissions.delete(threadId)
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Questions: cancel deletes pending context without replying to OpenCode.
|
|
298
|
+
void cancelPendingQuestion(threadId)
|
|
299
|
+
|
|
300
|
+
// Action buttons: resolves context and clears timer.
|
|
301
|
+
cancelPendingActionButtons(threadId)
|
|
302
|
+
|
|
303
|
+
// File uploads: resolves with empty files so OpenCode unblocks.
|
|
304
|
+
void cancelPendingFileUpload(threadId)
|
|
305
|
+
|
|
306
|
+
// HTML actions: clears registered action callbacks for this thread.
|
|
307
|
+
cancelHtmlActionsForThread(threadId)
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// ── Helpers ──────────────────────────────────────────────────────
|
|
311
|
+
|
|
312
|
+
function delay(ms: number): Promise<void> {
|
|
313
|
+
return new Promise((resolve) => {
|
|
314
|
+
setTimeout(resolve, ms)
|
|
315
|
+
})
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function getTimestampFromSnowflake(snowflake: string): number | undefined {
|
|
319
|
+
const discordEpochMs = 1_420_070_400_000n
|
|
320
|
+
const snowflakeIdResult = errore.try({
|
|
321
|
+
try: () => {
|
|
322
|
+
return BigInt(snowflake)
|
|
323
|
+
},
|
|
324
|
+
catch: () => {
|
|
325
|
+
return new Error('Invalid Discord snowflake')
|
|
326
|
+
},
|
|
327
|
+
})
|
|
328
|
+
if (snowflakeIdResult instanceof Error) {
|
|
329
|
+
return undefined
|
|
330
|
+
}
|
|
331
|
+
const timestampBigInt = (snowflakeIdResult >> 22n) + discordEpochMs
|
|
332
|
+
const timestampMs = Number(timestampBigInt)
|
|
333
|
+
if (!Number.isFinite(timestampMs) || timestampMs <= 0) {
|
|
334
|
+
return undefined
|
|
335
|
+
}
|
|
336
|
+
return timestampMs
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
type TokenUsage = {
|
|
340
|
+
input: number
|
|
341
|
+
output: number
|
|
342
|
+
reasoning: number
|
|
343
|
+
cache: { read: number; write: number }
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function getTokenTotal(tokens: TokenUsage): number {
|
|
347
|
+
return (
|
|
348
|
+
tokens.input +
|
|
349
|
+
tokens.output +
|
|
350
|
+
tokens.reasoning +
|
|
351
|
+
tokens.cache.read +
|
|
352
|
+
tokens.cache.write
|
|
353
|
+
)
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** Check if a tool part is "essential" (shown in text-and-essential-tools mode). */
|
|
357
|
+
export function isEssentialToolName(toolName: string): boolean {
|
|
358
|
+
const essentialTools = [
|
|
359
|
+
'edit',
|
|
360
|
+
'write',
|
|
361
|
+
'apply_patch',
|
|
362
|
+
'bash',
|
|
363
|
+
'webfetch',
|
|
364
|
+
'websearch',
|
|
365
|
+
'googlesearch',
|
|
366
|
+
'codesearch',
|
|
367
|
+
'task',
|
|
368
|
+
'todowrite',
|
|
369
|
+
'skill',
|
|
370
|
+
]
|
|
371
|
+
// Also match any MCP tool that contains these names
|
|
372
|
+
return essentialTools.some((name) => {
|
|
373
|
+
return toolName === name || toolName.endsWith(`_${name}`)
|
|
374
|
+
})
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
export function isEssentialToolPart(part: Part): boolean {
|
|
378
|
+
if (part.type !== 'tool') {
|
|
379
|
+
return false
|
|
380
|
+
}
|
|
381
|
+
if (!isEssentialToolName(part.tool)) {
|
|
382
|
+
return false
|
|
383
|
+
}
|
|
384
|
+
if (part.tool === 'bash') {
|
|
385
|
+
const hasSideEffect = part.state.input?.hasSideEffect
|
|
386
|
+
return hasSideEffect !== false
|
|
387
|
+
}
|
|
388
|
+
return true
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// ── Thread title derivation ──────────────────────────────────────
|
|
392
|
+
|
|
393
|
+
const DISCORD_THREAD_NAME_MAX = 100
|
|
394
|
+
const WORKTREE_THREAD_PREFIX = '⬦ '
|
|
395
|
+
|
|
396
|
+
// Pure derivation: given an OpenCode session title and the current thread name,
|
|
397
|
+
// return the new thread name to apply, or undefined when no rename is needed.
|
|
398
|
+
// - Skips placeholder titles ("New Session - ...") to match external-sync.
|
|
399
|
+
// - Preserves worktree prefix when the current name carries it.
|
|
400
|
+
// - Returns undefined when the candidate matches currentName already.
|
|
401
|
+
export function deriveThreadNameFromSessionTitle({
|
|
402
|
+
sessionTitle,
|
|
403
|
+
currentName,
|
|
404
|
+
}: {
|
|
405
|
+
sessionTitle: string | undefined | null
|
|
406
|
+
currentName: string
|
|
407
|
+
}): string | undefined {
|
|
408
|
+
const trimmed = sessionTitle?.trim()
|
|
409
|
+
if (!trimmed) {
|
|
410
|
+
return undefined
|
|
411
|
+
}
|
|
412
|
+
if (/^new session\s*-/i.test(trimmed)) {
|
|
413
|
+
return undefined
|
|
414
|
+
}
|
|
415
|
+
const hasWorktreePrefix = currentName.startsWith(WORKTREE_THREAD_PREFIX)
|
|
416
|
+
const prefix = hasWorktreePrefix ? WORKTREE_THREAD_PREFIX : ''
|
|
417
|
+
const candidate = `${prefix}${trimmed}`.slice(0, DISCORD_THREAD_NAME_MAX)
|
|
418
|
+
if (candidate === currentName) {
|
|
419
|
+
return undefined
|
|
420
|
+
}
|
|
421
|
+
return candidate
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// ── Ingress input type ───────────────────────────────────────────
|
|
425
|
+
|
|
426
|
+
export type EnqueueResult = {
|
|
427
|
+
/** True if the message is waiting in queue behind an active run. */
|
|
428
|
+
queued: boolean
|
|
429
|
+
/** Queue position (1-based). Only set when queued is true. */
|
|
430
|
+
position?: number
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Result of the preprocess callback. Returns the resolved prompt, images,
|
|
435
|
+
* and mode after expensive async work (voice transcription, context fetch,
|
|
436
|
+
* attachment download) completes.
|
|
437
|
+
*/
|
|
438
|
+
export type PreprocessResult = {
|
|
439
|
+
prompt: string
|
|
440
|
+
images?: DiscordFileAttachment[]
|
|
441
|
+
repliedMessage?: RepliedMessageContext
|
|
442
|
+
/** Resolved mode based on voice transcription result. */
|
|
443
|
+
mode: 'opencode' | 'local-queue'
|
|
444
|
+
/** When true, preprocessing determined the message should be silently dropped. */
|
|
445
|
+
skip?: boolean
|
|
446
|
+
/** Agent name extracted from voice transcription. Applied to the session if set. */
|
|
447
|
+
agent?: string
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
export type IngressInput = {
|
|
451
|
+
prompt: string
|
|
452
|
+
userId: string
|
|
453
|
+
username: string
|
|
454
|
+
// Discord message ID and thread ID for the source message, embedded in
|
|
455
|
+
// <discord-user> synthetic context so the external sync loop can detect
|
|
456
|
+
// messages that originated from Discord and skip re-mirroring them.
|
|
457
|
+
sourceMessageId?: string
|
|
458
|
+
sourceThreadId?: string
|
|
459
|
+
repliedMessage?: RepliedMessageContext
|
|
460
|
+
images?: DiscordFileAttachment[]
|
|
461
|
+
appId?: string
|
|
462
|
+
command?: { name: string; arguments: string }
|
|
463
|
+
/**
|
|
464
|
+
* `opencode` (default): send via session.promptAsync and let opencode
|
|
465
|
+
* serialize pending user turns internally.
|
|
466
|
+
* `local-queue`: keep in kimaki's local queue (used by /queue flows).
|
|
467
|
+
*/
|
|
468
|
+
mode?: 'opencode' | 'local-queue'
|
|
469
|
+
// Force a new assistant-part routing window by resetting run-state to
|
|
470
|
+
// running before enqueue. Used by model-switch retry flows where old
|
|
471
|
+
// assistant IDs can linger briefly after abort.
|
|
472
|
+
resetAssistantForNewRun?: boolean
|
|
473
|
+
// First-dispatch-only overrides (used when creating a new session)
|
|
474
|
+
agent?: string
|
|
475
|
+
model?: string
|
|
476
|
+
/**
|
|
477
|
+
* Raw permission rule strings from --permission flag ("tool:action" or
|
|
478
|
+
* "tool:pattern:action"). Parsed into PermissionRuleset entries by
|
|
479
|
+
* parsePermissionRules() and appended after buildSessionPermissions()
|
|
480
|
+
* so they win via opencode's findLast() evaluation. Only used on
|
|
481
|
+
* session creation (first dispatch).
|
|
482
|
+
*/
|
|
483
|
+
permissions?: string[]
|
|
484
|
+
injectionGuardPatterns?: string[]
|
|
485
|
+
sessionStartSource?: { scheduleKind: 'at' | 'cron'; scheduledTaskId?: number }
|
|
486
|
+
/** Optional guard for retries: skip enqueue when session has changed. */
|
|
487
|
+
expectedSessionId?: string
|
|
488
|
+
/**
|
|
489
|
+
* Lazy preprocessing callback. When set, the runtime serializes it via a
|
|
490
|
+
* lightweight promise chain (preprocessChain) to resolve prompt/images/mode
|
|
491
|
+
* from the raw Discord message. This replaces the threadIngressQueue in
|
|
492
|
+
* discord-bot.ts: expensive async work (voice transcription, context fetch,
|
|
493
|
+
* attachment download) runs in arrival order but outside dispatchAction,
|
|
494
|
+
* so SSE event handling and permission UI are not blocked.
|
|
495
|
+
*
|
|
496
|
+
* The closure captures Discord objects (Message, ThreadChannel) so the
|
|
497
|
+
* runtime stays platform-agnostic — it just awaits the callback.
|
|
498
|
+
*/
|
|
499
|
+
preprocess?: () => Promise<PreprocessResult>
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// Rewrite `{ prompt: "/build foo" }` → `{ prompt: "", command: { name, arguments }, mode: "local-queue" }`
|
|
503
|
+
// when the prompt's leading token matches a registered opencode command.
|
|
504
|
+
// Skip if a command is already set or there's no prompt to inspect.
|
|
505
|
+
function maybeConvertLeadingCommand(input: IngressInput): IngressInput {
|
|
506
|
+
if (input.command) return input
|
|
507
|
+
if (!input.prompt) return input
|
|
508
|
+
const extracted = extractLeadingOpencodeCommand(input.prompt)
|
|
509
|
+
if (!extracted) return input
|
|
510
|
+
return {
|
|
511
|
+
...input,
|
|
512
|
+
prompt: '',
|
|
513
|
+
command: extracted.command,
|
|
514
|
+
mode: 'local-queue',
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
type AbortRunOutcome = {
|
|
519
|
+
abortId: string
|
|
520
|
+
reason: string
|
|
521
|
+
apiAbortPromise: Promise<void> | undefined
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function getWorktreePromptKey(worktree: WorktreeInfo | undefined): string | null {
|
|
525
|
+
if (!worktree) {
|
|
526
|
+
return null
|
|
527
|
+
}
|
|
528
|
+
return [
|
|
529
|
+
worktree.worktreeDirectory,
|
|
530
|
+
worktree.branch,
|
|
531
|
+
worktree.mainRepoDirectory,
|
|
532
|
+
].join('::')
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
// ── Runtime class ────────────────────────────────────────────────
|
|
537
|
+
|
|
538
|
+
export class ThreadSessionRuntime {
|
|
539
|
+
readonly threadId: string
|
|
540
|
+
readonly projectDirectory: string
|
|
541
|
+
// Mutable: worktree threads transition from pending (projectDirectory)
|
|
542
|
+
// to ready (worktree path) after creation. getOrCreateRuntime reconciles
|
|
543
|
+
// this on each call so dispatch always uses the current path.
|
|
544
|
+
sdkDirectory: string
|
|
545
|
+
readonly channelId: string | undefined
|
|
546
|
+
readonly appId: string | undefined
|
|
547
|
+
readonly thread: ThreadChannel
|
|
548
|
+
|
|
549
|
+
// ── Resource handles (mechanisms, not domain state) ──
|
|
550
|
+
|
|
551
|
+
// Reentrancy guard for startEventListener (not domain state —
|
|
552
|
+
// just prevents calling the async loop twice).
|
|
553
|
+
private listenerLoopRunning = false
|
|
554
|
+
|
|
555
|
+
// Set to true by dispose(). Guards against queued work running after cleanup
|
|
556
|
+
// and lets dispatchAction/startEventListener bail out early.
|
|
557
|
+
private disposed = false
|
|
558
|
+
|
|
559
|
+
// Typing indicator scheduler handles.
|
|
560
|
+
// `typingKeepaliveTimeout` is the 7s keepalive loop while a run stays busy.
|
|
561
|
+
// `typingRepulseDebounce` collapses clustered immediate re-pulses after bot
|
|
562
|
+
// messages into one last pulse, because Discord hides typing on the next bot
|
|
563
|
+
// message and showing multiple back-to-back POSTs is wasteful.
|
|
564
|
+
private typingKeepaliveTimeout: ReturnType<typeof setTimeout> | null = null
|
|
565
|
+
private readonly typingRepulseDebounce: ReturnType<typeof createDebouncedTimeout>
|
|
566
|
+
|
|
567
|
+
private static TYPING_REPULSE_DEBOUNCE_MS = 500
|
|
568
|
+
|
|
569
|
+
// Notification throttles for retry/context notices.
|
|
570
|
+
private lastDisplayedContextPercentage = 0
|
|
571
|
+
private lastRateLimitDisplayTime = 0
|
|
572
|
+
|
|
573
|
+
// Last OpenCode-generated session title we successfully applied to the
|
|
574
|
+
// Discord thread name. Used to dedupe repeated session.updated events so
|
|
575
|
+
// we only call thread.setName() once per distinct title. Discord rate-limits
|
|
576
|
+
// channel/thread renames to ~2 per 10 minutes per thread, so we must avoid
|
|
577
|
+
// retrying. Not persisted — worst case on restart we re-apply the same title
|
|
578
|
+
// once (which is a no-op via deriveThreadNameFromSessionTitle).
|
|
579
|
+
private appliedOpencodeTitle: string | undefined
|
|
580
|
+
|
|
581
|
+
// Part output buffering (write-side cache, not domain state)
|
|
582
|
+
private partBuffer = new Map<string, Map<string, Part>>()
|
|
583
|
+
|
|
584
|
+
// Derivable cache (perf optimization for provider.list API call)
|
|
585
|
+
private modelContextLimit: number | undefined
|
|
586
|
+
private modelContextLimitKey: string | undefined
|
|
587
|
+
private lastPromptWorktreeKey: string | null | undefined
|
|
588
|
+
|
|
589
|
+
// Bounded buffer of recent SSE events with timestamps.
|
|
590
|
+
// Used by waitForEvent() to scan for specific events that arrived
|
|
591
|
+
// after a given point in time (e.g. wait for session.idle after abort).
|
|
592
|
+
// Generic: any future "wait for X event" can reuse this buffer.
|
|
593
|
+
private static EVENT_BUFFER_MAX = 1000
|
|
594
|
+
private static EVENT_BUFFER_DB_FLUSH_MS = 2_000
|
|
595
|
+
private static EVENT_BUFFER_TEXT_MAX_CHARS = 512
|
|
596
|
+
private eventBuffer: EventBufferEntry[] = []
|
|
597
|
+
private nextEventIndex = 0
|
|
598
|
+
private persistEventBufferDebounced: ReturnType<
|
|
599
|
+
typeof createDebouncedProcessFlush
|
|
600
|
+
>
|
|
601
|
+
|
|
602
|
+
// Serialized action queue for per-thread runtime transitions.
|
|
603
|
+
// Ingress and event handling both flow through this queue to keep ordering
|
|
604
|
+
// deterministic and avoid interleaving shared mutable structures.
|
|
605
|
+
private actionQueue: Array<() => Promise<void>> = []
|
|
606
|
+
private processingAction = false
|
|
607
|
+
|
|
608
|
+
// Lightweight promise chain for serializing preprocess callbacks.
|
|
609
|
+
// Runs OUTSIDE dispatchAction so heavy work (voice transcription, context
|
|
610
|
+
// fetch, attachment download) doesn't block SSE event handling, permission
|
|
611
|
+
// UI, or queue drain. Only preprocess ordering is serialized here; the
|
|
612
|
+
// resolved input is then routed through the normal enqueue paths which
|
|
613
|
+
// use dispatchAction internally.
|
|
614
|
+
private preprocessChain: Promise<void> = Promise.resolve()
|
|
615
|
+
|
|
616
|
+
constructor(opts: RuntimeOptions) {
|
|
617
|
+
this.threadId = opts.threadId
|
|
618
|
+
this.projectDirectory = opts.projectDirectory
|
|
619
|
+
this.sdkDirectory = opts.sdkDirectory
|
|
620
|
+
this.channelId = opts.channelId
|
|
621
|
+
this.appId = opts.appId
|
|
622
|
+
this.thread = opts.thread
|
|
623
|
+
threadState.updateThread(this.threadId, (t) => ({
|
|
624
|
+
...t,
|
|
625
|
+
listenerController: new AbortController(),
|
|
626
|
+
}))
|
|
627
|
+
this.persistEventBufferDebounced = createDebouncedProcessFlush({
|
|
628
|
+
waitMs: ThreadSessionRuntime.EVENT_BUFFER_DB_FLUSH_MS,
|
|
629
|
+
callback: async () => {
|
|
630
|
+
await this.persistSessionEventsToDatabase()
|
|
631
|
+
},
|
|
632
|
+
onError: (error) => {
|
|
633
|
+
logger.error(
|
|
634
|
+
`[SESSION EVENT DB] Debounced persistence failed for thread ${this.threadId}:`,
|
|
635
|
+
error,
|
|
636
|
+
)
|
|
637
|
+
},
|
|
638
|
+
})
|
|
639
|
+
this.typingRepulseDebounce = createDebouncedTimeout({
|
|
640
|
+
delayMs: ThreadSessionRuntime.TYPING_REPULSE_DEBOUNCE_MS,
|
|
641
|
+
callback: () => {
|
|
642
|
+
if (!this.shouldTypeNow()) {
|
|
643
|
+
return
|
|
644
|
+
}
|
|
645
|
+
this.restartTypingKeepalive({ sendNow: true })
|
|
646
|
+
},
|
|
647
|
+
})
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
private consumeWorktreePromptChange(
|
|
651
|
+
worktree: WorktreeInfo | undefined,
|
|
652
|
+
): boolean {
|
|
653
|
+
const nextKey = getWorktreePromptKey(worktree)
|
|
654
|
+
const changed = this.lastPromptWorktreeKey !== nextKey
|
|
655
|
+
this.lastPromptWorktreeKey = nextKey
|
|
656
|
+
return changed
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// Read own state from global store
|
|
660
|
+
get state(): threadState.ThreadRunState | undefined {
|
|
661
|
+
return threadState.getThreadState(this.threadId)
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
getDerivedPhase(): 'idle' | 'running' {
|
|
665
|
+
return this.isMainSessionBusy() ? 'running' : 'idle'
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/** Whether the listener has been disposed. */
|
|
669
|
+
private get listenerAborted(): boolean {
|
|
670
|
+
return this.state?.listenerController?.signal.aborted ?? true
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/** The listener AbortSignal, used to pass to SDK subscribe calls. */
|
|
674
|
+
private get listenerSignal(): AbortSignal | undefined {
|
|
675
|
+
return this.state?.listenerController?.signal
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
private getLastRuntimeActivityTimestamp({
|
|
679
|
+
nowMs: _nowMs,
|
|
680
|
+
}: {
|
|
681
|
+
nowMs: number
|
|
682
|
+
}): number {
|
|
683
|
+
const lastEvent = this.eventBuffer[this.eventBuffer.length - 1]
|
|
684
|
+
const lastEventTimestamp = lastEvent?.timestamp
|
|
685
|
+
if (typeof lastEventTimestamp === 'number' && Number.isFinite(lastEventTimestamp)) {
|
|
686
|
+
return lastEventTimestamp
|
|
687
|
+
}
|
|
688
|
+
const threadCreatedTimestamp = this.thread.createdTimestamp
|
|
689
|
+
if (
|
|
690
|
+
typeof threadCreatedTimestamp === 'number'
|
|
691
|
+
&& Number.isFinite(threadCreatedTimestamp)
|
|
692
|
+
&& threadCreatedTimestamp > 0
|
|
693
|
+
) {
|
|
694
|
+
return threadCreatedTimestamp
|
|
695
|
+
}
|
|
696
|
+
const snowflakeTimestamp = getTimestampFromSnowflake(this.thread.id)
|
|
697
|
+
if (snowflakeTimestamp) {
|
|
698
|
+
return snowflakeTimestamp
|
|
699
|
+
}
|
|
700
|
+
return 0
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
private isIdleCandidateForInactivityCheck(): boolean {
|
|
704
|
+
if (this.isMainSessionBusy()) {
|
|
705
|
+
return false
|
|
706
|
+
}
|
|
707
|
+
if ((this.state?.queueItems.length ?? 0) > 0) {
|
|
708
|
+
return false
|
|
709
|
+
}
|
|
710
|
+
if (this.hasPendingInteractiveUi()) {
|
|
711
|
+
return false
|
|
712
|
+
}
|
|
713
|
+
if (this.processingAction || this.actionQueue.length > 0) {
|
|
714
|
+
return false
|
|
715
|
+
}
|
|
716
|
+
return true
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
getInactivitySnapshot({
|
|
720
|
+
nowMs,
|
|
721
|
+
}: {
|
|
722
|
+
nowMs: number
|
|
723
|
+
}): {
|
|
724
|
+
idleCandidate: boolean
|
|
725
|
+
inactiveForMs: number
|
|
726
|
+
} {
|
|
727
|
+
const lastActivityTimestamp = this.getLastRuntimeActivityTimestamp({ nowMs })
|
|
728
|
+
return {
|
|
729
|
+
idleCandidate: this.isIdleCandidateForInactivityCheck(),
|
|
730
|
+
inactiveForMs: Math.max(0, nowMs - lastActivityTimestamp),
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
isIdleForInactivityTimeout({
|
|
735
|
+
idleMs,
|
|
736
|
+
nowMs,
|
|
737
|
+
}: {
|
|
738
|
+
idleMs: number
|
|
739
|
+
nowMs: number
|
|
740
|
+
}): boolean {
|
|
741
|
+
const snapshot = this.getInactivitySnapshot({ nowMs })
|
|
742
|
+
if (!snapshot.idleCandidate) {
|
|
743
|
+
return false
|
|
744
|
+
}
|
|
745
|
+
return snapshot.inactiveForMs >= idleMs
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
private async hydrateSessionEventsFromDatabase({
|
|
749
|
+
sessionId,
|
|
750
|
+
}: {
|
|
751
|
+
sessionId: string
|
|
752
|
+
}): Promise<void> {
|
|
753
|
+
if (this.eventBuffer.length > 0) {
|
|
754
|
+
return
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
const rows = await getSessionEventSnapshot({ sessionId })
|
|
758
|
+
if (rows.length === 0) {
|
|
759
|
+
return
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
const hydratedEvents: EventBufferEntry[] = rows.flatMap((row) => {
|
|
763
|
+
const eventResult = errore.try({
|
|
764
|
+
try: () => {
|
|
765
|
+
return JSON.parse(row.event_json) as OpenCodeEvent
|
|
766
|
+
},
|
|
767
|
+
catch: (error) => {
|
|
768
|
+
return new Error('Failed to parse persisted session event JSON', {
|
|
769
|
+
cause: error,
|
|
770
|
+
})
|
|
771
|
+
},
|
|
772
|
+
})
|
|
773
|
+
if (eventResult instanceof Error) {
|
|
774
|
+
logger.warn(
|
|
775
|
+
`[SESSION EVENT DB] Skipping invalid persisted event row for session ${sessionId}: ${eventResult.message}`,
|
|
776
|
+
)
|
|
777
|
+
return []
|
|
778
|
+
}
|
|
779
|
+
return [
|
|
780
|
+
{
|
|
781
|
+
event: eventResult,
|
|
782
|
+
timestamp: Number(row.timestamp),
|
|
783
|
+
eventIndex: Number(row.event_index),
|
|
784
|
+
},
|
|
785
|
+
]
|
|
786
|
+
})
|
|
787
|
+
|
|
788
|
+
this.eventBuffer = hydratedEvents.slice(-ThreadSessionRuntime.EVENT_BUFFER_MAX)
|
|
789
|
+
const lastHydratedEvent = this.eventBuffer[this.eventBuffer.length - 1]
|
|
790
|
+
this.nextEventIndex = lastHydratedEvent
|
|
791
|
+
? Number(lastHydratedEvent.eventIndex || 0) + 1
|
|
792
|
+
: 0
|
|
793
|
+
logger.log(
|
|
794
|
+
`[SESSION EVENT DB] Hydrated ${this.eventBuffer.length} events for session ${sessionId}`,
|
|
795
|
+
)
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
private async persistSessionEventsToDatabase(): Promise<void> {
|
|
799
|
+
const sessionId = this.state?.sessionId
|
|
800
|
+
if (!sessionId) {
|
|
801
|
+
return
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
const events = this.eventBuffer.flatMap((entry) => {
|
|
805
|
+
const eventSessionId = getOpencodeEventSessionId(entry.event)
|
|
806
|
+
if (eventSessionId !== sessionId) {
|
|
807
|
+
return []
|
|
808
|
+
}
|
|
809
|
+
return [
|
|
810
|
+
{
|
|
811
|
+
session_id: sessionId,
|
|
812
|
+
thread_id: this.threadId,
|
|
813
|
+
timestamp: BigInt(entry.timestamp),
|
|
814
|
+
event_index: entry.eventIndex || 0,
|
|
815
|
+
event_json: JSON.stringify(entry.event),
|
|
816
|
+
},
|
|
817
|
+
]
|
|
818
|
+
})
|
|
819
|
+
|
|
820
|
+
await appendSessionEventsSinceLastTimestamp({
|
|
821
|
+
sessionId,
|
|
822
|
+
events,
|
|
823
|
+
})
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
private nextAbortId(reason: string): string {
|
|
827
|
+
return `${reason}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
private formatRunStateForLog(): string {
|
|
831
|
+
const sessionId = this.state?.sessionId
|
|
832
|
+
if (!sessionId) {
|
|
833
|
+
return 'none'
|
|
834
|
+
}
|
|
835
|
+
const latestAssistant = this.getLatestAssistantMessageIdForCurrentTurn({
|
|
836
|
+
sessionId,
|
|
837
|
+
}) || 'none'
|
|
838
|
+
const assistantCount = this.getAssistantMessageIdsForCurrentTurn({
|
|
839
|
+
sessionId,
|
|
840
|
+
}).size
|
|
841
|
+
const phase = this.getDerivedPhase()
|
|
842
|
+
return `phase=${phase},assistant=${latestAssistant},assistantCount=${assistantCount}`
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
private isMainSessionBusy(): boolean {
|
|
846
|
+
const sessionId = this.state?.sessionId
|
|
847
|
+
if (!sessionId) {
|
|
848
|
+
return false
|
|
849
|
+
}
|
|
850
|
+
return isSessionBusy({ events: this.eventBuffer, sessionId })
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
private getAssistantMessageIdsForCurrentTurn({
|
|
854
|
+
sessionId,
|
|
855
|
+
upToIndex,
|
|
856
|
+
}: {
|
|
857
|
+
sessionId: string
|
|
858
|
+
upToIndex?: number
|
|
859
|
+
}): Set<string> {
|
|
860
|
+
const normalizedIndex = upToIndex === undefined ? undefined : upToIndex - 1
|
|
861
|
+
return getAssistantMessageIdsForLatestUserTurn({
|
|
862
|
+
events: this.eventBuffer,
|
|
863
|
+
sessionId,
|
|
864
|
+
upToIndex: normalizedIndex,
|
|
865
|
+
})
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
private getLatestAssistantMessageIdForCurrentTurn({
|
|
869
|
+
sessionId,
|
|
870
|
+
upToIndex,
|
|
871
|
+
}: {
|
|
872
|
+
sessionId: string
|
|
873
|
+
upToIndex?: number
|
|
874
|
+
}): string | undefined {
|
|
875
|
+
const normalizedIndex = upToIndex === undefined ? undefined : upToIndex - 1
|
|
876
|
+
return getLatestAssistantMessageIdForLatestUserTurn({
|
|
877
|
+
events: this.eventBuffer,
|
|
878
|
+
sessionId,
|
|
879
|
+
upToIndex: normalizedIndex,
|
|
880
|
+
})
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
private getSubtaskInfoForSession(
|
|
884
|
+
candidateSessionId: string,
|
|
885
|
+
): { label: string; assistantMessageId?: string } | undefined {
|
|
886
|
+
const mainSessionId = this.state?.sessionId
|
|
887
|
+
if (!mainSessionId || candidateSessionId === mainSessionId) {
|
|
888
|
+
return undefined
|
|
889
|
+
}
|
|
890
|
+
const subtaskIndex = getDerivedSubtaskIndex({
|
|
891
|
+
events: this.eventBuffer,
|
|
892
|
+
mainSessionId,
|
|
893
|
+
candidateSessionId,
|
|
894
|
+
})
|
|
895
|
+
if (!subtaskIndex) {
|
|
896
|
+
return undefined
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
const agentType = getDerivedSubtaskAgentType({
|
|
900
|
+
events: this.eventBuffer,
|
|
901
|
+
mainSessionId,
|
|
902
|
+
candidateSessionId,
|
|
903
|
+
})
|
|
904
|
+
const label = `${agentType || 'task'}-${subtaskIndex}`
|
|
905
|
+
const assistantMessageId = this.getLatestAssistantMessageIdForCurrentTurn({
|
|
906
|
+
sessionId: candidateSessionId,
|
|
907
|
+
})
|
|
908
|
+
return { label, assistantMessageId }
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
// ── Lifecycle ────────────────────────────────────────────────
|
|
912
|
+
|
|
913
|
+
dispose(): void {
|
|
914
|
+
this.disposed = true
|
|
915
|
+
this.state?.listenerController?.abort()
|
|
916
|
+
// waitForEvent loops check listenerAborted and exit naturally.
|
|
917
|
+
threadState.updateThread(this.threadId, (t) => ({
|
|
918
|
+
...t,
|
|
919
|
+
listenerController: undefined,
|
|
920
|
+
}))
|
|
921
|
+
void this.persistEventBufferDebounced.dispose()
|
|
922
|
+
this.stopTyping()
|
|
923
|
+
|
|
924
|
+
// Release large internal buffers so GC can reclaim memory immediately
|
|
925
|
+
// instead of waiting for the runtime object itself to become unreachable.
|
|
926
|
+
this.eventBuffer = []
|
|
927
|
+
this.nextEventIndex = 0
|
|
928
|
+
this.partBuffer.clear()
|
|
929
|
+
this.preprocessChain = Promise.resolve()
|
|
930
|
+
|
|
931
|
+
// Don't clear actionQueue here — queued closures own resolve/reject for
|
|
932
|
+
// dispatchAction() promises. Dropping them would leave awaiting callers
|
|
933
|
+
// hanging forever. Instead, drain them: each closure checks this.disposed
|
|
934
|
+
// and resolves early without executing real work.
|
|
935
|
+
void this.processActionQueue()
|
|
936
|
+
|
|
937
|
+
// Clean up all pending UI state for this thread (permissions, questions,
|
|
938
|
+
// action buttons, file uploads, html actions).
|
|
939
|
+
cleanupPendingUiForThread(this.thread.id)
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
// Called when sdkDirectory changes (e.g. worktree becomes ready after
|
|
943
|
+
// /new-worktree in an existing thread). The event listener was subscribed
|
|
944
|
+
// to the old directory's Instance in opencode — events from the new
|
|
945
|
+
// directory's Instance won't reach it. We must reconnect the listener
|
|
946
|
+
// and clear the old session so ensureSession creates a fresh one under
|
|
947
|
+
// the new Instance.
|
|
948
|
+
handleDirectoryChanged({
|
|
949
|
+
oldDirectory,
|
|
950
|
+
newDirectory,
|
|
951
|
+
}: {
|
|
952
|
+
oldDirectory: string
|
|
953
|
+
newDirectory: string
|
|
954
|
+
}): void {
|
|
955
|
+
logger.log(
|
|
956
|
+
`[LISTENER] sdkDirectory changed for thread ${this.threadId}: ${oldDirectory} → ${newDirectory}`,
|
|
957
|
+
)
|
|
958
|
+
this.sdkDirectory = newDirectory
|
|
959
|
+
|
|
960
|
+
// Clear cached session — it was created under the old directory's
|
|
961
|
+
// opencode Instance and can't be reused from the new one.
|
|
962
|
+
threadState.updateThread(this.threadId, (t) => ({
|
|
963
|
+
...t,
|
|
964
|
+
sessionId: undefined,
|
|
965
|
+
}))
|
|
966
|
+
|
|
967
|
+
// Restart event listener to subscribe under the new directory.
|
|
968
|
+
const currentController = this.state?.listenerController
|
|
969
|
+
if (currentController) {
|
|
970
|
+
currentController.abort(new Error('sdkDirectory changed'))
|
|
971
|
+
threadState.updateThread(this.threadId, (t) => ({
|
|
972
|
+
...t,
|
|
973
|
+
listenerController: new AbortController(),
|
|
974
|
+
}))
|
|
975
|
+
this.listenerLoopRunning = false
|
|
976
|
+
void this.startEventListener()
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
handleSharedServerStarted({
|
|
981
|
+
port,
|
|
982
|
+
}: {
|
|
983
|
+
port: number
|
|
984
|
+
}): void {
|
|
985
|
+
const currentController = this.state?.listenerController
|
|
986
|
+
if (!currentController) {
|
|
987
|
+
return
|
|
988
|
+
}
|
|
989
|
+
logger.log(
|
|
990
|
+
`[LISTENER] Refreshing listener for thread ${this.threadId} after shared server start on port ${port}`,
|
|
991
|
+
)
|
|
992
|
+
currentController.abort(new Error('Shared OpenCode server restarted'))
|
|
993
|
+
threadState.updateThread(this.threadId, (t) => ({
|
|
994
|
+
...t,
|
|
995
|
+
listenerController: new AbortController(),
|
|
996
|
+
}))
|
|
997
|
+
this.listenerLoopRunning = false
|
|
998
|
+
void this.startEventListener()
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
private compactTextForEventBuffer(text: string): string {
|
|
1002
|
+
if (text.length <= ThreadSessionRuntime.EVENT_BUFFER_TEXT_MAX_CHARS) {
|
|
1003
|
+
return text
|
|
1004
|
+
}
|
|
1005
|
+
return `${text.slice(0, ThreadSessionRuntime.EVENT_BUFFER_TEXT_MAX_CHARS)}…`
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
private isDefinedEventBufferValue<T>(value: T | undefined): value is T {
|
|
1009
|
+
return value !== undefined
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
private pruneLargeStringsForEventBuffer(
|
|
1013
|
+
value: unknown,
|
|
1014
|
+
seen: WeakSet<object>,
|
|
1015
|
+
): void {
|
|
1016
|
+
if (typeof value !== 'object' || value === null) {
|
|
1017
|
+
return
|
|
1018
|
+
}
|
|
1019
|
+
if (seen.has(value)) {
|
|
1020
|
+
return
|
|
1021
|
+
}
|
|
1022
|
+
seen.add(value)
|
|
1023
|
+
|
|
1024
|
+
if (Array.isArray(value)) {
|
|
1025
|
+
const compactedItems = value
|
|
1026
|
+
.map((item) => {
|
|
1027
|
+
if (typeof item === 'string') {
|
|
1028
|
+
if (item.length > ThreadSessionRuntime.EVENT_BUFFER_TEXT_MAX_CHARS) {
|
|
1029
|
+
return undefined
|
|
1030
|
+
}
|
|
1031
|
+
return item
|
|
1032
|
+
}
|
|
1033
|
+
this.pruneLargeStringsForEventBuffer(item, seen)
|
|
1034
|
+
return item
|
|
1035
|
+
})
|
|
1036
|
+
.filter((item) => {
|
|
1037
|
+
return this.isDefinedEventBufferValue(item)
|
|
1038
|
+
})
|
|
1039
|
+
value.splice(0, value.length, ...compactedItems)
|
|
1040
|
+
return
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
const objectValue = value as Record<string, unknown>
|
|
1044
|
+
for (const [key, nestedValue] of Object.entries(objectValue)) {
|
|
1045
|
+
if (typeof nestedValue === 'string') {
|
|
1046
|
+
if (nestedValue.length > ThreadSessionRuntime.EVENT_BUFFER_TEXT_MAX_CHARS) {
|
|
1047
|
+
delete objectValue[key]
|
|
1048
|
+
}
|
|
1049
|
+
continue
|
|
1050
|
+
}
|
|
1051
|
+
this.pruneLargeStringsForEventBuffer(nestedValue, seen)
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
private finalizeCompactedEventForEventBuffer(
|
|
1056
|
+
event: OpenCodeEvent,
|
|
1057
|
+
): OpenCodeEvent {
|
|
1058
|
+
this.pruneLargeStringsForEventBuffer(event, new WeakSet<object>())
|
|
1059
|
+
return event
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
private compactEventForEventBuffer(
|
|
1063
|
+
event: OpenCodeEvent,
|
|
1064
|
+
): OpenCodeEvent | undefined {
|
|
1065
|
+
if (event.type === 'session.diff') {
|
|
1066
|
+
return undefined
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
const compacted = structuredClone(event)
|
|
1070
|
+
|
|
1071
|
+
if (compacted.type === 'message.updated') {
|
|
1072
|
+
// Strip heavy fields from ALL roles. Derivation only needs lightweight
|
|
1073
|
+
// metadata (id, role, sessionID, parentID, time, finish, error, modelID,
|
|
1074
|
+
// providerID, mode, tokens). The parts array on assistant messages grows
|
|
1075
|
+
// with every tool call and was the primary OOM vector — 1000 buffer entries
|
|
1076
|
+
// each carrying the full cumulative parts array reached 4GB+.
|
|
1077
|
+
const info = compacted.properties.info as Record<string, unknown>
|
|
1078
|
+
const partsSummary = Array.isArray(info.parts)
|
|
1079
|
+
? info.parts.flatMap((part) => {
|
|
1080
|
+
if (!part || typeof part !== 'object') {
|
|
1081
|
+
return [] as Array<{ id: string; type: string }>
|
|
1082
|
+
}
|
|
1083
|
+
const candidate = part as { id?: unknown; type?: unknown }
|
|
1084
|
+
if (
|
|
1085
|
+
typeof candidate.id !== 'string'
|
|
1086
|
+
|| typeof candidate.type !== 'string'
|
|
1087
|
+
) {
|
|
1088
|
+
return [] as Array<{ id: string; type: string }>
|
|
1089
|
+
}
|
|
1090
|
+
return [{ id: candidate.id, type: candidate.type }]
|
|
1091
|
+
})
|
|
1092
|
+
: []
|
|
1093
|
+
delete info.system
|
|
1094
|
+
delete info.summary
|
|
1095
|
+
delete info.tools
|
|
1096
|
+
delete info.parts
|
|
1097
|
+
if (partsSummary.length > 0) {
|
|
1098
|
+
info.partsSummary = partsSummary
|
|
1099
|
+
}
|
|
1100
|
+
return this.finalizeCompactedEventForEventBuffer(compacted)
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
if (compacted.type !== 'message.part.updated') {
|
|
1104
|
+
return this.finalizeCompactedEventForEventBuffer(compacted)
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
const part = compacted.properties.part
|
|
1108
|
+
|
|
1109
|
+
if (part.type === 'text') {
|
|
1110
|
+
part.text = this.compactTextForEventBuffer(part.text)
|
|
1111
|
+
return this.finalizeCompactedEventForEventBuffer(compacted)
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
if (part.type === 'reasoning') {
|
|
1115
|
+
part.text = this.compactTextForEventBuffer(part.text)
|
|
1116
|
+
return this.finalizeCompactedEventForEventBuffer(compacted)
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
if (part.type === 'snapshot') {
|
|
1120
|
+
part.snapshot = this.compactTextForEventBuffer(part.snapshot)
|
|
1121
|
+
return this.finalizeCompactedEventForEventBuffer(compacted)
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
if (part.type === 'step-start' && part.snapshot) {
|
|
1125
|
+
part.snapshot = this.compactTextForEventBuffer(part.snapshot)
|
|
1126
|
+
return this.finalizeCompactedEventForEventBuffer(compacted)
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
if (part.type !== 'tool') {
|
|
1130
|
+
return this.finalizeCompactedEventForEventBuffer(compacted)
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
const state = part.state
|
|
1134
|
+
// Preserve subagent_type for task tools so derivation can build labels
|
|
1135
|
+
// like "explore-1" instead of generic "task-1" after compaction strips input
|
|
1136
|
+
const taskSubagentType =
|
|
1137
|
+
part.tool === 'task' ? state.input?.subagent_type : undefined
|
|
1138
|
+
state.input = {}
|
|
1139
|
+
if (typeof taskSubagentType === 'string') {
|
|
1140
|
+
state.input.subagent_type = taskSubagentType
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
if (state.status === 'pending') {
|
|
1144
|
+
state.raw = this.compactTextForEventBuffer(state.raw)
|
|
1145
|
+
return this.finalizeCompactedEventForEventBuffer(compacted)
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
if (state.status === 'running') {
|
|
1149
|
+
return this.finalizeCompactedEventForEventBuffer(compacted)
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
if (state.status === 'completed') {
|
|
1153
|
+
state.output = this.compactTextForEventBuffer(state.output)
|
|
1154
|
+
delete state.attachments
|
|
1155
|
+
return this.finalizeCompactedEventForEventBuffer(compacted)
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
if (state.status === 'error') {
|
|
1159
|
+
state.error = this.compactTextForEventBuffer(state.error)
|
|
1160
|
+
return this.finalizeCompactedEventForEventBuffer(compacted)
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
return this.finalizeCompactedEventForEventBuffer(compacted)
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
private appendEventToBuffer(event: OpenCodeEvent): void {
|
|
1167
|
+
const compactedEvent = this.compactEventForEventBuffer(event)
|
|
1168
|
+
if (!compactedEvent) {
|
|
1169
|
+
return
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
const timestamp = Date.now()
|
|
1173
|
+
const eventIndex = this.nextEventIndex
|
|
1174
|
+
this.nextEventIndex += 1
|
|
1175
|
+
this.eventBuffer.push({
|
|
1176
|
+
event: compactedEvent,
|
|
1177
|
+
timestamp,
|
|
1178
|
+
eventIndex,
|
|
1179
|
+
})
|
|
1180
|
+
if (this.eventBuffer.length > ThreadSessionRuntime.EVENT_BUFFER_MAX) {
|
|
1181
|
+
this.eventBuffer.splice(0, this.eventBuffer.length - ThreadSessionRuntime.EVENT_BUFFER_MAX)
|
|
1182
|
+
}
|
|
1183
|
+
this.persistEventBufferDebounced.trigger()
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
// Queue-dispatch lifecycle markers are synthetic buffer-only events.
|
|
1187
|
+
// They are not fed into handleEvent(), so they do not emit Discord messages;
|
|
1188
|
+
// they only stabilize event-derived busy/idle gating for local queue drains.
|
|
1189
|
+
private markQueueDispatchBusy(sessionId: string): void {
|
|
1190
|
+
this.appendEventToBuffer({
|
|
1191
|
+
type: 'session.status',
|
|
1192
|
+
properties: {
|
|
1193
|
+
sessionID: sessionId,
|
|
1194
|
+
status: { type: 'busy' },
|
|
1195
|
+
},
|
|
1196
|
+
})
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
private markQueueDispatchIdle(sessionId: string): void {
|
|
1200
|
+
this.appendEventToBuffer({
|
|
1201
|
+
type: 'session.idle',
|
|
1202
|
+
properties: {
|
|
1203
|
+
sessionID: sessionId,
|
|
1204
|
+
},
|
|
1205
|
+
})
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
/**
|
|
1209
|
+
* Generic event waiter: polls the event buffer until a matching event
|
|
1210
|
+
* appears (with timestamp >= sinceTimestamp), or timeout/abort.
|
|
1211
|
+
*
|
|
1212
|
+
* Unlike the old idleWaiter (a promise wired into handleSessionIdle),
|
|
1213
|
+
* this has zero coupling to specific event handlers — it just scans
|
|
1214
|
+
* the buffer that handleEvent() fills. Works for any event type.
|
|
1215
|
+
*/
|
|
1216
|
+
private async waitForEvent(opts: {
|
|
1217
|
+
predicate: (event: OpenCodeEvent) => boolean
|
|
1218
|
+
sinceTimestamp: number
|
|
1219
|
+
timeoutMs: number
|
|
1220
|
+
pollMs?: number
|
|
1221
|
+
}): Promise<OpenCodeEvent | undefined> {
|
|
1222
|
+
const { predicate, sinceTimestamp, timeoutMs, pollMs = 50 } = opts
|
|
1223
|
+
const deadline = Date.now() + timeoutMs
|
|
1224
|
+
|
|
1225
|
+
while (Date.now() < deadline) {
|
|
1226
|
+
if (this.listenerAborted) {
|
|
1227
|
+
return undefined
|
|
1228
|
+
}
|
|
1229
|
+
const match = this.eventBuffer.find((entry) => {
|
|
1230
|
+
return entry.timestamp >= sinceTimestamp && predicate(entry.event)
|
|
1231
|
+
})
|
|
1232
|
+
if (match) {
|
|
1233
|
+
return match.event
|
|
1234
|
+
}
|
|
1235
|
+
await delay(pollMs)
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
logger.warn(
|
|
1239
|
+
`[WAIT EVENT] Timeout after ${timeoutMs}ms for thread ${this.threadId}, proceeding`,
|
|
1240
|
+
)
|
|
1241
|
+
return undefined
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
// Seed sentPartIds from DB to avoid re-sending parts that were
|
|
1245
|
+
// already sent in a previous runtime or before a reconnect.
|
|
1246
|
+
private async bootstrapSentPartIds(): Promise<void> {
|
|
1247
|
+
const existingPartIds = await getPartMessageIds(this.thread.id)
|
|
1248
|
+
if (existingPartIds.length === 0) {
|
|
1249
|
+
return
|
|
1250
|
+
}
|
|
1251
|
+
threadState.updateThread(this.threadId, (t) => {
|
|
1252
|
+
const newIds = new Set(t.sentPartIds)
|
|
1253
|
+
for (const id of existingPartIds) {
|
|
1254
|
+
newIds.add(id)
|
|
1255
|
+
}
|
|
1256
|
+
return { ...t, sentPartIds: newIds }
|
|
1257
|
+
})
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
// ── Event Listener Loop (§7.3) ──────────────────────────────
|
|
1261
|
+
// Persistent event.subscribe loop with exponential backoff.
|
|
1262
|
+
// Reconnects automatically on transient disconnects.
|
|
1263
|
+
// Only killed when listenerController is aborted (dispose/fatal).
|
|
1264
|
+
// Run abort never affects this loop.
|
|
1265
|
+
|
|
1266
|
+
async startEventListener(): Promise<void> {
|
|
1267
|
+
if (this.listenerLoopRunning || this.disposed) {
|
|
1268
|
+
return
|
|
1269
|
+
}
|
|
1270
|
+
this.listenerLoopRunning = true
|
|
1271
|
+
|
|
1272
|
+
// Bootstrap sentPartIds from DB so we don't re-send parts that
|
|
1273
|
+
// were already sent in a previous runtime or before a reconnect.
|
|
1274
|
+
await this.bootstrapSentPartIds()
|
|
1275
|
+
|
|
1276
|
+
let backoffMs = 500
|
|
1277
|
+
const maxBackoffMs = 30_000
|
|
1278
|
+
|
|
1279
|
+
while (!this.listenerAborted) {
|
|
1280
|
+
const signal = this.listenerSignal
|
|
1281
|
+
if (!signal) {
|
|
1282
|
+
return // disposed before we could subscribe
|
|
1283
|
+
}
|
|
1284
|
+
const client = getOpencodeClient(this.projectDirectory)
|
|
1285
|
+
if (!client) {
|
|
1286
|
+
// This is expected during shared-server transitions: the listener can
|
|
1287
|
+
// outlive the current opencode process across cold start, explicit
|
|
1288
|
+
// restart, shutdown, or crash recovery. stopOpencodeServer()/exit clears
|
|
1289
|
+
// the cached per-directory clients immediately, so existing runtimes may
|
|
1290
|
+
// observe a brief no-client window before initialize/restart publishes
|
|
1291
|
+
// the next shared server and repopulates the client cache.
|
|
1292
|
+
logger.warn(
|
|
1293
|
+
`[LISTENER] No OpenCode client for thread ${this.threadId}, retrying in ${backoffMs}ms`,
|
|
1294
|
+
)
|
|
1295
|
+
await delay(backoffMs)
|
|
1296
|
+
backoffMs = Math.min(backoffMs * 2, maxBackoffMs)
|
|
1297
|
+
continue
|
|
1298
|
+
}
|
|
1299
|
+
const subscribeResult = await errore.tryAsync(() => {
|
|
1300
|
+
return client.event.subscribe(
|
|
1301
|
+
{ directory: this.sdkDirectory },
|
|
1302
|
+
{ signal },
|
|
1303
|
+
)
|
|
1304
|
+
})
|
|
1305
|
+
|
|
1306
|
+
if (subscribeResult instanceof Error) {
|
|
1307
|
+
if (isAbortError(subscribeResult)) {
|
|
1308
|
+
return // disposed
|
|
1309
|
+
}
|
|
1310
|
+
const subscribeError: Error = subscribeResult
|
|
1311
|
+
logger.warn(
|
|
1312
|
+
`[LISTENER] Subscribe failed for thread ${this.threadId}, retrying in ${backoffMs}ms:`,
|
|
1313
|
+
subscribeError.message,
|
|
1314
|
+
)
|
|
1315
|
+
await delay(backoffMs)
|
|
1316
|
+
backoffMs = Math.min(backoffMs * 2, maxBackoffMs)
|
|
1317
|
+
continue
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
// Reset backoff on successful connection
|
|
1321
|
+
backoffMs = 500
|
|
1322
|
+
const events = subscribeResult.stream
|
|
1323
|
+
|
|
1324
|
+
logger.log(
|
|
1325
|
+
`[LISTENER] Connected to event stream for thread ${this.threadId}`,
|
|
1326
|
+
)
|
|
1327
|
+
|
|
1328
|
+
// Re-bootstrap sentPartIds on reconnect to prevent re-sending
|
|
1329
|
+
// parts that arrived while we were disconnected.
|
|
1330
|
+
await this.bootstrapSentPartIds()
|
|
1331
|
+
|
|
1332
|
+
const iterResult = await errore.tryAsync(async () => {
|
|
1333
|
+
for await (const event of events) {
|
|
1334
|
+
// Each event is dispatched through the serialized action queue
|
|
1335
|
+
// to prevent interleaving mutations from concurrent events.
|
|
1336
|
+
await this.dispatchAction(() => {
|
|
1337
|
+
return this.handleEvent(event)
|
|
1338
|
+
})
|
|
1339
|
+
}
|
|
1340
|
+
})
|
|
1341
|
+
|
|
1342
|
+
if (iterResult instanceof Error) {
|
|
1343
|
+
if (isAbortError(iterResult)) {
|
|
1344
|
+
return // disposed
|
|
1345
|
+
}
|
|
1346
|
+
const iterError: Error = iterResult
|
|
1347
|
+
logger.warn(
|
|
1348
|
+
`[LISTENER] Stream broke for thread ${this.threadId}, reconnecting in ${backoffMs}ms:`,
|
|
1349
|
+
iterError.message,
|
|
1350
|
+
)
|
|
1351
|
+
await delay(backoffMs)
|
|
1352
|
+
backoffMs = Math.min(backoffMs * 2, maxBackoffMs)
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
// ── Session Demux Guard ─────────────────────────────────────
|
|
1358
|
+
// Events scoped to a session must match the current session.
|
|
1359
|
+
// Global events (tui.toast.show) bypass the guard.
|
|
1360
|
+
// Subtask sessions also bypass — they're tracked in subtaskSessions.
|
|
1361
|
+
|
|
1362
|
+
private async handleEvent(event: OpenCodeEvent): Promise<void> {
|
|
1363
|
+
// session.diff can carry repeated full-file before/after snapshots and is
|
|
1364
|
+
// not used by event-derived runtime state, queueing, typing, or UI routing.
|
|
1365
|
+
// Drop it at ingress so large diff payloads never hit memory buffers.
|
|
1366
|
+
if (event.type === 'session.diff') {
|
|
1367
|
+
return
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
// Skip message.part.delta from the event buffer — no derivation function
|
|
1371
|
+
// (isSessionBusy, doesLatestUserTurnHaveNaturalCompletion, waitForEvent,
|
|
1372
|
+
// etc.) uses them. During long streaming responses they flood the 1000-slot
|
|
1373
|
+
// buffer, evicting session.status busy events that isSessionBusy needs,
|
|
1374
|
+
// causing tryDrainQueue to drain the local queue while the session is
|
|
1375
|
+
// actually still busy. This was the root cause of "? queue" messages
|
|
1376
|
+
// interrupting instead of queuing.
|
|
1377
|
+
if (event.type !== 'message.part.delta') {
|
|
1378
|
+
this.appendEventToBuffer(event)
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
const sessionId = this.state?.sessionId
|
|
1382
|
+
|
|
1383
|
+
const eventSessionId = getOpencodeEventSessionId(event)
|
|
1384
|
+
|
|
1385
|
+
if (shouldLogSessionEvents) {
|
|
1386
|
+
const eventDetails = (() => {
|
|
1387
|
+
if (event.type === 'session.error') {
|
|
1388
|
+
const errorName = event.properties.error?.name || 'unknown'
|
|
1389
|
+
return ` error=${errorName}`
|
|
1390
|
+
}
|
|
1391
|
+
if (event.type === 'session.status') {
|
|
1392
|
+
const status = event.properties.status || 'unknown'
|
|
1393
|
+
return ` status=${status}`
|
|
1394
|
+
}
|
|
1395
|
+
if (event.type === 'message.updated') {
|
|
1396
|
+
return ` role=${event.properties.info.role} messageID=${event.properties.info.id}`
|
|
1397
|
+
}
|
|
1398
|
+
if (event.type === 'message.part.updated') {
|
|
1399
|
+
const partType = event.properties.part.type
|
|
1400
|
+
const partId = event.properties.part.id
|
|
1401
|
+
const messageId = event.properties.part.messageID
|
|
1402
|
+
const toolSuffix = partType === 'tool'
|
|
1403
|
+
? ` tool=${event.properties.part.tool} status=${event.properties.part.state.status}`
|
|
1404
|
+
: ''
|
|
1405
|
+
return ` part=${partType} partID=${partId} messageID=${messageId}${toolSuffix}`
|
|
1406
|
+
}
|
|
1407
|
+
return ''
|
|
1408
|
+
})()
|
|
1409
|
+
logger.log(
|
|
1410
|
+
`[EVENT] type=${event.type} eventSessionId=${eventSessionId || 'none'} activeSessionId=${sessionId || 'none'} ${this.formatRunStateForLog()}${eventDetails}`,
|
|
1411
|
+
)
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
const isGlobalEvent = event.type === 'tui.toast.show'
|
|
1415
|
+
|
|
1416
|
+
// Drop events that don't match current session (stale events from
|
|
1417
|
+
// previous sessions), unless it's a global event or a subtask session.
|
|
1418
|
+
if (!isGlobalEvent && eventSessionId && eventSessionId !== sessionId) {
|
|
1419
|
+
if (!this.getSubtaskInfoForSession(eventSessionId)) {
|
|
1420
|
+
return // stale event from previous session
|
|
1421
|
+
}
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
if (isOpencodeSessionEventLogEnabled()) {
|
|
1425
|
+
const eventLogResult = await appendOpencodeSessionEventLog({
|
|
1426
|
+
threadId: this.threadId,
|
|
1427
|
+
projectDirectory: this.projectDirectory,
|
|
1428
|
+
event,
|
|
1429
|
+
})
|
|
1430
|
+
if (eventLogResult instanceof Error) {
|
|
1431
|
+
logger.error(
|
|
1432
|
+
'[SESSION EVENT JSONL] Failed to write session event log:',
|
|
1433
|
+
eventLogResult,
|
|
1434
|
+
)
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
switch (event.type) {
|
|
1439
|
+
case 'message.updated':
|
|
1440
|
+
await this.handleMessageUpdated(event.properties.info)
|
|
1441
|
+
break
|
|
1442
|
+
case 'message.part.updated':
|
|
1443
|
+
await this.handlePartUpdated(event.properties.part)
|
|
1444
|
+
break
|
|
1445
|
+
case 'session.idle':
|
|
1446
|
+
await this.handleSessionIdle(event.properties.sessionID)
|
|
1447
|
+
break
|
|
1448
|
+
case 'session.error':
|
|
1449
|
+
await this.handleSessionError(event.properties)
|
|
1450
|
+
break
|
|
1451
|
+
case 'permission.asked':
|
|
1452
|
+
await this.handlePermissionAsked(event.properties)
|
|
1453
|
+
break
|
|
1454
|
+
case 'permission.replied':
|
|
1455
|
+
this.handlePermissionReplied(event.properties)
|
|
1456
|
+
break
|
|
1457
|
+
case 'question.asked':
|
|
1458
|
+
await this.handleQuestionAsked(event.properties)
|
|
1459
|
+
break
|
|
1460
|
+
case 'question.replied':
|
|
1461
|
+
this.handleQuestionReplied(event.properties)
|
|
1462
|
+
break
|
|
1463
|
+
case 'session.status':
|
|
1464
|
+
await this.handleSessionStatus(event.properties)
|
|
1465
|
+
break
|
|
1466
|
+
case 'session.updated':
|
|
1467
|
+
await this.handleSessionUpdated(event.properties.info)
|
|
1468
|
+
break
|
|
1469
|
+
case 'tui.toast.show':
|
|
1470
|
+
await this.handleTuiToast(event.properties)
|
|
1471
|
+
break
|
|
1472
|
+
default:
|
|
1473
|
+
break
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
// ── Serialized Action Queue (§7.4) ──────────────────────────
|
|
1478
|
+
// Serializes event handling + local-queue state mutations.
|
|
1479
|
+
|
|
1480
|
+
async dispatchAction(action: () => Promise<void>): Promise<void> {
|
|
1481
|
+
if (this.disposed) {
|
|
1482
|
+
return
|
|
1483
|
+
}
|
|
1484
|
+
return new Promise<void>((resolve, reject) => {
|
|
1485
|
+
this.actionQueue.push(async () => {
|
|
1486
|
+
if (this.disposed) {
|
|
1487
|
+
resolve()
|
|
1488
|
+
return
|
|
1489
|
+
}
|
|
1490
|
+
const result = await errore.tryAsync(action)
|
|
1491
|
+
if (result instanceof Error) {
|
|
1492
|
+
reject(result)
|
|
1493
|
+
return
|
|
1494
|
+
}
|
|
1495
|
+
resolve()
|
|
1496
|
+
})
|
|
1497
|
+
void this.processActionQueue()
|
|
1498
|
+
})
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
// Process serialized action queue. Uses try/finally to guarantee
|
|
1502
|
+
// processingAction is always reset — if we didn't, a thrown action
|
|
1503
|
+
// would leave the flag true and deadlock all future actions.
|
|
1504
|
+
private async processActionQueue(): Promise<void> {
|
|
1505
|
+
if (this.processingAction) {
|
|
1506
|
+
return
|
|
1507
|
+
}
|
|
1508
|
+
this.processingAction = true
|
|
1509
|
+
try {
|
|
1510
|
+
while (this.actionQueue.length > 0) {
|
|
1511
|
+
const next = this.actionQueue.shift()
|
|
1512
|
+
if (!next) {
|
|
1513
|
+
continue
|
|
1514
|
+
}
|
|
1515
|
+
// Each queued action already wraps itself with errore.tryAsync
|
|
1516
|
+
// and calls resolve/reject, so this should not throw. But if it
|
|
1517
|
+
// does, the try/finally ensures we don't deadlock.
|
|
1518
|
+
const result = await errore.tryAsync(next)
|
|
1519
|
+
if (result instanceof Error) {
|
|
1520
|
+
logger.error('[ACTION QUEUE] Unexpected action failure:', result)
|
|
1521
|
+
}
|
|
1522
|
+
}
|
|
1523
|
+
} finally {
|
|
1524
|
+
this.processingAction = false
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
// ── Typing Indicator Management ─────────────────────────────
|
|
1529
|
+
|
|
1530
|
+
private hasPendingInteractiveUi(): boolean {
|
|
1531
|
+
const hasPendingQuestion = [...pendingQuestionContexts.values()].some(
|
|
1532
|
+
(ctx) => {
|
|
1533
|
+
return ctx.thread.id === this.thread.id
|
|
1534
|
+
},
|
|
1535
|
+
)
|
|
1536
|
+
if (hasPendingQuestion) {
|
|
1537
|
+
return true
|
|
1538
|
+
}
|
|
1539
|
+
const hasPendingActionButtons = [...pendingActionButtonContexts.values()].some(
|
|
1540
|
+
(ctx) => {
|
|
1541
|
+
return ctx.thread.id === this.thread.id
|
|
1542
|
+
},
|
|
1543
|
+
)
|
|
1544
|
+
if (hasPendingActionButtons) {
|
|
1545
|
+
return true
|
|
1546
|
+
}
|
|
1547
|
+
const hasPendingFileUpload = [...pendingFileUploadContexts.values()].some(
|
|
1548
|
+
(ctx) => {
|
|
1549
|
+
return ctx.thread.id === this.thread.id
|
|
1550
|
+
},
|
|
1551
|
+
)
|
|
1552
|
+
if (hasPendingFileUpload) {
|
|
1553
|
+
return true
|
|
1554
|
+
}
|
|
1555
|
+
return (pendingPermissions.get(this.thread.id)?.size ?? 0) > 0
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
onInteractiveUiStateChanged(): void {
|
|
1559
|
+
this.ensureTypingNow()
|
|
1560
|
+
void this.dispatchAction(() => {
|
|
1561
|
+
return this.tryDrainQueue({ showIndicator: true })
|
|
1562
|
+
})
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
private shouldTypeNow(): boolean {
|
|
1566
|
+
if (this.listenerAborted) {
|
|
1567
|
+
return false
|
|
1568
|
+
}
|
|
1569
|
+
if (this.hasPendingInteractiveUi()) {
|
|
1570
|
+
return false
|
|
1571
|
+
}
|
|
1572
|
+
const sessionId = this.state?.sessionId
|
|
1573
|
+
if (!sessionId) {
|
|
1574
|
+
return false
|
|
1575
|
+
}
|
|
1576
|
+
return isSessionBusy({ events: this.eventBuffer, sessionId })
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
private async sendTypingPulse(): Promise<void> {
|
|
1580
|
+
const result = await errore.tryAsync(() => {
|
|
1581
|
+
return this.thread.sendTyping()
|
|
1582
|
+
})
|
|
1583
|
+
if (result instanceof Error) {
|
|
1584
|
+
discordLogger.log(`Failed to send typing: ${result}`)
|
|
1585
|
+
}
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
private clearTypingKeepalive(): void {
|
|
1589
|
+
if (!this.typingKeepaliveTimeout) {
|
|
1590
|
+
return
|
|
1591
|
+
}
|
|
1592
|
+
clearTimeout(this.typingKeepaliveTimeout)
|
|
1593
|
+
this.typingKeepaliveTimeout = null
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
private armTypingKeepalive({
|
|
1597
|
+
delayMs,
|
|
1598
|
+
}: {
|
|
1599
|
+
delayMs: number
|
|
1600
|
+
}): void {
|
|
1601
|
+
this.typingKeepaliveTimeout = setTimeout(() => {
|
|
1602
|
+
const activeTimer = this.typingKeepaliveTimeout
|
|
1603
|
+
if (!activeTimer) {
|
|
1604
|
+
return
|
|
1605
|
+
}
|
|
1606
|
+
void (async () => {
|
|
1607
|
+
if (!this.shouldTypeNow()) {
|
|
1608
|
+
this.stopTyping()
|
|
1609
|
+
return
|
|
1610
|
+
}
|
|
1611
|
+
await this.sendTypingPulse()
|
|
1612
|
+
if (this.typingKeepaliveTimeout !== activeTimer) {
|
|
1613
|
+
return
|
|
1614
|
+
}
|
|
1615
|
+
if (!this.shouldTypeNow()) {
|
|
1616
|
+
this.stopTyping()
|
|
1617
|
+
return
|
|
1618
|
+
}
|
|
1619
|
+
this.armTypingKeepalive({ delayMs: 7000 })
|
|
1620
|
+
})()
|
|
1621
|
+
}, delayMs)
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
private restartTypingKeepalive({
|
|
1625
|
+
sendNow,
|
|
1626
|
+
}: {
|
|
1627
|
+
sendNow: boolean
|
|
1628
|
+
}): void {
|
|
1629
|
+
this.clearTypingKeepalive()
|
|
1630
|
+
this.armTypingKeepalive({ delayMs: sendNow ? 0 : 7000 })
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
private ensureTypingNow(): void {
|
|
1634
|
+
if (!this.shouldTypeNow()) {
|
|
1635
|
+
this.stopTyping()
|
|
1636
|
+
return
|
|
1637
|
+
}
|
|
1638
|
+
if (!this.typingKeepaliveTimeout && !this.typingRepulseDebounce.isPending()) {
|
|
1639
|
+
this.armTypingKeepalive({ delayMs: 0 })
|
|
1640
|
+
return
|
|
1641
|
+
}
|
|
1642
|
+
this.typingRepulseDebounce.trigger()
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
private ensureTypingKeepalive(): void {
|
|
1646
|
+
if (!this.shouldTypeNow()) {
|
|
1647
|
+
this.stopTyping()
|
|
1648
|
+
return
|
|
1649
|
+
}
|
|
1650
|
+
if (this.typingKeepaliveTimeout || this.typingRepulseDebounce.isPending()) {
|
|
1651
|
+
return
|
|
1652
|
+
}
|
|
1653
|
+
this.armTypingKeepalive({ delayMs: 7000 })
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
private stopTyping(): void {
|
|
1657
|
+
this.typingRepulseDebounce.clear()
|
|
1658
|
+
this.clearTypingKeepalive()
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
private requestTypingRepulse(): void {
|
|
1662
|
+
if (!this.shouldTypeNow()) {
|
|
1663
|
+
return
|
|
1664
|
+
}
|
|
1665
|
+
this.typingRepulseDebounce.trigger()
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1668
|
+
// ── Part Buffering & Output ─────────────────────────────────
|
|
1669
|
+
|
|
1670
|
+
private getVerbosityChannelId(): string {
|
|
1671
|
+
return this.channelId || this.thread.parentId || this.thread.id
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1674
|
+
private async getVerbosity() {
|
|
1675
|
+
return getChannelVerbosity(this.getVerbosityChannelId())
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
private storePart(part: Part): void {
|
|
1679
|
+
const messageParts =
|
|
1680
|
+
this.partBuffer.get(part.messageID) || new Map<string, Part>()
|
|
1681
|
+
messageParts.set(part.id, part)
|
|
1682
|
+
this.partBuffer.set(part.messageID, messageParts)
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
private getBufferedParts(messageID: string): Part[] {
|
|
1686
|
+
return Array.from(this.partBuffer.get(messageID)?.values() ?? [])
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
private clearBufferedPartsForMessages(messageIDs: ReadonlyArray<string>): void {
|
|
1690
|
+
const uniqueMessageIDs = new Set(messageIDs)
|
|
1691
|
+
uniqueMessageIDs.forEach((messageID) => {
|
|
1692
|
+
this.partBuffer.delete(messageID)
|
|
1693
|
+
})
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
private hasBufferedStepFinish(messageID: string): boolean {
|
|
1697
|
+
return this.getBufferedParts(messageID).some((part) => {
|
|
1698
|
+
return part.type === 'step-finish'
|
|
1699
|
+
})
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
private shouldSendPart({
|
|
1703
|
+
part,
|
|
1704
|
+
force,
|
|
1705
|
+
}: {
|
|
1706
|
+
part: Part
|
|
1707
|
+
force: boolean
|
|
1708
|
+
}): boolean {
|
|
1709
|
+
if (part.type === 'step-start' || part.type === 'step-finish') {
|
|
1710
|
+
return false
|
|
1711
|
+
}
|
|
1712
|
+
if (part.type === 'tool' && part.state.status === 'pending') {
|
|
1713
|
+
return false
|
|
1714
|
+
}
|
|
1715
|
+
if (!force && part.type === 'text' && !part.time?.end) {
|
|
1716
|
+
return false
|
|
1717
|
+
}
|
|
1718
|
+
if (!force && part.type === 'tool' && part.state.status === 'completed') {
|
|
1719
|
+
return false
|
|
1720
|
+
}
|
|
1721
|
+
return true
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
private async sendPartMessage({
|
|
1725
|
+
part,
|
|
1726
|
+
repulseTyping = true,
|
|
1727
|
+
}: {
|
|
1728
|
+
part: Part
|
|
1729
|
+
repulseTyping?: boolean
|
|
1730
|
+
}): Promise<void> {
|
|
1731
|
+
const verbosity = await this.getVerbosity()
|
|
1732
|
+
if (verbosity === 'text_only' && part.type !== 'text') {
|
|
1733
|
+
return
|
|
1734
|
+
}
|
|
1735
|
+
if (verbosity === 'text_and_essential_tools') {
|
|
1736
|
+
if (part.type !== 'text' && !(part.type === 'tool' && isEssentialToolPart(part))) {
|
|
1737
|
+
return
|
|
1738
|
+
}
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
const content = formatPart(part)
|
|
1742
|
+
if (!content.trim() || content.length === 0) {
|
|
1743
|
+
return
|
|
1744
|
+
}
|
|
1745
|
+
if (this.state?.sentPartIds.has(part.id)) {
|
|
1746
|
+
return
|
|
1747
|
+
}
|
|
1748
|
+
// Mark as sent BEFORE the async send to prevent concurrent flushes
|
|
1749
|
+
// from sending the same part while this await is in-flight.
|
|
1750
|
+
threadState.updateThread(this.threadId, (t) => {
|
|
1751
|
+
const newIds = new Set(t.sentPartIds)
|
|
1752
|
+
newIds.add(part.id)
|
|
1753
|
+
return { ...t, sentPartIds: newIds }
|
|
1754
|
+
})
|
|
1755
|
+
|
|
1756
|
+
const sendResult = await errore.tryAsync(() => {
|
|
1757
|
+
return sendThreadMessage(this.thread, content)
|
|
1758
|
+
})
|
|
1759
|
+
if (sendResult instanceof Error) {
|
|
1760
|
+
threadState.updateThread(this.threadId, (t) => {
|
|
1761
|
+
const newIds = new Set(t.sentPartIds)
|
|
1762
|
+
newIds.delete(part.id)
|
|
1763
|
+
return { ...t, sentPartIds: newIds }
|
|
1764
|
+
})
|
|
1765
|
+
discordLogger.error(
|
|
1766
|
+
`ERROR: Failed to send part ${part.id}:`,
|
|
1767
|
+
sendResult,
|
|
1768
|
+
)
|
|
1769
|
+
return
|
|
1770
|
+
}
|
|
1771
|
+
await setPartMessage(part.id, sendResult.id, this.thread.id)
|
|
1772
|
+
if (repulseTyping) {
|
|
1773
|
+
this.requestTypingRepulse()
|
|
1774
|
+
}
|
|
1775
|
+
}
|
|
1776
|
+
|
|
1777
|
+
private async flushBufferedParts({
|
|
1778
|
+
messageID,
|
|
1779
|
+
force,
|
|
1780
|
+
skipPartId,
|
|
1781
|
+
repulseTyping = true,
|
|
1782
|
+
}: {
|
|
1783
|
+
messageID: string | undefined
|
|
1784
|
+
force: boolean
|
|
1785
|
+
skipPartId?: string
|
|
1786
|
+
repulseTyping?: boolean
|
|
1787
|
+
}): Promise<void> {
|
|
1788
|
+
if (!messageID) {
|
|
1789
|
+
return
|
|
1790
|
+
}
|
|
1791
|
+
const parts = this.getBufferedParts(messageID)
|
|
1792
|
+
for (const part of parts) {
|
|
1793
|
+
if (skipPartId && part.id === skipPartId) {
|
|
1794
|
+
continue
|
|
1795
|
+
}
|
|
1796
|
+
if (!this.shouldSendPart({ part, force })) {
|
|
1797
|
+
continue
|
|
1798
|
+
}
|
|
1799
|
+
await this.sendPartMessage({ part, repulseTyping })
|
|
1800
|
+
}
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1803
|
+
private async flushBufferedPartsForMessages({
|
|
1804
|
+
messageIDs,
|
|
1805
|
+
force,
|
|
1806
|
+
skipPartId,
|
|
1807
|
+
repulseTyping = true,
|
|
1808
|
+
}: {
|
|
1809
|
+
messageIDs: ReadonlyArray<string>
|
|
1810
|
+
force: boolean
|
|
1811
|
+
skipPartId?: string
|
|
1812
|
+
repulseTyping?: boolean
|
|
1813
|
+
}): Promise<void> {
|
|
1814
|
+
const uniqueMessageIDs = [...new Set(messageIDs)]
|
|
1815
|
+
for (const messageID of uniqueMessageIDs) {
|
|
1816
|
+
await this.flushBufferedParts({
|
|
1817
|
+
messageID,
|
|
1818
|
+
force,
|
|
1819
|
+
skipPartId,
|
|
1820
|
+
repulseTyping,
|
|
1821
|
+
})
|
|
1822
|
+
}
|
|
1823
|
+
}
|
|
1824
|
+
|
|
1825
|
+
private async showInteractiveUi({
|
|
1826
|
+
skipPartId,
|
|
1827
|
+
flushMessageId,
|
|
1828
|
+
show,
|
|
1829
|
+
}: {
|
|
1830
|
+
skipPartId?: string
|
|
1831
|
+
flushMessageId?: string
|
|
1832
|
+
show: () => Promise<void>
|
|
1833
|
+
}): Promise<void> {
|
|
1834
|
+
this.stopTyping()
|
|
1835
|
+
const sessionId = this.state?.sessionId
|
|
1836
|
+
const targetMessageId = (() => {
|
|
1837
|
+
if (flushMessageId) {
|
|
1838
|
+
return flushMessageId
|
|
1839
|
+
}
|
|
1840
|
+
if (!sessionId) {
|
|
1841
|
+
return undefined
|
|
1842
|
+
}
|
|
1843
|
+
return this.getLatestAssistantMessageIdForCurrentTurn({ sessionId })
|
|
1844
|
+
})()
|
|
1845
|
+
if (targetMessageId) {
|
|
1846
|
+
await this.flushBufferedParts({
|
|
1847
|
+
messageID: targetMessageId,
|
|
1848
|
+
force: true,
|
|
1849
|
+
skipPartId,
|
|
1850
|
+
})
|
|
1851
|
+
} else {
|
|
1852
|
+
const assistantMessageIds = sessionId
|
|
1853
|
+
? [...this.getAssistantMessageIdsForCurrentTurn({ sessionId })]
|
|
1854
|
+
: []
|
|
1855
|
+
await this.flushBufferedPartsForMessages({
|
|
1856
|
+
messageIDs: assistantMessageIds,
|
|
1857
|
+
force: true,
|
|
1858
|
+
skipPartId,
|
|
1859
|
+
})
|
|
1860
|
+
}
|
|
1861
|
+
await show()
|
|
1862
|
+
}
|
|
1863
|
+
|
|
1864
|
+
private async ensureModelContextLimit({
|
|
1865
|
+
providerID,
|
|
1866
|
+
modelID,
|
|
1867
|
+
}: {
|
|
1868
|
+
providerID: string
|
|
1869
|
+
modelID: string
|
|
1870
|
+
}): Promise<void> {
|
|
1871
|
+
const key = `${providerID}/${modelID}`
|
|
1872
|
+
if (this.modelContextLimit && this.modelContextLimitKey === key) {
|
|
1873
|
+
return
|
|
1874
|
+
}
|
|
1875
|
+
const client = getOpencodeClient(this.projectDirectory)
|
|
1876
|
+
if (!client) {
|
|
1877
|
+
return
|
|
1878
|
+
}
|
|
1879
|
+
const providersResponse = await errore.tryAsync(() => {
|
|
1880
|
+
return client.provider.list({ directory: this.sdkDirectory })
|
|
1881
|
+
})
|
|
1882
|
+
if (providersResponse instanceof Error) {
|
|
1883
|
+
logger.error(
|
|
1884
|
+
'Failed to fetch provider info for context limit:',
|
|
1885
|
+
providersResponse,
|
|
1886
|
+
)
|
|
1887
|
+
return
|
|
1888
|
+
}
|
|
1889
|
+
const provider = providersResponse.data?.all?.find(
|
|
1890
|
+
(p) => {
|
|
1891
|
+
return p.id === providerID
|
|
1892
|
+
},
|
|
1893
|
+
)
|
|
1894
|
+
const model = provider?.models?.[modelID]
|
|
1895
|
+
const contextLimit = model?.limit?.context || getFallbackContextLimit({
|
|
1896
|
+
providerID,
|
|
1897
|
+
})
|
|
1898
|
+
if (!contextLimit) {
|
|
1899
|
+
return
|
|
1900
|
+
}
|
|
1901
|
+
this.modelContextLimit = contextLimit
|
|
1902
|
+
this.modelContextLimitKey = key
|
|
1903
|
+
}
|
|
1904
|
+
|
|
1905
|
+
// ── Event Handlers ──────────────────────────────────────────
|
|
1906
|
+
// Extracted from session-handler.ts eventHandler closure.
|
|
1907
|
+
// These operate on runtime instance state + global store transitions.
|
|
1908
|
+
|
|
1909
|
+
private async handleMessageUpdated(msg: OpenCodeMessage): Promise<void> {
|
|
1910
|
+
const sessionId = this.state?.sessionId
|
|
1911
|
+
|
|
1912
|
+
if (msg.sessionID !== sessionId) {
|
|
1913
|
+
return
|
|
1914
|
+
}
|
|
1915
|
+
if (msg.role !== 'assistant') {
|
|
1916
|
+
return
|
|
1917
|
+
}
|
|
1918
|
+
if (!sessionId) {
|
|
1919
|
+
return
|
|
1920
|
+
}
|
|
1921
|
+
if (!isAssistantMessageInLatestUserTurn({
|
|
1922
|
+
events: this.eventBuffer,
|
|
1923
|
+
sessionId,
|
|
1924
|
+
messageId: msg.id,
|
|
1925
|
+
})) {
|
|
1926
|
+
logger.info(`[SKIP] message.updated for old assistant message ${msg.id}, not in latest user turn`)
|
|
1927
|
+
return
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1930
|
+
const knownMessage = this.partBuffer.has(msg.id)
|
|
1931
|
+
|
|
1932
|
+
// promptAsync paths can deliver complete parts via message.updated even when
|
|
1933
|
+
// message.part.updated events are sparse or absent. Seed the part buffer
|
|
1934
|
+
// from message.parts when we have not seen per-part events for this message.
|
|
1935
|
+
if (!knownMessage) {
|
|
1936
|
+
const messageParts = (() => {
|
|
1937
|
+
const candidate: { parts?: unknown } = msg as { parts?: unknown }
|
|
1938
|
+
if (!Array.isArray(candidate.parts)) {
|
|
1939
|
+
return [] as Part[]
|
|
1940
|
+
}
|
|
1941
|
+
return candidate.parts.filter((part): part is Part => {
|
|
1942
|
+
if (!part || typeof part !== 'object') {
|
|
1943
|
+
return false
|
|
1944
|
+
}
|
|
1945
|
+
const maybePart = part as {
|
|
1946
|
+
id?: unknown
|
|
1947
|
+
type?: unknown
|
|
1948
|
+
messageID?: unknown
|
|
1949
|
+
}
|
|
1950
|
+
return (
|
|
1951
|
+
typeof maybePart.id === 'string' &&
|
|
1952
|
+
typeof maybePart.type === 'string' &&
|
|
1953
|
+
typeof maybePart.messageID === 'string'
|
|
1954
|
+
)
|
|
1955
|
+
})
|
|
1956
|
+
})()
|
|
1957
|
+
messageParts.forEach((part) => {
|
|
1958
|
+
this.storePart(part)
|
|
1959
|
+
})
|
|
1960
|
+
}
|
|
1961
|
+
|
|
1962
|
+
await this.flushBufferedParts({
|
|
1963
|
+
messageID: msg.id,
|
|
1964
|
+
force: false,
|
|
1965
|
+
})
|
|
1966
|
+
|
|
1967
|
+
const wasAlreadyCompleted = hasAssistantMessageCompletedBefore({
|
|
1968
|
+
events: this.eventBuffer,
|
|
1969
|
+
sessionId,
|
|
1970
|
+
messageId: msg.id,
|
|
1971
|
+
upToIndex: this.eventBuffer.length - 2,
|
|
1972
|
+
})
|
|
1973
|
+
const completedAt = msg.time.completed
|
|
1974
|
+
if (
|
|
1975
|
+
!wasAlreadyCompleted
|
|
1976
|
+
&& typeof completedAt === 'number'
|
|
1977
|
+
&& isAssistantMessageNaturalCompletion({ message: msg })
|
|
1978
|
+
) {
|
|
1979
|
+
await this.handleNaturalAssistantCompletion({
|
|
1980
|
+
completedMessageId: msg.id,
|
|
1981
|
+
completedAt,
|
|
1982
|
+
})
|
|
1983
|
+
return
|
|
1984
|
+
}
|
|
1985
|
+
|
|
1986
|
+
// Context usage notice.
|
|
1987
|
+
// Skip the final assistant update for a run: by the time the last
|
|
1988
|
+
// message.updated arrives, the final text part has already ended and the
|
|
1989
|
+
// buffered parts usually include step-finish, so a notice here would land
|
|
1990
|
+
// immediately above the footer and add noise.
|
|
1991
|
+
if (this.hasBufferedStepFinish(msg.id)) {
|
|
1992
|
+
return
|
|
1993
|
+
}
|
|
1994
|
+
const latestRunInfo = getLatestRunInfo({
|
|
1995
|
+
events: this.eventBuffer,
|
|
1996
|
+
sessionId,
|
|
1997
|
+
})
|
|
1998
|
+
if (
|
|
1999
|
+
latestRunInfo.tokensUsed === 0
|
|
2000
|
+
|| !latestRunInfo.providerID
|
|
2001
|
+
|| !latestRunInfo.model
|
|
2002
|
+
) {
|
|
2003
|
+
return
|
|
2004
|
+
}
|
|
2005
|
+
await this.ensureModelContextLimit({
|
|
2006
|
+
providerID: latestRunInfo.providerID,
|
|
2007
|
+
modelID: latestRunInfo.model,
|
|
2008
|
+
})
|
|
2009
|
+
if (!this.modelContextLimit) {
|
|
2010
|
+
return
|
|
2011
|
+
}
|
|
2012
|
+
const currentPercentage = Math.floor(
|
|
2013
|
+
(latestRunInfo.tokensUsed / this.modelContextLimit) * 100,
|
|
2014
|
+
)
|
|
2015
|
+
const thresholdCrossed = Math.floor(currentPercentage / 10) * 10
|
|
2016
|
+
if (
|
|
2017
|
+
thresholdCrossed <= this.lastDisplayedContextPercentage ||
|
|
2018
|
+
thresholdCrossed < 10
|
|
2019
|
+
) {
|
|
2020
|
+
return
|
|
2021
|
+
}
|
|
2022
|
+
this.lastDisplayedContextPercentage = thresholdCrossed
|
|
2023
|
+
const chunk = `⬦ context usage ${currentPercentage}%`
|
|
2024
|
+
const sendResult = await errore.tryAsync(() => {
|
|
2025
|
+
return this.thread.send({ content: chunk, flags: SILENT_MESSAGE_FLAGS })
|
|
2026
|
+
})
|
|
2027
|
+
if (sendResult instanceof Error) {
|
|
2028
|
+
discordLogger.error('Failed to send context usage notice:', sendResult)
|
|
2029
|
+
}
|
|
2030
|
+
}
|
|
2031
|
+
|
|
2032
|
+
private async handlePartUpdated(part: Part): Promise<void> {
|
|
2033
|
+
this.storePart(part)
|
|
2034
|
+
const sessionId = this.state?.sessionId
|
|
2035
|
+
|
|
2036
|
+
const subtaskInfo = this.getSubtaskInfoForSession(part.sessionID)
|
|
2037
|
+
const isSubtaskEvent = Boolean(subtaskInfo)
|
|
2038
|
+
|
|
2039
|
+
if (part.sessionID !== sessionId && !isSubtaskEvent) {
|
|
2040
|
+
return
|
|
2041
|
+
}
|
|
2042
|
+
|
|
2043
|
+
if (isSubtaskEvent && subtaskInfo) {
|
|
2044
|
+
await this.handleSubtaskPart(part, subtaskInfo)
|
|
2045
|
+
return
|
|
2046
|
+
}
|
|
2047
|
+
|
|
2048
|
+
await this.handleMainPart(part)
|
|
2049
|
+
}
|
|
2050
|
+
|
|
2051
|
+
private async handleMainPart(part: Part): Promise<void> {
|
|
2052
|
+
if (part.type === 'step-start') {
|
|
2053
|
+
this.ensureTypingNow()
|
|
2054
|
+
return
|
|
2055
|
+
}
|
|
2056
|
+
|
|
2057
|
+
if (part.type === 'tool' && part.state.status === 'running') {
|
|
2058
|
+
await this.flushBufferedParts({
|
|
2059
|
+
messageID: part.messageID,
|
|
2060
|
+
force: true,
|
|
2061
|
+
skipPartId: part.id,
|
|
2062
|
+
})
|
|
2063
|
+
await this.sendPartMessage({ part })
|
|
2064
|
+
|
|
2065
|
+
// Track task tool spawning subtask sessions
|
|
2066
|
+
if (part.tool === 'task' && !this.state?.sentPartIds.has(part.id)) {
|
|
2067
|
+
const description = (part.state.input?.description as string) || ''
|
|
2068
|
+
const agent = (part.state.input?.subagent_type as string) || 'task'
|
|
2069
|
+
const childSessionId = (part.state.metadata?.sessionId as string) || ''
|
|
2070
|
+
if (description && childSessionId) {
|
|
2071
|
+
if ((await this.getVerbosity()) !== 'text_only') {
|
|
2072
|
+
const taskDisplay = `┣ ${agent} **${description}**`
|
|
2073
|
+
await sendThreadMessage(this.thread, taskDisplay + '\n\n')
|
|
2074
|
+
}
|
|
2075
|
+
}
|
|
2076
|
+
}
|
|
2077
|
+
return
|
|
2078
|
+
}
|
|
2079
|
+
|
|
2080
|
+
// Action buttons tool handler
|
|
2081
|
+
if (
|
|
2082
|
+
part.type === 'tool' &&
|
|
2083
|
+
part.state.status === 'completed' &&
|
|
2084
|
+
part.tool.endsWith('kimaki_action_buttons')
|
|
2085
|
+
) {
|
|
2086
|
+
const sessionId = this.state?.sessionId
|
|
2087
|
+
await this.showInteractiveUi({
|
|
2088
|
+
skipPartId: part.id,
|
|
2089
|
+
flushMessageId: part.messageID,
|
|
2090
|
+
show: async () => {
|
|
2091
|
+
if (!sessionId) {
|
|
2092
|
+
return
|
|
2093
|
+
}
|
|
2094
|
+
const request = await waitForQueuedActionButtonsRequest({
|
|
2095
|
+
sessionId,
|
|
2096
|
+
timeoutMs: 1500,
|
|
2097
|
+
})
|
|
2098
|
+
if (!request) {
|
|
2099
|
+
logger.warn(
|
|
2100
|
+
`[ACTION] No queued action-buttons request found for session ${sessionId}`,
|
|
2101
|
+
)
|
|
2102
|
+
return
|
|
2103
|
+
}
|
|
2104
|
+
if (request.threadId !== this.thread.id) {
|
|
2105
|
+
logger.warn(
|
|
2106
|
+
`[ACTION] Ignoring queued action-buttons for different thread`,
|
|
2107
|
+
)
|
|
2108
|
+
return
|
|
2109
|
+
}
|
|
2110
|
+
const showResult = await errore.tryAsync(() => {
|
|
2111
|
+
return showActionButtons({
|
|
2112
|
+
thread: this.thread,
|
|
2113
|
+
sessionId: request.sessionId,
|
|
2114
|
+
directory: request.directory,
|
|
2115
|
+
buttons: request.buttons,
|
|
2116
|
+
silent: this.getQueueLength() > 0,
|
|
2117
|
+
})
|
|
2118
|
+
})
|
|
2119
|
+
if (showResult instanceof Error) {
|
|
2120
|
+
logger.error(
|
|
2121
|
+
'[ACTION] Failed to show action buttons:',
|
|
2122
|
+
showResult,
|
|
2123
|
+
)
|
|
2124
|
+
await sendThreadMessage(
|
|
2125
|
+
this.thread,
|
|
2126
|
+
`Failed to show action buttons: ${showResult.message}`,
|
|
2127
|
+
{ flags: NOTIFY_MESSAGE_FLAGS },
|
|
2128
|
+
)
|
|
2129
|
+
}
|
|
2130
|
+
},
|
|
2131
|
+
})
|
|
2132
|
+
return
|
|
2133
|
+
}
|
|
2134
|
+
|
|
2135
|
+
// Large output notification for completed tools
|
|
2136
|
+
if (part.type === 'tool' && part.state.status === 'completed') {
|
|
2137
|
+
const sessionId = this.state?.sessionId
|
|
2138
|
+
if (sessionId) {
|
|
2139
|
+
const isCurrentRunMessage = isAssistantMessageInLatestUserTurn({
|
|
2140
|
+
events: this.eventBuffer,
|
|
2141
|
+
sessionId,
|
|
2142
|
+
messageId: part.messageID,
|
|
2143
|
+
})
|
|
2144
|
+
if (!isCurrentRunMessage) {
|
|
2145
|
+
logger.info(`[SKIP] tool part ${part.id} for old assistant message ${part.messageID}, not in latest user turn`)
|
|
2146
|
+
return
|
|
2147
|
+
}
|
|
2148
|
+
}
|
|
2149
|
+
const showLargeOutput = await (async () => {
|
|
2150
|
+
const verbosity = await this.getVerbosity()
|
|
2151
|
+
if (verbosity === 'text_only') {
|
|
2152
|
+
return false
|
|
2153
|
+
}
|
|
2154
|
+
if (verbosity === 'text_and_essential_tools') {
|
|
2155
|
+
return isEssentialToolPart(part)
|
|
2156
|
+
}
|
|
2157
|
+
return true
|
|
2158
|
+
})()
|
|
2159
|
+
if (showLargeOutput) {
|
|
2160
|
+
const output = part.state.output || ''
|
|
2161
|
+
const outputTokens = Math.ceil(output.length / 4)
|
|
2162
|
+
const largeOutputThreshold = 3000
|
|
2163
|
+
if (outputTokens >= largeOutputThreshold) {
|
|
2164
|
+
if (sessionId) {
|
|
2165
|
+
const latestRunInfo = getLatestRunInfo({
|
|
2166
|
+
events: this.eventBuffer,
|
|
2167
|
+
sessionId,
|
|
2168
|
+
})
|
|
2169
|
+
if (latestRunInfo.providerID && latestRunInfo.model) {
|
|
2170
|
+
await this.ensureModelContextLimit({
|
|
2171
|
+
providerID: latestRunInfo.providerID,
|
|
2172
|
+
modelID: latestRunInfo.model,
|
|
2173
|
+
})
|
|
2174
|
+
}
|
|
2175
|
+
}
|
|
2176
|
+
const formattedTokens =
|
|
2177
|
+
outputTokens >= 1000
|
|
2178
|
+
? `${(outputTokens / 1000).toFixed(1)}k`
|
|
2179
|
+
: String(outputTokens)
|
|
2180
|
+
const percentageSuffix = (() => {
|
|
2181
|
+
if (!this.modelContextLimit) {
|
|
2182
|
+
return ''
|
|
2183
|
+
}
|
|
2184
|
+
const pct = (outputTokens / this.modelContextLimit) * 100
|
|
2185
|
+
if (pct < 1) {
|
|
2186
|
+
return ''
|
|
2187
|
+
}
|
|
2188
|
+
return ` (${pct.toFixed(1)}%)`
|
|
2189
|
+
})()
|
|
2190
|
+
const chunk = `⬦ ${part.tool} returned ${formattedTokens} tokens${percentageSuffix}`
|
|
2191
|
+
const largeOutputResult = await errore.tryAsync(() => {
|
|
2192
|
+
return this.thread.send({
|
|
2193
|
+
content: chunk,
|
|
2194
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
2195
|
+
})
|
|
2196
|
+
})
|
|
2197
|
+
if (largeOutputResult instanceof Error) {
|
|
2198
|
+
discordLogger.error('Failed to send large output notice:', largeOutputResult)
|
|
2199
|
+
}
|
|
2200
|
+
}
|
|
2201
|
+
}
|
|
2202
|
+
}
|
|
2203
|
+
|
|
2204
|
+
if (part.type === 'reasoning') {
|
|
2205
|
+
await this.sendPartMessage({ part })
|
|
2206
|
+
return
|
|
2207
|
+
}
|
|
2208
|
+
|
|
2209
|
+
if (part.type === 'text' && part.time?.end) {
|
|
2210
|
+
await this.sendPartMessage({ part })
|
|
2211
|
+
return
|
|
2212
|
+
}
|
|
2213
|
+
|
|
2214
|
+
if (part.type === 'step-finish') {
|
|
2215
|
+
await this.flushBufferedParts({
|
|
2216
|
+
messageID: part.messageID,
|
|
2217
|
+
force: true,
|
|
2218
|
+
})
|
|
2219
|
+
this.ensureTypingKeepalive()
|
|
2220
|
+
}
|
|
2221
|
+
}
|
|
2222
|
+
|
|
2223
|
+
private async handleSubtaskPart(
|
|
2224
|
+
part: Part,
|
|
2225
|
+
subtaskInfo: { label: string; assistantMessageId?: string },
|
|
2226
|
+
): Promise<void> {
|
|
2227
|
+
const verbosity = await this.getVerbosity()
|
|
2228
|
+
if (verbosity === 'text_only') {
|
|
2229
|
+
return
|
|
2230
|
+
}
|
|
2231
|
+
if (verbosity === 'text_and_essential_tools') {
|
|
2232
|
+
if (!isEssentialToolPart(part)) {
|
|
2233
|
+
return
|
|
2234
|
+
}
|
|
2235
|
+
}
|
|
2236
|
+
if (part.type === 'step-start' || part.type === 'step-finish') {
|
|
2237
|
+
return
|
|
2238
|
+
}
|
|
2239
|
+
if (part.type === 'tool' && part.state.status === 'pending') {
|
|
2240
|
+
return
|
|
2241
|
+
}
|
|
2242
|
+
if (part.type === 'text') {
|
|
2243
|
+
return
|
|
2244
|
+
}
|
|
2245
|
+
if (
|
|
2246
|
+
!subtaskInfo.assistantMessageId ||
|
|
2247
|
+
part.messageID !== subtaskInfo.assistantMessageId
|
|
2248
|
+
) {
|
|
2249
|
+
return
|
|
2250
|
+
}
|
|
2251
|
+
|
|
2252
|
+
const content = formatPart(part, subtaskInfo.label)
|
|
2253
|
+
if (!content.trim() || this.state?.sentPartIds.has(part.id)) {
|
|
2254
|
+
return
|
|
2255
|
+
}
|
|
2256
|
+
const sendResult = await errore.tryAsync(() => {
|
|
2257
|
+
return sendThreadMessage(this.thread, content + '\n\n')
|
|
2258
|
+
})
|
|
2259
|
+
if (sendResult instanceof Error) {
|
|
2260
|
+
discordLogger.error(
|
|
2261
|
+
`ERROR: Failed to send subtask part ${part.id}:`,
|
|
2262
|
+
sendResult,
|
|
2263
|
+
)
|
|
2264
|
+
return
|
|
2265
|
+
}
|
|
2266
|
+
threadState.updateThread(this.threadId, (t) => {
|
|
2267
|
+
const newIds = new Set(t.sentPartIds)
|
|
2268
|
+
newIds.add(part.id)
|
|
2269
|
+
return { ...t, sentPartIds: newIds }
|
|
2270
|
+
})
|
|
2271
|
+
await setPartMessage(part.id, sendResult.id, this.thread.id)
|
|
2272
|
+
this.requestTypingRepulse()
|
|
2273
|
+
}
|
|
2274
|
+
|
|
2275
|
+
private async handleSessionIdle(idleSessionId: string): Promise<void> {
|
|
2276
|
+
const sessionId = this.state?.sessionId
|
|
2277
|
+
|
|
2278
|
+
// ── Subtask idle ──────────────────────────────────────────
|
|
2279
|
+
const subtask = this.getSubtaskInfoForSession(idleSessionId)
|
|
2280
|
+
if (subtask) {
|
|
2281
|
+
logger.log(
|
|
2282
|
+
`[SUBTASK IDLE] Subtask "${subtask?.label}" completed`,
|
|
2283
|
+
)
|
|
2284
|
+
return
|
|
2285
|
+
}
|
|
2286
|
+
|
|
2287
|
+
// ── Main session idle ─────────────────────────────────────
|
|
2288
|
+
// The event is also pushed into the event buffer by handleEvent(),
|
|
2289
|
+
// so waitForEvent() consumers (abort settlement) will see it too.
|
|
2290
|
+
if (idleSessionId === sessionId) {
|
|
2291
|
+
const shouldDrainQueuedMessages = doesLatestUserTurnHaveNaturalCompletion({
|
|
2292
|
+
events: this.eventBuffer,
|
|
2293
|
+
sessionId: idleSessionId,
|
|
2294
|
+
})
|
|
2295
|
+
|
|
2296
|
+
logger.log(
|
|
2297
|
+
`[SESSION IDLE] session became idle sessionId=${sessionId} drainQueue=${shouldDrainQueuedMessages} ${this.formatRunStateForLog()}`,
|
|
2298
|
+
)
|
|
2299
|
+
await this.persistEventBufferDebounced.flush()
|
|
2300
|
+
|
|
2301
|
+
if (!shouldDrainQueuedMessages) {
|
|
2302
|
+
return
|
|
2303
|
+
}
|
|
2304
|
+
// Drain any local-queue items that arrived while the session was busy
|
|
2305
|
+
// (e.g. slow voice transcription with queueMessage=true completing
|
|
2306
|
+
// during or just before idle). Same pattern as handleSessionError.
|
|
2307
|
+
await this.tryDrainQueue({ showIndicator: true })
|
|
2308
|
+
return
|
|
2309
|
+
}
|
|
2310
|
+
}
|
|
2311
|
+
|
|
2312
|
+
private async handleNaturalAssistantCompletion({
|
|
2313
|
+
completedMessageId,
|
|
2314
|
+
completedAt,
|
|
2315
|
+
}: {
|
|
2316
|
+
completedMessageId: string
|
|
2317
|
+
completedAt: number
|
|
2318
|
+
}): Promise<void> {
|
|
2319
|
+
const sessionId = this.state?.sessionId
|
|
2320
|
+
if (!sessionId) {
|
|
2321
|
+
return
|
|
2322
|
+
}
|
|
2323
|
+
|
|
2324
|
+
const assistantMessageIds = [
|
|
2325
|
+
...this.getAssistantMessageIdsForCurrentTurn({ sessionId }),
|
|
2326
|
+
]
|
|
2327
|
+
if (assistantMessageIds.length === 0) {
|
|
2328
|
+
return
|
|
2329
|
+
}
|
|
2330
|
+
|
|
2331
|
+
await this.flushBufferedPartsForMessages({
|
|
2332
|
+
messageIDs: assistantMessageIds,
|
|
2333
|
+
force: true,
|
|
2334
|
+
repulseTyping: false,
|
|
2335
|
+
})
|
|
2336
|
+
|
|
2337
|
+
this.stopTyping()
|
|
2338
|
+
|
|
2339
|
+
const turnStartTime = getCurrentTurnStartTime({
|
|
2340
|
+
events: this.eventBuffer,
|
|
2341
|
+
sessionId,
|
|
2342
|
+
})
|
|
2343
|
+
if (turnStartTime !== undefined) {
|
|
2344
|
+
await this.emitFooter({
|
|
2345
|
+
completedAt,
|
|
2346
|
+
runStartTime: turnStartTime,
|
|
2347
|
+
})
|
|
2348
|
+
}
|
|
2349
|
+
|
|
2350
|
+
this.resetPerRunState()
|
|
2351
|
+
this.clearBufferedPartsForMessages(assistantMessageIds)
|
|
2352
|
+
logger.log(
|
|
2353
|
+
`[ASSISTANT COMPLETED] footer emitted for message ${completedMessageId} sessionId=${sessionId} ${this.formatRunStateForLog()}`,
|
|
2354
|
+
)
|
|
2355
|
+
}
|
|
2356
|
+
|
|
2357
|
+
private async handleSessionError(properties: {
|
|
2358
|
+
sessionID?: string
|
|
2359
|
+
error?: {
|
|
2360
|
+
name?: string
|
|
2361
|
+
data?: {
|
|
2362
|
+
message?: string
|
|
2363
|
+
statusCode?: number
|
|
2364
|
+
providerID?: string
|
|
2365
|
+
isRetryable?: boolean
|
|
2366
|
+
responseBody?: string
|
|
2367
|
+
}
|
|
2368
|
+
}
|
|
2369
|
+
}): Promise<void> {
|
|
2370
|
+
const sessionId = this.state?.sessionId
|
|
2371
|
+
if (!properties.sessionID || properties.sessionID !== sessionId) {
|
|
2372
|
+
logger.log(
|
|
2373
|
+
`Ignoring error for different session (expected: ${sessionId}, got: ${properties.sessionID})`,
|
|
2374
|
+
)
|
|
2375
|
+
return
|
|
2376
|
+
}
|
|
2377
|
+
|
|
2378
|
+
// Skip abort errors — they are expected when operations are cancelled
|
|
2379
|
+
if (properties.error?.name === 'MessageAbortedError') {
|
|
2380
|
+
logger.log(
|
|
2381
|
+
`[SESSION ERROR] Operation aborted (expected) sessionId=${sessionId} ${this.formatRunStateForLog()}`,
|
|
2382
|
+
)
|
|
2383
|
+
await this.persistEventBufferDebounced.flush()
|
|
2384
|
+
return
|
|
2385
|
+
}
|
|
2386
|
+
|
|
2387
|
+
const errorMessage = formatSessionErrorFromProps(properties.error)
|
|
2388
|
+
logger.error(`Sending error to thread: ${errorMessage}`)
|
|
2389
|
+
await sendThreadMessage(
|
|
2390
|
+
this.thread,
|
|
2391
|
+
`✗ opencode session error: ${errorMessage}`,
|
|
2392
|
+
{ flags: NOTIFY_MESSAGE_FLAGS },
|
|
2393
|
+
)
|
|
2394
|
+
await this.persistEventBufferDebounced.flush()
|
|
2395
|
+
|
|
2396
|
+
// Inject synthetic idle so isSessionBusy() returns false and queued
|
|
2397
|
+
// messages can drain. Without this, a session error leaves the event
|
|
2398
|
+
// buffer in a "busy" state forever (no session.idle follows the error),
|
|
2399
|
+
// causing local-queue items to be stuck indefinitely. See #74.
|
|
2400
|
+
this.markQueueDispatchIdle(sessionId)
|
|
2401
|
+
await this.tryDrainQueue({ showIndicator: true })
|
|
2402
|
+
}
|
|
2403
|
+
|
|
2404
|
+
private async handlePermissionAsked(
|
|
2405
|
+
permission: PermissionRequest,
|
|
2406
|
+
): Promise<void> {
|
|
2407
|
+
const sessionId = this.state?.sessionId
|
|
2408
|
+
const subtaskInfo = this.getSubtaskInfoForSession(permission.sessionID)
|
|
2409
|
+
const isMainSession = permission.sessionID === sessionId
|
|
2410
|
+
const isSubtaskSession = Boolean(subtaskInfo)
|
|
2411
|
+
|
|
2412
|
+
if (!isMainSession && !isSubtaskSession) {
|
|
2413
|
+
logger.log(
|
|
2414
|
+
`[PERMISSION IGNORED] Permission for unknown session (expected: ${sessionId} or subtask, got: ${permission.sessionID})`,
|
|
2415
|
+
)
|
|
2416
|
+
return
|
|
2417
|
+
}
|
|
2418
|
+
|
|
2419
|
+
const subtaskLabel = subtaskInfo?.label
|
|
2420
|
+
|
|
2421
|
+
const dedupeKey = buildPermissionDedupeKey({
|
|
2422
|
+
permission,
|
|
2423
|
+
directory: this.projectDirectory,
|
|
2424
|
+
})
|
|
2425
|
+
const threadPermissions = pendingPermissions.get(this.thread.id)
|
|
2426
|
+
const existingPending = threadPermissions
|
|
2427
|
+
? Array.from(threadPermissions.values()).find((pending) => {
|
|
2428
|
+
if (pending.dedupeKey === dedupeKey) {
|
|
2429
|
+
return true
|
|
2430
|
+
}
|
|
2431
|
+
if (pending.directory !== this.projectDirectory) {
|
|
2432
|
+
return false
|
|
2433
|
+
}
|
|
2434
|
+
if (pending.permission.permission !== permission.permission) {
|
|
2435
|
+
return false
|
|
2436
|
+
}
|
|
2437
|
+
return arePatternsCoveredBy({
|
|
2438
|
+
patterns: permission.patterns,
|
|
2439
|
+
coveringPatterns: pending.permission.patterns,
|
|
2440
|
+
})
|
|
2441
|
+
})
|
|
2442
|
+
: undefined
|
|
2443
|
+
|
|
2444
|
+
if (existingPending) {
|
|
2445
|
+
logger.log(
|
|
2446
|
+
`[PERMISSION] Deduped permission ${permission.id} (matches pending ${existingPending.permission.id})`,
|
|
2447
|
+
)
|
|
2448
|
+
this.stopTyping()
|
|
2449
|
+
if (!pendingPermissions.has(this.thread.id)) {
|
|
2450
|
+
pendingPermissions.set(this.thread.id, new Map())
|
|
2451
|
+
}
|
|
2452
|
+
pendingPermissions.get(this.thread.id)!.set(permission.id, {
|
|
2453
|
+
permission,
|
|
2454
|
+
messageId: existingPending.messageId,
|
|
2455
|
+
directory: this.projectDirectory,
|
|
2456
|
+
permissionDirectory: existingPending.permissionDirectory,
|
|
2457
|
+
contextHash: existingPending.contextHash,
|
|
2458
|
+
dedupeKey,
|
|
2459
|
+
})
|
|
2460
|
+
const added = addPermissionRequestToContext({
|
|
2461
|
+
contextHash: existingPending.contextHash,
|
|
2462
|
+
requestId: permission.id,
|
|
2463
|
+
})
|
|
2464
|
+
if (!added) {
|
|
2465
|
+
logger.log(
|
|
2466
|
+
`[PERMISSION] Failed to attach duplicate request ${permission.id} to context`,
|
|
2467
|
+
)
|
|
2468
|
+
}
|
|
2469
|
+
return
|
|
2470
|
+
}
|
|
2471
|
+
|
|
2472
|
+
logger.log(
|
|
2473
|
+
`Permission requested: permission=${permission.permission}, patterns=${permission.patterns.join(', ')}${subtaskLabel ? `, subtask=${subtaskLabel}` : ''}`,
|
|
2474
|
+
)
|
|
2475
|
+
|
|
2476
|
+
this.stopTyping()
|
|
2477
|
+
|
|
2478
|
+
const { messageId, contextHash } = await showPermissionButtons({
|
|
2479
|
+
thread: this.thread,
|
|
2480
|
+
permission,
|
|
2481
|
+
directory: this.projectDirectory,
|
|
2482
|
+
permissionDirectory: this.sdkDirectory,
|
|
2483
|
+
subtaskLabel,
|
|
2484
|
+
})
|
|
2485
|
+
|
|
2486
|
+
if (!pendingPermissions.has(this.thread.id)) {
|
|
2487
|
+
pendingPermissions.set(this.thread.id, new Map())
|
|
2488
|
+
}
|
|
2489
|
+
pendingPermissions.get(this.thread.id)!.set(permission.id, {
|
|
2490
|
+
permission,
|
|
2491
|
+
messageId,
|
|
2492
|
+
directory: this.projectDirectory,
|
|
2493
|
+
permissionDirectory: this.sdkDirectory,
|
|
2494
|
+
contextHash,
|
|
2495
|
+
dedupeKey,
|
|
2496
|
+
})
|
|
2497
|
+
}
|
|
2498
|
+
|
|
2499
|
+
private handlePermissionReplied(properties: {
|
|
2500
|
+
requestID: string
|
|
2501
|
+
reply: string
|
|
2502
|
+
sessionID: string
|
|
2503
|
+
}): void {
|
|
2504
|
+
const sessionId = this.state?.sessionId
|
|
2505
|
+
const subtaskInfo = this.getSubtaskInfoForSession(properties.sessionID)
|
|
2506
|
+
const isMainSession = properties.sessionID === sessionId
|
|
2507
|
+
const isSubtaskSession = Boolean(subtaskInfo)
|
|
2508
|
+
|
|
2509
|
+
if (!isMainSession && !isSubtaskSession) {
|
|
2510
|
+
return
|
|
2511
|
+
}
|
|
2512
|
+
|
|
2513
|
+
logger.log(
|
|
2514
|
+
`Permission ${properties.requestID} replied with: ${properties.reply}`,
|
|
2515
|
+
)
|
|
2516
|
+
|
|
2517
|
+
const threadPermissions = pendingPermissions.get(this.thread.id)
|
|
2518
|
+
if (!threadPermissions) {
|
|
2519
|
+
return
|
|
2520
|
+
}
|
|
2521
|
+
const pending = threadPermissions.get(properties.requestID)
|
|
2522
|
+
if (!pending) {
|
|
2523
|
+
return
|
|
2524
|
+
}
|
|
2525
|
+
cleanupPermissionContext(pending.contextHash)
|
|
2526
|
+
threadPermissions.delete(properties.requestID)
|
|
2527
|
+
if (threadPermissions.size === 0) {
|
|
2528
|
+
pendingPermissions.delete(this.thread.id)
|
|
2529
|
+
}
|
|
2530
|
+
this.onInteractiveUiStateChanged()
|
|
2531
|
+
}
|
|
2532
|
+
|
|
2533
|
+
private async handleQuestionAsked(
|
|
2534
|
+
questionRequest: QuestionRequest,
|
|
2535
|
+
): Promise<void> {
|
|
2536
|
+
const sessionId = this.state?.sessionId
|
|
2537
|
+
if (questionRequest.sessionID !== sessionId) {
|
|
2538
|
+
logger.log(
|
|
2539
|
+
`[QUESTION IGNORED] Question for different session (expected: ${sessionId}, got: ${questionRequest.sessionID})`,
|
|
2540
|
+
)
|
|
2541
|
+
return
|
|
2542
|
+
}
|
|
2543
|
+
|
|
2544
|
+
logger.log(
|
|
2545
|
+
`Question requested: id=${questionRequest.id}, questions=${questionRequest.questions.length}`,
|
|
2546
|
+
)
|
|
2547
|
+
|
|
2548
|
+
await this.showInteractiveUi({
|
|
2549
|
+
show: async () => {
|
|
2550
|
+
if (!sessionId) {
|
|
2551
|
+
return
|
|
2552
|
+
}
|
|
2553
|
+
await showAskUserQuestionDropdowns({
|
|
2554
|
+
thread: this.thread,
|
|
2555
|
+
sessionId,
|
|
2556
|
+
directory: this.projectDirectory,
|
|
2557
|
+
requestId: questionRequest.id,
|
|
2558
|
+
input: { questions: questionRequest.questions },
|
|
2559
|
+
silent: this.getQueueLength() > 0,
|
|
2560
|
+
})
|
|
2561
|
+
},
|
|
2562
|
+
})
|
|
2563
|
+
|
|
2564
|
+
// Queue drain is intentionally NOT done here — tryDrainQueue() already
|
|
2565
|
+
// blocks dispatch while interactive UI (question/permission) is pending.
|
|
2566
|
+
}
|
|
2567
|
+
|
|
2568
|
+
private handleQuestionReplied(properties: { sessionID: string }): void {
|
|
2569
|
+
const sessionId = this.state?.sessionId
|
|
2570
|
+
if (properties.sessionID !== sessionId) {
|
|
2571
|
+
return
|
|
2572
|
+
}
|
|
2573
|
+
this.onInteractiveUiStateChanged()
|
|
2574
|
+
|
|
2575
|
+
// When a question is answered and the local queue has items, the model may
|
|
2576
|
+
// continue the same run without ever reaching the local-queue idle gate.
|
|
2577
|
+
// Hand the queued items to OpenCode's own prompt queue immediately instead
|
|
2578
|
+
// of waiting for tryDrainQueue() to see an idle session.
|
|
2579
|
+
if (this.getQueueLength() > 0 && !this.questionReplyQueueHandoffPromise) {
|
|
2580
|
+
logger.log(
|
|
2581
|
+
`[QUESTION REPLIED] Queue has ${this.getQueueLength()} items, handing off to opencode queue`,
|
|
2582
|
+
)
|
|
2583
|
+
this.questionReplyQueueHandoffPromise = this.handoffQueuedItemsAfterQuestionReply({
|
|
2584
|
+
sessionId,
|
|
2585
|
+
}).catch((error) => {
|
|
2586
|
+
logger.error('[QUESTION REPLIED] Failed to hand off queued messages:', error)
|
|
2587
|
+
if (error instanceof Error) {
|
|
2588
|
+
void notifyError(error, 'Failed to hand off queued messages after question reply')
|
|
2589
|
+
}
|
|
2590
|
+
}).finally(() => {
|
|
2591
|
+
this.questionReplyQueueHandoffPromise = null
|
|
2592
|
+
})
|
|
2593
|
+
}
|
|
2594
|
+
}
|
|
2595
|
+
|
|
2596
|
+
// Detached helper promise for the "question answered while local queue has
|
|
2597
|
+
// items" flow. Prevents starting two overlapping local->opencode queue
|
|
2598
|
+
// handoff sequences when multiple question replies land close together.
|
|
2599
|
+
private questionReplyQueueHandoffPromise: Promise<void> | null = null
|
|
2600
|
+
|
|
2601
|
+
private async handoffQueuedItemsAfterQuestionReply({
|
|
2602
|
+
sessionId,
|
|
2603
|
+
}: {
|
|
2604
|
+
sessionId: string
|
|
2605
|
+
}): Promise<void> {
|
|
2606
|
+
if (this.listenerAborted) {
|
|
2607
|
+
return
|
|
2608
|
+
}
|
|
2609
|
+
if (this.state?.sessionId !== sessionId) {
|
|
2610
|
+
logger.log(
|
|
2611
|
+
`[QUESTION REPLIED] Session changed before queue handoff for thread ${this.threadId}`,
|
|
2612
|
+
)
|
|
2613
|
+
return
|
|
2614
|
+
}
|
|
2615
|
+
|
|
2616
|
+
while (this.state?.sessionId === sessionId) {
|
|
2617
|
+
const next = threadState.dequeueItem(this.threadId)
|
|
2618
|
+
if (!next) {
|
|
2619
|
+
return
|
|
2620
|
+
}
|
|
2621
|
+
|
|
2622
|
+
const displayText = next.command
|
|
2623
|
+
? `/${next.command.name}`
|
|
2624
|
+
: `${next.prompt.slice(0, 150)}${next.prompt.length > 150 ? '...' : ''}`
|
|
2625
|
+
if (displayText.trim()) {
|
|
2626
|
+
await sendThreadMessage(
|
|
2627
|
+
this.thread,
|
|
2628
|
+
`» **${next.username}:** ${displayText}`,
|
|
2629
|
+
)
|
|
2630
|
+
}
|
|
2631
|
+
|
|
2632
|
+
await this.submitViaOpencodeQueue(next)
|
|
2633
|
+
}
|
|
2634
|
+
}
|
|
2635
|
+
|
|
2636
|
+
private async handleSessionStatus(properties: {
|
|
2637
|
+
sessionID: string
|
|
2638
|
+
status:
|
|
2639
|
+
| { type: 'idle' }
|
|
2640
|
+
| { type: 'retry'; attempt: number; message: string; next: number }
|
|
2641
|
+
| { type: 'busy' }
|
|
2642
|
+
}): Promise<void> {
|
|
2643
|
+
const sessionId = this.state?.sessionId
|
|
2644
|
+
if (properties.sessionID !== sessionId) {
|
|
2645
|
+
return
|
|
2646
|
+
}
|
|
2647
|
+
|
|
2648
|
+
if (properties.status.type === 'idle') {
|
|
2649
|
+
this.stopTyping()
|
|
2650
|
+
return
|
|
2651
|
+
}
|
|
2652
|
+
|
|
2653
|
+
if (properties.status.type === 'busy') {
|
|
2654
|
+
this.ensureTypingNow()
|
|
2655
|
+
return
|
|
2656
|
+
}
|
|
2657
|
+
|
|
2658
|
+
if (properties.status.type !== 'retry') {
|
|
2659
|
+
return
|
|
2660
|
+
}
|
|
2661
|
+
|
|
2662
|
+
// Throttle to once per 10 seconds
|
|
2663
|
+
const now = Date.now()
|
|
2664
|
+
if (now - this.lastRateLimitDisplayTime < 10_000) {
|
|
2665
|
+
return
|
|
2666
|
+
}
|
|
2667
|
+
this.lastRateLimitDisplayTime = now
|
|
2668
|
+
|
|
2669
|
+
const { attempt, message, next } = properties.status
|
|
2670
|
+
const remainingMs = Math.max(0, next - now)
|
|
2671
|
+
const remainingSec = Math.ceil(remainingMs / 1000)
|
|
2672
|
+
const duration = (() => {
|
|
2673
|
+
if (remainingSec < 60) {
|
|
2674
|
+
return `${remainingSec}s`
|
|
2675
|
+
}
|
|
2676
|
+
const mins = Math.floor(remainingSec / 60)
|
|
2677
|
+
const secs = remainingSec % 60
|
|
2678
|
+
return secs > 0 ? `${mins}m ${secs}s` : `${mins}m`
|
|
2679
|
+
})()
|
|
2680
|
+
|
|
2681
|
+
const chunk = `⬦ ${message} - retrying in ${duration} (attempt #${attempt})`
|
|
2682
|
+
const retryResult = await errore.tryAsync(() => {
|
|
2683
|
+
return this.thread.send({ content: chunk, flags: SILENT_MESSAGE_FLAGS })
|
|
2684
|
+
})
|
|
2685
|
+
if (retryResult instanceof Error) {
|
|
2686
|
+
discordLogger.error('Failed to send retry notice:', retryResult)
|
|
2687
|
+
}
|
|
2688
|
+
}
|
|
2689
|
+
|
|
2690
|
+
// Rename the Discord thread to match the OpenCode-generated session title.
|
|
2691
|
+
//
|
|
2692
|
+
// Discord rate-limits channel/thread renames heavily — reported as ~2 per
|
|
2693
|
+
// 10 minutes per thread (discord/discord-api-docs#1900, discordjs/discord.js#6651)
|
|
2694
|
+
// and discord.js setName() can block silently on the 3rd attempt. We therefore:
|
|
2695
|
+
// - rename at most once per distinct title (deduped via appliedOpencodeTitle)
|
|
2696
|
+
// - race setName() against an AbortSignal.timeout() so a throttled call never
|
|
2697
|
+
// blocks the event loop
|
|
2698
|
+
// - fail soft (log + continue) on timeout, 429, or any other error
|
|
2699
|
+
private async handleSessionUpdated(info: {
|
|
2700
|
+
id: string
|
|
2701
|
+
title: string
|
|
2702
|
+
}): Promise<void> {
|
|
2703
|
+
// Only act on the main session for this thread
|
|
2704
|
+
if (info.id !== this.state?.sessionId) {
|
|
2705
|
+
return
|
|
2706
|
+
}
|
|
2707
|
+
const desiredName = deriveThreadNameFromSessionTitle({
|
|
2708
|
+
sessionTitle: info.title,
|
|
2709
|
+
currentName: this.thread.name,
|
|
2710
|
+
})
|
|
2711
|
+
if (!desiredName) {
|
|
2712
|
+
return
|
|
2713
|
+
}
|
|
2714
|
+
const normalizedTitle = info.title.trim()
|
|
2715
|
+
if (this.appliedOpencodeTitle === normalizedTitle) {
|
|
2716
|
+
return
|
|
2717
|
+
}
|
|
2718
|
+
// Mark before the call so concurrent session.updated events don't stack
|
|
2719
|
+
// rename attempts. On failure we keep the mark — a retry won't help
|
|
2720
|
+
// because the failure is almost always a rate limit.
|
|
2721
|
+
this.appliedOpencodeTitle = normalizedTitle
|
|
2722
|
+
|
|
2723
|
+
const RENAME_TIMEOUT_MS = 3000
|
|
2724
|
+
const timeoutSignal = AbortSignal.timeout(RENAME_TIMEOUT_MS)
|
|
2725
|
+
const renameResult = await Promise.race([
|
|
2726
|
+
errore.tryAsync({
|
|
2727
|
+
try: () => this.thread.setName(desiredName),
|
|
2728
|
+
catch: (e) =>
|
|
2729
|
+
new Error('Failed to rename thread from OpenCode title', {
|
|
2730
|
+
cause: e,
|
|
2731
|
+
}),
|
|
2732
|
+
}),
|
|
2733
|
+
new Promise<'timeout'>((resolve) => {
|
|
2734
|
+
timeoutSignal.addEventListener('abort', () => {
|
|
2735
|
+
resolve('timeout')
|
|
2736
|
+
})
|
|
2737
|
+
}),
|
|
2738
|
+
])
|
|
2739
|
+
|
|
2740
|
+
if (renameResult === 'timeout') {
|
|
2741
|
+
logger.warn(
|
|
2742
|
+
`[TITLE] setName timed out after ${RENAME_TIMEOUT_MS}ms for thread ${this.threadId} (likely rate-limited)`,
|
|
2743
|
+
)
|
|
2744
|
+
return
|
|
2745
|
+
}
|
|
2746
|
+
if (renameResult instanceof Error) {
|
|
2747
|
+
logger.warn(
|
|
2748
|
+
`[TITLE] Could not rename thread ${this.threadId}: ${renameResult.message}`,
|
|
2749
|
+
)
|
|
2750
|
+
return
|
|
2751
|
+
}
|
|
2752
|
+
logger.log(
|
|
2753
|
+
`[TITLE] Renamed thread ${this.threadId} to "${desiredName}" from OpenCode session title`,
|
|
2754
|
+
)
|
|
2755
|
+
}
|
|
2756
|
+
|
|
2757
|
+
private async handleTuiToast(properties: {
|
|
2758
|
+
title?: string
|
|
2759
|
+
message: string
|
|
2760
|
+
variant: 'info' | 'success' | 'warning' | 'error'
|
|
2761
|
+
duration?: number
|
|
2762
|
+
}): Promise<void> {
|
|
2763
|
+
if (properties.variant === 'warning') {
|
|
2764
|
+
return
|
|
2765
|
+
}
|
|
2766
|
+
const toastMessage = properties.message.trim()
|
|
2767
|
+
if (!toastMessage) {
|
|
2768
|
+
return
|
|
2769
|
+
}
|
|
2770
|
+
const titlePrefix = properties.title
|
|
2771
|
+
? `${properties.title.trim()}: `
|
|
2772
|
+
: ''
|
|
2773
|
+
const chunk = `⬦ ${properties.variant}: ${titlePrefix}${toastMessage}`
|
|
2774
|
+
const toastResult = await errore.tryAsync(() => {
|
|
2775
|
+
return this.thread.send({ content: chunk, flags: SILENT_MESSAGE_FLAGS })
|
|
2776
|
+
})
|
|
2777
|
+
if (toastResult instanceof Error) {
|
|
2778
|
+
discordLogger.error('Failed to send toast notice:', toastResult)
|
|
2779
|
+
}
|
|
2780
|
+
}
|
|
2781
|
+
|
|
2782
|
+
// ── Ingress API ─────────────────────────────────────────────
|
|
2783
|
+
|
|
2784
|
+
/**
|
|
2785
|
+
* Submit a user turn directly to opencode's internal session queue.
|
|
2786
|
+
* This is the default path for normal Discord messages.
|
|
2787
|
+
*
|
|
2788
|
+
* Mirrors dispatchPrompt's preference resolution, abort handling, and error
|
|
2789
|
+
* recovery so that promptAsync receives the same agent/model/variant/system
|
|
2790
|
+
* fields that the local-queue path provides.
|
|
2791
|
+
*/
|
|
2792
|
+
private async submitViaOpencodeQueue(input: IngressInput): Promise<EnqueueResult> {
|
|
2793
|
+
let skippedBySessionGuard = false
|
|
2794
|
+
|
|
2795
|
+
await this.dispatchAction(async () => {
|
|
2796
|
+
if (
|
|
2797
|
+
input.expectedSessionId &&
|
|
2798
|
+
this.state?.sessionId !== input.expectedSessionId
|
|
2799
|
+
) {
|
|
2800
|
+
logger.log(
|
|
2801
|
+
`[ENQUEUE] Skipping stale promptAsync enqueue for thread ${this.threadId}: expected session ${input.expectedSessionId}, current session ${this.state?.sessionId || 'none'}`,
|
|
2802
|
+
)
|
|
2803
|
+
skippedBySessionGuard = true
|
|
2804
|
+
return
|
|
2805
|
+
}
|
|
2806
|
+
|
|
2807
|
+
if (!this.listenerLoopRunning) {
|
|
2808
|
+
void this.startEventListener()
|
|
2809
|
+
}
|
|
2810
|
+
|
|
2811
|
+
// Helper: stop typing and drain queued local messages on error.
|
|
2812
|
+
const cleanupOnError = async (errorMessage: string) => {
|
|
2813
|
+
this.stopTyping()
|
|
2814
|
+
await sendThreadMessage(this.thread, errorMessage, {
|
|
2815
|
+
flags: NOTIFY_MESSAGE_FLAGS,
|
|
2816
|
+
})
|
|
2817
|
+
await this.tryDrainQueue({ showIndicator: true })
|
|
2818
|
+
}
|
|
2819
|
+
|
|
2820
|
+
// ── Ensure session ──────────────────────────────────────
|
|
2821
|
+
const sessionResult = await this.ensureSession({
|
|
2822
|
+
prompt: input.prompt,
|
|
2823
|
+
agent: input.agent,
|
|
2824
|
+
permissions: input.permissions,
|
|
2825
|
+
injectionGuardPatterns: input.injectionGuardPatterns,
|
|
2826
|
+
sessionStartScheduleKind: input.sessionStartSource?.scheduleKind,
|
|
2827
|
+
sessionStartScheduledTaskId: input.sessionStartSource?.scheduledTaskId,
|
|
2828
|
+
})
|
|
2829
|
+
if (sessionResult instanceof Error) {
|
|
2830
|
+
await cleanupOnError(`✗ ${sessionResult.message}`)
|
|
2831
|
+
return
|
|
2832
|
+
}
|
|
2833
|
+
|
|
2834
|
+
const { session, getClient, createdNewSession } = sessionResult
|
|
2835
|
+
|
|
2836
|
+
// If listener startup happened before initializeOpencodeForDirectory(),
|
|
2837
|
+
// startEventListener may have exited early with "No OpenCode client".
|
|
2838
|
+
// Re-check after ensureSession so first promptAsync on a cold directory
|
|
2839
|
+
// still has an active SSE listener for message parts.
|
|
2840
|
+
if (!this.listenerLoopRunning) {
|
|
2841
|
+
void this.startEventListener()
|
|
2842
|
+
}
|
|
2843
|
+
|
|
2844
|
+
// ── Resolve model + agent preferences (mirrors dispatchPrompt) ──
|
|
2845
|
+
const channelId = this.channelId
|
|
2846
|
+
const resolvedAppId = input.appId
|
|
2847
|
+
|
|
2848
|
+
if (input.agent && createdNewSession) {
|
|
2849
|
+
await setSessionAgent(session.id, input.agent)
|
|
2850
|
+
}
|
|
2851
|
+
|
|
2852
|
+
await ensureSessionPreferencesSnapshot({
|
|
2853
|
+
sessionId: session.id,
|
|
2854
|
+
channelId,
|
|
2855
|
+
appId: resolvedAppId,
|
|
2856
|
+
getClient,
|
|
2857
|
+
agentOverride: input.agent,
|
|
2858
|
+
modelOverride: input.model,
|
|
2859
|
+
force: createdNewSession,
|
|
2860
|
+
})
|
|
2861
|
+
|
|
2862
|
+
const agentResult = await errore.tryAsync(() => {
|
|
2863
|
+
return resolveValidatedAgentPreference({
|
|
2864
|
+
agent: input.agent,
|
|
2865
|
+
sessionId: session.id,
|
|
2866
|
+
channelId,
|
|
2867
|
+
getClient,
|
|
2868
|
+
})
|
|
2869
|
+
})
|
|
2870
|
+
if (agentResult instanceof Error) {
|
|
2871
|
+
await cleanupOnError(`Failed to resolve agent: ${agentResult.message}`)
|
|
2872
|
+
return
|
|
2873
|
+
}
|
|
2874
|
+
const resolvedAgent = agentResult.agentPreference
|
|
2875
|
+
const availableAgents = agentResult.agents
|
|
2876
|
+
|
|
2877
|
+
const [modelResult, preferredVariant] = await Promise.all([
|
|
2878
|
+
errore.tryAsync(async () => {
|
|
2879
|
+
if (input.model) {
|
|
2880
|
+
const [providerID, ...modelParts] = input.model.split('/')
|
|
2881
|
+
const modelID = modelParts.join('/')
|
|
2882
|
+
if (providerID && modelID) {
|
|
2883
|
+
return { providerID, modelID }
|
|
2884
|
+
}
|
|
2885
|
+
}
|
|
2886
|
+
const modelInfo = await getCurrentModelInfo({
|
|
2887
|
+
sessionId: session.id,
|
|
2888
|
+
channelId,
|
|
2889
|
+
appId: resolvedAppId,
|
|
2890
|
+
agentPreference: resolvedAgent,
|
|
2891
|
+
getClient,
|
|
2892
|
+
})
|
|
2893
|
+
if (modelInfo.type === 'none') {
|
|
2894
|
+
return undefined
|
|
2895
|
+
}
|
|
2896
|
+
return { providerID: modelInfo.providerID, modelID: modelInfo.modelID }
|
|
2897
|
+
}),
|
|
2898
|
+
getVariantCascade({
|
|
2899
|
+
sessionId: session.id,
|
|
2900
|
+
channelId,
|
|
2901
|
+
appId: resolvedAppId,
|
|
2902
|
+
}),
|
|
2903
|
+
])
|
|
2904
|
+
if (modelResult instanceof Error) {
|
|
2905
|
+
await cleanupOnError(`Failed to resolve model: ${modelResult.message}`)
|
|
2906
|
+
return
|
|
2907
|
+
}
|
|
2908
|
+
const modelField = modelResult
|
|
2909
|
+
if (!modelField) {
|
|
2910
|
+
await cleanupOnError(
|
|
2911
|
+
'No AI provider connected. Configure a provider in OpenCode with `/connect` command.',
|
|
2912
|
+
)
|
|
2913
|
+
return
|
|
2914
|
+
}
|
|
2915
|
+
|
|
2916
|
+
// Resolve thinking variant
|
|
2917
|
+
const thinkingValue = await (async (): Promise<string | undefined> => {
|
|
2918
|
+
if (!preferredVariant) {
|
|
2919
|
+
return undefined
|
|
2920
|
+
}
|
|
2921
|
+
const providersResponse = await errore.tryAsync(() => {
|
|
2922
|
+
return getClient().provider.list({ directory: this.sdkDirectory })
|
|
2923
|
+
})
|
|
2924
|
+
if (providersResponse instanceof Error || !providersResponse.data) {
|
|
2925
|
+
return undefined
|
|
2926
|
+
}
|
|
2927
|
+
const availableValues = getThinkingValuesForModel({
|
|
2928
|
+
providers: providersResponse.data.all,
|
|
2929
|
+
providerId: modelField.providerID,
|
|
2930
|
+
modelId: modelField.modelID,
|
|
2931
|
+
})
|
|
2932
|
+
if (availableValues.length === 0) {
|
|
2933
|
+
return undefined
|
|
2934
|
+
}
|
|
2935
|
+
return matchThinkingValue({
|
|
2936
|
+
requestedValue: preferredVariant,
|
|
2937
|
+
availableValues,
|
|
2938
|
+
}) || undefined
|
|
2939
|
+
})()
|
|
2940
|
+
|
|
2941
|
+
const variantField = thinkingValue
|
|
2942
|
+
? { variant: thinkingValue }
|
|
2943
|
+
: {}
|
|
2944
|
+
|
|
2945
|
+
// ── Build prompt parts ──────────────────────────────────
|
|
2946
|
+
const images = input.images || []
|
|
2947
|
+
const promptWithImagePaths = (() => {
|
|
2948
|
+
if (images.length === 0) {
|
|
2949
|
+
return input.prompt
|
|
2950
|
+
}
|
|
2951
|
+
const imageList = images
|
|
2952
|
+
.map((img) => {
|
|
2953
|
+
return `- ${img.sourceUrl || img.filename}`
|
|
2954
|
+
})
|
|
2955
|
+
.join('\n')
|
|
2956
|
+
return `${input.prompt}\n\n**The following images are already included in this message as inline content (do not use Read tool on these):**\n${imageList}`
|
|
2957
|
+
})()
|
|
2958
|
+
|
|
2959
|
+
// ── Worktree + channel topic for per-turn prompt context ──
|
|
2960
|
+
const worktreeInfo = await getThreadWorktree(this.thread.id)
|
|
2961
|
+
const worktree: WorktreeInfo | undefined =
|
|
2962
|
+
worktreeInfo?.status === 'ready' && worktreeInfo.worktree_directory
|
|
2963
|
+
? {
|
|
2964
|
+
worktreeDirectory: worktreeInfo.worktree_directory,
|
|
2965
|
+
branch: worktreeInfo.worktree_name,
|
|
2966
|
+
mainRepoDirectory: worktreeInfo.project_directory,
|
|
2967
|
+
}
|
|
2968
|
+
: undefined
|
|
2969
|
+
|
|
2970
|
+
const channelTopic = await (async () => {
|
|
2971
|
+
if (this.thread.parent?.type === ChannelType.GuildText) {
|
|
2972
|
+
return this.thread.parent.topic?.trim() || undefined
|
|
2973
|
+
}
|
|
2974
|
+
if (!channelId) {
|
|
2975
|
+
return undefined
|
|
2976
|
+
}
|
|
2977
|
+
const fetched = await errore.tryAsync(() => {
|
|
2978
|
+
return this.thread.guild.channels.fetch(channelId)
|
|
2979
|
+
})
|
|
2980
|
+
if (fetched instanceof Error || !fetched) {
|
|
2981
|
+
return undefined
|
|
2982
|
+
}
|
|
2983
|
+
if (fetched.type !== ChannelType.GuildText) {
|
|
2984
|
+
return undefined
|
|
2985
|
+
}
|
|
2986
|
+
return fetched.topic?.trim() || undefined
|
|
2987
|
+
})()
|
|
2988
|
+
const worktreeChanged = this.consumeWorktreePromptChange(worktree)
|
|
2989
|
+
const syntheticContext = getOpencodePromptContext({
|
|
2990
|
+
username: input.username,
|
|
2991
|
+
userId: input.userId,
|
|
2992
|
+
sourceMessageId: input.sourceMessageId,
|
|
2993
|
+
sourceThreadId: input.sourceThreadId,
|
|
2994
|
+
repliedMessage: input.repliedMessage,
|
|
2995
|
+
worktree,
|
|
2996
|
+
currentAgent: resolvedAgent,
|
|
2997
|
+
worktreeChanged,
|
|
2998
|
+
})
|
|
2999
|
+
const parts = [
|
|
3000
|
+
{ type: 'text' as const, text: promptWithImagePaths },
|
|
3001
|
+
{ type: 'text' as const, text: syntheticContext, synthetic: true },
|
|
3002
|
+
...images,
|
|
3003
|
+
]
|
|
3004
|
+
|
|
3005
|
+
const request = {
|
|
3006
|
+
sessionID: session.id,
|
|
3007
|
+
directory: this.sdkDirectory,
|
|
3008
|
+
parts,
|
|
3009
|
+
system: getOpencodeSystemMessage({
|
|
3010
|
+
sessionId: session.id,
|
|
3011
|
+
channelId,
|
|
3012
|
+
guildId: this.thread.guildId,
|
|
3013
|
+
threadId: this.thread.id,
|
|
3014
|
+
channelTopic,
|
|
3015
|
+
agents: availableAgents,
|
|
3016
|
+
username: this.state?.sessionUsername || input.username,
|
|
3017
|
+
}),
|
|
3018
|
+
...(resolvedAgent ? { agent: resolvedAgent } : {}),
|
|
3019
|
+
...(modelField ? { model: modelField } : {}),
|
|
3020
|
+
...variantField,
|
|
3021
|
+
}
|
|
3022
|
+
const promptResult = await errore.tryAsync(() => {
|
|
3023
|
+
return getClient().session.promptAsync(request)
|
|
3024
|
+
})
|
|
3025
|
+
if (promptResult instanceof Error || promptResult.error) {
|
|
3026
|
+
const errorMessage = (() => {
|
|
3027
|
+
if (promptResult instanceof Error) {
|
|
3028
|
+
return promptResult.message
|
|
3029
|
+
}
|
|
3030
|
+
const err = promptResult.error
|
|
3031
|
+
if (err && typeof err === 'object') {
|
|
3032
|
+
if (
|
|
3033
|
+
'data' in err &&
|
|
3034
|
+
err.data &&
|
|
3035
|
+
typeof err.data === 'object' &&
|
|
3036
|
+
'message' in err.data
|
|
3037
|
+
) {
|
|
3038
|
+
return String(err.data.message)
|
|
3039
|
+
}
|
|
3040
|
+
if (
|
|
3041
|
+
'errors' in err &&
|
|
3042
|
+
Array.isArray(err.errors) &&
|
|
3043
|
+
err.errors.length > 0
|
|
3044
|
+
) {
|
|
3045
|
+
return JSON.stringify(err.errors)
|
|
3046
|
+
}
|
|
3047
|
+
}
|
|
3048
|
+
return 'Unknown OpenCode API error'
|
|
3049
|
+
})()
|
|
3050
|
+
const errObj = promptResult instanceof Error
|
|
3051
|
+
? promptResult
|
|
3052
|
+
: new Error(errorMessage)
|
|
3053
|
+
void notifyError(errObj, 'promptAsync failed in submitViaOpencodeQueue')
|
|
3054
|
+
await cleanupOnError(`✗ OpenCode API error: ${errorMessage}`)
|
|
3055
|
+
return
|
|
3056
|
+
}
|
|
3057
|
+
|
|
3058
|
+
logger.log(
|
|
3059
|
+
`[INGRESS] promptAsync accepted by opencode queue sessionId=${session.id} threadId=${this.threadId}`,
|
|
3060
|
+
)
|
|
3061
|
+
this.markQueueDispatchBusy(session.id)
|
|
3062
|
+
})
|
|
3063
|
+
|
|
3064
|
+
if (skippedBySessionGuard) {
|
|
3065
|
+
return { queued: false }
|
|
3066
|
+
}
|
|
3067
|
+
return { queued: false }
|
|
3068
|
+
}
|
|
3069
|
+
|
|
3070
|
+
/**
|
|
3071
|
+
* Enqueue in kimaki's local per-thread queue.
|
|
3072
|
+
* Used for explicit queue workflows (/queue, queueMessage=true).
|
|
3073
|
+
*/
|
|
3074
|
+
private async enqueueViaLocalQueue(input: IngressInput): Promise<EnqueueResult> {
|
|
3075
|
+
const queuedMessage: QueuedMessage = {
|
|
3076
|
+
prompt: input.prompt,
|
|
3077
|
+
userId: input.userId,
|
|
3078
|
+
username: input.username,
|
|
3079
|
+
images: input.images,
|
|
3080
|
+
appId: input.appId,
|
|
3081
|
+
command: input.command,
|
|
3082
|
+
agent: input.agent,
|
|
3083
|
+
model: input.model,
|
|
3084
|
+
permissions: input.permissions,
|
|
3085
|
+
injectionGuardPatterns: input.injectionGuardPatterns,
|
|
3086
|
+
sourceMessageId: input.sourceMessageId,
|
|
3087
|
+
sourceThreadId: input.sourceThreadId,
|
|
3088
|
+
repliedMessage: input.repliedMessage,
|
|
3089
|
+
sessionStartScheduleKind: input.sessionStartSource?.scheduleKind,
|
|
3090
|
+
sessionStartScheduledTaskId: input.sessionStartSource?.scheduledTaskId,
|
|
3091
|
+
}
|
|
3092
|
+
|
|
3093
|
+
let result: EnqueueResult = { queued: false }
|
|
3094
|
+
|
|
3095
|
+
await this.dispatchAction(async () => {
|
|
3096
|
+
// Enqueue the message
|
|
3097
|
+
threadState.enqueueItem(this.threadId, queuedMessage)
|
|
3098
|
+
|
|
3099
|
+
// Determine if the message is genuinely waiting in queue
|
|
3100
|
+
const stateAfterEnqueue = threadState.getThreadState(this.threadId)
|
|
3101
|
+
const position = stateAfterEnqueue?.queueItems.length ?? 0
|
|
3102
|
+
const willDrainNow = stateAfterEnqueue
|
|
3103
|
+
? (
|
|
3104
|
+
stateAfterEnqueue.queueItems.length > 0
|
|
3105
|
+
&& !this.isMainSessionBusy()
|
|
3106
|
+
)
|
|
3107
|
+
: false
|
|
3108
|
+
result = !willDrainNow && position > 0
|
|
3109
|
+
? { queued: true, position }
|
|
3110
|
+
: { queued: false }
|
|
3111
|
+
|
|
3112
|
+
// Ensure listener is running
|
|
3113
|
+
if (!this.listenerLoopRunning) {
|
|
3114
|
+
void this.startEventListener()
|
|
3115
|
+
}
|
|
3116
|
+
|
|
3117
|
+
await this.tryDrainQueue()
|
|
3118
|
+
})
|
|
3119
|
+
return result
|
|
3120
|
+
}
|
|
3121
|
+
|
|
3122
|
+
/**
|
|
3123
|
+
* Ingress API for Discord handlers and commands.
|
|
3124
|
+
* Defaults to opencode queue mode; local queue mode is explicit.
|
|
3125
|
+
*
|
|
3126
|
+
* When input.preprocess is set, the preprocessor runs inside dispatchAction
|
|
3127
|
+
* (serialized) to resolve prompt/images/mode before routing. This replaces
|
|
3128
|
+
* the threadIngressQueue that previously serialized pre-enqueue work in
|
|
3129
|
+
* discord-bot.ts.
|
|
3130
|
+
*/
|
|
3131
|
+
async enqueueIncoming(input: IngressInput): Promise<EnqueueResult> {
|
|
3132
|
+
threadState.setSessionUsername(this.threadId, input.username)
|
|
3133
|
+
|
|
3134
|
+
// When a preprocessor is provided, we must resolve it inside
|
|
3135
|
+
// dispatchAction before we know the final mode for routing.
|
|
3136
|
+
if (input.preprocess) {
|
|
3137
|
+
return this.enqueueWithPreprocess(input)
|
|
3138
|
+
}
|
|
3139
|
+
// If the prompt starts with `/cmdname ...` (and no explicit command is
|
|
3140
|
+
// already set), rewrite it into a command invocation so it goes through
|
|
3141
|
+
// opencode's session.command API instead of being sent to the model as
|
|
3142
|
+
// plain text. Covers Discord chat messages, /new-session, /queue, CLI
|
|
3143
|
+
// `kimaki send --prompt`, and scheduled tasks — all funnel through here.
|
|
3144
|
+
input = maybeConvertLeadingCommand(input)
|
|
3145
|
+
if (input.mode === 'local-queue') {
|
|
3146
|
+
return this.enqueueViaLocalQueue(input)
|
|
3147
|
+
}
|
|
3148
|
+
if (input.command) {
|
|
3149
|
+
// Commands keep using local queue so they still support /queue-command.
|
|
3150
|
+
return this.enqueueViaLocalQueue(input)
|
|
3151
|
+
}
|
|
3152
|
+
return this.submitViaOpencodeQueue(input)
|
|
3153
|
+
}
|
|
3154
|
+
|
|
3155
|
+
/**
|
|
3156
|
+
* Serialize the preprocess callback via a lightweight promise chain, then
|
|
3157
|
+
* route the resolved input through the normal enqueue paths.
|
|
3158
|
+
*
|
|
3159
|
+
* The preprocess chain is separate from dispatchAction so heavy work
|
|
3160
|
+
* (voice transcription, context fetch, attachment download) doesn't
|
|
3161
|
+
* block SSE event handling, permission UI, or queue drain. Only the
|
|
3162
|
+
* preprocessing order is serialized here — the enqueue itself goes
|
|
3163
|
+
* through dispatchAction as usual.
|
|
3164
|
+
*/
|
|
3165
|
+
private async enqueueWithPreprocess(input: IngressInput): Promise<EnqueueResult> {
|
|
3166
|
+
// Deferred result: the chain link resolves/rejects this promise.
|
|
3167
|
+
let resolveOuter!: (value: EnqueueResult | PromiseLike<EnqueueResult>) => void
|
|
3168
|
+
let rejectOuter!: (reason: unknown) => void
|
|
3169
|
+
const resultPromise = new Promise<EnqueueResult>((resolve, reject) => {
|
|
3170
|
+
resolveOuter = resolve
|
|
3171
|
+
rejectOuter = reject
|
|
3172
|
+
})
|
|
3173
|
+
|
|
3174
|
+
// Chain preprocess + enqueue calls so they run in arrival order but
|
|
3175
|
+
// outside dispatchAction. The chain awaits the full enqueue (including
|
|
3176
|
+
// ensureSession / setThreadSession) before releasing to the next
|
|
3177
|
+
// message, so session-creation races on fresh threads are avoided.
|
|
3178
|
+
// The chain itself never rejects (catch + resolve via rejectOuter)
|
|
3179
|
+
// so the next link always runs.
|
|
3180
|
+
this.preprocessChain = this.preprocessChain.then(async () => {
|
|
3181
|
+
try {
|
|
3182
|
+
const result = await input.preprocess!()
|
|
3183
|
+
if (result.skip) {
|
|
3184
|
+
resolveOuter({ queued: false })
|
|
3185
|
+
return
|
|
3186
|
+
}
|
|
3187
|
+
const resolvedInput: IngressInput = maybeConvertLeadingCommand({
|
|
3188
|
+
...input,
|
|
3189
|
+
prompt: result.prompt,
|
|
3190
|
+
images: result.images,
|
|
3191
|
+
mode: result.mode,
|
|
3192
|
+
// Voice transcription can extract an agent name — apply it only if
|
|
3193
|
+
// no explicit agent was already set (CLI --agent flag wins).
|
|
3194
|
+
agent: input.agent || result.agent,
|
|
3195
|
+
repliedMessage: result.repliedMessage,
|
|
3196
|
+
preprocess: undefined,
|
|
3197
|
+
})
|
|
3198
|
+
|
|
3199
|
+
const hasPromptText = resolvedInput.prompt.trim().length > 0
|
|
3200
|
+
const hasImages = (resolvedInput.images?.length || 0) > 0
|
|
3201
|
+
if (!hasPromptText && !hasImages && !resolvedInput.command) {
|
|
3202
|
+
logger.warn(
|
|
3203
|
+
`[INGRESS] Skipping empty preprocessed input threadId=${this.threadId}`,
|
|
3204
|
+
)
|
|
3205
|
+
resolveOuter({ queued: false })
|
|
3206
|
+
return
|
|
3207
|
+
}
|
|
3208
|
+
|
|
3209
|
+
// Route with the resolved mode through normal paths.
|
|
3210
|
+
// Await the enqueue so session state (ensureSession, setThreadSession)
|
|
3211
|
+
// is persisted before the next message's preprocessing reads it.
|
|
3212
|
+
const enqueueResult =
|
|
3213
|
+
resolvedInput.mode === 'local-queue' || resolvedInput.command
|
|
3214
|
+
? await this.enqueueViaLocalQueue(resolvedInput)
|
|
3215
|
+
: await this.submitViaOpencodeQueue(resolvedInput)
|
|
3216
|
+
resolveOuter(enqueueResult)
|
|
3217
|
+
} catch (err) {
|
|
3218
|
+
rejectOuter(err)
|
|
3219
|
+
}
|
|
3220
|
+
})
|
|
3221
|
+
|
|
3222
|
+
return resultPromise
|
|
3223
|
+
}
|
|
3224
|
+
|
|
3225
|
+
/**
|
|
3226
|
+
* Abort the currently active run. Does NOT kill the listener.
|
|
3227
|
+
* Calls session.abort best-effort and lets event-stream idle settle the run.
|
|
3228
|
+
*/
|
|
3229
|
+
private async abortSessionViaApi({
|
|
3230
|
+
abortId,
|
|
3231
|
+
reason,
|
|
3232
|
+
sessionId,
|
|
3233
|
+
}: {
|
|
3234
|
+
abortId: string
|
|
3235
|
+
reason: string
|
|
3236
|
+
sessionId: string
|
|
3237
|
+
}): Promise<void> {
|
|
3238
|
+
const client = getOpencodeClient(this.projectDirectory)
|
|
3239
|
+
if (!client) {
|
|
3240
|
+
logger.log(
|
|
3241
|
+
`[ABORT API] id=${abortId} reason=${reason} sessionId=${sessionId} skipped=no-client`,
|
|
3242
|
+
)
|
|
3243
|
+
return
|
|
3244
|
+
}
|
|
3245
|
+
|
|
3246
|
+
const startedAt = Date.now()
|
|
3247
|
+
logger.log(
|
|
3248
|
+
`[ABORT API] id=${abortId} reason=${reason} sessionId=${sessionId} start`,
|
|
3249
|
+
)
|
|
3250
|
+
const abortResult = await errore.tryAsync(() => {
|
|
3251
|
+
return client.session.abort({
|
|
3252
|
+
sessionID: sessionId,
|
|
3253
|
+
directory: this.sdkDirectory,
|
|
3254
|
+
})
|
|
3255
|
+
})
|
|
3256
|
+
if (!(abortResult instanceof Error)) {
|
|
3257
|
+
logger.log(
|
|
3258
|
+
`[ABORT API] id=${abortId} reason=${reason} sessionId=${sessionId} success durationMs=${Date.now() - startedAt}`,
|
|
3259
|
+
)
|
|
3260
|
+
return
|
|
3261
|
+
}
|
|
3262
|
+
logger.log(
|
|
3263
|
+
`[ABORT API] id=${abortId} reason=${reason} sessionId=${sessionId} failed durationMs=${Date.now() - startedAt} message=${abortResult.message}`,
|
|
3264
|
+
)
|
|
3265
|
+
}
|
|
3266
|
+
|
|
3267
|
+
private abortActiveRunInternal({
|
|
3268
|
+
reason,
|
|
3269
|
+
}: {
|
|
3270
|
+
reason: string
|
|
3271
|
+
}): AbortRunOutcome {
|
|
3272
|
+
const abortId = this.nextAbortId(reason)
|
|
3273
|
+
const state = this.state
|
|
3274
|
+
if (!state) {
|
|
3275
|
+
logger.log(
|
|
3276
|
+
`[ABORT] id=${abortId} reason=${reason} threadId=${this.threadId} skipped=no-state`,
|
|
3277
|
+
)
|
|
3278
|
+
return {
|
|
3279
|
+
abortId,
|
|
3280
|
+
reason,
|
|
3281
|
+
apiAbortPromise: undefined,
|
|
3282
|
+
}
|
|
3283
|
+
}
|
|
3284
|
+
|
|
3285
|
+
const sessionId = state.sessionId
|
|
3286
|
+
const sessionIsBusy = this.isMainSessionBusy()
|
|
3287
|
+
|
|
3288
|
+
logger.log(
|
|
3289
|
+
`[ABORT] id=${abortId} reason=${reason} threadId=${this.threadId} sessionId=${sessionId || 'none'} queueLength=${state.queueItems.length} ${this.formatRunStateForLog()} sessionBusy=${sessionIsBusy}`,
|
|
3290
|
+
)
|
|
3291
|
+
|
|
3292
|
+
this.stopTyping()
|
|
3293
|
+
|
|
3294
|
+
const apiAbortPromise = sessionId
|
|
3295
|
+
? this.abortSessionViaApi({ abortId, reason, sessionId })
|
|
3296
|
+
: undefined
|
|
3297
|
+
|
|
3298
|
+
logger.log(
|
|
3299
|
+
`[ABORT] id=${abortId} reason=${reason} threadId=${this.threadId} apiAbort=${Boolean(sessionId)} ${this.formatRunStateForLog()}`,
|
|
3300
|
+
)
|
|
3301
|
+
|
|
3302
|
+
return {
|
|
3303
|
+
abortId,
|
|
3304
|
+
reason,
|
|
3305
|
+
apiAbortPromise,
|
|
3306
|
+
}
|
|
3307
|
+
}
|
|
3308
|
+
|
|
3309
|
+
abortActiveRun(reason: string): void {
|
|
3310
|
+
const outcome = this.abortActiveRunInternal({
|
|
3311
|
+
reason,
|
|
3312
|
+
})
|
|
3313
|
+
if (outcome.apiAbortPromise) {
|
|
3314
|
+
void outcome.apiAbortPromise
|
|
3315
|
+
}
|
|
3316
|
+
// Drain local queued messages after explicit abort.
|
|
3317
|
+
void this.dispatchAction(() => {
|
|
3318
|
+
return this.tryDrainQueue({ showIndicator: true })
|
|
3319
|
+
})
|
|
3320
|
+
}
|
|
3321
|
+
|
|
3322
|
+
async abortActiveRunAndWait({
|
|
3323
|
+
reason,
|
|
3324
|
+
timeoutMs = 2_000,
|
|
3325
|
+
}: {
|
|
3326
|
+
reason: string
|
|
3327
|
+
timeoutMs?: number
|
|
3328
|
+
}): Promise<void> {
|
|
3329
|
+
const state = this.state
|
|
3330
|
+
const sessionId = state?.sessionId
|
|
3331
|
+
if (!sessionId) {
|
|
3332
|
+
return
|
|
3333
|
+
}
|
|
3334
|
+
|
|
3335
|
+
let needsIdleWait = false
|
|
3336
|
+
const waitSinceTimestamp = Date.now()
|
|
3337
|
+
const abortResult = await errore.tryAsync(() => {
|
|
3338
|
+
return this.dispatchAction(async () => {
|
|
3339
|
+
needsIdleWait = this.isMainSessionBusy()
|
|
3340
|
+
const outcome = this.abortActiveRunInternal({ reason })
|
|
3341
|
+
if (outcome.apiAbortPromise) {
|
|
3342
|
+
void outcome.apiAbortPromise
|
|
3343
|
+
}
|
|
3344
|
+
})
|
|
3345
|
+
})
|
|
3346
|
+
if (abortResult instanceof Error) {
|
|
3347
|
+
logger.error(`[ABORT WAIT] Failed to abort active run: ${abortResult.message}`)
|
|
3348
|
+
return
|
|
3349
|
+
}
|
|
3350
|
+
if (!needsIdleWait) {
|
|
3351
|
+
return
|
|
3352
|
+
}
|
|
3353
|
+
await this.waitForEvent({
|
|
3354
|
+
predicate: (event) => {
|
|
3355
|
+
return event.type === 'session.idle'
|
|
3356
|
+
&& (event.properties as { sessionID?: string }).sessionID === sessionId
|
|
3357
|
+
},
|
|
3358
|
+
sinceTimestamp: waitSinceTimestamp,
|
|
3359
|
+
timeoutMs,
|
|
3360
|
+
})
|
|
3361
|
+
}
|
|
3362
|
+
|
|
3363
|
+
/** Number of messages waiting in the queue. */
|
|
3364
|
+
getQueueLength(): number {
|
|
3365
|
+
return this.state?.queueItems.length ?? 0
|
|
3366
|
+
}
|
|
3367
|
+
|
|
3368
|
+
/** NOTIFY_MESSAGE_FLAGS unless queue has a next item, then SILENT.
|
|
3369
|
+
* Permissions should NOT use this — they always notify. */
|
|
3370
|
+
private getNotifyFlags(): number {
|
|
3371
|
+
return this.getQueueLength() > 0
|
|
3372
|
+
? SILENT_MESSAGE_FLAGS
|
|
3373
|
+
: NOTIFY_MESSAGE_FLAGS
|
|
3374
|
+
}
|
|
3375
|
+
|
|
3376
|
+
/** Clear all queued messages. */
|
|
3377
|
+
clearQueue(): void {
|
|
3378
|
+
threadState.clearQueueItems(this.threadId)
|
|
3379
|
+
}
|
|
3380
|
+
|
|
3381
|
+
// ── Queue Drain ─────────────────────────────────────────────
|
|
3382
|
+
|
|
3383
|
+
/**
|
|
3384
|
+
* Check if we can dispatch the next queued message. If so, dequeue and
|
|
3385
|
+
* start dispatchPrompt (detached — does not block the action queue).
|
|
3386
|
+
* Called after enqueue, after run finishes, or after a blocker resolves.
|
|
3387
|
+
*
|
|
3388
|
+
* @param showIndicator - When true, shows "» username: prompt" in Discord.
|
|
3389
|
+
* Only set to true when draining after a previous run finishes or a
|
|
3390
|
+
* blocker resolves — not on the immediate first dispatch from enqueueIncoming.
|
|
3391
|
+
*/
|
|
3392
|
+
private async tryDrainQueue({ showIndicator = false } = {}): Promise<void> {
|
|
3393
|
+
const thread = threadState.getThreadState(this.threadId)
|
|
3394
|
+
if (!thread) {
|
|
3395
|
+
return
|
|
3396
|
+
}
|
|
3397
|
+
if (thread.queueItems.length === 0) {
|
|
3398
|
+
return
|
|
3399
|
+
}
|
|
3400
|
+
// Interactive UI (action buttons, questions, permissions) does NOT block
|
|
3401
|
+
// queue drain. The isSessionBusy check is sufficient: questions and
|
|
3402
|
+
// permissions keep the OpenCode session busy, so drain is naturally
|
|
3403
|
+
// blocked. Action buttons are fire-and-forget (session already idle),
|
|
3404
|
+
// so queued messages should dispatch immediately.
|
|
3405
|
+
|
|
3406
|
+
const sessionBusy = thread.sessionId
|
|
3407
|
+
? isSessionBusy({ events: this.eventBuffer, sessionId: thread.sessionId })
|
|
3408
|
+
: false
|
|
3409
|
+
if (sessionBusy) {
|
|
3410
|
+
return
|
|
3411
|
+
}
|
|
3412
|
+
|
|
3413
|
+
const next = threadState.dequeueItem(this.threadId)
|
|
3414
|
+
if (!next) {
|
|
3415
|
+
return
|
|
3416
|
+
}
|
|
3417
|
+
|
|
3418
|
+
logger.log(
|
|
3419
|
+
`[QUEUE DRAIN] Processing queued message from ${next.username}`,
|
|
3420
|
+
)
|
|
3421
|
+
|
|
3422
|
+
// Show queued message indicator only for messages that actually waited
|
|
3423
|
+
// behind a running request — not for the first immediate dispatch.
|
|
3424
|
+
if (showIndicator) {
|
|
3425
|
+
const displayText = next.command
|
|
3426
|
+
? `/${next.command.name}`
|
|
3427
|
+
: `${next.prompt.slice(0, 150)}${next.prompt.length > 150 ? '...' : ''}`
|
|
3428
|
+
if (displayText.trim()) {
|
|
3429
|
+
await sendThreadMessage(
|
|
3430
|
+
this.thread,
|
|
3431
|
+
`» **${next.username}:** ${displayText}`,
|
|
3432
|
+
)
|
|
3433
|
+
}
|
|
3434
|
+
}
|
|
3435
|
+
|
|
3436
|
+
// Start dispatch (detached — does not block the action queue).
|
|
3437
|
+
// The prompt call is long-running. Events continue to flow through
|
|
3438
|
+
// the action queue while the SDK call is in-flight. Event-derived busy
|
|
3439
|
+
// gating prevents concurrent local-queue dispatches. Mark busy now to
|
|
3440
|
+
// close the tiny window before the first session.status busy arrives.
|
|
3441
|
+
const dispatchSessionId = thread.sessionId
|
|
3442
|
+
if (dispatchSessionId) {
|
|
3443
|
+
this.markQueueDispatchBusy(dispatchSessionId)
|
|
3444
|
+
}
|
|
3445
|
+
void this.dispatchPrompt(next).catch(async (err) => {
|
|
3446
|
+
logger.error('[DISPATCH] Prompt dispatch failed:', err)
|
|
3447
|
+
void notifyError(err, 'Runtime prompt dispatch failed')
|
|
3448
|
+
if (dispatchSessionId) {
|
|
3449
|
+
this.markQueueDispatchIdle(dispatchSessionId)
|
|
3450
|
+
}
|
|
3451
|
+
}).finally(() => {
|
|
3452
|
+
void this.dispatchAction(() => {
|
|
3453
|
+
return this.tryDrainQueue({ showIndicator: true })
|
|
3454
|
+
})
|
|
3455
|
+
})
|
|
3456
|
+
}
|
|
3457
|
+
|
|
3458
|
+
// ── Prompt Dispatch ─────────────────────────────────────────
|
|
3459
|
+
// Resolve session, build system message, send to OpenCode.
|
|
3460
|
+
// The listener is already running, so this only handles
|
|
3461
|
+
// session ensure + model/agent + SDK call + state.
|
|
3462
|
+
|
|
3463
|
+
private async dispatchPrompt(input: QueuedMessage): Promise<void> {
|
|
3464
|
+
this.lastDisplayedContextPercentage = 0
|
|
3465
|
+
this.lastRateLimitDisplayTime = 0
|
|
3466
|
+
|
|
3467
|
+
// ── Ensure session ────────────────────────────────────────
|
|
3468
|
+
const sessionResult = await this.ensureSession({
|
|
3469
|
+
prompt: input.prompt,
|
|
3470
|
+
agent: input.agent,
|
|
3471
|
+
permissions: input.permissions,
|
|
3472
|
+
injectionGuardPatterns: input.injectionGuardPatterns,
|
|
3473
|
+
sessionStartScheduleKind: input.sessionStartScheduleKind,
|
|
3474
|
+
sessionStartScheduledTaskId: input.sessionStartScheduledTaskId,
|
|
3475
|
+
})
|
|
3476
|
+
if (sessionResult instanceof Error) {
|
|
3477
|
+
this.stopTyping()
|
|
3478
|
+
await sendThreadMessage(
|
|
3479
|
+
this.thread,
|
|
3480
|
+
`✗ ${sessionResult.message}`,
|
|
3481
|
+
{ flags: NOTIFY_MESSAGE_FLAGS },
|
|
3482
|
+
)
|
|
3483
|
+
// Show indicator: this dispatch failed, so the next queued message
|
|
3484
|
+
// has been waiting — the user needs to see which one is starting.
|
|
3485
|
+
await this.tryDrainQueue({ showIndicator: true })
|
|
3486
|
+
return
|
|
3487
|
+
}
|
|
3488
|
+
const { session, getClient, createdNewSession } = sessionResult
|
|
3489
|
+
|
|
3490
|
+
// Ensure listener is running now that we have a valid OpenCode client.
|
|
3491
|
+
// The eager start in enqueueIncoming may have failed if the client
|
|
3492
|
+
// wasn't initialized yet (fresh thread, first message).
|
|
3493
|
+
if (!this.listenerLoopRunning) {
|
|
3494
|
+
void this.startEventListener()
|
|
3495
|
+
}
|
|
3496
|
+
|
|
3497
|
+
// ── Resolve model + agent preferences ─────────────────────
|
|
3498
|
+
const channelId = this.channelId
|
|
3499
|
+
const resolvedAppId = input.appId
|
|
3500
|
+
|
|
3501
|
+
if (input.agent && createdNewSession) {
|
|
3502
|
+
await setSessionAgent(session.id, input.agent)
|
|
3503
|
+
}
|
|
3504
|
+
|
|
3505
|
+
await ensureSessionPreferencesSnapshot({
|
|
3506
|
+
sessionId: session.id,
|
|
3507
|
+
channelId,
|
|
3508
|
+
appId: resolvedAppId,
|
|
3509
|
+
getClient,
|
|
3510
|
+
agentOverride: input.agent,
|
|
3511
|
+
modelOverride: input.model,
|
|
3512
|
+
force: createdNewSession,
|
|
3513
|
+
})
|
|
3514
|
+
|
|
3515
|
+
const earlyAgentResult = await errore.tryAsync(() => {
|
|
3516
|
+
return resolveValidatedAgentPreference({
|
|
3517
|
+
agent: input.agent,
|
|
3518
|
+
sessionId: session.id,
|
|
3519
|
+
channelId,
|
|
3520
|
+
getClient,
|
|
3521
|
+
})
|
|
3522
|
+
})
|
|
3523
|
+
if (earlyAgentResult instanceof Error) {
|
|
3524
|
+
this.stopTyping()
|
|
3525
|
+
await sendThreadMessage(
|
|
3526
|
+
this.thread,
|
|
3527
|
+
`Failed to resolve agent: ${earlyAgentResult.message}`,
|
|
3528
|
+
{ flags: NOTIFY_MESSAGE_FLAGS },
|
|
3529
|
+
)
|
|
3530
|
+
// Show indicator: dispatch failed mid-setup, next queued message was waiting.
|
|
3531
|
+
await this.tryDrainQueue({ showIndicator: true })
|
|
3532
|
+
return
|
|
3533
|
+
}
|
|
3534
|
+
const earlyAgentPreference = earlyAgentResult.agentPreference
|
|
3535
|
+
const earlyAvailableAgents = earlyAgentResult.agents
|
|
3536
|
+
|
|
3537
|
+
const [earlyModelResult, preferredVariant] = await Promise.all([
|
|
3538
|
+
errore.tryAsync(async () => {
|
|
3539
|
+
if (input.model) {
|
|
3540
|
+
const [providerID, ...modelParts] = input.model.split('/')
|
|
3541
|
+
const modelID = modelParts.join('/')
|
|
3542
|
+
if (providerID && modelID) {
|
|
3543
|
+
return { providerID, modelID }
|
|
3544
|
+
}
|
|
3545
|
+
}
|
|
3546
|
+
const modelInfo = await getCurrentModelInfo({
|
|
3547
|
+
sessionId: session.id,
|
|
3548
|
+
channelId,
|
|
3549
|
+
appId: resolvedAppId,
|
|
3550
|
+
agentPreference: earlyAgentPreference,
|
|
3551
|
+
getClient,
|
|
3552
|
+
})
|
|
3553
|
+
if (modelInfo.type === 'none') {
|
|
3554
|
+
return undefined
|
|
3555
|
+
}
|
|
3556
|
+
return { providerID: modelInfo.providerID, modelID: modelInfo.modelID }
|
|
3557
|
+
}),
|
|
3558
|
+
getVariantCascade({
|
|
3559
|
+
sessionId: session.id,
|
|
3560
|
+
channelId,
|
|
3561
|
+
appId: resolvedAppId,
|
|
3562
|
+
}),
|
|
3563
|
+
])
|
|
3564
|
+
if (earlyModelResult instanceof Error) {
|
|
3565
|
+
this.stopTyping()
|
|
3566
|
+
await sendThreadMessage(
|
|
3567
|
+
this.thread,
|
|
3568
|
+
`Failed to resolve model: ${earlyModelResult.message}`,
|
|
3569
|
+
{ flags: NOTIFY_MESSAGE_FLAGS },
|
|
3570
|
+
)
|
|
3571
|
+
// Show indicator: dispatch failed mid-setup, next queued message was waiting.
|
|
3572
|
+
await this.tryDrainQueue({ showIndicator: true })
|
|
3573
|
+
return
|
|
3574
|
+
}
|
|
3575
|
+
const earlyModelParam = earlyModelResult
|
|
3576
|
+
if (!earlyModelParam) {
|
|
3577
|
+
this.stopTyping()
|
|
3578
|
+
await sendThreadMessage(
|
|
3579
|
+
this.thread,
|
|
3580
|
+
'No AI provider connected. Configure a provider in OpenCode with `/connect` command.',
|
|
3581
|
+
)
|
|
3582
|
+
// Show indicator: dispatch failed, next queued message was waiting.
|
|
3583
|
+
await this.tryDrainQueue({ showIndicator: true })
|
|
3584
|
+
return
|
|
3585
|
+
}
|
|
3586
|
+
|
|
3587
|
+
// Resolve thinking variant
|
|
3588
|
+
const earlyThinkingValue = await (async (): Promise<string | undefined> => {
|
|
3589
|
+
if (!preferredVariant) {
|
|
3590
|
+
return undefined
|
|
3591
|
+
}
|
|
3592
|
+
const providersResponse = await errore.tryAsync(() => {
|
|
3593
|
+
return getClient().provider.list({ directory: this.sdkDirectory })
|
|
3594
|
+
})
|
|
3595
|
+
if (providersResponse instanceof Error || !providersResponse.data) {
|
|
3596
|
+
return undefined
|
|
3597
|
+
}
|
|
3598
|
+
const availableValues = getThinkingValuesForModel({
|
|
3599
|
+
providers: providersResponse.data.all,
|
|
3600
|
+
providerId: earlyModelParam.providerID,
|
|
3601
|
+
modelId: earlyModelParam.modelID,
|
|
3602
|
+
})
|
|
3603
|
+
if (availableValues.length === 0) {
|
|
3604
|
+
return undefined
|
|
3605
|
+
}
|
|
3606
|
+
return matchThinkingValue({
|
|
3607
|
+
requestedValue: preferredVariant,
|
|
3608
|
+
availableValues,
|
|
3609
|
+
}) || undefined
|
|
3610
|
+
})()
|
|
3611
|
+
|
|
3612
|
+
await this.ensureModelContextLimit({
|
|
3613
|
+
providerID: earlyModelParam.providerID,
|
|
3614
|
+
modelID: earlyModelParam.modelID,
|
|
3615
|
+
})
|
|
3616
|
+
|
|
3617
|
+
// ── Build prompt parts ────────────────────────────────────
|
|
3618
|
+
const images = input.images || []
|
|
3619
|
+
const promptWithImagePaths = (() => {
|
|
3620
|
+
if (images.length === 0) {
|
|
3621
|
+
return input.prompt
|
|
3622
|
+
}
|
|
3623
|
+
const imageList = images
|
|
3624
|
+
.map((img) => {
|
|
3625
|
+
return `- ${img.sourceUrl || img.filename}`
|
|
3626
|
+
})
|
|
3627
|
+
.join('\n')
|
|
3628
|
+
return `${input.prompt}\n\n**The following images are already included in this message as inline content (do not use Read tool on these):**\n${imageList}`
|
|
3629
|
+
})()
|
|
3630
|
+
|
|
3631
|
+
// ── Worktree info for per-turn prompt context ─────────────
|
|
3632
|
+
const worktreeInfo = await getThreadWorktree(this.thread.id)
|
|
3633
|
+
const worktree: WorktreeInfo | undefined =
|
|
3634
|
+
worktreeInfo?.status === 'ready' && worktreeInfo.worktree_directory
|
|
3635
|
+
? {
|
|
3636
|
+
worktreeDirectory: worktreeInfo.worktree_directory,
|
|
3637
|
+
branch: worktreeInfo.worktree_name,
|
|
3638
|
+
mainRepoDirectory: worktreeInfo.project_directory,
|
|
3639
|
+
}
|
|
3640
|
+
: undefined
|
|
3641
|
+
|
|
3642
|
+
const channelTopic = await (async () => {
|
|
3643
|
+
if (this.thread.parent?.type === ChannelType.GuildText) {
|
|
3644
|
+
return this.thread.parent.topic?.trim() || undefined
|
|
3645
|
+
}
|
|
3646
|
+
if (!channelId) {
|
|
3647
|
+
return undefined
|
|
3648
|
+
}
|
|
3649
|
+
const fetched = await errore.tryAsync(() => {
|
|
3650
|
+
return this.thread.guild.channels.fetch(channelId)
|
|
3651
|
+
})
|
|
3652
|
+
if (fetched instanceof Error || !fetched) {
|
|
3653
|
+
return undefined
|
|
3654
|
+
}
|
|
3655
|
+
if (fetched.type !== ChannelType.GuildText) {
|
|
3656
|
+
return undefined
|
|
3657
|
+
}
|
|
3658
|
+
return fetched.topic?.trim() || undefined
|
|
3659
|
+
})()
|
|
3660
|
+
const worktreeChanged = this.consumeWorktreePromptChange(worktree)
|
|
3661
|
+
const syntheticContext = getOpencodePromptContext({
|
|
3662
|
+
username: input.username,
|
|
3663
|
+
userId: input.userId,
|
|
3664
|
+
sourceMessageId: input.sourceMessageId,
|
|
3665
|
+
sourceThreadId: input.sourceThreadId,
|
|
3666
|
+
repliedMessage: input.repliedMessage,
|
|
3667
|
+
worktree,
|
|
3668
|
+
currentAgent: earlyAgentPreference,
|
|
3669
|
+
worktreeChanged,
|
|
3670
|
+
})
|
|
3671
|
+
const parts = [
|
|
3672
|
+
{ type: 'text' as const, text: promptWithImagePaths },
|
|
3673
|
+
{ type: 'text' as const, text: syntheticContext, synthetic: true },
|
|
3674
|
+
...images,
|
|
3675
|
+
]
|
|
3676
|
+
|
|
3677
|
+
const variantField = earlyThinkingValue
|
|
3678
|
+
? { variant: earlyThinkingValue }
|
|
3679
|
+
: {}
|
|
3680
|
+
|
|
3681
|
+
const parseOpenCodeErrorMessage = (err: unknown): string => {
|
|
3682
|
+
if (err && typeof err === 'object') {
|
|
3683
|
+
if (
|
|
3684
|
+
'data' in err &&
|
|
3685
|
+
err.data &&
|
|
3686
|
+
typeof err.data === 'object' &&
|
|
3687
|
+
'message' in err.data
|
|
3688
|
+
) {
|
|
3689
|
+
return String(err.data.message)
|
|
3690
|
+
}
|
|
3691
|
+
if (
|
|
3692
|
+
'errors' in err &&
|
|
3693
|
+
Array.isArray(err.errors) &&
|
|
3694
|
+
err.errors.length > 0
|
|
3695
|
+
) {
|
|
3696
|
+
return JSON.stringify(err.errors)
|
|
3697
|
+
}
|
|
3698
|
+
if ('message' in err && typeof err.message === 'string') {
|
|
3699
|
+
return err.message
|
|
3700
|
+
}
|
|
3701
|
+
}
|
|
3702
|
+
return 'Unknown OpenCode API error'
|
|
3703
|
+
}
|
|
3704
|
+
|
|
3705
|
+
if (input.command) {
|
|
3706
|
+
const queuedCommand = input.command
|
|
3707
|
+
const commandSignal = AbortSignal.timeout(30_000)
|
|
3708
|
+
// session.command() only accepts FilePart in parts, not text parts.
|
|
3709
|
+
// Append <discord-user /> tag to arguments so external sync can
|
|
3710
|
+
// detect this message came from Discord (same tag as promptAsync).
|
|
3711
|
+
const discordTag = getOpencodePromptContext({
|
|
3712
|
+
username: input.username,
|
|
3713
|
+
userId: input.userId,
|
|
3714
|
+
sourceMessageId: input.sourceMessageId,
|
|
3715
|
+
sourceThreadId: input.sourceThreadId,
|
|
3716
|
+
repliedMessage: input.repliedMessage,
|
|
3717
|
+
})
|
|
3718
|
+
const commandResponse = await errore.tryAsync(() => {
|
|
3719
|
+
return getClient().session.command(
|
|
3720
|
+
{
|
|
3721
|
+
sessionID: session.id,
|
|
3722
|
+
|
|
3723
|
+
directory: this.sdkDirectory,
|
|
3724
|
+
command: queuedCommand.name,
|
|
3725
|
+
arguments: queuedCommand.arguments + (discordTag ? `\n${discordTag}` : ''),
|
|
3726
|
+
agent: earlyAgentPreference,
|
|
3727
|
+
...variantField,
|
|
3728
|
+
},
|
|
3729
|
+
{ signal: commandSignal },
|
|
3730
|
+
)
|
|
3731
|
+
})
|
|
3732
|
+
|
|
3733
|
+
if (commandResponse instanceof Error) {
|
|
3734
|
+
const timeoutReason = commandSignal.reason
|
|
3735
|
+
const timedOut =
|
|
3736
|
+
commandSignal.aborted &&
|
|
3737
|
+
timeoutReason instanceof Error &&
|
|
3738
|
+
timeoutReason.name === 'TimeoutError'
|
|
3739
|
+
if (timedOut) {
|
|
3740
|
+
logger.warn(
|
|
3741
|
+
`[DISPATCH] Command timed out after 30s sessionId=${session.id}`,
|
|
3742
|
+
)
|
|
3743
|
+
this.stopTyping()
|
|
3744
|
+
await sendThreadMessage(
|
|
3745
|
+
this.thread,
|
|
3746
|
+
'✗ Command timed out after 30 seconds. Try a shorter command or run it with /run-shell-command.',
|
|
3747
|
+
{ flags: NOTIFY_MESSAGE_FLAGS },
|
|
3748
|
+
)
|
|
3749
|
+
await this.dispatchAction(() => {
|
|
3750
|
+
return this.tryDrainQueue({ showIndicator: true })
|
|
3751
|
+
})
|
|
3752
|
+
return
|
|
3753
|
+
}
|
|
3754
|
+
|
|
3755
|
+
const commandErrorForAbortCheck: unknown = commandResponse
|
|
3756
|
+
if (isAbortError(commandErrorForAbortCheck)) {
|
|
3757
|
+
logger.log(
|
|
3758
|
+
`[DISPATCH] Command aborted (expected) sessionId=${session.id}`,
|
|
3759
|
+
)
|
|
3760
|
+
this.stopTyping()
|
|
3761
|
+
return
|
|
3762
|
+
}
|
|
3763
|
+
|
|
3764
|
+
logger.error(
|
|
3765
|
+
`[DISPATCH] Command SDK call failed: ${commandResponse.message}`,
|
|
3766
|
+
)
|
|
3767
|
+
void notifyError(commandResponse, 'Failed to send command to OpenCode')
|
|
3768
|
+
this.stopTyping()
|
|
3769
|
+
await sendThreadMessage(
|
|
3770
|
+
this.thread,
|
|
3771
|
+
`✗ Unexpected bot Error: ${commandResponse.message}`,
|
|
3772
|
+
{ flags: NOTIFY_MESSAGE_FLAGS },
|
|
3773
|
+
)
|
|
3774
|
+
await this.dispatchAction(() => {
|
|
3775
|
+
return this.tryDrainQueue({ showIndicator: true })
|
|
3776
|
+
})
|
|
3777
|
+
return
|
|
3778
|
+
}
|
|
3779
|
+
|
|
3780
|
+
if (commandResponse.error) {
|
|
3781
|
+
const errorMessage = parseOpenCodeErrorMessage(commandResponse.error)
|
|
3782
|
+
if (errorMessage.includes('aborted')) {
|
|
3783
|
+
logger.log(
|
|
3784
|
+
`[DISPATCH] Command aborted (expected) sessionId=${session.id}`,
|
|
3785
|
+
)
|
|
3786
|
+
this.stopTyping()
|
|
3787
|
+
return
|
|
3788
|
+
}
|
|
3789
|
+
const apiError = new Error(`OpenCode API error: ${errorMessage}`)
|
|
3790
|
+
logger.error(`[DISPATCH] ${apiError.message}`)
|
|
3791
|
+
void notifyError(apiError, 'OpenCode API error during command')
|
|
3792
|
+
this.stopTyping()
|
|
3793
|
+
await sendThreadMessage(this.thread, `✗ ${apiError.message}`, {
|
|
3794
|
+
flags: NOTIFY_MESSAGE_FLAGS,
|
|
3795
|
+
})
|
|
3796
|
+
await this.dispatchAction(() => {
|
|
3797
|
+
return this.tryDrainQueue({ showIndicator: true })
|
|
3798
|
+
})
|
|
3799
|
+
return
|
|
3800
|
+
}
|
|
3801
|
+
|
|
3802
|
+
logger.log(`[DISPATCH] Successfully ran command for session ${session.id}`)
|
|
3803
|
+
return
|
|
3804
|
+
}
|
|
3805
|
+
|
|
3806
|
+
const promptResponse = await errore.tryAsync(() => {
|
|
3807
|
+
return getClient().session.promptAsync({
|
|
3808
|
+
sessionID: session.id,
|
|
3809
|
+
directory: this.sdkDirectory,
|
|
3810
|
+
parts,
|
|
3811
|
+
system: getOpencodeSystemMessage({
|
|
3812
|
+
sessionId: session.id,
|
|
3813
|
+
channelId,
|
|
3814
|
+
guildId: this.thread.guildId,
|
|
3815
|
+
threadId: this.thread.id,
|
|
3816
|
+
channelTopic,
|
|
3817
|
+
agents: earlyAvailableAgents,
|
|
3818
|
+
username: this.state?.sessionUsername || input.username,
|
|
3819
|
+
}),
|
|
3820
|
+
model: earlyModelParam,
|
|
3821
|
+
agent: earlyAgentPreference,
|
|
3822
|
+
...variantField,
|
|
3823
|
+
})
|
|
3824
|
+
})
|
|
3825
|
+
|
|
3826
|
+
if (promptResponse instanceof Error || promptResponse.error) {
|
|
3827
|
+
const errorMessage = (() => {
|
|
3828
|
+
if (promptResponse instanceof Error) {
|
|
3829
|
+
return promptResponse.message
|
|
3830
|
+
}
|
|
3831
|
+
return parseOpenCodeErrorMessage(promptResponse.error)
|
|
3832
|
+
})()
|
|
3833
|
+
const errorObject = promptResponse instanceof Error
|
|
3834
|
+
? promptResponse
|
|
3835
|
+
: new Error(errorMessage)
|
|
3836
|
+
logger.error(`[DISPATCH] Prompt API call failed: ${errorMessage}`)
|
|
3837
|
+
void notifyError(errorObject, 'OpenCode API error during local queue prompt')
|
|
3838
|
+
this.stopTyping()
|
|
3839
|
+
await sendThreadMessage(this.thread, `✗ OpenCode API error: ${errorMessage}`, {
|
|
3840
|
+
flags: NOTIFY_MESSAGE_FLAGS,
|
|
3841
|
+
})
|
|
3842
|
+
await this.dispatchAction(() => {
|
|
3843
|
+
return this.tryDrainQueue({ showIndicator: true })
|
|
3844
|
+
})
|
|
3845
|
+
return
|
|
3846
|
+
}
|
|
3847
|
+
|
|
3848
|
+
logger.log(
|
|
3849
|
+
`[DISPATCH] promptAsync accepted by opencode queue sessionId=${session.id} threadId=${this.threadId}`,
|
|
3850
|
+
)
|
|
3851
|
+
}
|
|
3852
|
+
|
|
3853
|
+
// ── Session Ensure ──────────────────────────────────────────
|
|
3854
|
+
// Creates or reuses the OpenCode session for this thread.
|
|
3855
|
+
|
|
3856
|
+
private async ensureSession({
|
|
3857
|
+
prompt,
|
|
3858
|
+
agent,
|
|
3859
|
+
permissions,
|
|
3860
|
+
injectionGuardPatterns,
|
|
3861
|
+
sessionStartScheduleKind,
|
|
3862
|
+
sessionStartScheduledTaskId,
|
|
3863
|
+
}: {
|
|
3864
|
+
prompt: string
|
|
3865
|
+
agent?: string
|
|
3866
|
+
/** Raw "tool:action" strings from --permission flag */
|
|
3867
|
+
permissions?: string[]
|
|
3868
|
+
injectionGuardPatterns?: string[]
|
|
3869
|
+
sessionStartScheduleKind?: 'at' | 'cron'
|
|
3870
|
+
sessionStartScheduledTaskId?: number
|
|
3871
|
+
}): Promise<
|
|
3872
|
+
| Error
|
|
3873
|
+
| {
|
|
3874
|
+
session: { id: string }
|
|
3875
|
+
getClient: () => OpencodeClient
|
|
3876
|
+
createdNewSession: boolean
|
|
3877
|
+
}
|
|
3878
|
+
> {
|
|
3879
|
+
const directory = this.projectDirectory
|
|
3880
|
+
|
|
3881
|
+
// Resolve worktree info for server initialization
|
|
3882
|
+
const worktreeInfo = await getThreadWorktree(this.thread.id)
|
|
3883
|
+
const worktreeDirectory =
|
|
3884
|
+
worktreeInfo?.status === 'ready' && worktreeInfo.worktree_directory
|
|
3885
|
+
? worktreeInfo.worktree_directory
|
|
3886
|
+
: undefined
|
|
3887
|
+
const originalRepoDirectory = worktreeDirectory
|
|
3888
|
+
? worktreeInfo?.project_directory
|
|
3889
|
+
: undefined
|
|
3890
|
+
|
|
3891
|
+
const getClientResult = await initializeOpencodeForDirectory(directory, {
|
|
3892
|
+
originalRepoDirectory,
|
|
3893
|
+
channelId: this.channelId,
|
|
3894
|
+
})
|
|
3895
|
+
if (getClientResult instanceof Error) {
|
|
3896
|
+
return getClientResult
|
|
3897
|
+
}
|
|
3898
|
+
const getClient = getClientResult
|
|
3899
|
+
|
|
3900
|
+
// Check thread state for existing session ID
|
|
3901
|
+
let sessionId = this.state?.sessionId
|
|
3902
|
+
if (!sessionId) {
|
|
3903
|
+
// Fallback to DB
|
|
3904
|
+
sessionId = await getThreadSession(this.thread.id) || undefined
|
|
3905
|
+
}
|
|
3906
|
+
|
|
3907
|
+
let session: { id: string } | undefined
|
|
3908
|
+
let createdNewSession = false
|
|
3909
|
+
|
|
3910
|
+
if (sessionId) {
|
|
3911
|
+
const sessionResponse = await errore.tryAsync(() => {
|
|
3912
|
+
return getClient().session.get({
|
|
3913
|
+
sessionID: sessionId,
|
|
3914
|
+
directory: this.sdkDirectory,
|
|
3915
|
+
})
|
|
3916
|
+
})
|
|
3917
|
+
if (!(sessionResponse instanceof Error) && sessionResponse.data) {
|
|
3918
|
+
session = sessionResponse.data
|
|
3919
|
+
}
|
|
3920
|
+
}
|
|
3921
|
+
|
|
3922
|
+
if (!session) {
|
|
3923
|
+
// Pass per-session external_directory permissions so this session can
|
|
3924
|
+
// access its own project directory (and worktree origin if applicable)
|
|
3925
|
+
// without prompts. These override the server-level 'ask' default via
|
|
3926
|
+
// opencode's findLast() rule evaluation.
|
|
3927
|
+
// CLI --permission rules are appended after base rules so they win
|
|
3928
|
+
// via opencode's findLast() evaluation.
|
|
3929
|
+
const sessionPermissions = [
|
|
3930
|
+
...buildSessionPermissions({
|
|
3931
|
+
directory: this.sdkDirectory,
|
|
3932
|
+
originalRepoDirectory,
|
|
3933
|
+
}),
|
|
3934
|
+
...parsePermissionRules(permissions ?? []),
|
|
3935
|
+
]
|
|
3936
|
+
// Omit title so OpenCode auto-generates a summary from the conversation
|
|
3937
|
+
const sessionResponse = await getClient().session.create({
|
|
3938
|
+
directory: this.sdkDirectory,
|
|
3939
|
+
permission: sessionPermissions,
|
|
3940
|
+
})
|
|
3941
|
+
session = sessionResponse.data
|
|
3942
|
+
// Insert DB row immediately so the external-sync poller sees
|
|
3943
|
+
// source='kimaki' before the next poll tick and skips this session.
|
|
3944
|
+
// The upsert at the end of ensureSession is kept for the reuse path.
|
|
3945
|
+
if (session) {
|
|
3946
|
+
await setThreadSession(this.thread.id, session.id)
|
|
3947
|
+
if (injectionGuardPatterns?.length) {
|
|
3948
|
+
writeInjectionGuardConfig({
|
|
3949
|
+
sessionId: session.id,
|
|
3950
|
+
scanPatterns: injectionGuardPatterns,
|
|
3951
|
+
})
|
|
3952
|
+
}
|
|
3953
|
+
}
|
|
3954
|
+
createdNewSession = true
|
|
3955
|
+
}
|
|
3956
|
+
|
|
3957
|
+
if (!session) {
|
|
3958
|
+
return new Error('Failed to create or get session')
|
|
3959
|
+
}
|
|
3960
|
+
|
|
3961
|
+
// Store session in DB and thread state
|
|
3962
|
+
await setThreadSession(this.thread.id, session.id)
|
|
3963
|
+
threadState.setSessionId(this.threadId, session.id)
|
|
3964
|
+
await this.hydrateSessionEventsFromDatabase({ sessionId: session.id })
|
|
3965
|
+
|
|
3966
|
+
// Store session start source for scheduled tasks
|
|
3967
|
+
if (createdNewSession && sessionStartScheduleKind) {
|
|
3968
|
+
const sessionStartSourceResult = await errore.tryAsync({
|
|
3969
|
+
try: () => {
|
|
3970
|
+
return setSessionStartSource({
|
|
3971
|
+
sessionId: session.id,
|
|
3972
|
+
scheduleKind: sessionStartScheduleKind,
|
|
3973
|
+
scheduledTaskId: sessionStartScheduledTaskId,
|
|
3974
|
+
})
|
|
3975
|
+
},
|
|
3976
|
+
catch: (e) =>
|
|
3977
|
+
new Error('Failed to persist scheduled session start source', {
|
|
3978
|
+
cause: e,
|
|
3979
|
+
}),
|
|
3980
|
+
})
|
|
3981
|
+
if (sessionStartSourceResult instanceof Error) {
|
|
3982
|
+
logger.warn(
|
|
3983
|
+
`[SESSION START SOURCE] ${sessionStartSourceResult.message}`,
|
|
3984
|
+
)
|
|
3985
|
+
}
|
|
3986
|
+
}
|
|
3987
|
+
|
|
3988
|
+
// Store agent preference if provided
|
|
3989
|
+
if (agent && createdNewSession) {
|
|
3990
|
+
await setSessionAgent(session.id, agent)
|
|
3991
|
+
}
|
|
3992
|
+
|
|
3993
|
+
return { session, getClient, createdNewSession }
|
|
3994
|
+
}
|
|
3995
|
+
|
|
3996
|
+
/**
|
|
3997
|
+
* Emit the run footer: duration, model, context%, project info.
|
|
3998
|
+
* Triggered directly from the terminal assistant message.updated event so the
|
|
3999
|
+
* footer lands next to the assistant output instead of waiting for session.idle.
|
|
4000
|
+
*/
|
|
4001
|
+
private async emitFooter({
|
|
4002
|
+
completedAt,
|
|
4003
|
+
runStartTime,
|
|
4004
|
+
}: {
|
|
4005
|
+
completedAt: number
|
|
4006
|
+
runStartTime: number
|
|
4007
|
+
}): Promise<void> {
|
|
4008
|
+
const sessionId = this.state?.sessionId
|
|
4009
|
+
const runInfo = sessionId
|
|
4010
|
+
? getLatestRunInfo({ events: this.eventBuffer, sessionId })
|
|
4011
|
+
: {
|
|
4012
|
+
model: undefined,
|
|
4013
|
+
providerID: undefined,
|
|
4014
|
+
agent: undefined,
|
|
4015
|
+
tokensUsed: 0,
|
|
4016
|
+
}
|
|
4017
|
+
const elapsedMs = completedAt - runStartTime
|
|
4018
|
+
const sessionDuration =
|
|
4019
|
+
elapsedMs < 1000
|
|
4020
|
+
? '<1s'
|
|
4021
|
+
: prettyMilliseconds(elapsedMs, { secondsDecimalDigits: 0 })
|
|
4022
|
+
const modelInfo = runInfo.model ? ` ⋅ ${runInfo.model}` : ''
|
|
4023
|
+
const agentInfo =
|
|
4024
|
+
runInfo.agent && runInfo.agent.toLowerCase() !== 'build'
|
|
4025
|
+
? ` ⋅ **${runInfo.agent}**`
|
|
4026
|
+
: ''
|
|
4027
|
+
let contextInfo = ''
|
|
4028
|
+
const folderName = path.basename(this.sdkDirectory)
|
|
4029
|
+
|
|
4030
|
+
const client = getOpencodeClient(this.projectDirectory)
|
|
4031
|
+
|
|
4032
|
+
// Run git branch and token fetch in parallel (fast, no external CLI)
|
|
4033
|
+
const [branchResult, contextResult] = await Promise.all([
|
|
4034
|
+
errore.tryAsync(() => {
|
|
4035
|
+
return execAsync('git symbolic-ref --short HEAD', {
|
|
4036
|
+
cwd: this.sdkDirectory,
|
|
4037
|
+
})
|
|
4038
|
+
}),
|
|
4039
|
+
errore.tryAsync(async () => {
|
|
4040
|
+
if (!client || !sessionId) {
|
|
4041
|
+
return
|
|
4042
|
+
}
|
|
4043
|
+
let tokensUsed = runInfo.tokensUsed
|
|
4044
|
+
// Fetch final token count from API
|
|
4045
|
+
const [messagesResult, providersResult] = await Promise.all([
|
|
4046
|
+
tokensUsed === 0
|
|
4047
|
+
? errore.tryAsync(() => {
|
|
4048
|
+
return client.session.messages({
|
|
4049
|
+
sessionID: sessionId,
|
|
4050
|
+
directory: this.sdkDirectory,
|
|
4051
|
+
})
|
|
4052
|
+
})
|
|
4053
|
+
: null,
|
|
4054
|
+
errore.tryAsync(() => {
|
|
4055
|
+
return client.provider.list({
|
|
4056
|
+
directory: this.sdkDirectory,
|
|
4057
|
+
})
|
|
4058
|
+
}),
|
|
4059
|
+
])
|
|
4060
|
+
|
|
4061
|
+
if (messagesResult && !(messagesResult instanceof Error)) {
|
|
4062
|
+
const messages = messagesResult.data || []
|
|
4063
|
+
const lastAssistant = [...messages]
|
|
4064
|
+
.reverse()
|
|
4065
|
+
.find((m) => {
|
|
4066
|
+
if (m.info.role !== 'assistant') {
|
|
4067
|
+
return false
|
|
4068
|
+
}
|
|
4069
|
+
if (!m.info.tokens) {
|
|
4070
|
+
return false
|
|
4071
|
+
}
|
|
4072
|
+
return getTokenTotal(m.info.tokens) > 0
|
|
4073
|
+
})
|
|
4074
|
+
if (lastAssistant && 'tokens' in lastAssistant.info) {
|
|
4075
|
+
tokensUsed = getTokenTotal(lastAssistant.info.tokens)
|
|
4076
|
+
}
|
|
4077
|
+
}
|
|
4078
|
+
|
|
4079
|
+
const fallbackLimit = runInfo.providerID
|
|
4080
|
+
? getFallbackContextLimit({
|
|
4081
|
+
providerID: runInfo.providerID,
|
|
4082
|
+
})
|
|
4083
|
+
: undefined
|
|
4084
|
+
|
|
4085
|
+
let contextLimit = fallbackLimit
|
|
4086
|
+
if (providersResult && !(providersResult instanceof Error)) {
|
|
4087
|
+
const provider = providersResult.data?.all?.find((p) => {
|
|
4088
|
+
return p.id === runInfo.providerID
|
|
4089
|
+
})
|
|
4090
|
+
const model = provider?.models?.[runInfo.model || '']
|
|
4091
|
+
contextLimit = model?.limit?.context || contextLimit
|
|
4092
|
+
}
|
|
4093
|
+
|
|
4094
|
+
if (contextLimit) {
|
|
4095
|
+
const percentage = Math.round(
|
|
4096
|
+
(tokensUsed / contextLimit) * 100,
|
|
4097
|
+
)
|
|
4098
|
+
contextInfo = ` ⋅ ${percentage}%`
|
|
4099
|
+
}
|
|
4100
|
+
}),
|
|
4101
|
+
])
|
|
4102
|
+
const branchName =
|
|
4103
|
+
branchResult instanceof Error ? '' : branchResult.stdout.trim()
|
|
4104
|
+
if (contextResult instanceof Error) {
|
|
4105
|
+
logger.error(
|
|
4106
|
+
'Failed to fetch provider info for context percentage:',
|
|
4107
|
+
contextResult,
|
|
4108
|
+
)
|
|
4109
|
+
}
|
|
4110
|
+
|
|
4111
|
+
const truncate = (s: string, max: number) => {
|
|
4112
|
+
return s.length > max ? s.slice(0, max - 1) + '\u2026' : s
|
|
4113
|
+
}
|
|
4114
|
+
const truncatedFolder = truncate(folderName, 15)
|
|
4115
|
+
const truncatedBranch = truncate(branchName, 15)
|
|
4116
|
+
const projectInfo = truncatedBranch
|
|
4117
|
+
? `${truncatedFolder} ⋅ ${truncatedBranch} ⋅ `
|
|
4118
|
+
: `${truncatedFolder} ⋅ `
|
|
4119
|
+
const footerText = `*${projectInfo}${sessionDuration}${contextInfo}${modelInfo}${agentInfo}*`
|
|
4120
|
+
this.stopTyping()
|
|
4121
|
+
|
|
4122
|
+
// Skip notification if there's a queued message next — the user only
|
|
4123
|
+
// needs to be notified when the entire queue finishes.
|
|
4124
|
+
await sendThreadMessage(this.thread, footerText, {
|
|
4125
|
+
flags: this.getNotifyFlags(),
|
|
4126
|
+
})
|
|
4127
|
+
logger.log(
|
|
4128
|
+
`DURATION: Session completed in ${sessionDuration}, model ${runInfo.model}, tokens ${runInfo.tokensUsed}`,
|
|
4129
|
+
)
|
|
4130
|
+
}
|
|
4131
|
+
|
|
4132
|
+
/** Reset per-run state for the next prompt dispatch. */
|
|
4133
|
+
private resetPerRunState(): void {
|
|
4134
|
+
this.modelContextLimit = undefined
|
|
4135
|
+
this.modelContextLimitKey = undefined
|
|
4136
|
+
this.lastDisplayedContextPercentage = 0
|
|
4137
|
+
this.lastRateLimitDisplayTime = 0
|
|
4138
|
+
}
|
|
4139
|
+
|
|
4140
|
+
// ── Retry Last User Prompt (for model-change flow) ──────────
|
|
4141
|
+
|
|
4142
|
+
/**
|
|
4143
|
+
* Abort the active run and immediately send an empty user prompt.
|
|
4144
|
+
*
|
|
4145
|
+
* Used by /model and /unset-model so opencode can restart from the
|
|
4146
|
+
* current session history with the updated model preference, without
|
|
4147
|
+
* replaying/fetching the last user message in kimaki.
|
|
4148
|
+
*/
|
|
4149
|
+
async retryLastUserPrompt(): Promise<boolean> {
|
|
4150
|
+
const state = this.state
|
|
4151
|
+
if (!state?.sessionId) {
|
|
4152
|
+
logger.log(`[RETRY] No session for thread ${this.threadId}`)
|
|
4153
|
+
return false
|
|
4154
|
+
}
|
|
4155
|
+
|
|
4156
|
+
const sessionId = state.sessionId
|
|
4157
|
+
|
|
4158
|
+
// 1. Abort active run.
|
|
4159
|
+
let needsIdleWait = false
|
|
4160
|
+
const waitSinceTimestamp = Date.now()
|
|
4161
|
+
const abortResult = await errore.tryAsync(() => {
|
|
4162
|
+
return this.dispatchAction(async () => {
|
|
4163
|
+
needsIdleWait = this.isMainSessionBusy()
|
|
4164
|
+
const outcome = this.abortActiveRunInternal({
|
|
4165
|
+
reason: 'model-change',
|
|
4166
|
+
})
|
|
4167
|
+
if (outcome.apiAbortPromise) {
|
|
4168
|
+
void outcome.apiAbortPromise
|
|
4169
|
+
}
|
|
4170
|
+
})
|
|
4171
|
+
})
|
|
4172
|
+
if (abortResult instanceof Error) {
|
|
4173
|
+
logger.error('[RETRY] Failed to abort active run before retry:', abortResult)
|
|
4174
|
+
return false
|
|
4175
|
+
}
|
|
4176
|
+
|
|
4177
|
+
if (needsIdleWait) {
|
|
4178
|
+
await this.waitForEvent({
|
|
4179
|
+
predicate: (event) => {
|
|
4180
|
+
return event.type === 'session.idle'
|
|
4181
|
+
&& (event.properties as { sessionID?: string }).sessionID === sessionId
|
|
4182
|
+
},
|
|
4183
|
+
sinceTimestamp: waitSinceTimestamp,
|
|
4184
|
+
timeoutMs: 2000,
|
|
4185
|
+
})
|
|
4186
|
+
}
|
|
4187
|
+
|
|
4188
|
+
if (this.listenerAborted) {
|
|
4189
|
+
logger.log(`[RETRY] Runtime disposed before retry for thread ${this.threadId}`)
|
|
4190
|
+
return false
|
|
4191
|
+
}
|
|
4192
|
+
|
|
4193
|
+
if (this.state?.sessionId !== sessionId) {
|
|
4194
|
+
logger.log(
|
|
4195
|
+
`[RETRY] Session changed before retry for thread ${this.threadId}`,
|
|
4196
|
+
)
|
|
4197
|
+
return false
|
|
4198
|
+
}
|
|
4199
|
+
|
|
4200
|
+
logger.log(
|
|
4201
|
+
`[RETRY] Re-submitting with empty prompt for session ${sessionId}`,
|
|
4202
|
+
)
|
|
4203
|
+
|
|
4204
|
+
// 2. Re-submit with empty prompt so opencode continues from session history.
|
|
4205
|
+
await this.enqueueIncoming({
|
|
4206
|
+
prompt: '',
|
|
4207
|
+
userId: '',
|
|
4208
|
+
username: '',
|
|
4209
|
+
appId: this.appId,
|
|
4210
|
+
mode: 'opencode',
|
|
4211
|
+
resetAssistantForNewRun: true,
|
|
4212
|
+
expectedSessionId: sessionId,
|
|
4213
|
+
})
|
|
4214
|
+
|
|
4215
|
+
if (this.state?.sessionId !== sessionId) {
|
|
4216
|
+
logger.log(
|
|
4217
|
+
`[RETRY] Session changed while retry was enqueued for thread ${this.threadId}`,
|
|
4218
|
+
)
|
|
4219
|
+
return false
|
|
4220
|
+
}
|
|
4221
|
+
|
|
4222
|
+
return true
|
|
4223
|
+
}
|
|
4224
|
+
}
|
|
4225
|
+
|
|
4226
|
+
// ── Module-level helpers ──────────────────────────────────────────
|
|
4227
|
+
|
|
4228
|
+
function buildPermissionDedupeKey({
|
|
4229
|
+
permission,
|
|
4230
|
+
directory,
|
|
4231
|
+
}: {
|
|
4232
|
+
permission: PermissionRequest
|
|
4233
|
+
directory: string
|
|
4234
|
+
}): string {
|
|
4235
|
+
const normalizedPatterns = [...permission.patterns].sort((a, b) => {
|
|
4236
|
+
return a.localeCompare(b)
|
|
4237
|
+
})
|
|
4238
|
+
return `${directory}::${permission.permission}::${normalizedPatterns.join('|')}`
|
|
4239
|
+
}
|
|
4240
|
+
|
|
4241
|
+
function getFallbackContextLimit({
|
|
4242
|
+
providerID,
|
|
4243
|
+
}: {
|
|
4244
|
+
providerID: string
|
|
4245
|
+
}): number | undefined {
|
|
4246
|
+
if (providerID === 'deterministic-provider') {
|
|
4247
|
+
return DETERMINISTIC_CONTEXT_LIMIT
|
|
4248
|
+
}
|
|
4249
|
+
return undefined
|
|
4250
|
+
}
|
|
4251
|
+
|
|
4252
|
+
/** Format a session error from event properties for display. */
|
|
4253
|
+
function formatSessionErrorFromProps(error?: {
|
|
4254
|
+
name?: string
|
|
4255
|
+
data?: {
|
|
4256
|
+
message?: string
|
|
4257
|
+
statusCode?: number
|
|
4258
|
+
providerID?: string
|
|
4259
|
+
isRetryable?: boolean
|
|
4260
|
+
responseBody?: string
|
|
4261
|
+
}
|
|
4262
|
+
}): string {
|
|
4263
|
+
if (!error) {
|
|
4264
|
+
return 'Unknown error'
|
|
4265
|
+
}
|
|
4266
|
+
const data = error.data
|
|
4267
|
+
if (!data) {
|
|
4268
|
+
return error.name || 'Unknown error'
|
|
4269
|
+
}
|
|
4270
|
+
const parts: string[] = []
|
|
4271
|
+
if (data.message) {
|
|
4272
|
+
parts.push(data.message)
|
|
4273
|
+
}
|
|
4274
|
+
if (data.statusCode) {
|
|
4275
|
+
parts.push(`(${data.statusCode})`)
|
|
4276
|
+
}
|
|
4277
|
+
if (data.providerID) {
|
|
4278
|
+
parts.push(`[${data.providerID}]`)
|
|
4279
|
+
}
|
|
4280
|
+
return parts.length > 0 ? parts.join(' ') : error.name || 'Unknown error'
|
|
4281
|
+
}
|