@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
package/src/opencode.ts
ADDED
|
@@ -0,0 +1,1110 @@
|
|
|
1
|
+
// OpenCode single-server process manager.
|
|
2
|
+
//
|
|
3
|
+
// Architecture: ONE opencode serve process shared by all project directories.
|
|
4
|
+
// Each SDK client uses the x-opencode-directory header to scope requests to a
|
|
5
|
+
// specific project. The server lazily creates and caches an Instance per unique
|
|
6
|
+
// directory path internally.
|
|
7
|
+
//
|
|
8
|
+
// Per-directory permissions (external_directory rules for worktrees, tmpdir,
|
|
9
|
+
// etc.) are passed via session.create({ permission }) at session creation time,
|
|
10
|
+
// NOT via the server config. The server config has permissive defaults
|
|
11
|
+
// (edit: allow, bash: allow, external_directory: ask) and session-level rules
|
|
12
|
+
// override them via opencode's findLast() evaluation (last matching rule wins).
|
|
13
|
+
//
|
|
14
|
+
// Uses errore for type-safe error handling.
|
|
15
|
+
|
|
16
|
+
import { spawn, execFileSync, type ChildProcess } from 'node:child_process'
|
|
17
|
+
import fs from 'node:fs'
|
|
18
|
+
import net from 'node:net'
|
|
19
|
+
import os from 'node:os'
|
|
20
|
+
import path from 'node:path'
|
|
21
|
+
import { fileURLToPath } from 'node:url'
|
|
22
|
+
|
|
23
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
24
|
+
import {
|
|
25
|
+
createOpencodeClient,
|
|
26
|
+
type OpencodeClient,
|
|
27
|
+
type Config as SdkConfig,
|
|
28
|
+
type PermissionRuleset,
|
|
29
|
+
} from '@opencode-ai/sdk/v2'
|
|
30
|
+
|
|
31
|
+
import {
|
|
32
|
+
getDataDir,
|
|
33
|
+
getLockPort,
|
|
34
|
+
} from './config.js'
|
|
35
|
+
import { store } from './store.js'
|
|
36
|
+
import { getHranaUrl } from './hrana-server.js'
|
|
37
|
+
|
|
38
|
+
// SDK Config type is simplified; opencode accepts nested permission objects with path patterns
|
|
39
|
+
type PermissionAction = 'ask' | 'allow' | 'deny'
|
|
40
|
+
type PermissionRule = PermissionAction | Record<string, PermissionAction>
|
|
41
|
+
type Config = Omit<SdkConfig, 'permission'> & {
|
|
42
|
+
permission?: {
|
|
43
|
+
edit?: PermissionRule
|
|
44
|
+
bash?: PermissionRule
|
|
45
|
+
external_directory?: PermissionRule
|
|
46
|
+
webfetch?: PermissionRule
|
|
47
|
+
[key: string]: PermissionRule | undefined
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
import * as errore from 'errore'
|
|
51
|
+
import { createLogger, LogPrefix } from './logger.js'
|
|
52
|
+
import { notifyError } from './sentry.js'
|
|
53
|
+
import {
|
|
54
|
+
DirectoryNotAccessibleError,
|
|
55
|
+
ServerStartError,
|
|
56
|
+
ServerNotReadyError,
|
|
57
|
+
FetchError,
|
|
58
|
+
type OpenCodeErrors,
|
|
59
|
+
} from './errors.js'
|
|
60
|
+
import {
|
|
61
|
+
ensureKimakiCommandShim,
|
|
62
|
+
getPathEnvKey,
|
|
63
|
+
getSpawnCommandAndArgs,
|
|
64
|
+
prependPathEntry,
|
|
65
|
+
selectResolvedCommand,
|
|
66
|
+
} from './opencode-command.js'
|
|
67
|
+
|
|
68
|
+
const opencodeLogger = createLogger(LogPrefix.OPENCODE)
|
|
69
|
+
|
|
70
|
+
// Tracks directories that have been initialized, to avoid repeated log spam
|
|
71
|
+
// from the external sync polling loop.
|
|
72
|
+
const initializedDirectories = new Set<string>()
|
|
73
|
+
|
|
74
|
+
const STARTUP_STDERR_TAIL_LIMIT = 30
|
|
75
|
+
const STARTUP_STDERR_LINE_MAX_LENGTH = 120
|
|
76
|
+
const STARTUP_ERROR_REASON_MAX_LENGTH = 1500
|
|
77
|
+
const ANSI_ESCAPE_REGEX =
|
|
78
|
+
/[\u001B\u009B][[\]()#;?]*(?:(?:(?:[a-zA-Z\d]*(?:;[a-zA-Z\d]*)*)?\u0007)|(?:(?:\d{1,4}(?:;\d{0,4})*)?[\dA-PR-TZcf-nq-uy=><~]))/g
|
|
79
|
+
|
|
80
|
+
function truncateWithEllipsis({
|
|
81
|
+
value,
|
|
82
|
+
maxLength,
|
|
83
|
+
}: {
|
|
84
|
+
value: string
|
|
85
|
+
maxLength: number
|
|
86
|
+
}): string {
|
|
87
|
+
if (maxLength <= 3) {
|
|
88
|
+
return value.slice(0, maxLength)
|
|
89
|
+
}
|
|
90
|
+
if (value.length <= maxLength) {
|
|
91
|
+
return value
|
|
92
|
+
}
|
|
93
|
+
return `${value.slice(0, maxLength - 3)}...`
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function stripAnsiCodes(value: string): string {
|
|
97
|
+
return value.replaceAll(ANSI_ESCAPE_REGEX, '')
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function splitOutputChunkLines(chunk: string): string[] {
|
|
101
|
+
return chunk
|
|
102
|
+
.split(/\r?\n/g)
|
|
103
|
+
.map((line) => stripAnsiCodes(line).trim())
|
|
104
|
+
.filter((line) => line.length > 0)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function sanitizeForCodeFence(line: string): string {
|
|
108
|
+
return line.replaceAll('```', '`\u200b``')
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function pushStartupStderrTail({
|
|
112
|
+
stderrTail,
|
|
113
|
+
chunk,
|
|
114
|
+
}: {
|
|
115
|
+
stderrTail: string[]
|
|
116
|
+
chunk: string
|
|
117
|
+
}): void {
|
|
118
|
+
const incomingLines = splitOutputChunkLines(chunk)
|
|
119
|
+
const truncatedLines = incomingLines.map((line) => {
|
|
120
|
+
const sanitizedLine = sanitizeForCodeFence(line)
|
|
121
|
+
return truncateWithEllipsis({
|
|
122
|
+
value: sanitizedLine,
|
|
123
|
+
maxLength: STARTUP_STDERR_LINE_MAX_LENGTH,
|
|
124
|
+
})
|
|
125
|
+
})
|
|
126
|
+
stderrTail.push(...truncatedLines)
|
|
127
|
+
if (stderrTail.length > STARTUP_STDERR_TAIL_LIMIT) {
|
|
128
|
+
stderrTail.splice(0, stderrTail.length - STARTUP_STDERR_TAIL_LIMIT)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function buildStartupTimeoutReason({
|
|
133
|
+
maxAttempts,
|
|
134
|
+
stderrTail,
|
|
135
|
+
}: {
|
|
136
|
+
maxAttempts: number
|
|
137
|
+
stderrTail: string[]
|
|
138
|
+
}): string {
|
|
139
|
+
const timeoutSeconds = Math.round((maxAttempts * 100) / 1000)
|
|
140
|
+
const baseReason = `Server did not start after ${timeoutSeconds} seconds`
|
|
141
|
+
if (stderrTail.length === 0) {
|
|
142
|
+
return baseReason
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const formatReason = ({
|
|
146
|
+
lines,
|
|
147
|
+
omitted,
|
|
148
|
+
}: {
|
|
149
|
+
lines: string[]
|
|
150
|
+
omitted: number
|
|
151
|
+
}): string => {
|
|
152
|
+
const omittedLine =
|
|
153
|
+
omitted > 0
|
|
154
|
+
? `[... ${omitted} older stderr lines omitted to fit Discord ...]\n`
|
|
155
|
+
: ''
|
|
156
|
+
const stderrCodeBlock = `${omittedLine}${lines.join('\n')}`
|
|
157
|
+
return `${baseReason}\nLast opencode stderr lines:\n\`\`\`text\n${stderrCodeBlock}\n\`\`\``
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
let lines = [...stderrTail]
|
|
161
|
+
let omitted = 0
|
|
162
|
+
let formattedReason = formatReason({ lines, omitted })
|
|
163
|
+
|
|
164
|
+
while (
|
|
165
|
+
formattedReason.length > STARTUP_ERROR_REASON_MAX_LENGTH &&
|
|
166
|
+
lines.length > 0
|
|
167
|
+
) {
|
|
168
|
+
lines = lines.slice(1)
|
|
169
|
+
omitted += 1
|
|
170
|
+
formattedReason = formatReason({ lines, omitted })
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return truncateWithEllipsis({
|
|
174
|
+
value: formattedReason,
|
|
175
|
+
maxLength: STARTUP_ERROR_REASON_MAX_LENGTH,
|
|
176
|
+
})
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ── Single server state ──────────────────────────────────────────
|
|
180
|
+
// One opencode serve process, shared by all project directories.
|
|
181
|
+
// Clients are created per-directory with the x-opencode-directory header.
|
|
182
|
+
|
|
183
|
+
type SingleServer = {
|
|
184
|
+
process: ChildProcess
|
|
185
|
+
port: number
|
|
186
|
+
baseUrl: string
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
type ServerLifecycleEvent =
|
|
190
|
+
| { type: 'started'; port: number }
|
|
191
|
+
| { type: 'stopped' }
|
|
192
|
+
|
|
193
|
+
let singleServer: SingleServer | null = null
|
|
194
|
+
let serverRetryCount = 0
|
|
195
|
+
const serverLifecycleListeners = new Set<(event: ServerLifecycleEvent) => void>()
|
|
196
|
+
let processCleanupHandlersRegistered = false
|
|
197
|
+
let startingServerProcess: ChildProcess | null = null
|
|
198
|
+
|
|
199
|
+
// Cached SDK clients per directory. Each client has a fixed
|
|
200
|
+
// x-opencode-directory header pointing to its project directory.
|
|
201
|
+
const clientCache = new Map<string, OpencodeClient>()
|
|
202
|
+
|
|
203
|
+
function notifyServerLifecycle(event: ServerLifecycleEvent): void {
|
|
204
|
+
for (const listener of serverLifecycleListeners) {
|
|
205
|
+
listener(event)
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function subscribeOpencodeServerLifecycle(
|
|
210
|
+
listener: (event: ServerLifecycleEvent) => void,
|
|
211
|
+
): () => void {
|
|
212
|
+
serverLifecycleListeners.add(listener)
|
|
213
|
+
return () => {
|
|
214
|
+
serverLifecycleListeners.delete(listener)
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function killSingleServerProcessNow({
|
|
219
|
+
reason,
|
|
220
|
+
}: {
|
|
221
|
+
reason: string
|
|
222
|
+
}): void {
|
|
223
|
+
if (!singleServer) {
|
|
224
|
+
return
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const serverProcess = singleServer.process
|
|
228
|
+
const pid = serverProcess.pid
|
|
229
|
+
if (!pid || serverProcess.killed) {
|
|
230
|
+
return
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const killResult = errore.try({
|
|
234
|
+
try: () => {
|
|
235
|
+
serverProcess.kill('SIGTERM')
|
|
236
|
+
},
|
|
237
|
+
catch: (error) => {
|
|
238
|
+
return new Error('Failed to send SIGTERM to opencode server', {
|
|
239
|
+
cause: error,
|
|
240
|
+
})
|
|
241
|
+
},
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
if (killResult instanceof Error) {
|
|
245
|
+
opencodeLogger.warn(
|
|
246
|
+
`[cleanup:${reason}] ${killResult.message} (pid: ${pid}, port: ${singleServer.port})`,
|
|
247
|
+
)
|
|
248
|
+
return
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
opencodeLogger.log(
|
|
252
|
+
`[cleanup:${reason}] Sent SIGTERM to opencode server (pid: ${pid}, port: ${singleServer.port})`,
|
|
253
|
+
)
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function killStartingServerProcessNow({
|
|
257
|
+
reason,
|
|
258
|
+
}: {
|
|
259
|
+
reason: string
|
|
260
|
+
}): void {
|
|
261
|
+
const serverProcess = startingServerProcess
|
|
262
|
+
if (!serverProcess) {
|
|
263
|
+
return
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const pid = serverProcess.pid
|
|
267
|
+
if (!pid || serverProcess.killed) {
|
|
268
|
+
return
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const killResult = errore.try({
|
|
272
|
+
try: () => {
|
|
273
|
+
serverProcess.kill('SIGTERM')
|
|
274
|
+
},
|
|
275
|
+
catch: (error) => {
|
|
276
|
+
return new Error('Failed to send SIGTERM to starting opencode server', {
|
|
277
|
+
cause: error,
|
|
278
|
+
})
|
|
279
|
+
},
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
if (killResult instanceof Error) {
|
|
283
|
+
opencodeLogger.warn(
|
|
284
|
+
`[cleanup:${reason}] ${killResult.message} (pid: ${pid})`,
|
|
285
|
+
)
|
|
286
|
+
return
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
opencodeLogger.log(
|
|
290
|
+
`[cleanup:${reason}] Sent SIGTERM to starting opencode server (pid: ${pid})`,
|
|
291
|
+
)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function ensureProcessCleanupHandlersRegistered(): void {
|
|
295
|
+
if (processCleanupHandlersRegistered) {
|
|
296
|
+
return
|
|
297
|
+
}
|
|
298
|
+
processCleanupHandlersRegistered = true
|
|
299
|
+
|
|
300
|
+
opencodeLogger.log('Registering process cleanup handlers for opencode server')
|
|
301
|
+
|
|
302
|
+
process.on('exit', () => {
|
|
303
|
+
killSingleServerProcessNow({ reason: 'process-exit' })
|
|
304
|
+
killStartingServerProcessNow({ reason: 'process-exit' })
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
// Fallback for short-lived CLI subcommands that call process.exit without
|
|
308
|
+
// running discord-bot.ts shutdown handlers.
|
|
309
|
+
process.on('SIGINT', () => {
|
|
310
|
+
killSingleServerProcessNow({ reason: 'sigint' })
|
|
311
|
+
killStartingServerProcessNow({ reason: 'sigint' })
|
|
312
|
+
})
|
|
313
|
+
process.on('SIGTERM', () => {
|
|
314
|
+
killSingleServerProcessNow({ reason: 'sigterm' })
|
|
315
|
+
killStartingServerProcessNow({ reason: 'sigterm' })
|
|
316
|
+
})
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// ── Resolve opencode binary ──────────────────────────────────────
|
|
320
|
+
// Resolve the full path to the opencode binary so we can spawn without
|
|
321
|
+
// shell: true. Using shell: true creates an intermediate sh process — when
|
|
322
|
+
// cleanup sends SIGTERM it only kills the shell, leaving the actual opencode
|
|
323
|
+
// process orphaned (reparented to PID 1). Resolving the path upfront lets
|
|
324
|
+
// us spawn the binary directly and SIGTERM reaches the right process.
|
|
325
|
+
let resolvedOpencodeCommand: string | null = null
|
|
326
|
+
|
|
327
|
+
export function resolveOpencodeCommand(): string {
|
|
328
|
+
if (resolvedOpencodeCommand) {
|
|
329
|
+
return resolvedOpencodeCommand
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const envPath = process.env.OPENCODE_PATH
|
|
333
|
+
if (envPath) {
|
|
334
|
+
const resolvedFromEnv = selectResolvedCommand({
|
|
335
|
+
output: envPath,
|
|
336
|
+
isWindows: process.platform === 'win32',
|
|
337
|
+
})
|
|
338
|
+
if (resolvedFromEnv) {
|
|
339
|
+
resolvedOpencodeCommand = resolvedFromEnv
|
|
340
|
+
return resolvedFromEnv
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const isWindows = process.platform === 'win32'
|
|
345
|
+
const whichCmd = isWindows ? 'where' : 'which'
|
|
346
|
+
const result = errore.try({
|
|
347
|
+
try: () => {
|
|
348
|
+
const commandOutput = execFileSync(whichCmd, ['opencode'], {
|
|
349
|
+
encoding: 'utf8',
|
|
350
|
+
timeout: 5000,
|
|
351
|
+
})
|
|
352
|
+
const resolved = selectResolvedCommand({
|
|
353
|
+
output: commandOutput,
|
|
354
|
+
isWindows,
|
|
355
|
+
})
|
|
356
|
+
if (resolved) {
|
|
357
|
+
return resolved
|
|
358
|
+
}
|
|
359
|
+
throw new Error('opencode not found in PATH')
|
|
360
|
+
},
|
|
361
|
+
catch: () => new Error('opencode not found in PATH'),
|
|
362
|
+
})
|
|
363
|
+
|
|
364
|
+
if (result instanceof Error) {
|
|
365
|
+
// Fall back to bare command name — spawn will fail with a clear error
|
|
366
|
+
// if it can't find the binary.
|
|
367
|
+
opencodeLogger.warn('Could not resolve opencode path via which, falling back to "opencode"')
|
|
368
|
+
return 'opencode'
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
resolvedOpencodeCommand = result
|
|
372
|
+
opencodeLogger.log(`Resolved opencode binary: ${result}`)
|
|
373
|
+
return result
|
|
374
|
+
}
|
|
375
|
+
async function getOpenPort(): Promise<number> {
|
|
376
|
+
return new Promise((resolve, reject) => {
|
|
377
|
+
const server = net.createServer()
|
|
378
|
+
server.listen(0, () => {
|
|
379
|
+
const address = server.address()
|
|
380
|
+
if (address && typeof address === 'object') {
|
|
381
|
+
const port = address.port
|
|
382
|
+
server.close(() => {
|
|
383
|
+
resolve(port)
|
|
384
|
+
})
|
|
385
|
+
} else {
|
|
386
|
+
reject(new Error('Failed to get port'))
|
|
387
|
+
}
|
|
388
|
+
})
|
|
389
|
+
server.on('error', reject)
|
|
390
|
+
})
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
async function waitForServer({
|
|
394
|
+
port,
|
|
395
|
+
maxAttempts = 300,
|
|
396
|
+
startupStderrTail,
|
|
397
|
+
}: {
|
|
398
|
+
port: number
|
|
399
|
+
maxAttempts?: number
|
|
400
|
+
startupStderrTail: string[]
|
|
401
|
+
}): Promise<ServerStartError | true> {
|
|
402
|
+
const endpoint = `http://127.0.0.1:${port}/api/health`
|
|
403
|
+
for (let i = 0; i < maxAttempts; i++) {
|
|
404
|
+
const response = await errore.tryAsync({
|
|
405
|
+
try: () => fetch(endpoint),
|
|
406
|
+
catch: (e) => new FetchError({ url: endpoint, cause: e }),
|
|
407
|
+
})
|
|
408
|
+
if (response instanceof Error) {
|
|
409
|
+
// Connection refused or other transient errors - continue polling.
|
|
410
|
+
// Use 100ms interval instead of 1s so we detect readiness faster.
|
|
411
|
+
// Critical for scale-to-zero cold starts where every ms matters.
|
|
412
|
+
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
413
|
+
continue
|
|
414
|
+
}
|
|
415
|
+
if (response.status < 500) {
|
|
416
|
+
return true
|
|
417
|
+
}
|
|
418
|
+
const body = await response.text()
|
|
419
|
+
// Fatal errors that won't resolve with retrying
|
|
420
|
+
if (body.includes('BunInstallFailedError')) {
|
|
421
|
+
return new ServerStartError({ port, reason: body.slice(0, 200) })
|
|
422
|
+
}
|
|
423
|
+
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
424
|
+
}
|
|
425
|
+
return new ServerStartError({
|
|
426
|
+
port,
|
|
427
|
+
reason: buildStartupTimeoutReason({
|
|
428
|
+
maxAttempts,
|
|
429
|
+
stderrTail: startupStderrTail,
|
|
430
|
+
}),
|
|
431
|
+
})
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// ── Single server lifecycle ──────────────────────────────────────
|
|
435
|
+
// The server is started lazily on first initializeOpencodeForDirectory() call.
|
|
436
|
+
// It uses permissive defaults (edit: allow, bash: allow, external_directory: ask).
|
|
437
|
+
// Per-directory permissions are applied at session creation time instead.
|
|
438
|
+
|
|
439
|
+
// In-flight promise to prevent concurrent startups from racing
|
|
440
|
+
let startingServer: Promise<ServerStartError | SingleServer> | null = null
|
|
441
|
+
|
|
442
|
+
async function ensureSingleServer(): Promise<ServerStartError | SingleServer> {
|
|
443
|
+
if (singleServer && !singleServer.process.killed) {
|
|
444
|
+
return singleServer
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// Deduplicate concurrent startup attempts
|
|
448
|
+
if (startingServer) {
|
|
449
|
+
return startingServer
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
startingServer = startSingleServer()
|
|
453
|
+
try {
|
|
454
|
+
return await startingServer
|
|
455
|
+
} finally {
|
|
456
|
+
startingServer = null
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
async function startSingleServer(): Promise<ServerStartError | SingleServer> {
|
|
461
|
+
ensureProcessCleanupHandlersRegistered()
|
|
462
|
+
|
|
463
|
+
const port = await getOpenPort()
|
|
464
|
+
|
|
465
|
+
const serveArgs = [
|
|
466
|
+
'serve',
|
|
467
|
+
'--port',
|
|
468
|
+
port.toString(),
|
|
469
|
+
'--print-logs',
|
|
470
|
+
'--log-level',
|
|
471
|
+
'WARN',
|
|
472
|
+
]
|
|
473
|
+
|
|
474
|
+
const {
|
|
475
|
+
command: spawnCommand,
|
|
476
|
+
args: spawnArgs,
|
|
477
|
+
windowsVerbatimArguments,
|
|
478
|
+
} = getSpawnCommandAndArgs({
|
|
479
|
+
resolvedCommand: resolveOpencodeCommand(),
|
|
480
|
+
baseArgs: serveArgs,
|
|
481
|
+
})
|
|
482
|
+
|
|
483
|
+
// Server config uses permissive defaults. Per-directory external_directory
|
|
484
|
+
// permissions are set at session creation time via session.create({ permission }).
|
|
485
|
+
// Common directories (tmpdir, ~/.config/opencode, ~/.kimaki) are pre-allowed
|
|
486
|
+
// at the server level so they never trigger permission prompts regardless of
|
|
487
|
+
// whether session-level rules compose correctly.
|
|
488
|
+
const tmpdir = os.tmpdir().replaceAll('\\', '/')
|
|
489
|
+
const opencodeConfigDir = path
|
|
490
|
+
.join(os.homedir(), '.config', 'opencode')
|
|
491
|
+
.replaceAll('\\', '/')
|
|
492
|
+
const kimakiDataDir = path
|
|
493
|
+
.join(os.homedir(), '.kimaki')
|
|
494
|
+
.replaceAll('\\', '/')
|
|
495
|
+
// No catch-all '*': 'ask' here — the user's opencode.json default is respected.
|
|
496
|
+
// Only allowlist specific known-safe directories at the server level.
|
|
497
|
+
const externalDirectoryPermissions: Record<string, 'ask' | 'allow' | 'deny'> = {
|
|
498
|
+
'/tmp': 'allow',
|
|
499
|
+
'/tmp/*': 'allow',
|
|
500
|
+
'/private/tmp': 'allow',
|
|
501
|
+
'/private/tmp/*': 'allow',
|
|
502
|
+
[tmpdir]: 'allow',
|
|
503
|
+
[`${tmpdir}/*`]: 'allow',
|
|
504
|
+
[opencodeConfigDir]: 'allow',
|
|
505
|
+
[`${opencodeConfigDir}/*`]: 'allow',
|
|
506
|
+
[kimakiDataDir]: 'allow',
|
|
507
|
+
[`${kimakiDataDir}/*`]: 'allow',
|
|
508
|
+
}
|
|
509
|
+
const kimakiShimDirectory = ensureKimakiCommandShim({
|
|
510
|
+
dataDir: getDataDir(),
|
|
511
|
+
execPath: process.execPath,
|
|
512
|
+
execArgv: process.execArgv,
|
|
513
|
+
entryScript: process.argv[1] || fileURLToPath(new URL('../bin.js', import.meta.url)),
|
|
514
|
+
})
|
|
515
|
+
const pathEnvKey = getPathEnvKey(process.env)
|
|
516
|
+
const pathEnv = kimakiShimDirectory instanceof Error
|
|
517
|
+
? process.env[pathEnvKey]
|
|
518
|
+
: prependPathEntry({
|
|
519
|
+
entry: kimakiShimDirectory,
|
|
520
|
+
existingPath: process.env[pathEnvKey],
|
|
521
|
+
})
|
|
522
|
+
if (kimakiShimDirectory instanceof Error) {
|
|
523
|
+
opencodeLogger.warn(kimakiShimDirectory.message)
|
|
524
|
+
}
|
|
525
|
+
const gatewayToken = store.getState().gatewayToken
|
|
526
|
+
const vitestOpencodeEnv = (() => {
|
|
527
|
+
if (process.env.KIMAKI_VITEST !== '1') {
|
|
528
|
+
return {}
|
|
529
|
+
}
|
|
530
|
+
const root = path.join(getDataDir(), 'opencode-vitest-home')
|
|
531
|
+
return {
|
|
532
|
+
OPENCODE_TEST_HOME: root,
|
|
533
|
+
OPENCODE_CONFIG_DIR: path.join(root, '.opencode-kimaki'),
|
|
534
|
+
XDG_CONFIG_HOME: path.join(root, '.config'),
|
|
535
|
+
XDG_DATA_HOME: path.join(root, '.local', 'share'),
|
|
536
|
+
XDG_CACHE_HOME: path.join(root, '.cache'),
|
|
537
|
+
XDG_STATE_HOME: path.join(root, '.local', 'state'),
|
|
538
|
+
}
|
|
539
|
+
})()
|
|
540
|
+
|
|
541
|
+
// Write config to a file instead of passing via OPENCODE_CONFIG_CONTENT env var.
|
|
542
|
+
// OPENCODE_CONFIG (file path) is loaded before project config in opencode's
|
|
543
|
+
// priority chain, so project-level opencode.json can override kimaki defaults.
|
|
544
|
+
// OPENCODE_CONFIG_CONTENT was loaded last and overrode user project configs,
|
|
545
|
+
// causing issue #90 (project permissions not being respected).
|
|
546
|
+
const opencodeConfig = {
|
|
547
|
+
$schema: 'https://opencode.ai/config.json',
|
|
548
|
+
lsp: false,
|
|
549
|
+
formatter: false,
|
|
550
|
+
plugin: [new URL('../src/kimaki-opencode-plugin.ts', import.meta.url).href],
|
|
551
|
+
permission: {
|
|
552
|
+
edit: 'allow',
|
|
553
|
+
bash: 'allow',
|
|
554
|
+
external_directory: externalDirectoryPermissions,
|
|
555
|
+
webfetch: 'allow',
|
|
556
|
+
},
|
|
557
|
+
agent: {
|
|
558
|
+
explore: {
|
|
559
|
+
permission: {
|
|
560
|
+
'*': 'deny',
|
|
561
|
+
grep: 'allow',
|
|
562
|
+
glob: 'allow',
|
|
563
|
+
list: 'allow',
|
|
564
|
+
read: {
|
|
565
|
+
'*': 'allow',
|
|
566
|
+
'*.env': 'deny',
|
|
567
|
+
'*.env.*': 'deny',
|
|
568
|
+
'*.env.example': 'allow',
|
|
569
|
+
},
|
|
570
|
+
webfetch: 'allow',
|
|
571
|
+
websearch: 'allow',
|
|
572
|
+
codesearch: 'allow',
|
|
573
|
+
external_directory: externalDirectoryPermissions,
|
|
574
|
+
},
|
|
575
|
+
},
|
|
576
|
+
},
|
|
577
|
+
skills: {
|
|
578
|
+
paths: [path.resolve(__dirname, '..', 'skills')],
|
|
579
|
+
},
|
|
580
|
+
} satisfies Config
|
|
581
|
+
const opencodeConfigPath = path.join(getDataDir(), 'opencode-config.json')
|
|
582
|
+
const opencodeConfigJson = JSON.stringify(opencodeConfig, null, 2)
|
|
583
|
+
const existingContent = (() => {
|
|
584
|
+
try {
|
|
585
|
+
return fs.readFileSync(opencodeConfigPath, 'utf-8')
|
|
586
|
+
} catch {
|
|
587
|
+
return ''
|
|
588
|
+
}
|
|
589
|
+
})()
|
|
590
|
+
if (existingContent !== opencodeConfigJson) {
|
|
591
|
+
fs.writeFileSync(opencodeConfigPath, opencodeConfigJson)
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
const serverProcess = spawn(
|
|
595
|
+
spawnCommand,
|
|
596
|
+
spawnArgs,
|
|
597
|
+
{
|
|
598
|
+
stdio: 'pipe',
|
|
599
|
+
detached: false,
|
|
600
|
+
windowsVerbatimArguments,
|
|
601
|
+
// No project-specific cwd — the server handles all directories via
|
|
602
|
+
// x-opencode-directory header. Use home dir as a neutral working dir.
|
|
603
|
+
cwd: os.homedir(),
|
|
604
|
+
env: {
|
|
605
|
+
...process.env,
|
|
606
|
+
OPENCODE_CONFIG: opencodeConfigPath,
|
|
607
|
+
OPENCODE_PORT: port.toString(),
|
|
608
|
+
KIMAKI: '1',
|
|
609
|
+
KIMAKI_DATA_DIR: getDataDir(),
|
|
610
|
+
KIMAKI_LOCK_PORT: getLockPort().toString(),
|
|
611
|
+
...(gatewayToken && { KIMAKI_DB_AUTH_TOKEN: gatewayToken }),
|
|
612
|
+
// Guard: prevents agents from running `kimaki` root command inside
|
|
613
|
+
// an OpenCode session, which would steal the lock port and break the bot.
|
|
614
|
+
KIMAKI_OPENCODE_PROCESS: '1',
|
|
615
|
+
...(getHranaUrl() && { KIMAKI_DB_URL: getHranaUrl()! }),
|
|
616
|
+
...(process.env.KIMAKI_SENTRY_DSN && {
|
|
617
|
+
KIMAKI_SENTRY_DSN: process.env.KIMAKI_SENTRY_DSN,
|
|
618
|
+
}),
|
|
619
|
+
...vitestOpencodeEnv,
|
|
620
|
+
...(pathEnv && { [pathEnvKey]: pathEnv }),
|
|
621
|
+
},
|
|
622
|
+
},
|
|
623
|
+
)
|
|
624
|
+
|
|
625
|
+
startingServerProcess = serverProcess
|
|
626
|
+
|
|
627
|
+
// Buffer logs until we know if server started successfully.
|
|
628
|
+
const logBuffer: string[] = []
|
|
629
|
+
const startupStderrTail: string[] = []
|
|
630
|
+
let serverReady = false
|
|
631
|
+
|
|
632
|
+
logBuffer.push(
|
|
633
|
+
`Spawned opencode serve --port ${port} (pid: ${serverProcess.pid})`,
|
|
634
|
+
)
|
|
635
|
+
|
|
636
|
+
serverProcess.stdout?.on('data', (data) => {
|
|
637
|
+
try {
|
|
638
|
+
const chunk = data.toString()
|
|
639
|
+
const lines = splitOutputChunkLines(chunk)
|
|
640
|
+
if (!serverReady) {
|
|
641
|
+
logBuffer.push(...lines.map((line) => `[stdout] ${line}`))
|
|
642
|
+
return
|
|
643
|
+
}
|
|
644
|
+
for (const line of lines) {
|
|
645
|
+
opencodeLogger.log(line)
|
|
646
|
+
}
|
|
647
|
+
} catch (error) {
|
|
648
|
+
logBuffer.push(`Failed to process stdout startup logs: ${error}`)
|
|
649
|
+
}
|
|
650
|
+
})
|
|
651
|
+
|
|
652
|
+
serverProcess.stderr?.on('data', (data) => {
|
|
653
|
+
try {
|
|
654
|
+
const chunk = data.toString()
|
|
655
|
+
const lines = splitOutputChunkLines(chunk)
|
|
656
|
+
if (!serverReady) {
|
|
657
|
+
logBuffer.push(...lines.map((line) => `[stderr] ${line}`))
|
|
658
|
+
pushStartupStderrTail({ stderrTail: startupStderrTail, chunk })
|
|
659
|
+
return
|
|
660
|
+
}
|
|
661
|
+
for (const line of lines) {
|
|
662
|
+
opencodeLogger.error(line)
|
|
663
|
+
}
|
|
664
|
+
} catch (error) {
|
|
665
|
+
logBuffer.push(`Failed to process stderr startup logs: ${error}`)
|
|
666
|
+
}
|
|
667
|
+
})
|
|
668
|
+
|
|
669
|
+
serverProcess.on('error', (error) => {
|
|
670
|
+
logBuffer.push(`Failed to start server on port ${port}: ${error}`)
|
|
671
|
+
})
|
|
672
|
+
|
|
673
|
+
serverProcess.on('exit', (code, signal) => {
|
|
674
|
+
if (startingServerProcess === serverProcess) {
|
|
675
|
+
startingServerProcess = null
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
opencodeLogger.log(
|
|
679
|
+
`Opencode server exited with code: ${code}, signal: ${signal}`,
|
|
680
|
+
)
|
|
681
|
+
singleServer = null
|
|
682
|
+
clientCache.clear()
|
|
683
|
+
notifyServerLifecycle({ type: 'stopped' })
|
|
684
|
+
|
|
685
|
+
// Intentional kills should not trigger auto-restart:
|
|
686
|
+
// - SIGTERM from our cleanup/restart code
|
|
687
|
+
// - SIGINT propagated from Ctrl+C (parent process group signal)
|
|
688
|
+
// - any exit during bot shutdown (shuttingDown flag)
|
|
689
|
+
// Only unexpected crashes (non-zero exit without signal) get retried.
|
|
690
|
+
if (signal === 'SIGTERM' || signal === 'SIGINT' || (global as any).shuttingDown) {
|
|
691
|
+
serverRetryCount = 0
|
|
692
|
+
return
|
|
693
|
+
}
|
|
694
|
+
if (code !== 0) {
|
|
695
|
+
if (serverRetryCount < 5) {
|
|
696
|
+
serverRetryCount += 1
|
|
697
|
+
opencodeLogger.log(
|
|
698
|
+
`Restarting server (attempt ${serverRetryCount}/5)`,
|
|
699
|
+
)
|
|
700
|
+
ensureSingleServer().then(
|
|
701
|
+
(result) => {
|
|
702
|
+
if (result instanceof Error) {
|
|
703
|
+
opencodeLogger.error(`Failed to restart opencode server:`, result)
|
|
704
|
+
void notifyError(result, `OpenCode server restart failed`)
|
|
705
|
+
}
|
|
706
|
+
},
|
|
707
|
+
)
|
|
708
|
+
} else {
|
|
709
|
+
const crashError = new Error(
|
|
710
|
+
`Server crashed too many times (5), not restarting`,
|
|
711
|
+
)
|
|
712
|
+
opencodeLogger.error(crashError.message)
|
|
713
|
+
void notifyError(crashError, `OpenCode server crash loop exhausted`)
|
|
714
|
+
}
|
|
715
|
+
} else {
|
|
716
|
+
serverRetryCount = 0
|
|
717
|
+
}
|
|
718
|
+
})
|
|
719
|
+
|
|
720
|
+
const waitResult = await waitForServer({
|
|
721
|
+
port,
|
|
722
|
+
startupStderrTail,
|
|
723
|
+
})
|
|
724
|
+
if (waitResult instanceof Error) {
|
|
725
|
+
killStartingServerProcessNow({ reason: 'startup-failed' })
|
|
726
|
+
if (startingServerProcess === serverProcess) {
|
|
727
|
+
startingServerProcess = null
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
// Dump buffered logs on failure
|
|
731
|
+
opencodeLogger.error(`Server failed to start:`)
|
|
732
|
+
for (const line of logBuffer) {
|
|
733
|
+
opencodeLogger.error(` ${line}`)
|
|
734
|
+
}
|
|
735
|
+
return waitResult
|
|
736
|
+
}
|
|
737
|
+
serverReady = true
|
|
738
|
+
opencodeLogger.log(`Server ready on port ${port}`)
|
|
739
|
+
|
|
740
|
+
// Always dump startup logs so plugin loading errors and other startup output
|
|
741
|
+
// are visible in kimaki.log.
|
|
742
|
+
for (const line of logBuffer) {
|
|
743
|
+
opencodeLogger.log(line)
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
const server: SingleServer = {
|
|
747
|
+
process: serverProcess,
|
|
748
|
+
port,
|
|
749
|
+
baseUrl: `http://127.0.0.1:${port}`,
|
|
750
|
+
}
|
|
751
|
+
if (startingServerProcess === serverProcess) {
|
|
752
|
+
startingServerProcess = null
|
|
753
|
+
}
|
|
754
|
+
singleServer = server
|
|
755
|
+
notifyServerLifecycle({ type: 'started', port })
|
|
756
|
+
return server
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
// ── Client cache ─────────────────────────────────────────────────
|
|
760
|
+
// One SDK client per directory, each with a fixed x-opencode-directory header.
|
|
761
|
+
|
|
762
|
+
function getOrCreateClient({
|
|
763
|
+
baseUrl,
|
|
764
|
+
directory,
|
|
765
|
+
}: {
|
|
766
|
+
baseUrl: string
|
|
767
|
+
directory: string
|
|
768
|
+
}): OpencodeClient {
|
|
769
|
+
const cached = clientCache.get(directory)
|
|
770
|
+
if (cached) {
|
|
771
|
+
return cached
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
const fetchWithTimeout = (request: Request) =>
|
|
775
|
+
fetch(request, {
|
|
776
|
+
// @ts-ignore
|
|
777
|
+
timeout: false,
|
|
778
|
+
})
|
|
779
|
+
|
|
780
|
+
const client = createOpencodeClient({
|
|
781
|
+
baseUrl,
|
|
782
|
+
directory,
|
|
783
|
+
fetch: fetchWithTimeout as typeof fetch,
|
|
784
|
+
})
|
|
785
|
+
clientCache.set(directory, client)
|
|
786
|
+
return client
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
// ── Public API ───────────────────────────────────────────────────
|
|
790
|
+
// Same signatures as before so callers don't need to change.
|
|
791
|
+
|
|
792
|
+
/**
|
|
793
|
+
* Initialize OpenCode server for a directory.
|
|
794
|
+
* Starts the single shared server if not running, then returns a client
|
|
795
|
+
* factory scoped to the given directory via x-opencode-directory header.
|
|
796
|
+
*
|
|
797
|
+
* @param directory - The project directory to scope requests to
|
|
798
|
+
* @param options.originalRepoDirectory - For worktrees: the original repo directory
|
|
799
|
+
* (no longer used for server-level permissions — use buildSessionPermissions
|
|
800
|
+
* at session.create() time instead)
|
|
801
|
+
*/
|
|
802
|
+
export async function initializeOpencodeForDirectory(
|
|
803
|
+
directory: string,
|
|
804
|
+
_options?: { originalRepoDirectory?: string; channelId?: string },
|
|
805
|
+
): Promise<OpenCodeErrors | (() => OpencodeClient)> {
|
|
806
|
+
// Verify directory exists and is accessible
|
|
807
|
+
const accessCheck = errore.tryFn({
|
|
808
|
+
try: () => {
|
|
809
|
+
fs.accessSync(directory, fs.constants.R_OK | fs.constants.X_OK)
|
|
810
|
+
},
|
|
811
|
+
catch: () => new DirectoryNotAccessibleError({ directory }),
|
|
812
|
+
})
|
|
813
|
+
if (accessCheck instanceof Error) {
|
|
814
|
+
return accessCheck
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
const server = await ensureSingleServer()
|
|
818
|
+
if (server instanceof Error) {
|
|
819
|
+
return server
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
if (!initializedDirectories.has(directory)) {
|
|
823
|
+
initializedDirectories.add(directory)
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
return () => {
|
|
827
|
+
if (!singleServer) {
|
|
828
|
+
throw new ServerNotReadyError({ directory })
|
|
829
|
+
}
|
|
830
|
+
return getOrCreateClient({
|
|
831
|
+
baseUrl: singleServer.baseUrl,
|
|
832
|
+
directory,
|
|
833
|
+
})
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
/**
|
|
838
|
+
* Build per-session permission rules for external_directory access.
|
|
839
|
+
* These rules are passed to session.create({ permission }) and override
|
|
840
|
+
* the server-level defaults via opencode's findLast() evaluation.
|
|
841
|
+
*
|
|
842
|
+
* This replaces the old per-server OPENCODE_CONFIG_CONTENT external_directory
|
|
843
|
+
* permissions — now each session carries its own directory-scoped rules.
|
|
844
|
+
*/
|
|
845
|
+
export function buildSessionPermissions({
|
|
846
|
+
directory,
|
|
847
|
+
originalRepoDirectory,
|
|
848
|
+
}: {
|
|
849
|
+
directory: string
|
|
850
|
+
originalRepoDirectory?: string
|
|
851
|
+
}): PermissionRuleset {
|
|
852
|
+
// Normalize path separators for cross-platform compatibility (Windows uses backslashes)
|
|
853
|
+
const tmpdir = os.tmpdir().replaceAll('\\', '/')
|
|
854
|
+
const normalizedDirectory = directory.replaceAll('\\', '/')
|
|
855
|
+
const originalRepo = originalRepoDirectory?.replaceAll('\\', '/')
|
|
856
|
+
|
|
857
|
+
const rules: PermissionRuleset = [
|
|
858
|
+
// Allow tmpdir access
|
|
859
|
+
{ permission: 'external_directory', pattern: '/tmp', action: 'allow' },
|
|
860
|
+
{ permission: 'external_directory', pattern: '/tmp/*', action: 'allow' },
|
|
861
|
+
{ permission: 'external_directory', pattern: '/private/tmp', action: 'allow' },
|
|
862
|
+
{ permission: 'external_directory', pattern: '/private/tmp/*', action: 'allow' },
|
|
863
|
+
{ permission: 'external_directory', pattern: tmpdir, action: 'allow' },
|
|
864
|
+
{ permission: 'external_directory', pattern: `${tmpdir}/*`, action: 'allow' },
|
|
865
|
+
// Allow the project directory itself
|
|
866
|
+
{ permission: 'external_directory', pattern: normalizedDirectory, action: 'allow' },
|
|
867
|
+
{ permission: 'external_directory', pattern: `${normalizedDirectory}/*`, action: 'allow' },
|
|
868
|
+
]
|
|
869
|
+
|
|
870
|
+
// Allow ~/.config/opencode so the agent doesn't get permission prompts when
|
|
871
|
+
// it tries to read the global AGENTS.md or opencode config (the path is
|
|
872
|
+
// visible in the system prompt, so models sometimes try to read it).
|
|
873
|
+
const opencodeConfigDir = path
|
|
874
|
+
.join(os.homedir(), '.config', 'opencode')
|
|
875
|
+
.replaceAll('\\', '/')
|
|
876
|
+
rules.push(
|
|
877
|
+
{ permission: 'external_directory', pattern: opencodeConfigDir, action: 'allow' },
|
|
878
|
+
{ permission: 'external_directory', pattern: `${opencodeConfigDir}/*`, action: 'allow' },
|
|
879
|
+
)
|
|
880
|
+
|
|
881
|
+
// Allow ~/.kimaki so the agent can access kimaki data dir (logs, db, etc.)
|
|
882
|
+
// without permission prompts.
|
|
883
|
+
const kimakiDataDir = path
|
|
884
|
+
.join(os.homedir(), '.kimaki')
|
|
885
|
+
.replaceAll('\\', '/')
|
|
886
|
+
rules.push(
|
|
887
|
+
{ permission: 'external_directory', pattern: kimakiDataDir, action: 'allow' },
|
|
888
|
+
{ permission: 'external_directory', pattern: `${kimakiDataDir}/*`, action: 'allow' },
|
|
889
|
+
)
|
|
890
|
+
|
|
891
|
+
// Allow opencode tool output artifacts under XDG data so agents can inspect
|
|
892
|
+
// prior tool outputs without interactive permission prompts.
|
|
893
|
+
const opencodeToolOutputDir = path
|
|
894
|
+
.join(os.homedir(), '.local', 'share', 'opencode', 'tool-output')
|
|
895
|
+
.replaceAll('\\', '/')
|
|
896
|
+
rules.push(
|
|
897
|
+
{
|
|
898
|
+
permission: 'external_directory',
|
|
899
|
+
pattern: opencodeToolOutputDir,
|
|
900
|
+
action: 'allow',
|
|
901
|
+
},
|
|
902
|
+
{
|
|
903
|
+
permission: 'external_directory',
|
|
904
|
+
pattern: `${opencodeToolOutputDir}/*`,
|
|
905
|
+
action: 'allow',
|
|
906
|
+
},
|
|
907
|
+
)
|
|
908
|
+
|
|
909
|
+
// For worktrees: allow access to the original repository directory
|
|
910
|
+
if (originalRepo) {
|
|
911
|
+
rules.push(
|
|
912
|
+
{ permission: 'external_directory', pattern: originalRepo, action: 'allow' },
|
|
913
|
+
{ permission: 'external_directory', pattern: `${originalRepo}/*`, action: 'allow' },
|
|
914
|
+
)
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
return rules
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
/**
|
|
921
|
+
* Parse raw permission strings into PermissionRuleset entries.
|
|
922
|
+
*
|
|
923
|
+
* Accepted formats:
|
|
924
|
+
* "tool:action" → { permission: tool, pattern: "*", action }
|
|
925
|
+
* "tool:pattern:action" → { permission: tool, pattern, action }
|
|
926
|
+
*
|
|
927
|
+
* The action must be one of "allow", "deny", "ask" (case-insensitive).
|
|
928
|
+
* Parts are trimmed to tolerate whitespace from YAML deserialization.
|
|
929
|
+
* Invalid entries are silently skipped (bad user input shouldn't crash the bot).
|
|
930
|
+
* If `raw` is not an array, returns empty (defensive against malformed YAML markers).
|
|
931
|
+
*/
|
|
932
|
+
export function parsePermissionRules(raw: unknown): PermissionRuleset {
|
|
933
|
+
if (!Array.isArray(raw)) {
|
|
934
|
+
return []
|
|
935
|
+
}
|
|
936
|
+
const validActions = new Set(['allow', 'deny', 'ask'])
|
|
937
|
+
return raw.flatMap((entry) => {
|
|
938
|
+
if (typeof entry !== 'string') {
|
|
939
|
+
return []
|
|
940
|
+
}
|
|
941
|
+
const parts = entry.split(':').map((s) => {
|
|
942
|
+
return s.trim()
|
|
943
|
+
})
|
|
944
|
+
if (parts.length === 2) {
|
|
945
|
+
const [permission, rawAction] = parts
|
|
946
|
+
const action = rawAction!.toLowerCase()
|
|
947
|
+
if (!permission || !validActions.has(action)) {
|
|
948
|
+
return []
|
|
949
|
+
}
|
|
950
|
+
return [{ permission, pattern: '*', action: action as 'allow' | 'deny' | 'ask' }]
|
|
951
|
+
}
|
|
952
|
+
if (parts.length >= 3) {
|
|
953
|
+
// Last segment is the action, first segment is the permission,
|
|
954
|
+
// everything in between is the pattern (may contain colons in theory,
|
|
955
|
+
// but unlikely for tool patterns).
|
|
956
|
+
const permission = parts[0]!
|
|
957
|
+
const rawAction = parts[parts.length - 1]!
|
|
958
|
+
const action = rawAction.toLowerCase()
|
|
959
|
+
const pattern = parts.slice(1, -1).join(':')
|
|
960
|
+
if (!permission || !pattern || !validActions.has(action)) {
|
|
961
|
+
return []
|
|
962
|
+
}
|
|
963
|
+
return [{ permission, pattern, action: action as 'allow' | 'deny' | 'ask' }]
|
|
964
|
+
}
|
|
965
|
+
return []
|
|
966
|
+
})
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
// ── Injection guard per-session config ───────────────────────────
|
|
970
|
+
// Per-session injection guard patterns are written as JSON files to
|
|
971
|
+
// <dataDir>/injection-guard/<sessionId>.json. The injection guard plugin
|
|
972
|
+
// (running inside the opencode server process) reads KIMAKI_DATA_DIR env
|
|
973
|
+
// var to find these files in tool.execute.after.
|
|
974
|
+
// This avoids needing env vars (which are per-process, not per-session).
|
|
975
|
+
|
|
976
|
+
function getInjectionGuardDir(): string {
|
|
977
|
+
return path.join(getDataDir(), 'injection-guard')
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
/**
|
|
981
|
+
* Write per-session injection guard config so the plugin picks it up.
|
|
982
|
+
* Only call this if injectionGuardPatterns is non-empty.
|
|
983
|
+
*/
|
|
984
|
+
export function writeInjectionGuardConfig({
|
|
985
|
+
sessionId,
|
|
986
|
+
scanPatterns,
|
|
987
|
+
}: {
|
|
988
|
+
sessionId: string
|
|
989
|
+
scanPatterns: string[]
|
|
990
|
+
}): void {
|
|
991
|
+
if (scanPatterns.length === 0) {
|
|
992
|
+
return
|
|
993
|
+
}
|
|
994
|
+
try {
|
|
995
|
+
const dir = getInjectionGuardDir()
|
|
996
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
997
|
+
fs.writeFileSync(
|
|
998
|
+
path.join(dir, `${sessionId}.json`),
|
|
999
|
+
JSON.stringify({ scanPatterns }),
|
|
1000
|
+
)
|
|
1001
|
+
} catch {
|
|
1002
|
+
// Best effort -- don't crash the bot if data dir write fails
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
/**
|
|
1007
|
+
* Remove per-session injection guard config file.
|
|
1008
|
+
*/
|
|
1009
|
+
export function removeInjectionGuardConfig({ sessionId }: { sessionId: string }): void {
|
|
1010
|
+
try {
|
|
1011
|
+
fs.unlinkSync(path.join(getInjectionGuardDir(), `${sessionId}.json`))
|
|
1012
|
+
} catch {
|
|
1013
|
+
// File may already be gone
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
/**
|
|
1018
|
+
* Read per-session injection guard config. Used by the kimaki plugin
|
|
1019
|
+
* inside the opencode server process.
|
|
1020
|
+
*/
|
|
1021
|
+
export function readInjectionGuardConfig({ sessionId }: { sessionId: string }): { scanPatterns: string[] } | null {
|
|
1022
|
+
try {
|
|
1023
|
+
const raw = fs.readFileSync(
|
|
1024
|
+
path.join(getInjectionGuardDir(), `${sessionId}.json`),
|
|
1025
|
+
'utf-8',
|
|
1026
|
+
)
|
|
1027
|
+
return JSON.parse(raw) as { scanPatterns: string[] }
|
|
1028
|
+
} catch {
|
|
1029
|
+
return null
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
// ── Public helpers ───────────────────────────────────────────────
|
|
1034
|
+
// These helpers expose the single shared server and directory-scoped clients.
|
|
1035
|
+
|
|
1036
|
+
export function getOpencodeServerPort(_directory?: string): number | null {
|
|
1037
|
+
return singleServer?.port ?? null
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
export function getOpencodeClient(directory: string): OpencodeClient | null {
|
|
1041
|
+
if (!singleServer) {
|
|
1042
|
+
return null
|
|
1043
|
+
}
|
|
1044
|
+
return getOrCreateClient({
|
|
1045
|
+
baseUrl: singleServer.baseUrl,
|
|
1046
|
+
directory,
|
|
1047
|
+
})
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
/**
|
|
1051
|
+
* Stop the single opencode server.
|
|
1052
|
+
* Used for process teardown, tests, and explicit restarts.
|
|
1053
|
+
*/
|
|
1054
|
+
export async function stopOpencodeServer(): Promise<boolean> {
|
|
1055
|
+
if (!singleServer) {
|
|
1056
|
+
return false
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
const server = singleServer
|
|
1060
|
+
opencodeLogger.log(
|
|
1061
|
+
`Stopping opencode server (pid: ${server.process.pid}, port: ${server.port})`,
|
|
1062
|
+
)
|
|
1063
|
+
if (!server.process.killed) {
|
|
1064
|
+
const killResult = errore.try({
|
|
1065
|
+
try: () => {
|
|
1066
|
+
server.process.kill('SIGTERM')
|
|
1067
|
+
},
|
|
1068
|
+
catch: (error) => {
|
|
1069
|
+
return new Error('Failed to send SIGTERM to opencode server', {
|
|
1070
|
+
cause: error,
|
|
1071
|
+
})
|
|
1072
|
+
},
|
|
1073
|
+
})
|
|
1074
|
+
if (killResult instanceof Error) {
|
|
1075
|
+
opencodeLogger.warn(killResult.message)
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
killStartingServerProcessNow({ reason: 'stop-opencode-server' })
|
|
1080
|
+
startingServerProcess = null
|
|
1081
|
+
|
|
1082
|
+
singleServer = null
|
|
1083
|
+
clientCache.clear()
|
|
1084
|
+
serverRetryCount = 0
|
|
1085
|
+
await new Promise((resolve) => {
|
|
1086
|
+
setTimeout(resolve, 1000)
|
|
1087
|
+
})
|
|
1088
|
+
return true
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
/**
|
|
1092
|
+
* Restart the single opencode server.
|
|
1093
|
+
* Kills the existing process and starts a new one.
|
|
1094
|
+
* All directory clients are invalidated and recreated on next use.
|
|
1095
|
+
* Used for resolving opencode state issues, refreshing auth, plugins, etc.
|
|
1096
|
+
*/
|
|
1097
|
+
export async function restartOpencodeServer(): Promise<OpenCodeErrors | true> {
|
|
1098
|
+
if (singleServer) {
|
|
1099
|
+
await stopOpencodeServer()
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
// Reset retry count for the fresh start
|
|
1103
|
+
serverRetryCount = 0
|
|
1104
|
+
|
|
1105
|
+
const result = await ensureSingleServer()
|
|
1106
|
+
if (result instanceof Error) {
|
|
1107
|
+
return result
|
|
1108
|
+
}
|
|
1109
|
+
return true
|
|
1110
|
+
}
|