Repo: https://github.com/ChrisRomp/copilot-bridge
Keep this file up to date as the architecture evolves or new conventions emerge. If you make structural changes, add a new platform adapter, or change key patterns, update the relevant section here.
For project overview, configuration, chat commands, and deployment, see README.md.
npm install # install dependencies
npx tsc --noEmit # type-check (always run before deploying)
npm run dev # run with watch mode (tsx watch)
npm test # vitest suite
npx vitest run src/path/to/file.test.ts # single test fileRestart the running service after changes:
scripts/restart-gateway.sh
# Or: launchctl kickstart -k gui/$(id -u)/com.copilot-bridge
⚠️ NEVER uselaunchctl unload && launchctl load—unloadkills the bridge process (including your session), so theloadhalf never executes and the service stays down.
MattermostAdapterreceives a WebSocket event, normalizes it toInboundMessageindex.tsserializes handling per channel via promise chains (channelLocks), checks for slash commands, then forwards toSessionManager.sendMessage()SessionManagercreates/resumes aCopilotSessionviaCopilotBridge(SDK wrapper), wiring up permission and user-input handlers- SDK session events flow back through
sessionManager.onSessionEvent()→handleSessionEvent()in index.ts StreamFormatterconverts SDK events toFormattedEvent, which are routed toStreamingHandlerfor edit-in-place message updates
Both inbound messages and session events are serialized per-channel via separate promise chains (channelLocks and eventLocks). This prevents race conditions on stream auto-start and permission resolution.
- Sessions are created on first message per channel, persisted in SQLite (
channel_sessionstable) - On restart, sessions resume via
CopilotBridge.resumeSession()using the stored session ID /newdestroys the current session and creates a fresh one/resumeaccepts partial session ID prefixes (case-insensitive); reports ambiguity if multiple match- MCP servers and skill directories are loaded once at startup and passed to every session
StreamingHandlermanages edit-in-place messages with throttled updates (500ms)- One "Working..." stream is created per user message; it persists across tool cycles
- Streams finalize only on
session.idle(notturn_end, which fires between every tool cycle) - Thinking/reasoning events (
assistant.reasoning,assistant.reasoning_delta) are suppressed from the stream to prevent message churn - In verbose mode, tool calls accumulate in a separate "activity feed" message that updates in place
- Verbose mode preserves "Working..." messages by updating in place instead of deleting and recreating
All bots default to it/its pronouns. Bots are software, not people. When writing templates, documentation, or referring to a bot in third person, use "it" — e.g., "the bot and its workspace," not "she and her workspace." Users may override this in per-agent AGENTS.md if they prefer.
The bridge loads AGENTS.local.md from each bot's working directory if present. This file is gitignored and injected into sessions via custom_instructions. Use it for per-operator conventions (e.g., push policies, workflow preferences) that don't belong in the repo.
New platforms implement ChannelAdapter (in src/types.ts). The Mattermost adapter (src/channels/mattermost/adapter.ts) is the reference implementation.
Permission flow: config rules → SQLite stored rules (from /remember) → interactive prompt. MCP permissions are stored at server level (mcp:serverName → *), not per individual tool. The PendingPermission type carries a serverName field for MCP tools.
If the user sends unrecognized text during a permission prompt (not /approve, /deny, etc.), the permission is auto-denied and the text is processed as a normal message. This prevents lost messages when users ignore a permission prompt.
When a model returns a capacity, rate limit, or availability error, model-fallback.ts automatically tries alternative models:
parseModelId()extracts provider/family/version from model IDsSTATIC_FALLBACK_MAPdefines explicit chains for known models (e.g., opus 4.6 → opus 4.5 → sonnet 4.6)buildFallbackChain()merges config overrides (fallbackModels) with auto-detected chains, filtered against available models (whenlistModels()fails and the available list is empty, config fallbacks are included unfiltered so the user's explicit preferences still apply)tryWithFallback()wraps session creation; on model error, tries each fallback in ordersendMessage()in session-manager.ts has its own fallback loop for send-time failures
The working model is saved to channel prefs and a assistant.message event with data.content.
LoopDetector (src/core/loop-detector.ts) tracks tool calls per channel. When the same tool is called with identical arguments 5+ times within 60 seconds, it warns the user. At 10+ repetitions, it forces a new session. History is reset on /new and session changes.
All slash commands are parsed in command-handler.ts. Commands starting with / are intercepted by the bridge before reaching the Copilot session. parseCommand() splits command and args; handleCommand() returns a CommandResult with an optional action for the orchestrator to execute.
resolveModel() does exact → substring → token matching against model IDs and names, with provider awareness. If the input contains a known provider prefix (e.g., ollama-local:qwen3:8b), resolution is scoped to that provider's models. Bare model IDs resolve Copilot first, then BYOK providers in config order. pickBestMatch() prefers shorter IDs (base model over specialized variants like -1m). Always validate models against the live listModels() response before passing to the SDK.
External model providers are configured under providers in config.json. Each provider has a name (used as prefix in model IDs), baseUrl, optional auth (apiKeyEnv/bearerTokenEnv), and a models array. Provider names cannot contain : or whitespace.
resolveProviderConfig()inconfig.tsresolves env vars to build SDKProviderConfigat session creation time/modellisting groups models by provider (Copilot section first, then BYOK providers)/provider test <name>calls the provider's models endpoint (${baseUrl}/models) to verify connectivityswitchModel()in session-manager detects provider changes and creates a fresh session (different endpoint/auth)- BYOK models are excluded from auto-fallback chains unless explicitly in
configFallbacks - Provider config changes are hot-reloadable via
/reload config
- ESM modules (
"type": "module"in package.json,NodeNextmodule resolution) - All imports use
.jsextensions (required for ESM) - Strict mode enabled
- SDK types that aren't exported from the package root are defined locally in
bridge.ts
Use createLogger(tag) from src/logger.ts. Tags identify the subsystem (e.g., bridge, session, mattermost, streaming).
Pluggable state store via the StateStore interface (src/state/types.ts). The built-in default is SqliteStateStore (src/state/sqlite-store.ts), which uses SQLite at ~/.copilot-bridge/state.db in WAL mode. src/state/store.ts is a thin facade that delegates to the active backend — all callers import from store.ts unchanged. Custom backends (Postgres, etc.) can be loaded via the database.module config option.
Persistent agent memory via MEMORY.md in workspace roots. Configured under memory in config.json.
- System message injection:
buildMemoryPointer()insession-manager.tsinjects a<memory>block with MEMORY.md section headlines and read/write instructions. - Cloud memory gating:
store_memory/vote_memoryare excluded viaexcludedToolsby default. Setmemory.cloudMemory: trueto enable. - Compaction save: On
session.compaction_complete,mergeCompactionSummary()appends summaryContent to MEMORY.md with a backup (.memory/MEMORY.md.bak). - Idle consolidation: After
memory.consolidation.idleMinutes(default 5) of idle, an ephemeral session prunes/organizes MEMORY.md. Timer resets on new activity. - Write lock: Per-workspace async mutex (
src/core/workspace-lock.ts) prevents concurrent MEMORY.md corruption. Background ops usetryAcquireand yield on contention. - Key files:
src/core/memory-consolidation.ts(compaction save, idle consolidation),src/core/workspace-lock.ts(mutex).
Use the repo's YAML issue templates (.github/ISSUE_TEMPLATE/) when creating issues via gh issue create. Two templates exist:
bug_report.yml— for bugs. Required fields: Summary, Steps to Reproduce, Expected Behavior, Actual Behavior. Include component, version (git rev-parse --short HEAD), platform, and logs when available.feature_request.yml— for enhancements. Required fields: Summary, Motivation. Include Proposed Solution and Alternatives Considered when known.
Always set Reported By: Agent (automated) when filing programmatically. Reference related issues with #N. Keep issue bodies factual — describe observed behavior, not speculative fixes.