- Agents lack cross-session access by design: Agents lack cross-session access by default: the SDK client (session.list/get/messages/prompt) is not exposed to agent tools. Cross-session behavior is mediated via dispatches [[019e19e2-9c09-7787-b48d-ca2d2685b261]]. To enable it, explicitly expose wrapper tools (e.g. lifecycle tools) that call `client.session.*` via closure. Pattern: use `store` for discovery (entities/dispatches → session_id), then `client` inside tools for direct session interaction (read/post messages). This preserves audit semantics while enabling controlled cross-session access.
- Concurrency limited by semaphore, not sessions: Throughput is controlled by a global semaphore (`max_concurrent`, default 2, configured in `opentower.config.json`) that limits parallel LLM dispatches, not total sessions. Sessions, entities, DB rows, and per-session queues are unbounded; excess events queue and batch via `batch_window_ms`. Increasing `max_concurrent` raises parallel LLM load and resource usage but does not change queueing behavior.
- Cron scheduling lives inside opentower: Cron scheduling is implemented inside opentower: jobs are stored in SQLite and executed via the pipeline to preserve session affinity and observability. Each run creates a cron_executions row with delivery_id `cron:{jobId}:{timestamp}`. `run_once` jobs auto-disable after success. Recurring jobs enforce a ≥1h interval via shared validation (API and agent tools). There is no general delayed dispatch primitive—short delays must use `run_once` jobs (e.g. polling loops), which can bypass the 1h limit but are one-off executions, not true recurring sub-hour schedules.
- Dashboard bundled and served by opentower: Dashboard is bundled into `packages/opentower/public` and served via Hono for a single-origin API+UI. `public/` is a build artifact (gitignored) generated in `prepublishOnly` and included via `files` when published. If missing at runtime, the UI won’t be served. The frontend assumes same-origin APIs: requests use `new URL(path, window.location.origin)` with endpoints like `/api/*` and `/healthz`, with no configurable base URL. This design simplifies auth and avoids CORS but requires the server and UI to be deployed together.
- Plugin self-hosts HTTP server via Bun.serve: The opentower plugin does not integrate into an existing OpenCode HTTP server; it starts its own standalone server using `Bun.serve()` (default port 5050). OpenCode only loads the plugin function for side effects. This means routing, static serving, and API endpoints are entirely owned by the plugin process, not OpenCode.
- Proxy Cloudflare worker APIs through opentower: Expose Cloudflare email-worker data (e.g. allowed_senders) via opentower API instead of calling the worker directly from the dashboard. Add proxy routes in opentower that forward to the worker using the same API token/secret. This maintains the single-origin model, avoids leaking worker URLs to the client, and keeps auth consistent with OPENTOWER_API_TOKEN.
- Removed multi-server support in dashboard: Dashboard no longer manages multiple servers. All server config (URL, token, opencodeUrl) and related hooks (`use-servers`, schemas, server CRUD UI) were removed. The app now assumes a single backend (same origin) and only handles a single auth token.
- @loreai/opencode 0.13.0–0.13.3 unusable in bun workspaces due to fastembed workspace deps: `@loreai/opencode` versions 0.13.0–0.13.3 and `@loreai/core` 0.13.3 transitively depend on `fastembed@^2.1.0`, which declared unresolvable `workspace:*` deps (`gearhash-jit`, `@huggingface/blake3-jit`). Bun failed with "Workspace dependency not found" during install. This was resolved in 0.13.4 — `fastembed@2.1.0` on npm no longer carries the broken workspace deps. Note: bun's registry cache may lag npm by minutes — a version appearing in `npm view ... versions` may not resolve immediately in bun.
- Biome expects 2-space indentation; tabs cause CI failure: Biome must ignore generated assets. The repo enforces 2-space indentation, but bundled/minified files (e.g. `packages/opentower/public`) will fail linting. Fix: add `public` (and similar build output dirs) to `biome.json.files.ignore`. Otherwise CI fails on style rules against compiled code.
- bun pm pack skips prepublishOnly (missing public/): `public/` inclusion depends on install method: npm installs include it via `files`, but `file:` deps do not. When `opentower` is used as `"file:./packages/opentower"`, Bun uses the raw source directory, bypassing `files` and `prepublishOnly`, so `public/` (gitignored) is missing. Docker builds must explicitly build and copy the dashboard into `public/`. This is why Docker cannot rely on npm packaging behavior when using local file deps.
- bun.lock goes stale when adding packages inside a workspace subdirectory: Running `bun add` inside a workspace package (e.g. `packages/dashboard`) updates that package's local state but may not update the root `bun.lock`. CI uses `bun install --frozen-lockfile` at the repo root, so a stale root lockfile causes build failure with "lockfile had changes, but lockfile is frozen". Fix: always run `bun install` from the repo root after adding any dependency, then commit the updated `bun.lock`.
- Craft publish expects prebuilt GHCR image by commit SHA: Craft publish requires a GHCR image tagged with the commit SHA; if CI hasn’t built/pushed it, publish fails with "ghcr.io/...:<sha>: not found". Fix by explicitly waiting for CI on the release branch before publish (e.g. `gh run watch`). When resolving the run via `gh run list`, guard against empty/null results—fail fast if no run exists, otherwise `gh run watch` may receive `null` and error.
- Dashboard build fails without installing dashboard deps: Running `bun run build:dashboard` from the opentower package can fail with missing types/plugins (vite, @vitejs/plugin-react, @tailwindcss/vite) if the dashboard workspace deps aren’t installed. The build script assumes `packages/dashboard` already has its node_modules. Fix: run `bun install` at repo root (or in the dashboard dir) before building, then rerun the build.
- Dispatch records can exist without sessions: Dispatch rows are inserted with `session_id: null` before session creation. If `client.session.create()` fails or times out, they remain without sessions. Additionally, cron jobs may call `pipeline.dispatch()` without `await`, allowing the cron execution to complete before the session is created—producing dispatches that appear finished but still lack a `session_id`. Check for async gaps in cron execution flow.
- Hono middleware registration order: /healthz bypasses app.use('*') registered after it: Hono middleware order matters: middleware only applies to routes registered after it. If `/healthz` is defined before `app.use('*', ...)`, it bypasses global middleware like CORS. Fix: register `app.use('*', cors({...}))` as the very first call on the app, before any routes or `app.onError`. Also ensure CORS is not scoped only to `/api/*` if endpoints like `/healthz` must be browser-accessible, and include required methods (e.g. POST) for webhook preflight.
- opencode-config-bun.lock must be regenerated via temp install: `opencode-config-bun.lock` is validated in CI by regenerating it via an isolated temp install and diffing. Rebases or merges can leave stale entries (e.g. removed deps like `typescript`) that cause CI failure even if builds pass locally. Do not edit or merge manually—always regenerate using the temp-install method from `opencode-config-package.json` and commit the result.
- Centralize SDK error extraction with helper: Avoid inline casting of SDK errors (e.g. `(err as { data?: { message?: string } })...`). Instead, use a small helper (e.g. `errorMessage(err, fallback)`) to safely extract `data.message`. This reduces repetition, simplifies call sites, and avoids brittle type assertions across tools.
- Dispatches are canonical for opentower sessions: Dispatches are the source of truth for opentower sessions. Do not use `client.session.*` to reason about activity—they include all OpenCode sessions and bypass pipeline features. Use `store.listDispatches()` and related store queries for session discovery, entity context, trigger state, and observability. `client.session.prompt()` sends messages directly to a session without creating a dispatch, so it skips audit trails, trigger evaluation, and consistency guarantees—only use it for simple nudges. Pattern: use `store` for opentower concepts (dispatches, entities, triggers) and `client` only for session internals (messages, prompting) once you already have a `session_id`. This separation avoids abstraction leaks and ensures correct session tracking.
- Email events only create dispatches if a trigger matches: Email webhook handling calls `evaluateAndDispatch`, which first filters triggers via `findMatching`. If no trigger matches an event like `email.ci_activity`, nothing is dispatched and no dispatch record is created—only a log entry (`trigger.no_match`). Dispatch creation only happens inside pipeline.dispatch/dispatchNoAffinity for matched triggers.
- OpenCode base URL stored in localStorage: The dashboard stores a user-provided OpenCode base URL (e.g. separate instance) in localStorage alongside the API token. This value is used only for constructing session links, not API requests. Pattern: keep API client same-origin, but allow UI-level overrides for external navigation targets.
- Plugins can register in-process agent tools: Plugins expose agent tools via the `tool` hook using `tool()` from `@opencode-ai/plugin`, with zod args and async `execute(args, context)`. Tools run in-process and should prefer direct access to internal components (e.g. `store`, `pipeline`, `scheduler`) via closure rather than calling HTTP APIs or using API tokens. This avoids unnecessary network layers and ensures full access to internal state.
- Propagate ToolContext.abort to SDK calls: Agent tools should accept the `context` parameter and pass `context.abort` to SDK calls (e.g. `client.session.prompt/messages({ ..., signal: context.abort })`). Without this, long-running requests continue after the invoking session is aborted, causing wasted work and inconsistent lifecycle behavior. Many existing tools omit this for simplicity, but for any network/LLM call, propagating the abort signal is the correct pattern.
- Token state via localStorage + useSyncExternalStore: Auth token is stored in `localStorage` (`opentower-token`) and exposed via a custom store using `useSyncExternalStore`. `emitTokenChange()` manually notifies subscribers after mutations (set/clear). This avoids React context and keeps token access global and reactive across components.
- Verify email-resolved entities via gh CLI: AI-based email entity resolution must be verified with `gh` CLI before dispatch. The resolver may misclassify kind (issue vs PR), breaking session affinity and link extraction. Pattern: after AI extracts `{repo, number}`, call `gh pr view` then `gh issue view` to confirm existence and canonical kind, and fetch the PR body for linked issue parsing. Fall back to AI-only if verification fails. This ensures correct session reuse and avoids split sessions from wrong entity types.
- Keep Dockerfile comments concise and why-focused: Dockerfile comments should be brief (1–2 lines) and explain *why*, not restate obvious steps. Avoid multi-line explanatory blocks unless the behavior is non-obvious. Match existing style: short stage headers and minimal inline notes; trim verbose AI-style comments during cleanup.