@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,314 @@
|
|
|
1
|
+
// In-process HTTP server speaking the Hrana v2 protocol.
|
|
2
|
+
// Backed by the `libsql` npm package (better-sqlite3 API).
|
|
3
|
+
// Binds to the fixed lock port for single-instance enforcement.
|
|
4
|
+
//
|
|
5
|
+
// Protocol logic is implemented in the `libsqlproxy` package.
|
|
6
|
+
// This file handles: server lifecycle, single-instance enforcement,
|
|
7
|
+
// auth, and kimaki-specific endpoints (/kimaki/wake, /health).
|
|
8
|
+
//
|
|
9
|
+
// Hrana v2 protocol spec ("Hrana over HTTP"):
|
|
10
|
+
// https://github.com/tursodatabase/libsql/blob/main/docs/HTTP_V2_SPEC.md
|
|
11
|
+
|
|
12
|
+
import fs from 'node:fs'
|
|
13
|
+
import http from 'node:http'
|
|
14
|
+
import path from 'node:path'
|
|
15
|
+
import crypto from 'node:crypto'
|
|
16
|
+
import Database from 'libsql'
|
|
17
|
+
import * as errore from 'errore'
|
|
18
|
+
import {
|
|
19
|
+
createLibsqlHandler,
|
|
20
|
+
createLibsqlNodeHandler,
|
|
21
|
+
libsqlExecutor,
|
|
22
|
+
} from 'libsqlproxy'
|
|
23
|
+
import { createLogger, LogPrefix } from './logger.js'
|
|
24
|
+
import { ServerStartError, FetchError } from './errors.js'
|
|
25
|
+
import { getLockPort } from './config.js'
|
|
26
|
+
import { store } from './store.js'
|
|
27
|
+
|
|
28
|
+
const hranaLogger = createLogger(LogPrefix.DB)
|
|
29
|
+
|
|
30
|
+
let db: Database.Database | null = null
|
|
31
|
+
let server: http.Server | null = null
|
|
32
|
+
let hranaUrl: string | null = null
|
|
33
|
+
let discordGatewayReady = false
|
|
34
|
+
let readyWaiters: Array<() => void> = []
|
|
35
|
+
|
|
36
|
+
export function markDiscordGatewayReady(): void {
|
|
37
|
+
if (discordGatewayReady) {
|
|
38
|
+
return
|
|
39
|
+
}
|
|
40
|
+
discordGatewayReady = true
|
|
41
|
+
for (const resolve of readyWaiters) {
|
|
42
|
+
resolve()
|
|
43
|
+
}
|
|
44
|
+
readyWaiters = []
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function waitForDiscordGatewayReady({ timeoutMs }: { timeoutMs: number }): Promise<boolean> {
|
|
48
|
+
if (discordGatewayReady) {
|
|
49
|
+
return true
|
|
50
|
+
}
|
|
51
|
+
const readyPromise = new Promise<boolean>((resolve) => {
|
|
52
|
+
readyWaiters.push(() => {
|
|
53
|
+
resolve(true)
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
const timeoutPromise = new Promise<boolean>((resolve) => {
|
|
57
|
+
setTimeout(() => {
|
|
58
|
+
resolve(false)
|
|
59
|
+
}, timeoutMs)
|
|
60
|
+
})
|
|
61
|
+
return Promise.race([readyPromise, timeoutPromise])
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function getRequestAuthToken(req: http.IncomingMessage): string | null {
|
|
65
|
+
const authorizationHeader = req.headers.authorization
|
|
66
|
+
if (typeof authorizationHeader === 'string' && authorizationHeader.startsWith('Bearer ')) {
|
|
67
|
+
return authorizationHeader.slice('Bearer '.length)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return null
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Timing-safe comparison to prevent timing attacks when the hrana server
|
|
74
|
+
// is internet-facing (bindAll=true / KIMAKI_INTERNET_REACHABLE_URL set).
|
|
75
|
+
function isAuthorizedRequest(req: http.IncomingMessage): boolean {
|
|
76
|
+
const expectedToken = store.getState().gatewayToken
|
|
77
|
+
if (!expectedToken) {
|
|
78
|
+
return false
|
|
79
|
+
}
|
|
80
|
+
const providedToken = getRequestAuthToken(req)
|
|
81
|
+
if (!providedToken) {
|
|
82
|
+
return false
|
|
83
|
+
}
|
|
84
|
+
const expectedBuf = Buffer.from(expectedToken, 'utf8')
|
|
85
|
+
const providedBuf = Buffer.from(providedToken, 'utf8')
|
|
86
|
+
if (expectedBuf.length !== providedBuf.length) {
|
|
87
|
+
return false
|
|
88
|
+
}
|
|
89
|
+
return crypto.timingSafeEqual(expectedBuf, providedBuf)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function ensureServiceAuthTokenInStore(): string {
|
|
93
|
+
const existingToken = store.getState().gatewayToken
|
|
94
|
+
if (existingToken) {
|
|
95
|
+
return existingToken
|
|
96
|
+
}
|
|
97
|
+
const generatedToken = `${crypto.randomUUID()}:${crypto.randomBytes(32).toString('hex')}`
|
|
98
|
+
store.setState({ gatewayToken: generatedToken })
|
|
99
|
+
return generatedToken
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Get the Hrana HTTP URL for injecting into plugin child processes.
|
|
104
|
+
* Returns null if the server hasn't been started yet.
|
|
105
|
+
* Only used for KIMAKI_DB_URL env var in opencode.ts — the bot process
|
|
106
|
+
* itself always uses direct file: access via Prisma.
|
|
107
|
+
*/
|
|
108
|
+
export function getHranaUrl(): string | null {
|
|
109
|
+
return hranaUrl
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Start the in-process Hrana v2 server on the fixed lock port.
|
|
114
|
+
* Handles single-instance enforcement: if the port is occupied, kills the
|
|
115
|
+
* existing process first.
|
|
116
|
+
*/
|
|
117
|
+
export async function startHranaServer({
|
|
118
|
+
dbPath,
|
|
119
|
+
bindAll = false,
|
|
120
|
+
}: {
|
|
121
|
+
dbPath: string
|
|
122
|
+
/** Bind to 0.0.0.0 instead of 127.0.0.1. Set when KIMAKI_INTERNET_REACHABLE_URL is defined. */
|
|
123
|
+
bindAll?: boolean
|
|
124
|
+
}) {
|
|
125
|
+
if (server && db && hranaUrl) return hranaUrl
|
|
126
|
+
|
|
127
|
+
const port = getLockPort()
|
|
128
|
+
const bindHost = bindAll ? '0.0.0.0' : '127.0.0.1'
|
|
129
|
+
const serviceAuthToken = ensureServiceAuthTokenInStore()
|
|
130
|
+
process.env.KIMAKI_DB_AUTH_TOKEN = serviceAuthToken
|
|
131
|
+
|
|
132
|
+
fs.mkdirSync(path.dirname(dbPath), { recursive: true })
|
|
133
|
+
await evictExistingInstance({ port })
|
|
134
|
+
|
|
135
|
+
hranaLogger.log(
|
|
136
|
+
`Starting hrana server on ${bindHost}:${port} with db: ${dbPath}`,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
const database = new Database(dbPath)
|
|
140
|
+
database.exec('PRAGMA journal_mode = WAL')
|
|
141
|
+
database.exec('PRAGMA busy_timeout = 5000')
|
|
142
|
+
db = database
|
|
143
|
+
|
|
144
|
+
// Create the Hrana handler using libsqlproxy
|
|
145
|
+
const hranaFetchHandler = createLibsqlHandler(libsqlExecutor(database))
|
|
146
|
+
const hranaNodeHandler = createLibsqlNodeHandler(hranaFetchHandler)
|
|
147
|
+
|
|
148
|
+
// Combined handler: kimaki-specific endpoints + hrana protocol
|
|
149
|
+
const handler: http.RequestListener = async (req, res) => {
|
|
150
|
+
const pathname = new URL(req.url || '/', 'http://localhost').pathname
|
|
151
|
+
if (pathname === '/kimaki/wake') {
|
|
152
|
+
if (req.method !== 'POST') {
|
|
153
|
+
res.writeHead(405, { 'content-type': 'application/json' })
|
|
154
|
+
res.end(JSON.stringify({ error: 'method_not_allowed' }))
|
|
155
|
+
return
|
|
156
|
+
}
|
|
157
|
+
if (!isAuthorizedRequest(req)) {
|
|
158
|
+
res.writeHead(401, { 'content-type': 'application/json' })
|
|
159
|
+
res.end(JSON.stringify({ error: 'unauthorized' }))
|
|
160
|
+
return
|
|
161
|
+
}
|
|
162
|
+
const isReady = await waitForDiscordGatewayReady({ timeoutMs: 30_000 })
|
|
163
|
+
if (!isReady) {
|
|
164
|
+
res.writeHead(504, { 'content-type': 'application/json' })
|
|
165
|
+
res.end(JSON.stringify({ ready: false, error: 'timeout_waiting_for_discord_ready' }))
|
|
166
|
+
return
|
|
167
|
+
}
|
|
168
|
+
res.writeHead(200, { 'content-type': 'application/json' })
|
|
169
|
+
res.end(JSON.stringify({ ready: true }))
|
|
170
|
+
return
|
|
171
|
+
}
|
|
172
|
+
// Health check — no auth required
|
|
173
|
+
if (pathname === '/health') {
|
|
174
|
+
res.writeHead(200, { 'content-type': 'application/json' })
|
|
175
|
+
res.end(JSON.stringify({ status: 'ok', pid: process.pid }))
|
|
176
|
+
return
|
|
177
|
+
}
|
|
178
|
+
// Hrana routes: /v2, /v2/pipeline — require auth
|
|
179
|
+
if (pathname === '/v2' || pathname === '/v2/pipeline') {
|
|
180
|
+
if (!isAuthorizedRequest(req)) {
|
|
181
|
+
res.writeHead(401, { 'content-type': 'application/json' })
|
|
182
|
+
res.end(JSON.stringify({ error: 'unauthorized' }))
|
|
183
|
+
return
|
|
184
|
+
}
|
|
185
|
+
hranaNodeHandler(req, res)
|
|
186
|
+
return
|
|
187
|
+
}
|
|
188
|
+
res.writeHead(404)
|
|
189
|
+
res.end()
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const started = await new Promise<ServerStartError | true>((resolve) => {
|
|
193
|
+
const srv = http.createServer(handler)
|
|
194
|
+
|
|
195
|
+
srv.on('error', (err: NodeJS.ErrnoException) => {
|
|
196
|
+
resolve(
|
|
197
|
+
new ServerStartError({
|
|
198
|
+
port,
|
|
199
|
+
reason:
|
|
200
|
+
err.code === 'EADDRINUSE'
|
|
201
|
+
? `Port ${port} still in use after eviction`
|
|
202
|
+
: err.message,
|
|
203
|
+
}),
|
|
204
|
+
)
|
|
205
|
+
})
|
|
206
|
+
srv.listen(port, bindHost, () => {
|
|
207
|
+
server = srv
|
|
208
|
+
resolve(true)
|
|
209
|
+
})
|
|
210
|
+
})
|
|
211
|
+
if (started instanceof Error) {
|
|
212
|
+
database.close()
|
|
213
|
+
db = null
|
|
214
|
+
return started
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
hranaUrl = `http://127.0.0.1:${port}`
|
|
218
|
+
hranaLogger.log(`Hrana server ready at ${hranaUrl}`)
|
|
219
|
+
return hranaUrl
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Stop the Hrana server and close the database.
|
|
224
|
+
*/
|
|
225
|
+
export async function stopHranaServer() {
|
|
226
|
+
if (server) {
|
|
227
|
+
hranaLogger.log('Stopping hrana server...')
|
|
228
|
+
await new Promise<void>((resolve) => {
|
|
229
|
+
server!.close(() => {
|
|
230
|
+
resolve()
|
|
231
|
+
})
|
|
232
|
+
})
|
|
233
|
+
server = null
|
|
234
|
+
}
|
|
235
|
+
if (db) {
|
|
236
|
+
db.close()
|
|
237
|
+
db = null
|
|
238
|
+
}
|
|
239
|
+
hranaUrl = null
|
|
240
|
+
discordGatewayReady = false
|
|
241
|
+
readyWaiters = []
|
|
242
|
+
hranaLogger.log('Hrana server stopped')
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ── Single-instance enforcement ──────────────────────────────────────
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Evict a previous kimaki instance on the lock port.
|
|
249
|
+
* Fetches /health to get the running process PID, then kills it directly.
|
|
250
|
+
* No lsof/netstat/spawnSync needed — the PID comes from the health response.
|
|
251
|
+
*/
|
|
252
|
+
export async function evictExistingInstance({ port }: { port: number }) {
|
|
253
|
+
const url = `http://127.0.0.1:${port}/health`
|
|
254
|
+
|
|
255
|
+
const probe = await fetch(url, { signal: AbortSignal.timeout(1000) }).catch(
|
|
256
|
+
(e) => new FetchError({ url, cause: e }),
|
|
257
|
+
)
|
|
258
|
+
if (probe instanceof Error) return
|
|
259
|
+
|
|
260
|
+
const body = await (probe.json() as Promise<{ pid?: number }>).catch(
|
|
261
|
+
(e) => new FetchError({ url, cause: e }),
|
|
262
|
+
)
|
|
263
|
+
if (body instanceof Error) return
|
|
264
|
+
|
|
265
|
+
const targetPid = body.pid
|
|
266
|
+
if (!targetPid || targetPid === process.pid) return
|
|
267
|
+
|
|
268
|
+
hranaLogger.log(
|
|
269
|
+
`Evicting existing kimaki process (PID: ${targetPid}) on port ${port}`,
|
|
270
|
+
)
|
|
271
|
+
const killResult = errore.try({
|
|
272
|
+
try: () => {
|
|
273
|
+
process.kill(targetPid, 'SIGTERM')
|
|
274
|
+
},
|
|
275
|
+
catch: (e) =>
|
|
276
|
+
new Error('Failed to send SIGTERM to existing kimaki process', {
|
|
277
|
+
cause: e,
|
|
278
|
+
}),
|
|
279
|
+
})
|
|
280
|
+
if (killResult instanceof Error) {
|
|
281
|
+
hranaLogger.log(`Failed to kill PID ${targetPid}: ${killResult.message}`)
|
|
282
|
+
return
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
await new Promise((resolve) => {
|
|
286
|
+
setTimeout(resolve, 1000)
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
// Verify it's gone — if still alive, escalate to SIGKILL
|
|
290
|
+
const secondProbe = await fetch(url, {
|
|
291
|
+
signal: AbortSignal.timeout(500),
|
|
292
|
+
}).catch((e) => new FetchError({ url, cause: e }))
|
|
293
|
+
if (secondProbe instanceof Error) return
|
|
294
|
+
|
|
295
|
+
hranaLogger.log(`PID ${targetPid} still alive after SIGTERM, sending SIGKILL`)
|
|
296
|
+
const forceKillResult = errore.try({
|
|
297
|
+
try: () => {
|
|
298
|
+
process.kill(targetPid, 'SIGKILL')
|
|
299
|
+
},
|
|
300
|
+
catch: (e) =>
|
|
301
|
+
new Error('Failed to send SIGKILL to existing kimaki process', {
|
|
302
|
+
cause: e,
|
|
303
|
+
}),
|
|
304
|
+
})
|
|
305
|
+
if (forceKillResult instanceof Error) {
|
|
306
|
+
hranaLogger.log(
|
|
307
|
+
`Failed to force-kill PID ${targetPid}: ${forceKillResult.message}`,
|
|
308
|
+
)
|
|
309
|
+
return
|
|
310
|
+
}
|
|
311
|
+
await new Promise((resolve) => {
|
|
312
|
+
setTimeout(resolve, 1000)
|
|
313
|
+
})
|
|
314
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
buildHtmlActionCustomId,
|
|
4
|
+
cancelHtmlActionsForOwner,
|
|
5
|
+
cancelHtmlActionsForThread,
|
|
6
|
+
pendingHtmlActions,
|
|
7
|
+
registerHtmlAction,
|
|
8
|
+
} from './html-actions.js'
|
|
9
|
+
|
|
10
|
+
const TEST_OWNER_A = 'worktrees:user-a:channel-a'
|
|
11
|
+
const TEST_OWNER_B = 'worktrees:user-b:channel-a'
|
|
12
|
+
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
cancelHtmlActionsForOwner(TEST_OWNER_A)
|
|
15
|
+
cancelHtmlActionsForOwner(TEST_OWNER_B)
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
describe('html action registry', () => {
|
|
19
|
+
test('registers action ids with expected custom id prefix', () => {
|
|
20
|
+
const actionId = registerHtmlAction({
|
|
21
|
+
ownerKey: TEST_OWNER_A,
|
|
22
|
+
run: async () => {
|
|
23
|
+
return undefined
|
|
24
|
+
},
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
expect(buildHtmlActionCustomId(actionId)).toMatch(/^html_action:/)
|
|
28
|
+
expect(pendingHtmlActions.has(actionId)).toBe(true)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
test('cancels actions by owner', () => {
|
|
32
|
+
registerHtmlAction({
|
|
33
|
+
ownerKey: TEST_OWNER_A,
|
|
34
|
+
run: async () => {
|
|
35
|
+
return undefined
|
|
36
|
+
},
|
|
37
|
+
})
|
|
38
|
+
registerHtmlAction({
|
|
39
|
+
ownerKey: TEST_OWNER_A,
|
|
40
|
+
run: async () => {
|
|
41
|
+
return undefined
|
|
42
|
+
},
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
expect(cancelHtmlActionsForOwner(TEST_OWNER_A)).toBe(2)
|
|
46
|
+
expect(pendingHtmlActions.size).toBe(0)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('cancels only actions from the matching thread', () => {
|
|
50
|
+
const threadAActionId = registerHtmlAction({
|
|
51
|
+
ownerKey: TEST_OWNER_A,
|
|
52
|
+
threadId: 'thread-a',
|
|
53
|
+
run: async () => {
|
|
54
|
+
return undefined
|
|
55
|
+
},
|
|
56
|
+
})
|
|
57
|
+
const threadBActionId = registerHtmlAction({
|
|
58
|
+
ownerKey: TEST_OWNER_B,
|
|
59
|
+
threadId: 'thread-b',
|
|
60
|
+
run: async () => {
|
|
61
|
+
return undefined
|
|
62
|
+
},
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
expect(cancelHtmlActionsForThread('thread-a')).toBe(1)
|
|
66
|
+
expect(pendingHtmlActions.has(threadAActionId)).toBe(false)
|
|
67
|
+
expect(pendingHtmlActions.has(threadBActionId)).toBe(true)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
test('expires actions after ttl', async () => {
|
|
71
|
+
const actionId = registerHtmlAction({
|
|
72
|
+
ownerKey: TEST_OWNER_A,
|
|
73
|
+
ttlMs: 10,
|
|
74
|
+
run: async () => {
|
|
75
|
+
return undefined
|
|
76
|
+
},
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
await new Promise<void>((resolve) => {
|
|
80
|
+
setTimeout(() => {
|
|
81
|
+
resolve()
|
|
82
|
+
}, 30)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
expect(pendingHtmlActions.has(actionId)).toBe(false)
|
|
86
|
+
})
|
|
87
|
+
})
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// HTML action registry for rendered Discord components.
|
|
2
|
+
// Stores short-lived button callbacks by generated id so HTML-backed UI can
|
|
3
|
+
// attach interactions without leaking closures across rerenders.
|
|
4
|
+
|
|
5
|
+
import crypto from 'node:crypto'
|
|
6
|
+
import {
|
|
7
|
+
ComponentType,
|
|
8
|
+
MessageFlags,
|
|
9
|
+
type ButtonInteraction,
|
|
10
|
+
} from 'discord.js'
|
|
11
|
+
import { createLogger } from './logger.js'
|
|
12
|
+
import { notifyError } from './sentry.js'
|
|
13
|
+
|
|
14
|
+
const logger = createLogger('HTML_ACT')
|
|
15
|
+
const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000
|
|
16
|
+
|
|
17
|
+
type PendingHtmlAction = {
|
|
18
|
+
actionId: string
|
|
19
|
+
ownerKey: string
|
|
20
|
+
threadId?: string
|
|
21
|
+
resolved: boolean
|
|
22
|
+
timer: ReturnType<typeof setTimeout>
|
|
23
|
+
run: ({ interaction }: { interaction: ButtonInteraction }) => Promise<void>
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const pendingHtmlActions = new Map<string, PendingHtmlAction>()
|
|
27
|
+
const actionIdsByOwner = new Map<string, Set<string>>()
|
|
28
|
+
|
|
29
|
+
export function buildHtmlActionCustomId(actionId: string): string {
|
|
30
|
+
return `html_action:${actionId}`
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function registerHtmlAction({
|
|
34
|
+
ownerKey,
|
|
35
|
+
threadId,
|
|
36
|
+
run,
|
|
37
|
+
ttlMs = DEFAULT_TTL_MS,
|
|
38
|
+
}: {
|
|
39
|
+
ownerKey: string
|
|
40
|
+
threadId?: string
|
|
41
|
+
run: ({ interaction }: { interaction: ButtonInteraction }) => Promise<void>
|
|
42
|
+
ttlMs?: number
|
|
43
|
+
}): string {
|
|
44
|
+
const actionId = crypto.randomBytes(8).toString('hex')
|
|
45
|
+
const timer = setTimeout(() => {
|
|
46
|
+
resolveHtmlAction({ actionId })
|
|
47
|
+
}, ttlMs)
|
|
48
|
+
|
|
49
|
+
pendingHtmlActions.set(actionId, {
|
|
50
|
+
actionId,
|
|
51
|
+
ownerKey,
|
|
52
|
+
threadId,
|
|
53
|
+
resolved: false,
|
|
54
|
+
timer,
|
|
55
|
+
run,
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
const ownerActionIds = actionIdsByOwner.get(ownerKey) ?? new Set<string>()
|
|
59
|
+
ownerActionIds.add(actionId)
|
|
60
|
+
actionIdsByOwner.set(ownerKey, ownerActionIds)
|
|
61
|
+
return actionId
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function cancelHtmlActionsForOwner(ownerKey: string): number {
|
|
65
|
+
const actionIds = actionIdsByOwner.get(ownerKey)
|
|
66
|
+
if (!actionIds) {
|
|
67
|
+
return 0
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
let cancelled = 0
|
|
71
|
+
for (const actionId of actionIds) {
|
|
72
|
+
const resolved = resolveHtmlAction({ actionId })
|
|
73
|
+
if (!resolved) {
|
|
74
|
+
continue
|
|
75
|
+
}
|
|
76
|
+
cancelled++
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return cancelled
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function cancelHtmlActionsForThread(threadId: string): number {
|
|
83
|
+
let cancelled = 0
|
|
84
|
+
|
|
85
|
+
for (const [actionId, action] of pendingHtmlActions) {
|
|
86
|
+
if (action.threadId !== threadId) {
|
|
87
|
+
continue
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const resolved = resolveHtmlAction({ actionId })
|
|
91
|
+
if (!resolved) {
|
|
92
|
+
continue
|
|
93
|
+
}
|
|
94
|
+
cancelled++
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return cancelled
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export async function handleHtmlActionButton(
|
|
101
|
+
interaction: ButtonInteraction,
|
|
102
|
+
): Promise<void> {
|
|
103
|
+
const customId = interaction.customId
|
|
104
|
+
if (!customId.startsWith('html_action:')) {
|
|
105
|
+
return
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const actionId = customId.slice('html_action:'.length)
|
|
109
|
+
if (!actionId) {
|
|
110
|
+
await interaction.reply({
|
|
111
|
+
content: 'Invalid action button.',
|
|
112
|
+
flags: MessageFlags.Ephemeral,
|
|
113
|
+
})
|
|
114
|
+
return
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const action = pendingHtmlActions.get(actionId)
|
|
118
|
+
if (!action || action.resolved) {
|
|
119
|
+
await interaction.reply({
|
|
120
|
+
content: 'This action is no longer available.',
|
|
121
|
+
flags: MessageFlags.Ephemeral,
|
|
122
|
+
})
|
|
123
|
+
return
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
await interaction.deferUpdate()
|
|
127
|
+
const resolvedAction = resolveHtmlAction({ actionId })
|
|
128
|
+
if (!resolvedAction) {
|
|
129
|
+
return
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
try {
|
|
133
|
+
await resolvedAction.run({ interaction })
|
|
134
|
+
} catch (error) {
|
|
135
|
+
logger.error('[HTML_ACTION] Failed to run action:', error)
|
|
136
|
+
void notifyError(error, 'HTML action button failed')
|
|
137
|
+
await interaction
|
|
138
|
+
.editReply({
|
|
139
|
+
components: [
|
|
140
|
+
{
|
|
141
|
+
type: ComponentType.TextDisplay,
|
|
142
|
+
content: `Action failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
143
|
+
},
|
|
144
|
+
],
|
|
145
|
+
flags: MessageFlags.IsComponentsV2,
|
|
146
|
+
})
|
|
147
|
+
.catch(() => {
|
|
148
|
+
return undefined
|
|
149
|
+
})
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function resolveHtmlAction({
|
|
154
|
+
actionId,
|
|
155
|
+
}: {
|
|
156
|
+
actionId: string
|
|
157
|
+
}): PendingHtmlAction | undefined {
|
|
158
|
+
const action = pendingHtmlActions.get(actionId)
|
|
159
|
+
if (!action || action.resolved) {
|
|
160
|
+
return undefined
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
action.resolved = true
|
|
164
|
+
clearTimeout(action.timer)
|
|
165
|
+
pendingHtmlActions.delete(actionId)
|
|
166
|
+
|
|
167
|
+
const ownerActionIds = actionIdsByOwner.get(action.ownerKey)
|
|
168
|
+
ownerActionIds?.delete(actionId)
|
|
169
|
+
if (ownerActionIds && ownerActionIds.size === 0) {
|
|
170
|
+
actionIdsByOwner.delete(action.ownerKey)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return action
|
|
174
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { describe, expect, test } from 'vitest'
|
|
2
|
+
import { parseInlineHtmlRenderables } from './html-components.js'
|
|
3
|
+
|
|
4
|
+
describe('parseInlineHtmlRenderables', () => {
|
|
5
|
+
test('parses text and button fragments', () => {
|
|
6
|
+
const result = parseInlineHtmlRenderables({
|
|
7
|
+
html: 'Before <button id="delete-a" variant="danger">Delete</button> after',
|
|
8
|
+
})
|
|
9
|
+
expect(result).toMatchInlineSnapshot(`
|
|
10
|
+
[
|
|
11
|
+
{
|
|
12
|
+
"text": "Before ",
|
|
13
|
+
"type": "text",
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"disabled": false,
|
|
17
|
+
"id": "delete-a",
|
|
18
|
+
"label": "Delete",
|
|
19
|
+
"type": "button",
|
|
20
|
+
"variant": "danger",
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"text": " after",
|
|
24
|
+
"type": "text",
|
|
25
|
+
},
|
|
26
|
+
]
|
|
27
|
+
`)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
test('rejects buttons without id', () => {
|
|
31
|
+
const result = parseInlineHtmlRenderables({
|
|
32
|
+
html: '<button>Delete</button>',
|
|
33
|
+
})
|
|
34
|
+
expect(result instanceof Error ? result.message : result).toBe(
|
|
35
|
+
'<button> is missing required id attribute',
|
|
36
|
+
)
|
|
37
|
+
})
|
|
38
|
+
})
|