diff --git a/AGENTS.md b/AGENTS.md index bff162c2f..f07f9b6e0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,7 +11,7 @@ - Keep things in one function unless composable or reusable - Avoid `try`/`catch` where possible -- Avoid using the `any` type +- Never use `any` or `unknown` types. Always prefer specific, strong types — define interfaces, use generics, use branded types, use discriminated unions, or use Zod-inferred types. When you encounter `any` or `unknown` in existing code, replace it with the actual type the value holds. For catch blocks use `catch (e)` and narrow with `e instanceof Error`. For Zod schemas use specific Zod types (`z.string()`, `z.number()`, `z.record(z.string(), z.string())`, etc.) — never `z.any()` or `z.unknown()`. The only exceptions are: (1) type-erased storage in event emitter patterns where heterogeneous callbacks coexist, and (2) SDK/library boundary casts where the external API has an incompatible type — document both with a comment explaining why. - Prefer single word variable names where possible - Use Bun APIs when possible, like `Bun.file()` - Rely on type inference when possible; avoid explicit type annotations or interfaces unless necessary for exports or clarity @@ -156,7 +156,7 @@ Use `context_history` to navigate the edit DAG: ## Pre-existing Failures and Bugs -**IMPORTANT:** Pre-existing failures, bugs, and issues MUST be fixed too — always. Do not ignore typecheck errors, lint warnings, unused variables, broken imports, or failing tests just because they existed before your changes. If you encounter a pre-existing issue during your work, fix it as part of your changes. This applies to all types of issues: type errors, dead code, incorrect logic, missing exports, stale references, etc. +**IMPORTANT:** Pre-existing failures, bugs, and issues MUST be fixed too — always, no exceptions. Do not ignore typecheck errors, lint warnings, unused variables, broken imports, or failing tests just because they existed before your changes. If you encounter a pre-existing issue during your work, fix it as part of your changes. This applies to all types of issues: type errors, dead code, incorrect logic, missing exports, stale references, runtime errors, circular imports, etc. If a pre-existing issue is too complex to fix in a single session, document it in `BUGS.md` with full details so it can be tracked and fixed later — but never silently skip it. ## Git Workflow diff --git a/BUGS.md b/BUGS.md index 78556e6a0..e429ee217 100644 --- a/BUGS.md +++ b/BUGS.md @@ -4,90 +4,93 @@ All bugs tracked here. Do not create per-package bug files. --- -## Open Design Issues +## Open (0) -| Issue | Location | Impact | -| --- | --- | --- | -| No CAS garbage collection | `cas/index.ts` | Unbounded `cas_object` growth | -| Objective extraction never updates | `session/objective.ts` | Stale auto-extracted objectives | -| `Session.remove()` leaks EditGraph rows | `session/index.ts:665-689` | Orphan `edit_graph_node`/`edit_graph_head` rows | -| `CAS.store()` overwrites session_id on hash collision | `cas/index.ts:53-61` | Cross-session content deletion | +_No open bugs._ --- -## Manual TUI Testing (2026-03-20) +## Deferred (1) -Tested on branch `effect/complete-effectification` (27 commits ahead of `dev`) using tmux-based test harness and manual interaction. Model: GLM-5 Z.AI Coding. - -| Flow | Result | Notes | -| --- | --- | --- | -| Home screen | ✅ Pass | Logo, prompt, tips, status bar render correctly | -| Command palette (Ctrl+P) | ✅ Pass | Opens, shows Session/Skills/Open editor/Switch session | -| Agent cycling (Tab) | ✅ Pass | Cycles through Build/Plan/Docs agents | -| Message submission | ✅ Pass | Enter submits, streaming dots visible, response renders with token/cost metadata | -| Cost dialog (/cost) | ✅ Pass | Shows Sess/☼-ly/☽-ly rows with cache hit %, esc dismisses | -| Status bar | ✅ Pass | Shows branch, agent, model, hints | - -No new bugs found during manual testing. +| # | Issue | Sev | Location | Notes | +| --- | ------------------------------ | --- | ----------------- | --------------------------------------------------------------------------- | +| B51 | ID generator counter not atomic | Low | `id/id.ts:25-27` | Fine single-threaded; documented with comment. Fix if worker threads added. | --- -## False Positives / Intentional - -| Issue | Resolution | -| --- | --- | -| Fork-based ephemeral: message IDs point to deleted session | **Intentional** — ephemeral results serialized immediately, never dereferenced later | -| #32 Skill template returns Promise not string | **By design** — type is `Promise | string`, all consumers `await`, `hints: []` for skill commands | +## Fixed (51) + +| # | Issue | Sev | Fix | +| --- | ------------------------------------------------------ | ---- | --------------------------------------------------- | +| B1 | `reset()`/`checkout()` JSON.parse plain text CAS | Crit | `JSON.stringify(part)` in `CAS.store()` | +| B2 | `withTimeout` timer leak on rejection | Crit | `.finally()` cleanup | +| B3 | `unhide`/`annotate` missing transaction | Crit | `Database.transaction()` wrapper | +| B4 | `SideThread.list` total ignores status filter | High | `countWhere` applies filter | +| B5 | Unsafe cast to `MessageV2.User` in classifier | High | `.find()` + optional chaining | +| B6 | `sweep()` no transaction wrapping | High | `Database.transaction()` wrapper | +| B7 | `filterEdited` breaks message alternation | High | Synthetic placeholder preserves structure | +| B8 | N+1 queries in `getLog`/`buildPathToRoot` | High | `loadAllNodes()` single query | +| B9 | Missing plugin hooks in `unhide`/`annotate` | High | Added `pluginGuard` + `pluginNotify` | +| B10 | CAS `onConflictDoNothing` loses metadata | Med | `onConflictDoUpdate` | +| B11 | `AsyncQueue` no termination | Med | `close()` with CLOSED sentinel | +| B12 | Template variable bash-style syntax | Med | `$ARGUMENTS` substitution | +| B13 | `work()` drops `undefined` items | Med | Check `pending.length` not `item` | +| B14 | Modular bias in random ID generation | Med | Rejection sampling (`MAX_UNBIASED = 248`) | +| B15 | Ephemeral commands crash (schema validation) | Crit | Replaced sweep with `filterEphemeral()` | +| B16 | Ephemeral sweep leaks content into LLM turn | High | Filter upstream via `filterEphemeral()` | +| B17 | `context_edit` accepts invalid `afterTurns` | Med | `.int().min(1)` validation | +| B18 | Unused imports in message-v2.ts | Low | Removed | +| B19 | Unused constant `MAX_EDITS_PER_TURN` | Low | Removed | +| B20 | `focus-rewrite-history` missing tool perms | High | Added `classifier_threads`/`distill_threads` | +| B21 | Fork-based ephemeral: leaked sessions on error | High | try/finally around `Session.remove()` | +| B22 | Fork-based ephemeral: `Command.Event.Executed` skipped | Med | Added `Bus.publish()` in ephemeral path | +| B23 | `Session.remove()` doesn't clean up CAS | High | Added `CAS.deleteBySession()` | +| B24 | Duplicate skill name warns but doesn't skip | Low | Added warning log | +| B25 | Circuit breaker `lastFailure` after throw | High | Move `lastFailure = now` before threshold check | +| B26 | Circuit breaker never resets on success | Med | Added `recordSuccess()`, called on pass | +| B27 | Circuit breaker inverted `open` semantics | Low | Renamed `open` → `healthy` | +| B28 | Default cooldown (1s) effectively zero | Med | Increased to 30000ms | +| B29 | `scope`/`files`/`criteria` params unused | Med | Removed from schema | +| B30 | `command.split(" ")` breaks quoted args | Low | Changed to `["bash", "-c", command]` | +| B31 | Verify config shallow merge loses nested | High | `mergeDeep()` from remeda | +| B32 | Evaluator has no code context | Crit | Build change summary from parent session messages | +| B33 | `tools: {}` blocks agent tools | Crit | Removed `tools: {}` from prompt calls | +| B34 | `parseEvaluation` brittle parsing | Med | Extract `` block first, NaN guard | +| B35 | Child sessions never cleaned up | Low | try/finally with `Session.remove()` | +| B36 | Skill template returns Promise | High | By design — added comment, no code change | +| B37 | `Skill.get()` re-parses every call | Low | Module-level content cache, cleared on state reload | +| B38 | Scripts: argument injection | Med | Insert `--` separator before user args | +| B39 | Scripts: tool ID collision | Low | Changed separator to `::` (`script::skill/name`) | +| B40 | Evaluator agent has bash access | Med | Removed `bash: "allow"` from evaluator permissions | +| B41 | No CAS garbage collection | High | `runGC()` + `deleteOrphans()` implemented | +| B42 | `Session.remove()` leaks EditGraph rows | High | `EditGraph.deleteBySession()` added | +| B43 | `CAS.store()` overwrites session_id on collision | High | Changed to `onConflictDoNothing()` | +| B44 | Lock starvation in read/write lock | High | Writer prioritization with reader batch wakeup | +| B45 | `EditGraph.commit()` race condition | Med | `getHead()` moved inside `Database.use()` tx | +| B46 | FileWatcher subscription timeout cleanup | Low | `.then(s => s.unsubscribe()).catch()` on timeout | +| B47 | Objective extraction never updates | Med | Removed cache check in `extract()`, always re-evaluates | +| B48 | `Session.remove()` swallows errors | Med | Removed outer try-catch, errors now propagate | +| B49 | `context-edit mark()` not in transaction | Med | Wrapped `updatePart()` in `Database.transaction()` | +| B50 | `AsyncQueue.push()` after close silent | Low | Throws `Error("Cannot push to a closed queue")` | +| B52 | `Bus.publish` doesn't catch subscriber errors | Low | try-catch per subscriber + `Promise.allSettled()` | --- -## Previously Fixed (40 bugs) - -| # | Issue | Sev | Fix | -| --- | --- | --- | --- | -| 1 | `reset()`/`checkout()` JSON.parse plain text CAS | Crit | `JSON.stringify(part)` in `CAS.store()` | -| 2 | `withTimeout` timer leak on rejection | Crit | `.finally()` cleanup | -| 3 | `unhide`/`annotate` missing transaction | Crit | `Database.transaction()` wrapper | -| 4 | `SideThread.list` total ignores status filter | High | `countWhere` applies filter | -| 5 | Unsafe cast to `MessageV2.User` in classifier | High | `.find()` + optional chaining | -| 6 | `sweep()` no transaction wrapping | High | `Database.transaction()` wrapper | -| 7 | `filterEdited` breaks message alternation | High | Synthetic placeholder preserves structure | -| 8 | N+1 queries in `getLog`/`buildPathToRoot` | High | `loadAllNodes()` single query | -| 9 | Missing plugin hooks in `unhide`/`annotate` | High | Added `pluginGuard` + `pluginNotify` | -| 10 | CAS `onConflictDoNothing` loses metadata | Med | `onConflictDoUpdate` | -| 11 | `AsyncQueue` no termination | Med | `close()` with CLOSED sentinel | -| 12 | Template variable bash-style syntax | Med | `$ARGUMENTS` substitution | -| 13 | `work()` drops `undefined` items | Med | Check `pending.length` not `item` | -| 14 | Modular bias in random ID generation | Med | Rejection sampling (`MAX_UNBIASED = 248`) | -| 15 | Ephemeral commands crash (schema validation) | Crit | Replaced sweep with `filterEphemeral()` | -| 16 | Ephemeral sweep leaks content into LLM turn | High | Filter upstream via `filterEphemeral()` | -| 17 | `context_edit` accepts invalid `afterTurns` | Med | `.int().min(1)` validation | -| 18 | Unused imports in message-v2.ts | Low | Removed | -| 19 | Unused constant `MAX_EDITS_PER_TURN` | Low | Removed | -| 20 | `focus-rewrite-history` missing tool perms | High | Added `classifier_threads`/`distill_threads` | -| — | Fork-based ephemeral: leaked sessions on error | High | try/finally around `Session.remove()` | -| — | Fork-based ephemeral: `Command.Event.Executed` skipped | Med | Added `Bus.publish()` in ephemeral path | -| — | `Session.remove()` doesn't clean up CAS | High | Added `CAS.deleteBySession()` | -| — | Duplicate skill name warns but doesn't skip | Low | Added warning log | -| 21 | Circuit breaker `lastFailure` after throw | High | Move `lastFailure = now` before threshold check | -| 22 | Circuit breaker never resets on success | Med | Added `recordSuccess()`, called on pass | -| 23 | Circuit breaker inverted `open` semantics | Low | Renamed `open` → `healthy` | -| 24 | Default cooldown (1s) effectively zero | Med | Increased to 30000ms | -| 25 | `scope`/`files`/`criteria` params unused | Med | Removed from schema | -| 26 | `command.split(" ")` breaks quoted args | Low | Changed to `["bash", "-c", command]` | -| 27 | Verify config shallow merge loses nested | High | `mergeDeep()` from remeda | -| 28 | Evaluator has no code context | Crit | Build change summary from parent session messages | -| 29 | `tools: {}` blocks agent tools | Crit | Removed `tools: {}` from prompt calls | -| 30 | `parseEvaluation` brittle parsing | Med | Extract `` block first, NaN guard | -| 31 | Child sessions never cleaned up | Low | try/finally with `Session.remove()` | -| 32 | Skill template returns Promise | High | By design — added comment, no code change | -| 33 | `Skill.get()` re-parses every call | Low | Module-level content cache, cleared on state reload | -| 34 | Scripts: argument injection | Med | Insert `--` separator before user args | -| 35 | Scripts: tool ID collision | Low | Changed separator to `::` (`script::skill/name`) | -| 36 | Evaluator agent has bash access | Med | Removed `bash: "allow"` from evaluator permissions | +## False Positives / Intentional (6) + +| Issue | Resolution | +| ---------------------------------------------------------- | --------------------------------------------------------------------------------- | +| Fork-based ephemeral: message IDs point to deleted session | **Intentional** — ephemeral results serialized immediately, never dereferenced | +| Skill template returns Promise not string | **By design** — type is `Promise \| string`, all consumers `await` | +| Provider state map key inconsistency | **False positive** — all usages consistently key by `InstanceALS.directory` | +| Config state map same issue | **False positive** — same consistent keying as provider | +| Bus subscription cleanup gap | **False positive** — unsubscribe + BusService finalizer both clean up properly | +| `CAS.deleteBySession()` race with store | **False positive** — deletion is idempotent, no transaction needed | --- ## Notes **TUI Testing:** Playwright not feasible (OpenTUI+SolidJS). Use `testRender()` from `@opentui/solid` for unit tests. tmux-based integration harness at `test/cli/tui/tmux-tui-test.ts` for E2E flows. + +**TUI Manual Test (2026-03-20):** All 6 flows passed (home screen, command palette, agent cycling, message submission, cost dialog, status bar). No bugs found. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 000000000..47dc3e3d8 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/FEATURE_TOGGLES.md b/FEATURE_TOGGLES.md new file mode 100644 index 000000000..1efb12680 --- /dev/null +++ b/FEATURE_TOGGLES.md @@ -0,0 +1,122 @@ +# Feature Toggles + +Environment variables that control OpenCode behavior. Set via `export VAR=value` or prefix commands: `OPENCODE_EXPERIMENTAL=1 opencode`. + +See also: [docs/FRANKENCODE_DIFFERENCES.md](docs/FRANKENCODE_DIFFERENCES.md) for Frankencode-specific changes, [docs/API_PROVIDERS.md](docs/API_PROVIDERS.md) for provider configuration. + +--- + +## Core Flags + +| Environment Variable | Values | Description | +| ------------------------- | ------ | ------------------------------------------ | +| `OPENCODE_CONFIG` | path | Path to custom config file | +| `OPENCODE_CONFIG_DIR` | path | Directory for config files | +| `OPENCODE_CONFIG_CONTENT` | string | Inline config JSON (bypasses file loading) | +| `OPENCODE_TUI_CONFIG` | path | Path to TUI-specific config | +| `OPENCODE_CLIENT` | string | Client identifier (default: `cli`) | +| `OPENCODE_PERMISSION` | string | Default permission mode | + +--- + +## Experimental Features + +Controlled by `OPENCODE_EXPERIMENTAL=1` or individual flags. + +| Environment Variable | Default | Description | +| ---------------------------------------------- | ----------- | --------------------------------------------------------- | +| `OPENCODE_EXPERIMENTAL` | false | Master switch for all experimental features | +| `OPENCODE_EXPERIMENTAL_FILEWATCHER` | false | Enable experimental file watcher implementation | +| `OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER` | false | Disable file watching entirely | +| `OPENCODE_EXPERIMENTAL_ICON_DISCOVERY` | false | Enable icon discovery for UI | +| `OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT` | win32: true | Disable clipboard copy on text selection | +| `OPENCODE_EXPERIMENTAL_OXFMT` | false | Enable experimental output formatting | +| `OPENCODE_EXPERIMENTAL_LSP_TY` | false | Use `ty` LSP instead of pyright for Python | +| `OPENCODE_EXPERIMENTAL_LSP_TOOL` | false | Enable LSP as a tool for AI agents | +| `OPENCODE_EXPERIMENTAL_PLAN_MODE` | false | Enable planning agent mode (**Frankencode: always enabled, flag ignored**) | +| `OPENCODE_EXPERIMENTAL_WORKSPACES` | false | Enable workspace management features | +| `OPENCODE_EXPERIMENTAL_MARKDOWN` | true | Enable markdown rendering (set to `false`/`0` to disable) | + +--- + +## Enable/Disable Flags + +| Environment Variable | Default | Description | +| ------------------------------------- | ------- | --------------------------------------- | +| `OPENCODE_AUTO_SHARE` | false | Automatically share sessions | +| `OPENCODE_DISABLE_AUTOUPDATE` | false | Disable automatic updates | +| `OPENCODE_DISABLE_PRUNE` | false | Disable automatic session pruning | +| `OPENCODE_DISABLE_TERMINAL_TITLE` | false | Disable terminal title updates | +| `OPENCODE_DISABLE_DEFAULT_PLUGINS` | false | Skip loading default plugins | +| `OPENCODE_DISABLE_LSP_DOWNLOAD` | false | Disable LSP server downloads | +| `OPENCODE_DISABLE_AUTOCOMPACT` | false | Disable automatic context compaction | +| `OPENCODE_DISABLE_MODELS_FETCH` | false | Disable fetching model list from remote | +| `OPENCODE_DISABLE_CLAUDE_CODE` | false | Disable Claude Code integration | +| `OPENCODE_DISABLE_CLAUDE_CODE_PROMPT` | false | Disable Claude Code prompt instructions | +| `OPENCODE_DISABLE_CLAUDE_CODE_SKILLS` | false | Disable Claude Code skill loading | +| `OPENCODE_DISABLE_EXTERNAL_SKILLS` | false | Disable external skill loading | +| `OPENCODE_DISABLE_PROJECT_CONFIG` | false | Skip loading project-level config | +| `OPENCODE_DISABLE_CHANNEL_DB` | false | Disable channel-based database | +| `OPENCODE_DISABLE_FILETIME_CHECK` | false | Disable file modification time checks | +| `OPENCODE_ENABLE_EXPERIMENTAL_MODELS` | false | Show experimental/preview models | +| `OPENCODE_ENABLE_QUESTION_TOOL` | false | Enable interactive question tool | +| `OPENCODE_ENABLE_EXA` | false | Enable Exa search integration | + +--- + +## Configuration Overrides + +| Environment Variable | Description | +| -------------------------- | -------------------------- | +| `OPENCODE_MODELS_URL` | Custom URL for models.json | +| `OPENCODE_MODELS_PATH` | Local path for models.json | +| `OPENCODE_GIT_BASH_PATH` | Path to Git Bash (Windows) | +| `OPENCODE_SERVER_PASSWORD` | Password for remote server | +| `OPENCODE_SERVER_USERNAME` | Username for remote server | + +--- + +## Tuning Parameters + +| Environment Variable | Type | Description | +| ----------------------------------------------- | ------ | --------------------------------- | +| `OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS` | number | Default timeout for bash commands | +| `OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX` | number | Maximum output tokens for LLM | + +--- + +## Internal/Testing + +| Environment Variable | Description | +| ---------------------------------- | ---------------------------------------- | +| `OPENCODE_FAKE_VCS` | Fake VCS for testing | +| `OPENCODE_SKIP_MIGRATIONS` | Skip database migrations | +| `OPENCODE_STRICT_CONFIG_DEPS` | Enable strict config dependency checking | +| `OPENCODE_TEST_HOME` | Override home directory for tests | +| `OPENCODE_TEST_MANAGED_CONFIG_DIR` | Managed config dir for tests | + +--- + +## Value Format + +- **Boolean flags:** `true`, `1` = enabled; `false`, `0` = disabled +- **Paths:** Absolute or relative to current directory +- **Numbers:** Integer values only + +--- + +## Examples + +```bash +# Enable all experimental features +OPENCODE_EXPERIMENTAL=1 opencode + +# Disable auto-updates and use custom config +OPENCODE_DISABLE_AUTOUPDATE=1 OPENCODE_CONFIG=~/.config/opencode/custom.json opencode + +# Use experimental LSP and plan mode +OPENCODE_EXPERIMENTAL_LSP_TY=1 OPENCODE_EXPERIMENTAL_PLAN_MODE=1 opencode + +# Increase bash timeout to 60 seconds +OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS=60000 opencode +``` diff --git a/GAP_ANALYSIS.md b/GAP_ANALYSIS.md new file mode 100644 index 000000000..370091cc0 --- /dev/null +++ b/GAP_ANALYSIS.md @@ -0,0 +1,38 @@ +# Frankencode — Gap Analysis + +**Date:** 2026-03-21 + +--- + +## Goal 1: Fix all remaining bugs (B47-B52) — DONE + +## Goal 2: Eliminate weak typing — DONE + +**~95 `any` eliminated.** Only 3 remain in upstream OpenAI SDK types (not our code). + +### Strong Zod schemas defined + +| Schema | Location | Purpose | +|--------|----------|---------| +| `JsonValue` | `message-v2.ts` | Recursive JSON-serializable value (string, number, boolean, null, object, array) | +| `ProviderMeta` | `message-v2.ts` | Provider metadata: `Record>` | +| `ToolInput` | `message-v2.ts` | Tool parameters: `Record` | +| `ToolMeta` | `message-v2.ts` | Tool execution metadata: `Record` | + +### SDK boundary casts (~15 documented points) + +| Location | Direction | Reason | +|----------|-----------|--------| +| `message-v2.ts:toModelMessages` | Our → AI SDK | `ProviderMeta` → `SharedV2ProviderMetadata` | +| `processor.ts` | AI SDK → Our | `value.providerMetadata` → `ProviderMeta` | +| `batch.ts` | AI SDK → Our | `call.parameters` → `ToolInput` | +| `prompt.ts` | Tool → AI SDK | `metadata` → `ToolMeta`; `id`/`schema` → AI SDK tool types | +| `provider.ts` | Config → SDK | Options accessed as specific types (string, number, object) | + +### Documented exceptions (3 total) + +| File | Reason | +|------|--------| +| `provider/sdk/copilot/openai-compatible-error.ts` | Upstream OpenAI `param` field | +| `provider/sdk/copilot/responses/openai-error.ts` | Upstream OpenAI `param` field | +| `provider/sdk/copilot/responses/openai-responses-language-model.ts` | Upstream OpenAI `metadata` field | diff --git a/PLAN.md b/PLAN.md index 57784dc9f..4f3d6f62b 100644 --- a/PLAN.md +++ b/PLAN.md @@ -2,11 +2,137 @@ > **Frankencode** is a fork of [OpenCode](https://github.com/anomalyco/opencode) (`dev` branch) that adds context editing, content-addressable storage, and an edit graph. -**Status (2026-03-20):** Features implemented. 40 bugs fixed. Upstream synced. Effect-ification complete — `src/project/instance.ts` deleted, Instance split into InstanceALS + InstanceLifecycle + InstanceContext. 36 ALS fallbacks remain (wide-caller modules, deferred). 1447 tests passing (123 test files), 0 TS errors. See `STATUS.md`, `DO_NEXT.md`. +**Status (2026-03-21):** Features implemented. 51 bugs fixed, 0 open, 1 deferred. Upstream synced. Effect-ification complete. 1448 tests passing (122 test files), 0 TS errors. See `STATUS.md`, `GAP_ANALYSIS.md`. --- -## Next: Upstream Sync Strategy +## Next: Type Safety Audit — Eliminate `any` / weak typing + +**~158 `any` occurrences** across 58 files, **8 double-casts** (`as unknown as`). Organized into 7 tiers by fixability. + +### Tier 1: Frankencode-owned code (our files, easiest to fix) + +| File | Occurrences | Pattern | Fix | +|------|-------------|---------|-----| +| `session/side-thread.ts` | 6 | `projectID as any` in Drizzle queries, `Record` | Type the `projectID` param to match Drizzle column type; define `SideThreadUpdate` type | +| `context-edit/index.ts` | 3 | `part.state as any`, `} as any)` | Define `ToolPartState` type; type the return objects properly | +| `tool/context-edit.ts` | 1 | `(part as any).state.output` | Narrow part type with type guard | +| `tool/thread-list.ts` | 1 | `args.status as any` | Match Zod enum to TS union | +| `tool/objective-set.ts` | 1 | `Record` metadata | Define `ObjectiveMetadata` type | +| `session/objective.ts` | 0 | _(clean after B47 fix)_ | — | + +### Tier 2: Utility modules (small, self-contained) + +| File | Occurrences | Pattern | Fix | +|------|-------------|---------|-----| +| `util/signal.ts` | 1 | `let resolve: any` | Type as `(value: void) => void` | +| `util/defer.ts` | 1 | `} as any` | Define `Deferred` interface properly | +| `util/log.ts` | 15 | Pervasive `any` in logger interface | Define `LogData = Record` | +| `util/wildcard.ts` | 2 | `Record` params | `Record` or specific pattern type | +| `util/eventloop.ts` | 4 | `(process as any)._getActiveHandles()` | Declare module augmentation for private Node APIs | +| `util/filesystem.ts` | 1 | `Readable.fromWeb(stream as any)` | Cast via `ReadableStream` | + +### Tier 3: Session/message pipeline + +| File | Occurrences | Pattern | Fix | +|------|-------------|---------|-----| +| `session/prompt.ts` | 6 | `id as any`, `schema as any`, `Record` | Type tool IDs; narrow Zod schemas; define `SchemaObject` | +| `session/message-v2.ts` | 3 | `as unknown as MessageV2.Part` double-casts | Create builder/factory for parts instead of raw object literals | +| `session/llm.ts` | 1 | `Record` options | Define `LLMOptions` type | +| `session/processor.ts` | 2 | `(value.error as any).toString()`, `catch (e: any)` | Error type guard; narrow error type | + +### Tier 4: Config + storage + +| File | Occurrences | Pattern | Fix | +|------|-------------|---------|-----| +| `config/config.ts` | 3 | `as any` on JSON response, error catch | Define `WellKnownConfig` type; typed catch | +| `config/tui-service.ts` | 1 | `Effect.Effect` | Parameterize with actual config type | +| `storage/db.ts` | 2 | `() => any \| Promise`, `transaction as any` | Type effect fn; investigate Drizzle transaction type | +| `storage/json-migration.ts` | 6 | `[] as any[]` batch arrays | Define row value tuple types | +| `storage/storage.ts` | 6 | `readJson`, `(sum: any, x: any)` | Parameterize `readJson` calls with proper types | + +### Tier 5: LSP + server + +| File | Occurrences | Pattern | Fix | +|------|-------------|---------|-----| +| `lsp/server.ts` | 4 | JSON response `as any`, `(a: any) => a.name` | Define `GHRelease` / `GHAsset` types | +| `lsp/index.ts` | 4 | `(result: any)`, `as any[]` | Type LSP response types from protocol | +| `lsp/client.ts` | 2 | `stdout as any`, `stdin as any` | Module augmentation or typed Bun subprocess IO | +| `server/routes/experimental.ts` | 3 | `(t.parameters as any)?._def`, `status as any` | Zod introspection type; enum alignment | + +### Tier 6: Provider SDK / copilot (upstream-originated, highest risk) + +| File | Occurrences | Pattern | Fix | +|------|-------------|---------|-----| +| `plugin/copilot.ts` | 8 | `(msg: any)`, `(part: any)`, `(item: any)` | Define `CopilotMessage` / `CopilotPart` types | +| `plugin/codex.ts` | 1 | `Record>` | Define `CodexVariant` type | +| `provider/provider.ts` | 1 | `Auth.get() as any` | Align auth loader type | +| `provider/transform.ts` | 1 | `providerOptions as any` | Type `ProviderOptions` | +| `effect/service-layers.ts` | 1 | `(client.transport as any)?.pid` | Module augmentation for transport | +| `mcp/index.ts` | 1 | `(client.transport as any)?.pid` | Same fix as service-layers | + +### Tier 7: Bus + schema + branded types (already documented / structural) + +| File | Occurrences | Pattern | Fix | +|------|-------------|---------|-----| +| `bus/index.ts` | 1 | `BusCallback` with `any` event | ✅ Already documented with comment | +| `bus/global.ts` | 1 | `payload: any` | Match `EventPayload` type from bus | +| `bus/bus-event.ts` | 1 | `.toArray() as any` | Zod `discriminatedUnion` typing limitation | +| `util/schema.ts` | 2 | `as unknown as Self` | Branded type pattern — intentional | +| `permission/schema.ts` | 1 | `as unknown as z.ZodType` | Branded type coercion — intentional | +| `question/schema.ts` | 1 | `as unknown as z.ZodType` | Branded type coercion — intentional | +| `tool/tool.ts` | 2 | `[key: string]: any` in metadata | Define tool metadata interface | + +### TUI components (separate pass) + +| File | Occurrences | Pattern | +|------|-------------|---------| +| `cli/cmd/tui/routes/session/index.tsx` | 10+ | `props.input as any`, `part as any`, `state as any` | +| `cli/cmd/tui/win32.ts` | 1 | `process.stdin as any` | +| `cli/cmd/tui/thread.ts` | 1 | `(e: unknown)` error handler | + +### Recommended execution order + +1. **Tier 1** (Frankencode-owned) — ~12 fixes, lowest risk, our code +2. **Tier 2** (utilities) — ~24 fixes, self-contained modules +3. **Tier 3** (session pipeline) — ~12 fixes, core path, needs care +4. **Tier 7** (bus/schema) — triage: mark intentional ones, fix the rest +5. **Tier 4** (config/storage) — ~18 fixes, moderate risk +6. **Tier 5** (LSP/server) — ~13 fixes, define external API types +7. **Tier 6** (provider SDK) — ~13 fixes, upstream-originated, highest risk +8. **TUI** — ~12 fixes, separate PR + +--- + +## Previous: Bug Fix Pass — B47-B52 (6 remaining bugs) + +### Plan + +1. **Create branch** `fix/remaining-bugs-b47-b52` from `dev` +2. **Fix B52** (Bus.publish) — `Promise.all` → `Promise.allSettled` + warn log. Low risk, isolated. +3. **Fix B50** (AsyncQueue.push) — throw on push-after-close. Low risk, isolated. +4. **Fix B47** (Objective extract) — remove early-return cache so extraction always re-evaluates. +5. **Fix B49** (context-edit mark) — wrap `updatePart()` in `Database.transaction()`. +6. **Fix B48** (Session.remove) — remove outer try-catch that swallows errors; propagate failures. +7. **Document B51** (ID counter atomicity) — add code comment, move to "Deferred" in BUGS.md. No runtime risk. +8. **Write regression tests** for B47, B48, B49, B50, B52. +9. **Run full test suite** — verify 0 failures, 0 TS errors. +10. **PR to dev** — one PR for all 6 bugs. + +### Bug Summary + +| Bug | File | Fix | Risk | +|-----|------|-----|------| +| B47 | `session/objective.ts` | Remove cache check in `extract()` | Low | +| B48 | `session/index.ts` | Remove swallowing try-catch in `remove()` | Med — callers must handle errors | +| B49 | `context-edit/index.ts` | Wrap `mark()` in `Database.transaction()` | Low | +| B50 | `util/queue.ts` | Throw on `push()` after `close()` | Low — verify no callers rely on silent discard | +| B51 | `id/id.ts` | Code comment only (single-threaded assumption) | None | +| B52 | `bus/index.ts` | `Promise.allSettled()` + warn log | Low | + +--- + +## Previous: Upstream Sync Strategy Upstream (`anomalyco/opencode`) has diverged by ~50 commits. Two classes of changes: @@ -71,6 +197,22 @@ These appear as "deletions" in `git diff dev..upstream/dev` because upstream nev --- +## Future: Zod v3 → v4 Migration + +The codebase uses Zod v4 (`zod` package) but some patterns and downstream libraries (`zod-to-json-schema`, `hono-openapi`) expect Zod v3 types. Sites needing conversion: + +| File | Pattern | Issue | +|------|---------|-------| +| `server/routes/experimental.ts:89` | `zodToJsonSchema(t.parameters as any)` | `zod-to-json-schema` expects Zod v3 `ZodType`, not v4 | +| `server/routes/*.ts` | `resolver()`, `validator()` from `hono-openapi` | May expect v3 schemas | +| `util/json.ts` | `JsonValue` uses `z.any()` with cast | `z.lazy()` generates `__schema0` $ref breaking SDK generation | +| `session/message-v2.ts` | `z.toJSONSchema()` | Uses Zod v4 native JSON Schema generation | +| `util/effect-zod.ts` | Effect-to-Zod bridge | Converts between Effect Schema and Zod | + +**Action:** Audit all `zod-to-json-schema` call sites and `hono-openapi` validators. Either migrate them to Zod v4's built-in `z.toJSONSchema()` or ensure the v3 compatibility layer works. Track in a separate PR. + +--- + ## Completed Features | Feature | Status | diff --git a/STATUS.md b/STATUS.md index e1b8d6463..610632fd9 100644 --- a/STATUS.md +++ b/STATUS.md @@ -1,99 +1,27 @@ # Frankencode — Project Status -**Date:** 2026-03-20 +**Date:** 2026-03-21 **Upstream:** `anomalyco/opencode` @ `dev` **Fork:** `e6qu/frankencode` @ `dev` -## Overview +## Current Focus -Frankencode is a fork of OpenCode that adds context editing, CAS, and an edit graph. Effect-ification complete — `src/project/instance.ts` deleted, Instance split into InstanceALS + InstanceLifecycle + InstanceContext. Test shim at `test/fixture/instance-shim.ts`. All 59 ALS fallback patterns eliminated — zero `?? InstanceALS.x` in src/. 1447 tests passing across 122 files, 0 TS errors. +Type safety audit complete. All fixable `any` eliminated. 31 remain at SDK boundaries and structural patterns. -## Branch Status - -| Branch | Status | PR | -|--------|--------|----| -| `dev` | Main development branch | — | -| `effect/complete-effectification` | B1-B10g complete, Instance deleted, 81 TUI tests, 27 commits | Pending PR to `dev` | -| `fix/code-review-bugs` | 16 bug fixes + 25 tests | [#12](https://github.com/e6qu/frankencode/pull/12) (merged) | -| `fix/upstream-backports-p1` | Phase 1: 9 upstream bug fixes (B1-B9) | [#16](https://github.com/e6qu/frankencode/pull/16) (merged) | -| `fix/upstream-backports-p2` | Phase 2: 6 upstream bug fixes (B10-B16) | [#17](https://github.com/e6qu/frankencode/pull/17) (merged) | -| `fix/upstream-backports-p3` | Phase 3: 6 upstream app fixes (B17-B22) | [#18](https://github.com/e6qu/frankencode/pull/18) (merged) | -| `fix/upstream-backports-p4` | Phase 4: rebase onto upstream/dev (Effect integration) | [#19](https://github.com/e6qu/frankencode/pull/19) (merged) | -| `refactor/effectify-trivial` | B1: 16 Instance.state() modules → module-level state maps | [#20](https://github.com/e6qu/frankencode/pull/20) (merged) | - -## Effect-ification Status - -### Goal: Replace Instance ALS with explicit parameter threading - -The `Instance` singleton used AsyncLocalStorage (ALS) for per-directory context. The Effect runtime has a per-directory `LayerMap` with 24+ services. We threaded explicit parameters through all modules to replace ALS reads. - -### Progress: B1-B10g complete, Instance deleted - -| Stage | Name | Files | Status | -|-------|------|-------|--------| -| B1 | Instance.state() elimination | 16 modules | **Done** (PR #20) | -| B2 | Tool layer migration | 31 files | **Done** | -| B3 | Leaf state-map modules + agent | 9 files | **Done** | -| B4 | Instance.bind() elimination | 5 files | **Done** | -| B5 | Formatter parameter threading | 2 files | **Done** | -| B6 | LSP module | 3 files | **Done** | -| B7 | Session leaf helpers | 5 files | **Done** | -| B8 | Worktree + Config modules | 4 files | **Done** | -| B9 | Server + CLI entry points | ~20 files | **Done** | -| B10a-b | Effect runtime + service-layers | 3 files | **Done** | -| B10c | prompt.ts construction sites | 1 file | **Done** | -| B10d | ALS fallback removal (leaf state()) | 15 files | **Done** | -| B10e | Additional fallbacks (command, mcp, status, config) | 10 files | **Done** | -| B10f | InstanceLifecycle module | 2 files | **Done** | -| B10g | Instance deleted, tests migrated | 59 files | **Done** | - -### Remaining ALS fallbacks (36 patterns, deferred) - -| Module | Count | Reason deferred | -|--------|-------|-----------------| -| `session/prompt.ts` | 5 | Deep call chains, many callers | -| `session/instruction.ts` | 5 | Interleaved directory/worktree params | -| `session/index.ts` | 4 | projectID/vcs/worktree threading | -| `session/system.ts` | 3 | ctx parameter threading | -| `session/compaction.ts` | 2 | directory/worktree in process() | -| `session/llm.ts` | 1 | projectID header | -| `worktree/index.ts` | 4 | ctx parameter threading | -| `env/index.ts` | 4 | 26+ callers in provider module | -| `plugin/index.ts` | 4 | 14+ caller files | -| `bus/index.ts` | 2 | 33+ caller files | -| `pty/index.ts` | 1 | Test file dependency | -| `tool/bash.ts` | 1 | Test file dependency | - -### Entry-point reads (correct usage, 150 across 40 files) - -These are `InstanceALS.directory` / `.worktree` / `.project` reads inside `InstanceALS.run()` callbacks at server routes, CLI commands, and event handlers. This is the intended usage pattern — they capture context at the boundary and pass it down. - -## Test Status +## Bug Status -- **1447 tests passing**, 0 failures, 8 skipped, across **123 test files** -- **81 TUI component tests** (helpers + 5 dialog + 3 standalone) across 13 files -- **1 tmux integration test harness** (5 flows: home, command palette, agent cycle, submit, cost dialog) -- **25 regression tests** for bug fixes -- **0 TypeScript errors** (`npx tsc --noEmit`) +- **0 open bugs**, 1 deferred (B51), 51 fixed -## Bug Status +## Type Safety Status -- **0 active bugs** (confirmed via manual TUI testing 2026-03-20) -- **40 bugs fixed** -- **4 open design issues** (CAS GC, objective staleness, EditGraph leak, CAS ownership) +- **~160+ `any`/`z.any()` eliminated** across ~40 files +- **31 remaining** — all documented SDK boundaries, generic patterns, or upstream code +- **Strong schemas:** `JsonValue`, `ProviderMeta`, `ToolInput`, `ToolMeta` in message-v2.ts +- **0 TypeScript errors**, **1448 tests passing** -## Feature Inventory +## Branch Status -| Feature | Status | Files | -|---------|--------|-------| -| Content-Addressable Store | Done | `src/cas/` | -| Context editing (6 operations) | Done | `src/context-edit/`, `src/tool/context-edit.ts` | -| Edit graph (DAG history) | Done | `src/cas/graph.ts`, `src/tool/context-history.ts` | -| Side threads | Done | `src/session/side-thread.ts`, `src/tool/thread-*.ts` | -| Focus agent | Done | `src/agent/agent.ts`, `src/agent/prompt/focus.txt` | -| Classifier + distill | Done | `src/tool/classifier-threads.ts`, `src/tool/distill-threads.ts` | -| Ephemeral commands | Done | `src/command/index.ts`, `src/session/prompt.ts` | -| Verify tool | Done | `src/tool/verify.ts` | -| Refine tool | Done | `src/tool/refine.ts` | -| Script discovery | Done | `src/skill/scripts.ts` | -| /cost command | Done | TUI dialog | +| Branch | Status | +|--------|--------| +| `dev` | Main development | +| `fix/remaining-bugs-b47-b52` | Bug fixes + type safety audit — pending commit & PR | diff --git a/WHAT_WE_DID.md b/WHAT_WE_DID.md index 688444914..6995eebfc 100644 --- a/WHAT_WE_DID.md +++ b/WHAT_WE_DID.md @@ -1,223 +1,49 @@ # Frankencode — What We Did -## Phase 0: Research & Design - -- Deep research on OpenCode architecture -- Designed editable context system (6 modes, Merkle CAS, focus agent, side threads) -- Literature review of 40+ papers/tools/frameworks -- Narrowed to MVP plan - -### Documents produced (in `docs/research/`): - -`DEEP_RESEARCH_POST_FACTUM_CONTEXT_EDITING.md`, `UI_CUSTOMIZATION.md` - -Root-level: `PLAN.md`, `WHAT_WE_DID.md`, `DO_NEXT.md` - ---- - -## Phase 1: CAS + Part Editing Foundation - -- `cas_object` SQLite table for content-addressable storage -- `EditMeta` and `LifecycleMeta` schemas on `PartBase` (all 12 part types inherit) -- `filterEdited()` in message pipeline + deterministic sweeper for lifecycle markers -- `context_edit` tool (hide, unhide, replace, annotate, externalize, mark) -- `context_deref` tool (retrieve CAS content by hash) -- Plugin hooks: `context.edit.before` / `context.edit.after` - -## Phase 2: Conversation Graph - -- `edit_graph_node` + `edit_graph_head` SQLite tables (DAG with parent pointers) -- `EditGraph` module (commit, log, tree, checkout, fork, switchBranch) -- `context_history` tool (log, tree, checkout, fork) -- All edit operations record graph nodes atomically - -## Phase 3: Focus Agent + Side Threads - -- `side_thread` SQLite table (project-level, survives sessions) -- `SideThread` CRUD module -- `thread_park` / `thread_list` tools -- Objective tracker (extracts goal from first user message) -- Focus agent (hidden, on-demand via `/focus` command) -- Classifier agent (read-only, labels messages as main/side/mixed with topics) -- Focus-rewrite-history agent (full conversation rewrite with user confirmation) - -## Phase 4: Integration + v2 - -- System prompt injection (focus status + side threads when context_edit available) -- `classifier_threads` tool (run classifier, return structured JSON) -- `distill_threads` tool (classify + park side threads + store metadata) -- Config-based control (no feature toggles): tools disabled via config, agents via `disable: true` -- `/btw`, `/focus`, `/focus-rewrite-history`, `/reset-context` commands -- Lifecycle markers (discardable, ephemeral, side-thread, pinned) with deterministic sweeper -- Privileged agents (focus, compaction) can edit any message -- Query and toolName targeting for `context_edit` - -## Phase 5: Claude Blog Research - -- Researched 6 months of Claude Blog posts (Sept 2025 - Mar 2026) -- Identified applicable patterns for coding agents: - - Verification subagent pattern - - Progressive disclosure for skills - - Skills as scripts - - Evaluator-optimizer workflow -- Created feature roadmap in `PLAN.md` -- Fixed bug: `focus-rewrite-history` agent missing `classifier_threads`/`distill_threads` permissions - -## Phase 6: Plan Mode Fixes (Current Session) - -- Removed `OPENCODE_EXPERIMENTAL_PLAN_MODE` flag dependency -- Uncommented and enabled `PlanEnterTool` in `src/tool/plan.ts` -- Added `PlanEnterTool` import and registration in `src/tool/registry.ts` -- Both `plan_exit` and `plan_enter` tools now available unconditionally -- Build ↔ Plan mode switching works without experimental flag +_Cleared 2026-03-21. Previous phases archived in git history._ --- -## Files (all paths relative to `packages/opencode/`) - -### New files: - -| File | Purpose | -| ------------------------------------------------ | ------------------------------------------------------------- | -| `src/cas/cas.sql.ts` | Drizzle tables: cas_object, edit_graph_node, edit_graph_head | -| `src/cas/index.ts` | CAS module: store, get, exists, listBySession | -| `src/cas/graph.ts` | Edit graph DAG: commit, log, tree, checkout, fork | -| `src/context-edit/index.ts` | Edit operations + validation + sweeper + reset + plugin hooks | -| `src/tool/context-edit.ts` | context_edit tool (query/toolName targeting) | -| `src/tool/context-deref.ts` | context_deref tool | -| `src/tool/context-history.ts` | context_history tool | -| `src/tool/thread-park.ts` | thread_park tool | -| `src/tool/thread-list.ts` | thread_list tool | -| `src/tool/classifier-threads.ts` | classifier_threads tool | -| `src/tool/distill-threads.ts` | distill_threads tool | -| `src/session/side-thread.sql.ts` | Drizzle table: side_thread | -| `src/session/side-thread.ts` | SideThread CRUD module | -| `src/session/objective.ts` | Objective tracker | -| `src/agent/prompt/focus.txt` | Focus agent prompt | -| `src/agent/prompt/classifier.txt` | Classifier agent prompt | -| `src/agent/prompt/rewrite-history.txt` | Rewrite-history agent prompt | -| `src/command/template/btw.txt` | /btw command template | -| `src/command/template/focus.txt` | /focus command template | -| `src/command/template/focus-rewrite-history.txt` | /focus-rewrite-history template | -| `src/command/template/reset-context.txt` | /reset-context template | -| `migration/20260315120000_context_editing/` | SQL migration for 4 new tables | - -### Modified files: - -| File | Changes | -|------|---------| -| `src/session/message-v2.ts` | +EditMeta +LifecycleMeta on PartBase, +filterEdited() | -| `src/session/prompt.ts` | +filterEdited +sweeper in pipeline, +focus status in system prompt | -| `src/storage/schema.ts` | +exports for new tables | -| `src/tool/registry.ts` | +10 new tools in BUILTIN array | -| `src/agent/agent.ts` | +classifier +focus +focus-rewrite-history agent definitions | -| `src/command/index.ts` | +btw +focus +focus-rewrite-history +reset-context commands | -| `packages/plugin/src/index.ts` | +context.edit.before/after hook types | -| `src/tool/plan.ts` | +uncommented PlanEnterTool, +exported | - ---- - -## Phase 5: Hardening — Ephemeral Commands + Bug Fixes - -### Ephemeral commands (PRs #7, #8) - -- `/threads`, `/history`, `/tree`, `/deref`, `/classify` — readonly commands that don't pollute context -- Fork-based ephemeral: fork session → run prompt → extract result → delete session -- `filterEphemeral()` — drops ephemeral messages from LLM context entirely -- Fixed schema crash (`afterTurns: 0` violated `min(1)`) and content leak into 1 LLM turn - -### /cost TUI command (PR #11) - -- `/cost` slash command showing session usage (input/output/cache tokens, cost breakdown) -- TUI dialog with formatted cost metrics - -### Code review bug fixes (PRs #10, #12) - -- Fixed 40 bugs total across the codebase (24 in earlier PRs, 16 in PR #12) -- Circuit breaker fixes in `verify.ts`: lastFailure timing, success reset, naming, cooldown, config merge, command splitting -- Refine tool fixes: evaluator context, tool access, parsing robustness, session cleanup -- Script tool fixes: argument injection prevention, tool ID collision -- Skill content caching, evaluator permission lockdown -- 25 regression tests covering all fixed bugs - -### New files (Phase 5): - -| File | Purpose | -|------|---------| -| `src/tool/verify.ts` | Verify tool (test/lint/typecheck with circuit breaker) | -| `src/tool/refine.ts` | Refine tool (evaluator-optimizer loop) | -| `src/skill/scripts.ts` | Script discovery and execution from skills | -| `src/agent/prompt/evaluator.txt` | Evaluator agent prompt | -| `src/agent/prompt/optimizer.txt` | Optimizer agent prompt | -| `src/command/template/verify.txt` | /verify command template | -| `test/tool/verify.test.ts` | Verify tool tests (circuit breaker, config, commands) | -| `test/tool/refine.test.ts` | Refine tool tests (parseEvaluation, session cleanup) | -| `test/tool/scripts.test.ts` | Script tool tests (ID format, arg injection) | -| `test/skill/skill-cache.test.ts` | Skill content caching tests | - -### Modified files (Phase 5): - -| File | Changes | -|------|---------| -| `src/agent/agent.ts` | +evaluator +optimizer agents, fixed evaluator perms | -| `src/command/index.ts` | +verify +objective +threads +history +tree +deref +classify commands | -| `src/config/config.ts` | +verification config schema | -| `src/session/prompt.ts` | +filterEphemeral in pipeline | -| `src/skill/skill.ts` | +content cache with state-reload clearing | -| `test/agent/agent.test.ts` | +evaluator/optimizer permission tests | - ---- - -## Phase 7: Effect-ification — Remove Instance ALS - -Goal: eliminate the `Instance` AsyncLocalStorage singleton entirely. The Effect runtime already has a per-directory `LayerMap` with 24+ services via `InstanceContext`. - -### Completed stages (B1-B8): - -| Stage | Commit | What changed | -|-------|--------|-------------| -| B1 | PR #20 | 16 modules converted from `Instance.state()` to module-level state maps with `registerDisposer` | -| B2 | `14e5c7e60` | 17 tool files + 12 test files: `Tool.Context` extended with directory/worktree/projectID/containsPath | -| B3 | `0e9915688` | 9 leaf modules (env, bus, command, provider, plugin, mcp, pty, agent): `state()` parameterized | -| B4 | `f573d0511` | 5 `Instance.bind()` sites replaced with captured closures (watcher, vcs, format, pty) | -| B5 | `893745855` | 25 formatter `enabled()` functions: accept (directory, worktree) params | -| B6 | `184abfa24` | LSP module: 37 spawn + root functions accept directory/worktree; Instance removed from server.ts, client.ts | -| B7 | `632cd4f88` | Session leaf helpers (system, instruction, compaction, status, llm): parameterized with ALS fallback | -| B8 | `464c13cf1` | Worktree (21→9 refs) + Config (state() parameterized, initConfig captures at entry) | - -**Progress:** 221 → 172 `Instance.*` references (49 removed). All inner modules accept explicit parameters. - -### Remaining stages (B9-B10): -- **B9:** Server + CLI entry points (~18 files, ~45 occurrences) — capture Instance values at handler top, pass down -- **B10:** ALS elimination — parameterize runtime, delete Instance module - ---- - -## Upstream Sync Status (2026-03-18) - -**Upstream:** `anomalyco/opencode` (`dev` branch) -**Our fork:** `e6qu/frankencode` (`dev` branch) -**Divergence:** 10 commits ahead, ~50 commits behind - -### Notable upstream changes since fork: - -- **Effect-ification wave:** `SkillService`, `FileService`, `FormatService`, `FileTimeService`, `VcsService`, `FileWatcherService` all refactored to Effect scoped services with `LayerMap` -- **Instance refactor:** `instance-state.ts` deleted, services moved to Effect layer -- **Compaction fix:** Message transforms now applied during compaction (#17823) -- **Context overflow:** `context_length_exceeded` error code now handled (#17748) -- **Permission fix:** Prompt tool enables preserved with empty agent permissions (#17064) -- **VCS fix:** HEAD filter bug fixed (#17829) -- **Zen updates:** Model pricing, Gemini 3 Pro deprecated -- **Docs:** `tools` config marked deprecated (#17951), snapshot config annotated (#17861) - -### Rebase risk assessment: - -| Area | Risk | Notes | -|------|------|-------| -| `skill/skill.ts` | **High** | Upstream rewrote to Effect service (333 lines changed); we added content cache | -| `session/prompt.ts` | **High** | Upstream changed ~99 lines; we added filterEdited, filterEphemeral, focus injection | -| `session/message-v2.ts` | **Medium** | Upstream changed ~107 lines; we added EditMeta, LifecycleMeta, filterEdited | -| `project/instance.ts` | **Medium** | Upstream refactored Instance; we use `Instance.state()` for skill cache | -| `agent/agent.ts` | **Low** | Upstream didn't touch agent definitions; our changes are additive | -| `tool/registry.ts` | **Low** | Upstream removed some tools; we added 9 | -| New Frankencode files | **None** | CAS, edit graph, context tools — no upstream conflict | +## Current Session: Bug Fix Pass (B47-B52) + Type Safety Audit (Tiers 1-2) + +### Bug Fixes (B47-B52) + +| Bug | File | Fix | +|-----|------|-----| +| B47 | `session/objective.ts` | Removed cache early-return in `extract()` — always re-evaluates messages | +| B48 | `session/index.ts` | Removed outer try-catch in `remove()` — cleanup errors now propagate | +| B49 | `context-edit/index.ts` | Wrapped `mark()` updatePart in `Database.transaction()` | +| B50 | `util/queue.ts` | `push()` after `close()` now throws instead of silently discarding | +| B51 | `id/id.ts` | Added comment documenting single-threaded assumption (deferred) | +| B52 | `bus/index.ts` | try-catch per subscriber + `Promise.allSettled()` for async errors | + +### Type Safety — Tier 1: Frankencode-owned code (12 `any` eliminated) + +| File | Change | +|------|--------| +| `session/side-thread.ts` | `projectID` typed as `ProjectID` (branded), removed 5 `as any` casts; `update()` uses Drizzle inferred insert type | +| `context-edit/index.ts` | `part.state as any` → narrowed via `ToolPart` cast; 2x `} as any)` → proper `SessionID.make()` / `MessageID.make()` | +| `tool/context-edit.ts` | `(part as any).state.output` → proper narrowing with `ToolPart` type | +| `tool/thread-list.ts` | `args.status as any` → removed cast (types already compatible) | +| `tool/objective-set.ts` | `Record` → `Record` | +| `tool/tool.ts` | `Metadata` uses `[key: string]: unknown` (documented — DB round-trip); `extra` same; `projectID: ProjectID`; `InferMetadata` uses `z.ZodType` instead of `any` | + +### Type Safety — Tier 2: Utility modules (24 `any` eliminated) + +| File | Change | +|------|--------| +| `util/signal.ts` | `let resolve: any` → `let resolve: (value?: void) => void` | +| `util/defer.ts` | `} as any` → function overloads instead of conditional return type | +| `util/log.ts` | 15 `any` occurrences → `LogMessage = unknown` (documented boundary), `LogExtra = Record`; `write` typed as `(msg: string)` | +| `util/wildcard.ts` | 2x `Record` → generic `` parameter | +| `util/eventloop.ts` | 4x `(process as any)._getActiveHandles()` → `declare global` module augmentation | +| `util/filesystem.ts` | `Readable.fromWeb(stream as any)` → kept with comment (Bun/Node type incompatibility at boundary) | + +### Supporting Changes + +| File | Change | +|------|--------| +| `session/prompt.ts` | `projectID: string` → `projectID: ProjectID` in input interfaces | +| `bus/index.ts` | `BusCallback` type with documented `any` (event emitter pattern); unused `Subscription` type removed | +| 6 test files | `projectID: ""` → `ProjectID.make("")`; `projectID: "test"` → `ProjectID.make("test")` | +| `AGENTS.md` | Strengthened no-`any`/`unknown` rule; created `CLAUDE.md` symlink | diff --git a/docs/AGENT_CLIENT_PROTOCOL.md b/docs/AGENT_CLIENT_PROTOCOL.md new file mode 100644 index 000000000..da17b3663 --- /dev/null +++ b/docs/AGENT_CLIENT_PROTOCOL.md @@ -0,0 +1,155 @@ +# Agent Client Protocol (ACP) Support + +## What is ACP? + +The [Agent Client Protocol](https://agentclientprotocol.com/) (ACP) is a standardized protocol for enabling agent-to-client communication. It defines how AI coding agents expose their capabilities to IDE clients (like Zed, VS Code) over JSON-RPC, enabling tool execution, permission handling, session management, and streaming output. + +OpenCode implements ACP v1 using the official `@agentclientprotocol/sdk` library. + +--- + +## Architecture + +OpenCode plays the **Agent** role in ACP. IDE clients connect to it, not the other way around. + +``` + +------------------+ +-------------------+ + | IDE Client | JSON-RPC/stdio | OpenCode Agent | + | (Zed, VS Code) | <===============> | | + | | | ACP Agent | + | - File editor | initialize | (acp/agent.ts) | + | - Terminal | newSession | | | + | - Permission UI | loadSession | SessionManager | + | - MCP config | prompt | (acp/session.ts)| + | | events (SSE) | | | + +------------------+ | OpenCode SDK | + | - Session | + | - Provider | + | - Tools | + +-------------------+ +``` + +--- + +## Protocol Flow + +### 1. Initialization + +``` +Client → Agent: InitializeRequest { protocolVersion, clientInfo, capabilities } +Agent → Client: InitializeResponse { protocolVersion, agentInfo, agentCapabilities } +``` + +Advertised capabilities (`acp/agent.ts`): +``` +loadSession: true +mcpCapabilities: { http: true, sse: true } +promptCapabilities: { embeddedContext: true, image: true } +sessionCapabilities: { fork: {}, list: {}, resume: {} } +``` + +### 2. Session Lifecycle + +``` +Client → Agent: NewSessionRequest { cwd, model?, mode?, mcpServers? } +Agent → Client: NewSessionResponse { sessionId } + +Client → Agent: LoadSessionRequest { sessionId } +Agent → Client: LoadSessionResponse { sessionId, messages[] } +``` + +Sessions map 1:1 to internal OpenCode sessions. The `SessionManager` (`acp/session.ts`) maintains: +- Working directory per session +- Model/variant selection per session +- MCP server configuration + +### 3. Prompt Handling + +``` +Client → Agent: PromptRequest { sessionId, content[], resources? } +Agent → Client: (streaming events via SSE) + - TextContent chunks + - ToolCallContent (pending → approved/denied → result) + - SessionInfo updates (title, cost, tokens) +Agent → Client: PromptResponse { sessionId } +``` + +### 4. Tool Execution with Permissions + +``` +Agent → Client: PermissionRequest { toolCallId, toolName, rawInput, locations[] } +Client → Agent: PermissionResponse { status: "approved" | "denied", always? } +Agent → Client: ToolCallContent { status: "running", output chunks... } +Agent → Client: ToolCallContent { status: "completed" | "error" } +``` + +Permission requests include: +- **`kind`** — categorized as `file-edit`, `file-read`, `command`, `mcp`, `server-start`, `special` +- **`locations`** — file paths affected by the tool (extracted from tool input) +- **`rawInput`** — full tool parameters for client-side display + +--- + +## Key Files + +| File | Purpose | +|------|---------| +| `src/acp/agent.ts` | Main ACP agent implementation (1748 lines) | +| `src/acp/session.ts` | Session state management, maps ACP sessions to OpenCode sessions | +| `src/acp/types.ts` | TypeScript types for ACP configuration and session state | +| `src/acp/README.md` | Protocol documentation and usage guide | + +--- + +## Tool Mapping + +ACP tools map to OpenCode's internal tool system: + +| ACP Tool Category | OpenCode Tools | Behavior | +|-------------------|----------------|----------| +| File operations | edit, write, apply_patch | Streamed diffs, permission required | +| Shell commands | bash | Output streamed, permission required | +| File reading | read, glob, grep, list | Read-only, may auto-approve | +| Task management | todowrite | Task list updates | +| Web access | webfetch, websearch | External requests | +| Code intelligence | lsp, codesearch | Language server integration | + +--- + +## MCP Integration + +ACP sessions can configure MCP (Model Context Protocol) servers at session creation: + +``` +NewSessionRequest { + mcpServers: { + "server-name": { + transport: "sse" | "http", + url: "https://...", + headers?: { ... } + } + } +} +``` + +This allows IDE clients to inject additional context sources (documentation, APIs, databases) into the agent's tool set. + +--- + +## Frankencode Differences + +The ACP protocol implementation is identical to upstream OpenCode — no Frankencode-specific protocol extensions. + +However, Frankencode's additional tools are **transparently available** to ACP clients. When an ACP client sends a prompt, the agent can use all Frankencode tools including `context_edit`, `thread_park`, `classifier_threads`, `distill_threads`, `verify`, `refine`, and `objective_set`. These appear as standard tool calls in the ACP event stream — no client-side changes needed. + +See [FRANKENCODE_DIFFERENCES.md](FRANKENCODE_DIFFERENCES.md) for the complete list of Frankencode additions. + +--- + +## See Also + +- [agents.md](agents.md) — Frankencode agents (ACP exposes the same agent set) +- [API_PROVIDERS.md](API_PROVIDERS.md) — provider/model selection (used in ACP NewSessionRequest) +- [context-editing.md](context-editing.md) — context editing tools available through ACP +- [EFFECTIFICATION.md](EFFECTIFICATION.md) — Effect architecture (ACP sessions boot via InstanceLifecycle) +- [FRANKENCODE_DIFFERENCES.md](FRANKENCODE_DIFFERENCES.md) — all Frankencode vs OpenCode differences diff --git a/docs/API_PROVIDERS.md b/docs/API_PROVIDERS.md new file mode 100644 index 000000000..ef6516e0f --- /dev/null +++ b/docs/API_PROVIDERS.md @@ -0,0 +1,226 @@ +# API Providers + +## Overview + +OpenCode supports 21+ LLM providers through the [Vercel AI SDK](https://sdk.vercel.ai/). Providers are loaded from a combination of bundled SDK packages, custom initialization logic, and the [models.dev](https://models.dev) model catalog API. + +--- + +## Architecture + +``` + opencode.json models.dev API Environment + (user config) (model catalog) Variables + | | | + +----------+-----------+----------+-----------+ + | | + Config.load() ModelsDev.refresh() + | | + +----------+-----------+ + | + Provider.initProvider() + | + +--------------+--------------+ + | | | + BUNDLED_ CUSTOM_ Plugin + PROVIDERS LOADERS Auth + | | | + +------+-------+------+------+ + | | + SDK Instance Provider.Info + (createOpenAI, (models, + createAnthropic, options, + etc.) variants) + | + LanguageModelV2 + | + Session.prompt() +``` + +--- + +## Bundled Providers + +These SDK packages are imported at build time and available without runtime installation: + +| Provider | Package | Environment Variable | +|----------|---------|---------------------| +| Amazon Bedrock | `@ai-sdk/amazon-bedrock` | `AWS_ACCESS_KEY_ID` + `AWS_SECRET_ACCESS_KEY` or `AWS_BEARER_TOKEN` | +| Anthropic | `@ai-sdk/anthropic` | `ANTHROPIC_API_KEY` | +| Azure OpenAI | `@ai-sdk/azure` | `AZURE_OPENAI_API_KEY` + `AZURE_RESOURCE_NAME` | +| Cerebras | `@ai-sdk/cerebras` | `CEREBRAS_API_KEY` | +| Cohere | `@ai-sdk/cohere` | `COHERE_API_KEY` | +| DeepInfra | `@ai-sdk/deepinfra` | `DEEPINFRA_API_KEY` | +| Gateway | `@ai-sdk/gateway` | `GATEWAY_API_KEY` | +| GitHub Copilot | Custom (`./sdk/copilot`) | Copilot OAuth token | +| GitLab Duo | `@gitlab/gitlab-ai-provider` | `GITLAB_API_TOKEN` or OAuth | +| Google AI | `@ai-sdk/google` | `GOOGLE_GENERATIVE_AI_API_KEY` | +| Google Vertex | `@ai-sdk/google-vertex` | `GOOGLE_CLOUD_PROJECT` | +| Google Vertex (Anthropic) | `@ai-sdk/google-vertex/anthropic` | `GOOGLE_CLOUD_PROJECT` | +| Groq | `@ai-sdk/groq` | `GROQ_API_KEY` | +| Mistral | `@ai-sdk/mistral` | `MISTRAL_API_KEY` | +| OpenAI | `@ai-sdk/openai` | `OPENAI_API_KEY` | +| OpenAI Compatible | `@ai-sdk/openai-compatible` | Provider-specific | +| OpenRouter | `@openrouter/ai-sdk-provider` | `OPENROUTER_API_KEY` | +| Perplexity | `@ai-sdk/perplexity` | `PERPLEXITY_API_KEY` | +| Together AI | `@ai-sdk/togetherai` | `TOGETHER_AI_API_KEY` | +| Vercel | `@ai-sdk/vercel` | `VERCEL_API_KEY` | +| XAI (Grok) | `@ai-sdk/xai` | `XAI_API_KEY` | + +--- + +## Custom Loaders + +These providers have custom initialization logic beyond the standard SDK constructor. Defined in `src/provider/provider.ts` `CUSTOM_LOADERS`: + +### Anthropic +- **Custom headers:** `claude-code-20250219`, `interleaved-thinking-2025-05-14`, `fine-grained-tool-streaming-2025-05-14` +- **Autoload:** false (requires API key) + +### OpenAI +- **Custom model routing:** Uses `sdk.responses(modelID)` for the OpenAI Responses API +- **Autoload:** false + +### GitHub Copilot +- **Custom model routing:** Routes to `responses()` or `chat()` based on model version (GPT-5+ uses Responses API) +- **Custom SDK:** Built-in `./sdk/copilot` implementation +- **Auth:** Copilot OAuth token flow +- **Autoload:** false + +### Azure OpenAI +- **Custom model routing:** Routes to `responses()` or `chat()` +- **Custom vars:** Resolves `AZURE_RESOURCE_NAME` from config or environment +- **Autoload:** false + +### Amazon Bedrock +- **Complex credential handling:** Bearer token > credential chain > profiles > IAM roles > web identity tokens +- **Region-specific model prefixes:** `us.`, `eu.`, `jp.`, `au.`, `global.`, `apac.` based on region and model +- **Autoload:** true (if AWS credentials configured) + +### Google Vertex +- **Custom authentication:** Google Cloud Auth library for service account credentials +- **Custom vars:** Resolves `GOOGLE_CLOUD_PROJECT`, `GOOGLE_VERTEX_LOCATION` +- **Autoload:** true (if project configured) + +### GitLab Duo +- **Complex auth:** OAuth or API token, instance URL resolution +- **Feature flags:** `duo_agent_platform_agentic_chat`, `duo_agent_platform` +- **Custom headers:** User-Agent, anthropic-beta for extended context +- **Autoload:** true (if API key available) + +### Cloudflare Workers AI +- **Custom auth:** From environment or Auth storage +- **Custom vars:** Resolves `CLOUDFLARE_ACCOUNT_ID` +- **Autoload:** conditional + +### Cloudflare AI Gateway +- **Dynamic import:** `ai-gateway-provider` package (loaded at runtime, not bundled) +- **Features:** Metadata, cache settings (TTL, key, skip), unified API format +- **Autoload:** true + +### SAP AI Core +- **Service key auth:** From environment or Auth storage +- **Autoload:** conditional + +### OpenRouter, Vercel, Zenmux, Kilo, Cerebras +- **Custom headers:** HTTP-Referer, X-Title, or integration identifiers +- **Autoload:** false + +--- + +## Model Discovery + +### models.dev API + +Models are fetched from the [models.dev](https://models.dev) API: +- **Endpoint:** `${OPENCODE_MODELS_URL}/api.json` (default: `https://models.dev/api.json`) +- **Fallback:** Bundled snapshot at `./models-snapshot` if fetch fails +- **Cache:** Stored at `~/.opencode/cache/models.json` +- **Refresh:** On startup + hourly interval + +### Model schema + +Each model from models.dev includes: +- Identity: `id`, `name`, `family`, `release_date` +- Capabilities: `temperature`, `reasoning`, `tool_call`, `attachment`, `interleaved` +- Cost: `input`, `output`, `cache` (per million tokens) +- Limits: `context`, `input`, `output` token counts +- Modalities: input/output support for `text`, `audio`, `image`, `video`, `pdf` +- Status: `alpha`, `beta`, `deprecated`, or active +- Provider info: `npm` package, `api` endpoint +- Variants: named configuration presets (e.g., "fast", "extended-thinking") + +### Model loading flow + +1. Fetch models from models.dev API (or fallback snapshot) +2. Apply `CUSTOM_LOADERS` transformations (custom auth, headers, model routing) +3. Merge with user config overrides (`opencode.json` provider settings) +4. Filter by enabled/disabled provider lists +5. Apply per-provider model blacklist/whitelist +6. Apply variant transformations per model + +--- + +## Transform Pipeline + +`src/provider/transform.ts` handles message and schema transformations: + +| Transform | Purpose | +|-----------|---------| +| `normalizeMessages()` | Converts messages to provider-expected format (reasoning part extraction, content structure) | +| `schema()` | Converts Zod JSON Schema to provider-compatible format (Gemini sanitization, strict mode) | +| `options()` | Builds provider-specific options (store, reasoning config, cache keys, prompt caching) | +| `variants()` | Maps variant names to provider option overrides (e.g., "extended-thinking" → reasoning budget) | +| `providerOptions()` | Restructures flat options into nested provider option namespaces | +| `smallOptions()` | Builds minimal options for small/fast model calls (evaluator, title generation) | + +--- + +## Adding Custom Providers + +Users can add providers via `opencode.json`: + +```json +{ + "provider": { + "my-provider": { + "npm": "@ai-sdk/openai-compatible", + "api": { + "url": "https://api.my-provider.com/v1" + }, + "env": ["MY_PROVIDER_API_KEY"], + "models": { + "my-model": { + "name": "My Model", + "attachment": true, + "tool_call": true + } + } + } + } +} +``` + +Alternatively, providers can register through the plugin system (`plugin.auth.loader`). + +--- + +## Frankencode Differences + +The provider system is identical to upstream OpenCode — all 21+ providers, custom loaders, models.dev integration, and transform pipeline are upstream-compatible. + +Frankencode adds features at the **session/tool layer** that work with all providers: +- **Verify tool** (`src/tool/verify.ts`) — runs test/lint/typecheck with circuit breaker, uses session's current provider +- **Refine tool** (`src/tool/refine.ts`) — evaluator-optimizer loop, spawns child sessions on the same provider +- **Evaluator/optimizer agents** — use the session's model for code review scoring + +See [FRANKENCODE_DIFFERENCES.md](FRANKENCODE_DIFFERENCES.md) for the complete list. + +--- + +## See Also + +- [EFFECTIFICATION.md](EFFECTIFICATION.md) — Effect architecture (ProviderAuthService, ConfigService are Effect services) +- [agents.md](agents.md) — agents that use providers (evaluator, optimizer inherit session model) +- [AGENT_CLIENT_PROTOCOL.md](AGENT_CLIENT_PROTOCOL.md) — model selection via ACP NewSessionRequest +- [FRANKENCODE_DIFFERENCES.md](FRANKENCODE_DIFFERENCES.md) — all Frankencode vs OpenCode differences diff --git a/docs/EFFECTIFICATION.md b/docs/EFFECTIFICATION.md new file mode 100644 index 000000000..dc977adfc --- /dev/null +++ b/docs/EFFECTIFICATION.md @@ -0,0 +1,292 @@ +# Effect-ification: OpenCode's Effect-TS Architecture + +## What is Effect-TS? + +[Effect-TS](https://effect.website) is a TypeScript library whose core type `Effect` encodes typed errors, dependency injection, and structured concurrency into the type system. OpenCode uses Effect for per-directory service isolation, resource lifecycle management, and testable dependency injection. + +Key Effect concepts used in OpenCode: +- **`ServiceMap.Service`** — type-safe service registry with automatic dependency tracking +- **`LayerMap`** — keyed map giving each key (directory) its own isolated set of services +- **`Layer`** — composable service factory with scope-based lifecycle +- **`Effect.addFinalizer`** — guaranteed cleanup when a scope exits +- **`ManagedRuntime`** — global runtime that hosts all service layers + +--- + +## Why Effect Was Adopted + +There is no single RFC or design document. The rationale is reconstructed from PR descriptions by **Kit Langton** (`@kitlangton`), who authored all 20+ effectify PRs, and **Tim Smart** (`@tim-smart`), Founding Engineer at Effectful Technologies. + +### 1. Per-project service isolation + +> *"Move question, permission, and provider auth onto a shared per-instance `LayerMap` with `InstanceContext` for directory/project resolution. Replace `InstanceState` with closure-backed service state."* +> — [PR #17544](https://github.com/anomalyco/opencode/pull/17544) + +OpenCode serves multiple projects simultaneously. The old `Instance.state()` used mutable Maps keyed by directory. Effect's `LayerMap` gives each directory key a fresh, automatically-disposed set of services. + +### 2. ALS propagation bugs + +> *"Fix `InstanceState.get` — was eagerly capturing `Instance.directory` from ALS at the call site, freezing to whichever directory triggered first layer construction. Now uses `Effect.suspend` for lazy per-evaluation reads."* +> — [PR #17511](https://github.com/anomalyco/opencode/pull/17511) + +Includes adversarial stress tests for ALS propagation through Effect fibers. + +### 3. Hidden control-flow in the event bus + +> *"A pub/sub system that blocks on all subscribers is a hidden control-flow dependency, not a bus. One slow listener shouldn't turn the bus into a synchronous pipeline."* +> — [PR #18173](https://github.com/anomalyco/opencode/pull/18173) + +### 4. Resource lifecycle management + +> *"Hourly cleanup via `Effect.forkScoped` + `Schedule.spaced` (replaces `Scheduler.register`). Cleanup starts automatically when `ManagedRuntime` is created (no more `Truncate.init()` in bootstrap)."* +> — [PR #17957](https://github.com/anomalyco/opencode/pull/17957) + +### 5. Testability via Layer-based DI + +> *"Tests use mock `HttpClient` + `ChildProcessSpawner` layers instead of `globalThis.fetch`."* +> — [PR #18266](https://github.com/anomalyco/opencode/pull/18266) + +--- + +## Architecture + +``` + CLI / Server Entry Point + | + InstanceLifecycle.boot(directory) + | + InstanceALS.run(ctx, fn) + | + +-------------+-------------+ + | | + Imperative Code Effect Runtime + (InstanceALS) (InstanceContext) + | | + InstanceALS.directory InstanceContext.directory + InstanceALS.worktree InstanceContext.worktree + InstanceALS.project InstanceContext.project + | | + Module-level LayerMap + state Maps | + | +--------+--------+ + registerDisposer() | | | + Layer Layer Layer + (dir A) (dir B) (dir C) + | + +----------+----------+ + | | | + BusService EnvService FileService + ConfigService SkillService ... + (22 services total) +``` + +### The dual-layer context model + +OpenCode maintains **two parallel context systems**: + +1. **InstanceALS** (`src/project/instance-als.ts`) — Node.js `AsyncLocalStorage` providing `directory`, `worktree`, and `project` to imperative code. Wraps `AsyncLocalStorage` via a `Context.create()` utility (`src/util/context.ts`). + +2. **InstanceContext** (`src/effect/instance-context.ts`) — an Effect `ServiceMap.Service` carrying the same three values through the Effect type system. Required by all 22 Effect services. + +Both are populated at boot time and carry identical values. InstanceALS serves non-Effect code (tools, commands, prompt pipeline). InstanceContext serves Effect service layers. + +--- + +## Key Patterns + +### ServiceMap.Service + +Every Effect service follows this pattern: + +```typescript +// 1. Module-level state map (keyed by directory) +const states = new Map }>() + +// 2. Imperative accessor +function state(directory: string) { + let s = states.get(directory) + if (!s) { s = { subscriptions: new Map() }; states.set(directory, s) } + return s +} + +// 3. Effect service definition +export class BusService extends ServiceMap.Service()("@opencode/Bus") { + static readonly layer = Layer.effect(BusService, Effect.gen(function* () { + const ctx = yield* InstanceContext // Get per-directory context + const dir = ctx.directory + state(dir) // Initialize state + + yield* Effect.addFinalizer(() => // Cleanup on scope exit + Effect.sync(() => { states.delete(dir) }) + ) + + return BusService.of({ // Return service interface + publish: Bus.publish, + subscribe: Bus.subscribe, + }) + })) +} +``` + +### All 22 Effect services + +| Service | File | Scope | +|---------|------|-------| +| InstanceContext | `src/effect/instance-context.ts` | Per-directory | +| BusService | `src/bus/index.ts` | Per-directory | +| EnvService | `src/env/index.ts` | Per-directory | +| FileService | `src/file/index.ts` | Per-directory | +| FileTimeService | `src/file/time.ts` | Per-directory | +| FileWatcherService | `src/file/watcher.ts` | Per-directory | +| FormatService | `src/format/index.ts` | Per-directory | +| PermissionService | `src/permission/service.ts` | Per-directory | +| ProviderAuthService | `src/provider/auth-service.ts` | Per-directory | +| VcsService | `src/project/vcs.ts` | Per-directory | +| QuestionService | `src/question/service.ts` | Per-directory | +| InstructionService | `src/session/instruction.ts` | Per-directory | +| SessionStatusService | `src/session/status.ts` | Per-directory | +| SkillService | `src/skill/skill.ts` | Per-directory | +| SnapshotService | `src/snapshot/index.ts` | Per-directory | +| ConfigService | `src/effect/service-layers.ts` | Per-directory | +| PluginService | `src/effect/service-layers.ts` | Per-directory | +| ToolRegistryService | `src/effect/service-layers.ts` | Per-directory | +| AgentService | `src/effect/service-layers.ts` | Per-directory | +| TuiConfigService | `src/config/tui-service.ts` | Per-directory | +| AccountService | `src/account/service.ts` | Global | +| AuthService | `src/auth/service.ts` | Global | + +Note: Service definitions for Config, Plugin, ToolRegistry, and Agent are in `service-layers.ts` rather than their respective modules to avoid changing Bun's module evaluation order and breaking existing circular dependency chains. + +### State maps with registerDisposer + +Non-Effect modules maintain per-directory state via module-level Maps with cleanup: + +```typescript +const agentStates = new Map>>() + +registerDisposer(async (directory) => { + agentStates.delete(directory) +}) +``` + +`registerDisposer()` (`src/effect/instance-registry.ts`) registers a callback invoked when `InstanceLifecycle.dispose(directory)` is called, ensuring no memory leaks. + +### LayerMap per-directory isolation + +`src/effect/instances.ts` creates fresh service layers per directory: + +```typescript +function lookup(key: string) { + const shape = contextByDirectory.get(key) ?? InstanceALS.current + const ctx = Layer.sync(InstanceContext, () => InstanceContext.of(shape)) + return Layer.mergeAll( + Layer.fresh(BusService.layer), + Layer.fresh(EnvService.layer), + Layer.fresh(QuestionService.layer), + // ... 11 more services + ).pipe(Layer.provide(ctx)) +} +``` + +`Layer.fresh()` ensures each directory gets its own service instances with no shared state. + +--- + +## Service Lifecycle + +``` + boot(directory) + | + InstanceALS.run(ctx) # Enter ALS context + | + InstanceBootstrap() # Initialize plugins, LSP, watchers + | + runPromiseInstance(effect, dir) # Run Effect computations in context + | + LayerMap.get(directory) # Create/retrieve per-directory services + | + [application runs] + | + dispose(directory) + | + disposeInstance(dir) # Call all registerDisposer callbacks + LayerMap.invalidate(dir) # Tear down Effect service layers + Effect.addFinalizer callbacks # Clean up Effect-managed resources + cache.delete(dir) # Remove boot cache entry + emit(InstanceDisposed) # Notify listeners +``` + +--- + +## Upstream PR Timeline + +| Date | PR | Description | +|------|-----|-------------| +| 2026-03-11 | [#17072](https://github.com/anomalyco/opencode/pull/17072) | Tighten effect-based account flows | +| 2026-03-12 | [#17212](https://github.com/anomalyco/opencode/pull/17212) | effectify AuthService | +| 2026-03-12 | [#17227](https://github.com/anomalyco/opencode/pull/17227) | effectify ProviderAuthService | +| 2026-03-12 | [#17238](https://github.com/anomalyco/opencode/pull/17238) | Effect logger compatibility layer | +| 2026-03-13 | [#17273](https://github.com/anomalyco/opencode/pull/17273) | Scaffold effect-to-zod bridge | +| 2026-03-14 | [#17432](https://github.com/anomalyco/opencode/pull/17432) | effectify QuestionService | +| 2026-03-14 | [#17511](https://github.com/anomalyco/opencode/pull/17511) | effectify PermissionNext + fix ALS propagation bug | +| 2026-03-15 | [#17544](https://github.com/anomalyco/opencode/pull/17544) | Move scoped services to LayerMap | +| 2026-03-16 | [#17827](https://github.com/anomalyco/opencode/pull/17827)--[#17849](https://github.com/anomalyco/opencode/pull/17849) | effectify FileWatcher, Vcs, FileTime, Format, File, Skill (6 PRs) | +| 2026-03-17 | [#17957](https://github.com/anomalyco/opencode/pull/17957) | effectify Truncate, delete Scheduler | +| 2026-03-18 | [#17878](https://github.com/anomalyco/opencode/pull/17878) | effectify Snapshot | +| 2026-03-18 | [#18093](https://github.com/anomalyco/opencode/pull/18093) | Unify all service namespaces | +| 2026-03-18 | [#18158](https://github.com/anomalyco/opencode/pull/18158) | Upgrade to effect 4.0.0-beta.35 | +| 2026-03-19 | [#18266](https://github.com/anomalyco/opencode/pull/18266) | effectify Installation (mock layers for tests) | +| 2026-03-19 | [#18173](https://github.com/anomalyco/opencode/pull/18173) | Migrate Bus to Effect PubSub | +| 2026-03-19 | [#18282](https://github.com/anomalyco/opencode/pull/18282) | Skill: forkScoped + Fiber.join | +| 2026-03-20 | [#18336](https://github.com/anomalyco/opencode/pull/18336) | Tim Smart: refactor effect runtime | +| 2026-03-21 | [#18483](https://github.com/anomalyco/opencode/pull/18483) | Move state into InstanceState, flatten facades | + +Still open: [#18269](https://github.com/anomalyco/opencode/pull/18269) (ToolRegistry), [#18270](https://github.com/anomalyco/opencode/pull/18270) (Plugin), [#18271](https://github.com/anomalyco/opencode/pull/18271) (Command), [#18319](https://github.com/anomalyco/opencode/pull/18319) (Pty), [#18321](https://github.com/anomalyco/opencode/pull/18321) (LSP), [#18323](https://github.com/anomalyco/opencode/pull/18323) (Worktree), [#18313](https://github.com/anomalyco/opencode/pull/18313) (SessionStatus) + +--- + +## Pros and Cons + +| Pros | Cons | +|------|------| +| Per-directory isolation is type-safe and guaranteed | Large dependency: `effect` package adds ~500KB | +| `addFinalizer` prevents resource leaks | Learning curve: Effect's generator syntax (`yield*`) is unfamiliar to most TS developers | +| LayerMap creates fresh services per key automatically | Dual context (ALS + Effect) is conceptual overhead | +| Testable via mock layers without monkey-patching | Service definitions split across files to avoid circular imports | +| Typed errors surface issues at compile time | Debug stack traces are longer due to Effect's fiber runtime | +| `Layer.fresh()` prevents cross-directory contamination | Migration is incremental — some modules still use raw state maps | + +--- + +## Frankencode Differences + +Frankencode completed the Effect-ification ahead of upstream in several areas: + +### Our stages (PRs #20, #21) + +| Stage | Commit | What changed | +|-------|--------|-------------| +| B1 | PR #20 | 16 modules converted from `Instance.state()` to module-level state maps with `registerDisposer` | +| B2-B8 | Branch commits | Tool layer migration, leaf state-map modules, formatter params, LSP module, session helpers, worktree + config | +| B9 | Branch commits | Server + CLI entry points capture Instance values at handler top | +| B10a-g | Branch commits | Effect runtime, service-layers, prompt construction, ALS fallback removal, InstanceLifecycle module | + +### Key differences from upstream + +1. **`src/project/instance.ts` deleted** — upstream still has `instance-state.ts` using `ScopedCache`; we deleted the entire Instance module and split into InstanceALS + InstanceLifecycle + InstanceContext. + +2. **0 ALS fallbacks** — we eliminated all 59 `?? InstanceALS.x` fallback patterns from src/. Upstream still has ~36 deferred. + +3. **Test shim** — `test/fixture/instance-shim.ts` provides backward-compatible `Instance.provide()` API for 58 test files, avoiding mechanical rewriting. + +4. **81 TUI component tests** — added as part of the Effect-ification branch to verify the refactored code. + +--- + +## See Also + +- [FRANKENCODE_DIFFERENCES.md](FRANKENCODE_DIFFERENCES.md) — complete list of Frankencode vs OpenCode changes +- [API_PROVIDERS.md](API_PROVIDERS.md) — provider architecture (ProviderAuthService is an Effect service) +- [context-editing.md](context-editing.md) — context editing tools (use Effect services for state management) +- [AGENT_CLIENT_PROTOCOL.md](AGENT_CLIENT_PROTOCOL.md) — ACP protocol (sessions boot via InstanceLifecycle) +- [schema.md](schema.md) — database schema (Drizzle ORM, managed by Effect service layers) diff --git a/docs/FRANKENCODE_DIFFERENCES.md b/docs/FRANKENCODE_DIFFERENCES.md new file mode 100644 index 000000000..586afe6ca --- /dev/null +++ b/docs/FRANKENCODE_DIFFERENCES.md @@ -0,0 +1,148 @@ +# Frankencode vs OpenCode: All Differences + +This document lists every change Frankencode makes relative to upstream [OpenCode](https://github.com/anomalyco/opencode) (`dev` branch). + +--- + +## New Modules + +| Module | Files | Purpose | +|--------|-------|---------| +| Content-Addressable Store | `src/cas/index.ts`, `src/cas/cas.sql.ts` | SHA-256 deduplicated content storage for edit originals | +| Edit Graph | `src/cas/graph.ts` | DAG-based version history for context edits | +| Context Editing | `src/context-edit/index.ts` | 6 edit operations (hide, unhide, replace, externalize, annotate, mark) | +| Side Threads | `src/session/side-thread.ts`, `src/session/side-thread.sql.ts` | Project-level deferred findings that survive across sessions | +| Objective Tracker | `src/session/objective.ts` | Extracts and tracks session objective from first user message | + +See [context-editing.md](context-editing.md) and [schema.md](schema.md) for details. + +--- + +## New Tools (10) + +| Tool | File | Purpose | +|------|------|---------| +| `context_edit` | `src/tool/context-edit.ts` | Edit conversation parts (hide, replace, externalize, annotate, mark) | +| `context_deref` | `src/tool/context-deref.ts` | Retrieve CAS content by hash | +| `context_history` | `src/tool/context-history.ts` | Navigate edit DAG (log, tree, checkout, fork) | +| `thread_park` | `src/tool/thread-park.ts` | Park off-topic findings as side threads | +| `thread_list` | `src/tool/thread-list.ts` | List project-level side threads | +| `classifier_threads` | `src/tool/classifier-threads.ts` | Run classifier agent, return structured JSON | +| `distill_threads` | `src/tool/distill-threads.ts` | Classify + park side threads in one step | +| `objective_set` | `src/tool/objective-set.ts` | Set/update session objective | +| `verify` | `src/tool/verify.ts` | Run test/lint/typecheck with circuit breaker | +| `refine` | `src/tool/refine.ts` | Evaluator-optimizer loop for iterative improvement | + +See [context-editing.md](context-editing.md) for usage details. + +--- + +## New Agents (5) + +| Agent | Prompt File | Purpose | Default | +|-------|-------------|---------|---------| +| classifier | `src/agent/prompt/classifier.txt` | Label messages as main/side/mixed with topics | Enabled (hidden) | +| focus | `src/agent/prompt/focus.txt` | Context cleanup based on classification | Disabled | +| focus-rewrite-history | `src/agent/prompt/rewrite-history.txt` | Full conversation rewrite with confirmation | Disabled | +| evaluator | `src/agent/prompt/evaluator.txt` | Score code changes 1-10 with feedback | Enabled (hidden) | +| optimizer | `src/agent/prompt/optimizer.txt` | Improve code based on evaluator feedback | Enabled (hidden) | + +See [agents.md](agents.md) for configuration and model recommendations. + +--- + +## New Commands (10) + +| Command | Type | Purpose | +|---------|------|---------| +| `/btw ` | Ephemeral | Side conversation in subagent, doesn't pollute context | +| `/focus` | Ephemeral | Classify + externalize stale output + park side threads | +| `/focus-rewrite-history` | Ephemeral | Full conversation rewrite with user confirmation | +| `/reset-context` | Ephemeral | Restore all edited parts from CAS originals | +| `/cost` | TUI dialog | Show session cost breakdown (tokens, pricing) | +| `/verify` | Command | Run test/lint/typecheck | +| `/threads` | Ephemeral | List side threads | +| `/history` | Ephemeral | Show edit history | +| `/tree` | Ephemeral | Show edit DAG | +| `/classify` | Ephemeral | Classify conversation topics | + +--- + +## Schema Changes (4 new tables) + +| Table | Purpose | +|-------|---------| +| `cas_object` | Content-addressable store (SHA-256 keyed) | +| `edit_graph_node` | Edit version DAG nodes | +| `edit_graph_head` | Per-session DAG head tracking + branches | +| `side_thread` | Project-level side threads | + +Plus 2 new fields on `PartBase` (all message parts): `edit` (EditMeta) and `lifecycle` (LifecycleMeta). + +See [schema.md](schema.md) for column details. + +--- + +## Prompt Pipeline Changes + +Added to the message processing pipeline in `src/session/prompt.ts`: + +1. **`filterEdited()`** — removes hidden parts from LLM context (originals preserved in CAS) +2. **`filterEphemeral()`** — drops ephemeral command messages entirely +3. **Deterministic sweeper** — auto-hides/externalizes parts based on lifecycle markers +4. **Focus status injection** — adds objective + parked threads to system prompt when context_edit is available + +--- + +## Effect-ification Differences + +Frankencode completed several Effect-ification stages ahead of upstream: + +| Difference | Frankencode | Upstream | +|------------|-------------|----------| +| `src/project/instance.ts` | Deleted, split into InstanceALS + InstanceLifecycle + InstanceContext | Still has `instance-state.ts` using ScopedCache | +| ALS fallback patterns | 0 remaining (all 59 eliminated) | ~36 deferred | +| Test compatibility | `test/fixture/instance-shim.ts` for 58 test files | N/A | +| TUI tests | 81 component tests + tmux integration harness | Fewer tests | + +See [EFFECTIFICATION.md](EFFECTIFICATION.md) for architecture details. + +--- + +## Type Safety Improvements + +Frankencode's type safety audit eliminated ~236 `any` types: + +| Area | Changes | +|------|---------| +| Strong Zod schemas | `JsonValue`, `ProviderMeta`, `ToolInput`, `ToolMeta` defined in message-v2.ts | +| z.any() elimination | All `z.any()` in message-v2.ts, config, permission, server routes replaced | +| SDK boundary casts | ~15 documented cast points (AI SDK, Drizzle, Bun/Node boundaries) | +| Remaining | 14 documented `any` at structural boundaries | + +--- + +## Bug Fixes (51 total) + +| Range | Category | +|-------|----------| +| B1-B9 | Upstream backports (Phase 1) | +| B10-B16 | Upstream backports (Phase 2) | +| B17-B22 | Upstream app fixes (Phase 3) | +| B23-B46 | Code review fixes (CAS, circuit breaker, evaluator, scripts, lock starvation, etc.) | +| B47-B52 | Final pass (objective cache, session cleanup, mark transaction, queue, bus errors) | + +See `BUGS.md` at repo root for the complete bug tracker. + +--- + +## What Is NOT Different + +These areas are identical to upstream OpenCode: + +- **API providers** — all 21+ providers, models.dev integration, transform pipeline (see [API_PROVIDERS.md](API_PROVIDERS.md)) +- **ACP support** — full ACP v1 protocol, same capabilities (see [AGENT_CLIENT_PROTOCOL.md](AGENT_CLIENT_PROTOCOL.md)) +- **TUI** — same terminal UI (OpenTUI + SolidJS) +- **Session/message format** — same MessageV2 schema (extended with EditMeta/LifecycleMeta) +- **Plugin system** — same plugin hooks (plus `context.edit.before`/`context.edit.after`) +- **Permission system** — same PermissionNext framework diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 000000000..51700d8f0 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,52 @@ +# Frankencode Documentation + +> **Frankencode** is a fork of [OpenCode](https://github.com/anomalyco/opencode) that adds context editing, content-addressable storage, an edit graph, focus agents, side threads, and a verification/refinement loop. + +## Documentation Map + +| Document | Description | +|----------|-------------| +| [FRANKENCODE_DIFFERENCES.md](FRANKENCODE_DIFFERENCES.md) | All differences between Frankencode and upstream OpenCode | +| [context-editing.md](context-editing.md) | Context editing tools, lifecycle markers, and deterministic sweeper | +| [agents.md](agents.md) | Frankencode-specific agents (classifier, focus, evaluator, optimizer) | +| [schema.md](schema.md) | Database schema changes (4 new tables, PartBase extensions) | +| [EFFECTIFICATION.md](EFFECTIFICATION.md) | Effect-TS architecture, 22 services, LayerMap, dual-layer context | +| [AGENT_CLIENT_PROTOCOL.md](AGENT_CLIENT_PROTOCOL.md) | ACP v1 protocol support for IDE integration | +| [API_PROVIDERS.md](API_PROVIDERS.md) | 21+ LLM providers, models.dev API, transform pipeline | + +## Architecture at a Glance + +``` + User / IDE Client + | + ACP (JSON-RPC/stdio) or TUI or HTTP API + | + InstanceLifecycle.boot(directory) + | + InstanceALS + InstanceContext (dual context) + | + +----+----+----+----+----+----+ + | | | | | | | + Session Provider Tools Agents Bus Config + | | | | + Prompt 21+ LLM 40+ classifier + Pipeline SDKs tools focus + | | evaluator + context_edit verify + filterEdited refine + sweeper thread_park + | + CAS + EditGraph + | + SQLite (Drizzle ORM) +``` + +## Tracking Documents (repo root) + +| File | Purpose | +|------|---------| +| `BUGS.md` | Bug tracker (51 fixed, 0 open, 1 deferred) | +| `PLAN.md` | Feature roadmap and current work plan | +| `STATUS.md` | Project status snapshot | +| `WHAT_WE_DID.md` | Session work log | +| `GAP_ANALYSIS.md` | Target state vs current state | diff --git a/docs/agents.md b/docs/agents.md index d2f8b70be..73a1e5db7 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -115,3 +115,13 @@ All Frankencode agents inherit the session's model by default. Override per-agen | compaction | No | primary (hidden) | enabled | | title | No | primary (hidden) | enabled | | summary | No | primary (hidden) | enabled | + +--- + +## See Also + +- [context-editing.md](context-editing.md) — tools used by focus/classifier agents +- [API_PROVIDERS.md](API_PROVIDERS.md) — model selection for agents +- [AGENT_CLIENT_PROTOCOL.md](AGENT_CLIENT_PROTOCOL.md) — agents exposed via ACP protocol +- [EFFECTIFICATION.md](EFFECTIFICATION.md) — AgentService Effect layer +- [FRANKENCODE_DIFFERENCES.md](FRANKENCODE_DIFFERENCES.md) — all Frankencode additions diff --git a/docs/context-editing.md b/docs/context-editing.md index a9e7543d2..e5206e02a 100644 --- a/docs/context-editing.md +++ b/docs/context-editing.md @@ -87,3 +87,12 @@ Park and list project-level side threads. Threads survive across sessions. | `/focus-rewrite-history` | Full conversation rewrite with user confirmation (disabled by default) | | `/btw ` | Side conversation in a subagent — doesn't pollute main thread | | `/reset-context` | Restore all edited parts to originals from CAS | + +--- + +## See Also + +- [schema.md](schema.md) — database tables (cas_object, edit_graph_node/head, side_thread, PartBase extensions) +- [agents.md](agents.md) — classifier, focus, and focus-rewrite-history agents +- [FRANKENCODE_DIFFERENCES.md](FRANKENCODE_DIFFERENCES.md) — all Frankencode vs OpenCode changes +- [EFFECTIFICATION.md](EFFECTIFICATION.md) — Effect services powering the context editing pipeline diff --git a/docs/schema.md b/docs/schema.md index 850646f52..b001169cb 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -127,3 +127,12 @@ Per-session classification results from `distill_threads`: ## Migration All new tables are created in a single migration: `20260315120000_context_editing/migration.sql` + +--- + +## See Also + +- [context-editing.md](context-editing.md) — tools that read/write these tables +- [agents.md](agents.md) — agents that create side threads and edit graph nodes +- [FRANKENCODE_DIFFERENCES.md](FRANKENCODE_DIFFERENCES.md) — all Frankencode vs OpenCode changes +- [EFFECTIFICATION.md](EFFECTIFICATION.md) — database access via Drizzle ORM and Effect service layers diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index 2a6bbbb1e..23f5d7b23 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -172,7 +172,11 @@ export namespace ACP { }) for await (const event of events.stream) { if (this.eventAbort.signal.aborted) return - const payload = (event as any)?.payload + const payload = ( + event as { + payload?: { type: string; properties: Record } + } + )?.payload if (!payload) continue await this.handleEvent(payload as Event).catch((error) => { log.error("failed to handle event", { error, type: payload.type }) @@ -202,7 +206,10 @@ export namespace ACP { title: permission.permission, rawInput: permission.metadata, kind: toToolKind(permission.permission), - locations: toLocations(permission.permission, permission.metadata), + locations: toLocations( + permission.permission, + permission.metadata as Record, + ), }, options: this.permissionOptions, }) @@ -296,7 +303,10 @@ export namespace ACP { status: "in_progress", kind: toToolKind(part.tool), title: part.tool, - locations: toLocations(part.tool, part.state.input), + locations: toLocations( + part.tool, + part.state.input as Record, + ), rawInput: part.state.input, }, }) @@ -324,7 +334,7 @@ export namespace ACP { status: "in_progress", kind: toToolKind(part.tool), title: part.tool, - locations: toLocations(part.tool, part.state.input), + locations: toLocations(part.tool, part.state.input as Record), rawInput: part.state.input, ...(content.length > 0 && { content }), }, @@ -840,7 +850,7 @@ export namespace ACP { status: "in_progress", kind: toToolKind(part.tool), title: part.tool, - locations: toLocations(part.tool, part.state.input), + locations: toLocations(part.tool, part.state.input as Record), rawInput: part.state.input, ...(runningContent.length > 0 && { content: runningContent }), }, @@ -1154,12 +1164,21 @@ export namespace ACP { const providers = await this.sdk.config.providers({ directory }).then((x) => x.data!.providers) const entries = sortProvidersByName(providers) - const availableVariants = modelVariantsFromProviders(entries, model) + // z.lazy() causes Provider.Model.variants to infer as Record — safe to widen + const typedEntries = entries as Array<{ + id: string + name: string + models: Record< + string, + { id: string; name: string; variants?: Record> } + > + }> + const availableVariants = modelVariantsFromProviders(typedEntries, model) const currentVariant = this.sessionManager.getVariant(sessionId) if (currentVariant && !availableVariants.includes(currentVariant)) { this.sessionManager.setVariant(sessionId, undefined) } - const availableModels = buildAvailableModels(entries, { includeVariants: true }) + const availableModels = buildAvailableModels(typedEntries, { includeVariants: true }) const modeState = await this.resolveModeState(directory, sessionId) const currentModeId = modeState.currentModeId const modes = currentModeId @@ -1260,11 +1279,21 @@ export namespace ACP { .providers({ directory: session.cwd }, { throwOnError: true }) .then((x) => x.data!.providers) - const selection = parseModelSelection(params.modelId, providers) + // z.lazy() causes Provider.Model.variants to infer as Record — safe cast + type TypedProvider = { + id: string + name: string + models: Record< + string, + { id: string; name: string; variants?: Record> } + > + } + const typed = providers as TypedProvider[] + const selection = parseModelSelection(params.modelId, typed) this.sessionManager.setModel(session.id, selection.model) this.sessionManager.setVariant(session.id, selection.variant) - const entries = sortProvidersByName(providers) + const entries = sortProvidersByName(providers) as TypedProvider[] const availableVariants = modelVariantsFromProviders(entries, selection.model) return { @@ -1508,20 +1537,20 @@ export namespace ACP { } } - function toLocations(toolName: string, input: Record): { path: string }[] { + function toLocations(toolName: string, input: Record): { path: string }[] { const tool = toolName.toLocaleLowerCase() switch (tool) { case "read": case "edit": case "write": - return input["filePath"] ? [{ path: input["filePath"] }] : [] + return input["filePath"] ? [{ path: input["filePath"] as string }] : [] case "glob": case "grep": - return input["path"] ? [{ path: input["path"] }] : [] + return input["path"] ? [{ path: input["path"] as string }] : [] case "bash": return [] case "list": - return input["path"] ? [{ path: input["path"] }] : [] + return input["path"] ? [{ path: input["path"] as string }] : [] default: return [] } @@ -1648,7 +1677,11 @@ export namespace ACP { } function modelVariantsFromProviders( - providers: Array<{ id: string; models: Record }> }>, + // z.lazy() causes variants to infer as Record — cast at call sites + providers: Array<{ + id: string + models: Record> }> + }>, model: { providerID: ProviderID; modelID: ModelID }, ): string[] { const provider = providers.find((entry) => entry.id === model.providerID) @@ -1659,14 +1692,19 @@ export namespace ACP { } function buildAvailableModels( - providers: Array<{ id: string; name: string; models: Record }>, + providers: Array<{ + id: string + name: string + models: Record< + string, + { id: string; name: string; variants?: Record> } + > + }>, options: { includeVariants?: boolean } = {}, ): ModelOption[] { const includeVariants = options.includeVariants ?? false return providers.flatMap((provider) => { - const unsorted: Array<{ id: string; name: string; variants?: Record }> = Object.values( - provider.models, - ) + const unsorted = Object.values(provider.models) const models = Provider.sort(unsorted) return models.flatMap((model) => { const base: ModelOption = { @@ -1711,7 +1749,11 @@ export namespace ACP { function parseModelSelection( modelId: string, - providers: Array<{ id: string; models: Record }> }>, + // z.lazy() causes variants to infer as Record — cast at call sites + providers: Array<{ + id: string + models: Record> }> + }>, ): { model: { providerID: ProviderID; modelID: ModelID }; variant?: string } { const parsed = Provider.parseModel(modelId) const provider = providers.find((p) => p.id === parsed.providerID) diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 993bfe566..d3bb3dc46 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -26,6 +26,7 @@ import { Global } from "@/global" import path from "path" import { Plugin } from "@/plugin" import { Skill } from "../skill" +import { JsonValue } from "@/util/json" export const agentStates = new Map>>() registerDisposer(async (directory) => { @@ -52,7 +53,7 @@ export namespace Agent { .optional(), variant: z.string().optional(), prompt: z.string().optional(), - options: z.record(z.string(), z.any()), + options: z.record(z.string(), JsonValue), steps: z.number().int().positive().optional(), }) .meta({ @@ -354,7 +355,7 @@ export namespace Agent { item.mode = value.mode ?? item.mode item.color = value.color ?? item.color item.hidden = value.hidden ?? item.hidden - item.name = value.name ?? item.name + item.name = (value.name as string) ?? item.name item.steps = value.steps ?? item.steps item.options = mergeDeep(item.options, value.options ?? {}) item.permission = PermissionNext.merge(item.permission, PermissionNext.fromConfig(value.permission ?? {})) diff --git a/packages/opencode/src/bus/global.ts b/packages/opencode/src/bus/global.ts index 43386dd6b..f2d1c8e9e 100644 --- a/packages/opencode/src/bus/global.ts +++ b/packages/opencode/src/bus/global.ts @@ -1,5 +1,8 @@ import { EventEmitter } from "events" +// Event emitter boundary: payload varies per BusEvent.Definition. +// GlobalBus carries heterogeneous event types — subscribers narrow via Bus.subscribe(). +// biome-ignore lint: event emitter with heterogeneous payload types export const GlobalBus = new EventEmitter<{ event: [ { diff --git a/packages/opencode/src/bus/index.ts b/packages/opencode/src/bus/index.ts index 2cf40dd8f..fc3d6d4d3 100644 --- a/packages/opencode/src/bus/index.ts +++ b/packages/opencode/src/bus/index.ts @@ -5,8 +5,13 @@ import { GlobalBus } from "./global" import { Effect, Layer, ServiceMap } from "effect" import { InstanceContext } from "../effect/instance-context" -type BusSubscription = (event: any) => void -const states = new Map }>() +// Bus callbacks are stored in a heterogeneous map keyed by event type. +// At the storage level, callbacks for different event definitions coexist +// in the same array — type narrowing happens at the subscribe() boundary +// via generics, mirroring Node.js EventEmitter's approach. +// biome-ignore lint: event emitter pattern requires type erasure at storage level +type BusCallback = (event: any) => void | Promise +const states = new Map }>() function state(directory: string) { let s = states.get(directory) @@ -19,7 +24,6 @@ function state(directory: string) { export namespace Bus { const log = Log.create({ service: "bus" }) - type Subscription = (event: any) => void export const InstanceDisposed = BusEvent.define( "server.instance.disposed", @@ -41,18 +45,29 @@ export namespace Bus { log.info("publishing", { type: def.type, }) - const pending = [] + const pending: Promise[] = [] for (const key of [def.type, "*"]) { const match = state(dir).subscriptions.get(key) for (const sub of match ?? []) { - pending.push(sub(payload)) + try { + const result = sub(payload) + if (result instanceof Promise) { + pending.push(result) + } + } catch (e) { + log.warn("subscriber threw", { type: def.type, error: e }) + } } } GlobalBus.emit("event", { directory: dir, payload, }) - return Promise.all(pending) + const results = await Promise.allSettled(pending) + const rejected = results.filter((r): r is PromiseRejectedResult => r.status === "rejected") + if (rejected.length > 0) { + log.warn("subscriber errors", { count: rejected.length, errors: rejected.map((r) => r.reason) }) + } } export function subscribe( @@ -60,7 +75,7 @@ export namespace Bus { callback: (event: { type: Definition["type"]; properties: z.infer }) => void, directory: string, ) { - return raw(def.type, callback, directory) + return raw(def.type, callback as BusCallback, directory) } export function once( @@ -81,11 +96,11 @@ export namespace Bus { return unsub } - export function subscribeAll(callback: (event: any) => void, directory: string) { + export function subscribeAll(callback: BusCallback, directory: string) { return raw("*", callback, directory) } - function raw(type: string, callback: (event: any) => void, directory: string) { + function raw(type: string, callback: BusCallback, directory: string) { log.info("subscribing", { type }) const subscriptions = state(directory).subscriptions let match = subscriptions.get(type) ?? [] diff --git a/packages/opencode/src/cli/cmd/context.ts b/packages/opencode/src/cli/cmd/context.ts index 6fd2fe4fd..53c8f4a32 100644 --- a/packages/opencode/src/cli/cmd/context.ts +++ b/packages/opencode/src/cli/cmd/context.ts @@ -195,7 +195,7 @@ const ContextThreadsCommand = cmd({ const projectID = InstanceALS.project.id const result = SideThread.list({ projectID, - status: args.status as any, + status: args.status as "parked" | "investigating" | "resolved" | "deferred" | "all", limit: args.limit, }) diff --git a/packages/opencode/src/cli/cmd/github.ts b/packages/opencode/src/cli/cmd/github.ts index 8a7fe999b..73471ce68 100644 --- a/packages/opencode/src/cli/cmd/github.ts +++ b/packages/opencode/src/cli/cmd/github.ts @@ -677,10 +677,10 @@ export const GithubRunCommand = cmd({ await removeReaction(commentType) } } - } catch (e: any) { + } catch (e) { exitCode = 1 console.error(e instanceof Error ? e.message : String(e)) - let msg = e + let msg: string | Error = e instanceof Error ? e : String(e) if (e instanceof Process.RunFailedError) { msg = e.stderr.toString() } else if (e instanceof Error) { diff --git a/packages/opencode/src/cli/cmd/providers.ts b/packages/opencode/src/cli/cmd/providers.ts index a1c6379ad..164feb401 100644 --- a/packages/opencode/src/cli/cmd/providers.ts +++ b/packages/opencode/src/cli/cmd/providers.ts @@ -274,7 +274,9 @@ export const ProvidersLoginCommand = cmd({ prompts.intro("Add credential") if (args.url) { const url = args.url.replace(/\/+$/, "") - const wellknown = await fetch(`${url}/.well-known/opencode`).then((x) => x.json() as any) + const wellknown = (await (await fetch(`${url}/.well-known/opencode`)).json()) as { + auth: { command: string[]; env: string } + } prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``) const proc = Process.spawn(wellknown.auth.command, { stdout: "pipe", diff --git a/packages/opencode/src/cli/cmd/stats.ts b/packages/opencode/src/cli/cmd/stats.ts index 78d64567d..e5d1c62da 100644 --- a/packages/opencode/src/cli/cmd/stats.ts +++ b/packages/opencode/src/cli/cmd/stats.ts @@ -1,4 +1,5 @@ import type { Argv } from "yargs" +import z from "zod" import { cmd } from "./cmd" import { Session } from "../../session" import { bootstrap } from "../bootstrap" @@ -7,44 +8,38 @@ import { SessionTable } from "../../session/session.sql" import { Project } from "../../project/project" import { InstanceALS } from "../../project/instance-als" -interface SessionStats { - totalSessions: number - totalMessages: number - totalCost: number - totalTokens: { - input: number - output: number - reasoning: number - cache: { - read: number - write: number - } - } - toolUsage: Record - modelUsage: Record< - string, - { - messages: number - tokens: { - input: number - output: number - cache: { - read: number - write: number - } - } - cost: number - } - > - dateRange: { - earliest: number - latest: number - } - days: number - costPerDay: number - tokensPerSession: number - medianTokensPerSession: number -} +const TokenCount = z.object({ + input: z.number(), + output: z.number(), + reasoning: z.number().optional(), + cache: z.object({ read: z.number(), write: z.number() }), +}) + +export const SessionStatsSchema = z.object({ + totalSessions: z.number(), + totalMessages: z.number(), + totalCost: z.number(), + totalTokens: TokenCount, + toolUsage: z.record(z.string(), z.number()), + modelUsage: z.record( + z.string(), + z.object({ + messages: z.number(), + tokens: z.object({ + input: z.number(), + output: z.number(), + cache: z.object({ read: z.number(), write: z.number() }), + }), + cost: z.number(), + }), + ), + dateRange: z.object({ earliest: z.number(), latest: z.number() }), + days: z.number(), + costPerDay: z.number(), + tokensPerSession: z.number(), + medianTokensPerSession: z.number(), +}) +type SessionStats = z.infer export const StatsCommand = cmd({ command: "stats", @@ -254,7 +249,7 @@ export async function aggregateSessionStats(days?: number, projectFilter?: strin stats.totalCost += result.sessionCost stats.totalTokens.input += result.sessionTokens.input stats.totalTokens.output += result.sessionTokens.output - stats.totalTokens.reasoning += result.sessionTokens.reasoning + stats.totalTokens.reasoning = (stats.totalTokens.reasoning ?? 0) + (result.sessionTokens.reasoning ?? 0) stats.totalTokens.cache.read += result.sessionTokens.cache.read stats.totalTokens.cache.write += result.sessionTokens.cache.write @@ -291,7 +286,7 @@ export async function aggregateSessionStats(days?: number, projectFilter?: strin const totalTokens = stats.totalTokens.input + stats.totalTokens.output + - stats.totalTokens.reasoning + + (stats.totalTokens.reasoning ?? 0) + stats.totalTokens.cache.read + stats.totalTokens.cache.write stats.tokensPerSession = filteredSessions.length > 0 ? totalTokens / filteredSessions.length : 0 diff --git a/packages/opencode/src/cli/cmd/tui/win32.ts b/packages/opencode/src/cli/cmd/tui/win32.ts index 23e9f4485..375a4ad43 100644 --- a/packages/opencode/src/cli/cmd/tui/win32.ts +++ b/packages/opencode/src/cli/cmd/tui/win32.ts @@ -71,7 +71,8 @@ export function win32InstallCtrlCGuard() { if (!load()) return if (unhook) return unhook - const stdin = process.stdin as any + // Bun/Node process.stdin type boundary — setRawMode exists at runtime but types differ + const stdin = process.stdin as NodeJS.ReadStream & { setRawMode: (mode: boolean) => NodeJS.ReadStream } const original = stdin.setRawMode const handle = k32!.symbols.GetStdHandle(STD_INPUT_HANDLE) @@ -93,11 +94,11 @@ export function win32InstallCtrlCGuard() { setImmediate(enforce) } - let wrapped: ((mode: boolean) => unknown) | undefined + let wrapped: ((mode: boolean) => NodeJS.ReadStream) | undefined if (typeof original === "function") { - wrapped = (mode: boolean) => { - const result = original.call(stdin, mode) + wrapped = (mode: boolean): NodeJS.ReadStream => { + const result = original.call(stdin, mode) as NodeJS.ReadStream later() return result } diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 61e82e1c9..28dfa4c29 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -40,6 +40,7 @@ import { ConfigPaths } from "./paths" import { Filesystem } from "@/util/filesystem" import { Process } from "@/util/process" import { Lock } from "@/util/lock" +import { JsonValue } from "@/util/json" type ConfigStateResult = { config: Config.Info; directories: string[]; deps: Promise[] } export const configStates = new Map>() @@ -115,7 +116,10 @@ export namespace Config { if (!response.ok) { throw new Error(`failed to fetch remote config from ${url}: ${response.status}`) } - const wellknown = (await response.json()) as any + const wellknown = (await response.json()) as { + config?: Record + $schema?: string + } const remoteConfig = wellknown.config ?? {} // Add $schema to prevent load() from trying to write back to a non-existent file if (!remoteConfig.$schema) remoteConfig.$schema = "https://opencode.ai/config.json" @@ -217,8 +221,8 @@ export namespace Config { }), ) } - } catch (err: any) { - log.debug("failed to fetch remote account config", { error: err?.message ?? err }) + } catch (err) { + log.debug("failed to fetch remote account config", { error: err instanceof Error ? err.message : String(err) }) } } @@ -759,7 +763,7 @@ export namespace Config { .boolean() .optional() .describe("Hide this subagent from the @ autocomplete menu (default: false, only applies to mode: subagent)"), - options: z.record(z.string(), z.any()).optional(), + options: z.record(z.string(), JsonValue).optional(), color: z .union([ z.string().regex(/^#[0-9a-fA-F]{6}$/, "Invalid hex color format"), @@ -776,7 +780,7 @@ export namespace Config { maxSteps: z.number().int().positive().optional().describe("@deprecated Use 'steps' field instead."), permission: Permission.optional(), }) - .catchall(z.any()) + .catchall(JsonValue) .transform((agent, ctx) => { const knownKeys = new Set([ "name", @@ -1022,7 +1026,7 @@ export namespace Config { .object({ disabled: z.boolean().optional().describe("Disable this variant for the model"), }) - .catchall(z.any()), + .catchall(JsonValue), ) .optional() .describe("Variant-specific configuration"), @@ -1059,7 +1063,7 @@ export namespace Config { "Timeout in milliseconds between streamed SSE chunks for this provider. If no chunk arrives within this window, the request is aborted.", ), }) - .catchall(z.any()) + .catchall(JsonValue) .optional(), }) .strict() @@ -1195,7 +1199,7 @@ export namespace Config { extensions: z.array(z.string()).optional(), disabled: z.boolean().optional(), env: z.record(z.string(), z.string()).optional(), - initialization: z.record(z.string(), z.any()).optional(), + initialization: z.record(z.string(), JsonValue).optional(), }), ]), ), @@ -1504,8 +1508,8 @@ export namespace Config { export async function updateGlobal(config: Info) { const filepath = globalConfigFile() - const before = await Filesystem.readText(filepath).catch((err: any) => { - if (err.code === "ENOENT") return "{}" + const before = await Filesystem.readText(filepath).catch((err) => { + if (err instanceof Error && "code" in err && (err as NodeJS.ErrnoException).code === "ENOENT") return "{}" throw new JsonError({ path: filepath }, { cause: err }) }) diff --git a/packages/opencode/src/config/tui-service.ts b/packages/opencode/src/config/tui-service.ts index c38bd24e4..4ecd6bcab 100644 --- a/packages/opencode/src/config/tui-service.ts +++ b/packages/opencode/src/config/tui-service.ts @@ -1,9 +1,11 @@ import { Effect, Layer, ServiceMap } from "effect" import { InstanceALS } from "@/project/instance-als" +import type z from "zod" +import type { TuiInfo } from "./tui-schema" export namespace TuiConfigService { export interface Service { - readonly get: () => Effect.Effect + readonly get: () => Effect.Effect> } } diff --git a/packages/opencode/src/context-edit/index.ts b/packages/opencode/src/context-edit/index.ts index 557c07cdb..7e34b4ebc 100644 --- a/packages/opencode/src/context-edit/index.ts +++ b/packages/opencode/src/context-edit/index.ts @@ -150,8 +150,8 @@ export namespace ContextEdit { function getPartContent(part: MessageV2.Part): string { if ("text" in part && typeof part.text === "string") return part.text - if ("state" in part && part.type === "tool") { - const state = part.state as any + if (part.type === "tool") { + const state = (part as MessageV2.ToolPart).state if (state.status === "completed") return state.output ?? "" return JSON.stringify(state.input ?? {}) } @@ -344,8 +344,8 @@ export namespace ContextEdit { // Insert replacement Session.updatePart({ id: newPartID, - sessionID: input.sessionID, - messageID: input.messageID, + sessionID: SessionID.make(input.sessionID), + messageID: MessageID.make(input.messageID), type: "text", text: input.replacement, edit: { @@ -355,7 +355,7 @@ export namespace ContextEdit { editedBy: input.agent, version, }, - } as any) + }) Database.effect(() => Bus.publish( @@ -531,8 +531,8 @@ export namespace ContextEdit { }) Session.updatePart({ id: newPartID, - sessionID: input.sessionID, - messageID: input.messageID, + sessionID: SessionID.make(input.sessionID), + messageID: MessageID.make(input.messageID), type: "text", text: summaryText, edit: { @@ -542,7 +542,7 @@ export namespace ContextEdit { editedBy: input.agent, version, }, - } as any) + }) } Database.effect(() => @@ -583,16 +583,19 @@ export namespace ContextEdit { const part = findPart(msg, input.partID) if (!part) return { success: false, error: "Part not found" } - Session.updatePart({ - ...part, - lifecycle: { - hint: input.hint, - afterTurns: input.afterTurns ?? (input.hint === "discardable" ? 3 : input.hint === "ephemeral" ? 5 : undefined), - reason: input.reason, - setAt: Date.now(), - setBy: input.agent, - turnWhenSet: input.currentTurn, - }, + Database.transaction(() => { + Session.updatePart({ + ...part, + lifecycle: { + hint: input.hint, + afterTurns: + input.afterTurns ?? (input.hint === "discardable" ? 3 : input.hint === "ephemeral" ? 5 : undefined), + reason: input.reason, + setAt: Date.now(), + setBy: input.agent, + turnWhenSet: input.currentTurn, + }, + }) }) log.info("marked", { partID: input.partID, hint: input.hint }) diff --git a/packages/opencode/src/effect/service-layers.ts b/packages/opencode/src/effect/service-layers.ts index 38d2590f3..5d5c43602 100644 --- a/packages/opencode/src/effect/service-layers.ts +++ b/packages/opencode/src/effect/service-layers.ts @@ -329,7 +329,7 @@ export class McpService extends ServiceMap.Service = {} + let data: Record = {} if (e instanceof NamedError) { const obj = e.toObject() Object.assign(data, { diff --git a/packages/opencode/src/installation/index.ts b/packages/opencode/src/installation/index.ts index 92a3bfc79..643b37b8a 100644 --- a/packages/opencode/src/installation/index.ts +++ b/packages/opencode/src/installation/index.ts @@ -252,7 +252,7 @@ export namespace Installation { if (!res.ok) throw new Error(res.statusText) return res.json() }) - .then((data: any) => data.versions.stable) + .then((data: { versions: { stable: string } }) => data.versions.stable) } if (detectedMethod === "npm" || detectedMethod === "bun" || detectedMethod === "pnpm") { @@ -267,7 +267,7 @@ export namespace Installation { if (!res.ok) throw new Error(res.statusText) return res.json() }) - .then((data: any) => data.version) + .then((data: { version: string }) => data.version) } if (detectedMethod === "choco") { @@ -279,7 +279,7 @@ export namespace Installation { if (!res.ok) throw new Error(res.statusText) return res.json() }) - .then((data: any) => data.d.results[0].Version) + .then((data: { d: { results: Array<{ Version: string }> } }) => data.d.results[0].Version) } if (detectedMethod === "scoop") { @@ -290,7 +290,7 @@ export namespace Installation { if (!res.ok) throw new Error(res.statusText) return res.json() }) - .then((data: any) => data.version) + .then((data: { version: string }) => data.version) } return fetch("https://api.github.com/repos/anomalyco/opencode/releases/latest") @@ -298,6 +298,6 @@ export namespace Installation { if (!res.ok) throw new Error(res.statusText) return res.json() }) - .then((data: any) => data.tag_name.replace(/^v/, "")) + .then((data: { tag_name: string }) => data.tag_name.replace(/^v/, "")) } } diff --git a/packages/opencode/src/lsp/client.ts b/packages/opencode/src/lsp/client.ts index 3ef68bc25..63b8d2d6f 100644 --- a/packages/opencode/src/lsp/client.ts +++ b/packages/opencode/src/lsp/client.ts @@ -44,8 +44,8 @@ export namespace LSPClient { l.info("starting client") const connection = createMessageConnection( - new StreamMessageReader(input.server.process.stdout as any), - new StreamMessageWriter(input.server.process.stdin as any), + new StreamMessageReader(input.server.process.stdout as NodeJS.ReadableStream), + new StreamMessageWriter(input.server.process.stdin as NodeJS.WritableStream), ) const diagnostics = new Map() diff --git a/packages/opencode/src/lsp/index.ts b/packages/opencode/src/lsp/index.ts index 810724757..2b9d19501 100644 --- a/packages/opencode/src/lsp/index.ts +++ b/packages/opencode/src/lsp/index.ts @@ -388,8 +388,8 @@ export namespace LSP { .sendRequest("workspace/symbol", { query, }) - .then((result: any) => result.filter((x: LSP.Symbol) => kinds.includes(x.kind))) - .then((result: any) => result.slice(0, 10)) + .then((result) => (result as LSP.Symbol[]).filter((x: LSP.Symbol) => kinds.includes(x.kind))) + .then((result) => result.slice(0, 10)) .catch(() => []), ).then((result) => result.flat() as LSP.Symbol[]) } @@ -461,7 +461,7 @@ export namespace LSP { textDocument: { uri: pathToFileURL(input.file).href }, position: { line: input.line, character: input.character }, }) - .catch(() => [])) as any[] + .catch(() => [])) as Record[] if (!items?.length) return [] return client.connection.sendRequest("callHierarchy/incomingCalls", { item: items[0] }).catch(() => []) }).then((result) => result.flat().filter(Boolean)) @@ -474,7 +474,7 @@ export namespace LSP { textDocument: { uri: pathToFileURL(input.file).href }, position: { line: input.line, character: input.character }, }) - .catch(() => [])) as any[] + .catch(() => [])) as Record[] if (!items?.length) return [] return client.connection.sendRequest("callHierarchy/outgoingCalls", { item: items[0] }).catch(() => []) }).then((result) => result.flat().filter(Boolean)) diff --git a/packages/opencode/src/lsp/server.ts b/packages/opencode/src/lsp/server.ts index 526ea0af0..c92fcedc6 100644 --- a/packages/opencode/src/lsp/server.ts +++ b/packages/opencode/src/lsp/server.ts @@ -30,7 +30,7 @@ export namespace LSPServer { export interface Handle { process: ChildProcessWithoutNullStreams - initialization?: Record + initialization?: Record } type RootFunction = (file: string, directory: string, worktree: string) => Promise @@ -653,7 +653,9 @@ export namespace LSPServer { return } - const release = (await releaseResponse.json()) as any + const release = (await releaseResponse.json()) as { + assets: Array<{ name: string; browser_download_url: string }> + } const platform = process.platform const arch = process.arch @@ -688,7 +690,7 @@ export namespace LSPServer { return } - const asset = release.assets.find((a: any) => a.name === assetName) + const asset = release.assets.find((a: { name: string; browser_download_url: string }) => a.name === assetName) if (!asset) { log.error(`Could not find asset ${assetName} in latest zls release`) return @@ -1466,7 +1468,7 @@ export namespace LSPServer { return } - const asset = release.assets.find((a: any) => a.name === assetName) + const asset = release.assets.find((a: { name: string; browser_download_url: string }) => a.name === assetName) if (!asset) { log.error(`Could not find asset ${assetName} in latest lua-language-server release`) return diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 8c47b9dff..595e95a1f 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -68,7 +68,7 @@ registerDisposer(async (directory) => { // Kill the full descendant tree first so the server exits promptly // and no processes are left behind. for (const client of Object.values(state.clients)) { - const pid = (client.transport as any)?.pid + const pid = (client.transport as { pid?: number })?.pid if (typeof pid !== "number") continue for (const dpid of await descendants(pid)) { try { diff --git a/packages/opencode/src/permission/service.ts b/packages/opencode/src/permission/service.ts index b4c43299f..a0fd126ec 100644 --- a/packages/opencode/src/permission/service.ts +++ b/packages/opencode/src/permission/service.ts @@ -11,6 +11,7 @@ import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect" import z from "zod" import { PermissionID } from "./schema" import { InstanceALS } from "@/project/instance-als" +import { JsonValue } from "@/util/json" const log = Log.create({ service: "permission" }) @@ -41,7 +42,7 @@ export const Request = z sessionID: SessionID.zod, permission: z.string(), patterns: z.string().array(), - metadata: z.record(z.string(), z.any()), + metadata: z.record(z.string(), JsonValue), always: z.string().array(), tool: z .object({ diff --git a/packages/opencode/src/plugin/codex.ts b/packages/opencode/src/plugin/codex.ts index 37bcdd74f..ce1fc9330 100644 --- a/packages/opencode/src/plugin/codex.ts +++ b/packages/opencode/src/plugin/codex.ts @@ -6,6 +6,7 @@ import os from "os" import { ProviderTransform } from "@/provider/transform" import { ModelID, ProviderID } from "@/provider/schema" import { setTimeout as sleep } from "node:timers/promises" +import { type JsonValueType } from "@/util/json" const log = Log.create({ service: "plugin.codex" }) @@ -399,7 +400,7 @@ export async function CodexAuthPlugin(input: PluginInput): Promise { options: {}, headers: {}, release_date: "2026-02-05", - variants: {} as Record>, + variants: {} as Record>, family: "gpt-codex", } model.variants = ProviderTransform.variants(model) diff --git a/packages/opencode/src/plugin/copilot.ts b/packages/opencode/src/plugin/copilot.ts index 9a55c7376..9e9ae84aa 100644 --- a/packages/opencode/src/plugin/copilot.ts +++ b/packages/opencode/src/plugin/copilot.ts @@ -75,8 +75,9 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise { const last = body.messages[body.messages.length - 1] return { isVision: body.messages.some( - (msg: any) => - Array.isArray(msg.content) && msg.content.some((part: any) => part.type === "image_url"), + (msg: { content?: Array<{ type?: string }> }) => + Array.isArray(msg.content) && + msg.content.some((part: { type?: string }) => part.type === "image_url"), ), isAgent: last?.role !== "user", } @@ -87,8 +88,9 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise { const last = body.input[body.input.length - 1] return { isVision: body.input.some( - (item: any) => - Array.isArray(item?.content) && item.content.some((part: any) => part.type === "input_image"), + (item: { content?: Array<{ type?: string }> }) => + Array.isArray(item?.content) && + item.content.some((part: { type?: string }) => part.type === "input_image"), ), isAgent: last?.role !== "user", } @@ -98,18 +100,19 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise { if (body?.messages) { const last = body.messages[body.messages.length - 1] const hasNonToolCalls = - Array.isArray(last?.content) && last.content.some((part: any) => part?.type !== "tool_result") + Array.isArray(last?.content) && + last.content.some((part: { type?: string }) => part?.type !== "tool_result") return { isVision: body.messages.some( - (item: any) => + (item: { content?: Array<{ type?: string; content?: Array<{ type?: string }> }> }) => Array.isArray(item?.content) && item.content.some( - (part: any) => + (part: { type?: string; content?: Array<{ type?: string }> }) => part?.type === "image" || // images can be nested inside tool_result content (part?.type === "tool_result" && Array.isArray(part?.content) && - part.content.some((nested: any) => nested?.type === "image")), + part.content.some((nested: { type?: string }) => nested?.type === "image")), ), ), isAgent: !(last?.role === "user" && hasNonToolCalls), diff --git a/packages/opencode/src/project/instance-als.ts b/packages/opencode/src/project/instance-als.ts index a09f8784c..6e257a894 100644 --- a/packages/opencode/src/project/instance-als.ts +++ b/packages/opencode/src/project/instance-als.ts @@ -36,6 +36,9 @@ export const InstanceALS = { * restores it when called. Use for callbacks that fire outside the * instance async context (native addons, event emitters, timers, etc.). */ + // Generic function binding: any[] args and any return is the standard TS pattern + // for preserving arbitrary function signatures through F. Using unknown[] would + // break contravariance — callers with specific arg types wouldn't match. bind any>(fn: F): F { const ctx = context.use() return ((...args: any[]) => context.provide(ctx, () => fn(...args))) as F diff --git a/packages/opencode/src/project/lifecycle.ts b/packages/opencode/src/project/lifecycle.ts index 8effa25cc..9f5687386 100644 --- a/packages/opencode/src/project/lifecycle.ts +++ b/packages/opencode/src/project/lifecycle.ts @@ -32,7 +32,7 @@ function emit(directory: string) { function bootContext(input: { directory: string - init?: () => Promise + init?: () => Promise project?: Project.Info worktree?: string }) { @@ -70,7 +70,7 @@ export const InstanceLifecycle = { * Boot an instance for the given directory. If already cached, returns * the existing context. Runs init inside ALS context. */ - async boot(directory: string, init?: () => Promise): Promise { + async boot(directory: string, init?: () => Promise): Promise { const dir = Filesystem.resolve(directory) let existing = cache.get(dir) if (!existing) { @@ -129,7 +129,7 @@ export const InstanceLifecycle = { /** * Reload an instance: dispose, clear cache, re-boot. */ - async reload(input: { directory: string; init?: () => Promise; project?: Project.Info; worktree?: string }) { + async reload(input: { directory: string; init?: () => Promise; project?: Project.Info; worktree?: string }) { const directory = Filesystem.resolve(input.directory) Log.Default.info("reloading instance", { directory }) await disposeInstance(directory) diff --git a/packages/opencode/src/provider/models.ts b/packages/opencode/src/provider/models.ts index bae331784..0b1a14663 100644 --- a/packages/opencode/src/provider/models.ts +++ b/packages/opencode/src/provider/models.ts @@ -6,6 +6,7 @@ import { Installation } from "../installation" import { Flag } from "../flag/flag" import { lazy } from "@/util/lazy" import { Filesystem } from "../util/filesystem" +import { JsonValue } from "@/util/json" // Try to import bundled snapshot (generated at build time) // Falls back to undefined in dev mode when snapshot doesn't exist @@ -63,10 +64,10 @@ export namespace ModelsDev { .optional(), experimental: z.boolean().optional(), status: z.enum(["alpha", "beta", "deprecated"]).optional(), - options: z.record(z.string(), z.any()), + options: z.record(z.string(), JsonValue), headers: z.record(z.string(), z.string()).optional(), provider: z.object({ npm: z.string().optional(), api: z.string().optional() }).optional(), - variants: z.record(z.string(), z.record(z.string(), z.any())).optional(), + variants: z.record(z.string(), z.record(z.string(), JsonValue)).optional(), }) export type Model = z.infer diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 35f12fbbd..a892b01e1 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -4,6 +4,7 @@ import fuzzysort from "fuzzysort" import { Config } from "../config/config" import { mapValues, mergeDeep, omit, pickBy, sortBy } from "remeda" import { NoSuchModelError, type Provider as SDK } from "ai" +import type { JSONValue } from "@ai-sdk/provider" import { Log } from "../util/log" import { BunProc } from "../bun" import { Hash } from "../util/hash" @@ -47,18 +48,32 @@ import { GoogleAuth } from "google-auth-library" import { ProviderTransform } from "./transform" import { Installation } from "../installation" import { ModelID, ProviderID } from "./schema" +import { JsonValue } from "@/util/json" const DEFAULT_CHUNK_TIMEOUT = 300_000 +// Provider SDK layer: each AI SDK provider (OpenAI, Anthropic, Google, etc.) returns a unique type +// with different methods (.responses, .chat, .languageModel). The BUNDLED_PROVIDERS dispatch table, +// CustomModelLoader, and getModel() callbacks all receive these heterogeneous SDK instances. +// Using `any` here is an SDK boundary decision — each provider's type surface is incompatible +// with others and with the base `Provider` interface. +// biome-ignore lint: SDK boundary — provider instances are heterogeneous types +type ProviderSDK = any + type ProviderStateResult = { models: Map providers: { [providerID: string]: Provider.Info } - sdk: Map + sdk: Map modelLoaders: { - [providerID: string]: (sdk: any, modelID: string, options?: Record) => Promise + // biome-ignore lint: SDK boundary — each provider's SDK type is unique + [providerID: string]: ( + sdk: ProviderSDK, + modelID: string, + options?: Record, + ) => Promise } varsLoaders: { - [providerID: string]: (options: Record) => Record + [providerID: string]: (options: Record) => Record } } export const providerStates = new Map>() @@ -123,7 +138,9 @@ export namespace Provider { }) } - const BUNDLED_PROVIDERS: Record SDK> = { + // SDK boundary: each createXXX() takes provider-specific settings and returns a provider-specific SDK + // biome-ignore lint: provider constructors have incompatible option types + const BUNDLED_PROVIDERS: Record ProviderSDK> = { "@ai-sdk/amazon-bedrock": createAmazonBedrock, "@ai-sdk/anthropic": createAnthropic, "@ai-sdk/azure": createAzure, @@ -148,16 +165,21 @@ export namespace Provider { "@ai-sdk/github-copilot": createGitHubCopilotOpenAICompatible, } - type CustomModelLoader = (sdk: any, modelID: string, options?: Record) => Promise - type CustomVarsLoader = (options: Record) => Record + type CustomModelLoader = ( + sdk: ProviderSDK, + modelID: string, + options?: Record, + ) => Promise + type CustomVarsLoader = (options: Record) => Record type CustomLoader = (provider: Info) => Promise<{ autoload: boolean getModel?: CustomModelLoader vars?: CustomVarsLoader + // biome-ignore lint: provider init options include optional fields (apiKey?: undefined) options?: Record }> - function useLanguageModel(sdk: any) { + function useLanguageModel(sdk: ProviderSDK) { return sdk.responses === undefined && sdk.chat === undefined } @@ -198,7 +220,7 @@ export namespace Provider { openai: async () => { return { autoload: false, - async getModel(sdk: any, modelID: string, _options?: Record) { + async getModel(sdk: ProviderSDK, modelID: string, _options?: Record) { return sdk.responses(modelID) }, options: {}, @@ -207,7 +229,7 @@ export namespace Provider { "github-copilot": async () => { return { autoload: false, - async getModel(sdk: any, modelID: string, _options?: Record) { + async getModel(sdk: ProviderSDK, modelID: string, _options?: Record) { if (useLanguageModel(sdk)) return sdk.languageModel(modelID) return shouldUseCopilotResponsesApi(modelID) ? sdk.responses(modelID) : sdk.chat(modelID) }, @@ -223,7 +245,7 @@ export namespace Provider { return { autoload: false, - async getModel(sdk: any, modelID: string, options?: Record) { + async getModel(sdk: ProviderSDK, modelID: string, options?: Record) { if (useLanguageModel(sdk)) return sdk.languageModel(modelID) if (options?.["useCompletionUrls"]) { return sdk.chat(modelID) @@ -243,7 +265,7 @@ export namespace Provider { const resourceName = Env.get("AZURE_COGNITIVE_SERVICES_RESOURCE_NAME", InstanceALS.directory) return { autoload: false, - async getModel(sdk: any, modelID: string, options?: Record) { + async getModel(sdk: ProviderSDK, modelID: string, options?: Record) { if (useLanguageModel(sdk)) return sdk.languageModel(modelID) if (options?.["useCompletionUrls"]) { return sdk.chat(modelID) @@ -296,14 +318,14 @@ export namespace Provider { return { autoload: false } const providerOptions: AmazonBedrockProviderSettings = { - region: defaultRegion, + region: defaultRegion as string, } // Only use credential chain if no bearer token exists // Bearer token takes precedence over credential chain (profiles, access keys, IAM roles, web identity tokens) if (!awsBearerToken) { // Build credential provider options (only pass profile if specified) - const credentialProviderOptions = profile ? { profile } : {} + const credentialProviderOptions = profile ? { profile: profile as string } : {} providerOptions.credentialProvider = fromNodeProviderChain(credentialProviderOptions) } @@ -311,13 +333,13 @@ export namespace Provider { // Add custom endpoint if specified (endpoint takes precedence over baseURL) const endpoint = providerConfig?.options?.endpoint ?? providerConfig?.options?.baseURL if (endpoint) { - providerOptions.baseURL = endpoint + providerOptions.baseURL = endpoint as string } return { autoload: true, options: providerOptions, - async getModel(sdk: any, modelID: string, options?: Record) { + async getModel(sdk: ProviderSDK, modelID: string, options?: Record) { // Skip region prefixing if model already has a cross-region inference profile prefix // Models from models.dev may already include prefixes like us., eu., global., etc. const crossRegionPrefixes = ["global.", "us.", "eu.", "jp.", "apac.", "au."] @@ -329,7 +351,7 @@ export namespace Provider { // 1. options.region from opencode.json provider config // 2. defaultRegion from AWS_REGION environment variable // 3. Default "us-east-1" (baked into defaultRegion) - const region = options?.region ?? defaultRegion + const region = (options?.region as string) ?? defaultRegion let regionPrefix = region.split("-")[0] @@ -445,10 +467,10 @@ export namespace Provider { if (!autoload) return { autoload: false } return { autoload: true, - vars(_options: Record) { + vars(_options: Record) { const endpoint = location === "global" ? "aiplatform.googleapis.com" : `${location}-aiplatform.googleapis.com` return { - ...(project && { GOOGLE_VERTEX_PROJECT: project }), + ...(project && { GOOGLE_VERTEX_PROJECT: project as string | undefined }), GOOGLE_VERTEX_LOCATION: location, GOOGLE_VERTEX_ENDPOINT: endpoint, } @@ -467,7 +489,7 @@ export namespace Provider { return fetch(input, { ...init, headers }) }, }, - async getModel(sdk: any, modelID: string) { + async getModel(sdk: ProviderSDK, modelID: string) { const id = String(modelID).trim() return sdk.languageModel(id) }, @@ -490,7 +512,7 @@ export namespace Provider { project, location, }, - async getModel(sdk: any, modelID) { + async getModel(sdk: ProviderSDK, modelID) { const id = String(modelID).trim() return sdk.languageModel(id) }, @@ -515,7 +537,7 @@ export namespace Provider { return { autoload: !!envServiceKey, options: envServiceKey ? { deploymentId, resourceGroup } : {}, - async getModel(sdk: any, modelID: string) { + async getModel(sdk: ProviderSDK, modelID: string) { return sdk(modelID) }, } @@ -547,7 +569,7 @@ export namespace Provider { const aiGatewayHeaders = { "User-Agent": `opencode/${Installation.VERSION} gitlab-ai-provider/${GITLAB_PROVIDER_VERSION} (${os.platform()} ${os.release()}; ${os.arch()})`, "anthropic-beta": "context-1m-2025-08-07", - ...(providerConfig?.options?.aiGatewayHeaders || {}), + ...((providerConfig?.options?.aiGatewayHeaders || {}) as Record), } return { @@ -559,7 +581,7 @@ export namespace Provider { featureFlags: { duo_agent_platform_agentic_chat: true, duo_agent_platform: true, - ...(providerConfig?.options?.featureFlags || {}), + ...((providerConfig?.options?.featureFlags || {}) as Record), }, }, async getModel(sdk: ReturnType, modelID: string) { @@ -568,7 +590,7 @@ export namespace Provider { featureFlags: { duo_agent_platform_agentic_chat: true, duo_agent_platform: true, - ...(providerConfig?.options?.featureFlags || {}), + ...((providerConfig?.options?.featureFlags || {}) as Record), }, }) }, @@ -591,7 +613,7 @@ export namespace Provider { options: { apiKey, }, - async getModel(sdk: any, modelID: string) { + async getModel(sdk: ProviderSDK, modelID: string) { return sdk.languageModel(modelID) }, vars(_options) { @@ -631,17 +653,17 @@ export namespace Provider { const metadata = iife(() => { if (input.options?.metadata) return input.options.metadata try { - return JSON.parse(input.options?.headers?.["cf-aig-metadata"]) + return JSON.parse((input.options?.headers as Record)?.["cf-aig-metadata"]) } catch { return undefined } }) const opts = { metadata, - cacheTtl: input.options?.cacheTtl, - cacheKey: input.options?.cacheKey, - skipCache: input.options?.skipCache, - collectLog: input.options?.collectLog, + cacheTtl: input.options?.cacheTtl as number | undefined, + cacheKey: input.options?.cacheKey as string | undefined, + skipCache: input.options?.skipCache as boolean | undefined, + collectLog: input.options?.collectLog as boolean | undefined, } const aigateway = createAiGateway({ @@ -654,7 +676,7 @@ export namespace Provider { return { autoload: true, - async getModel(_sdk: any, modelID: string, _options?: Record) { + async getModel(_sdk: ProviderSDK, modelID: string, _options?: Record) { // Model IDs use Unified API format: provider/model (e.g., "anthropic/claude-sonnet-4-5") return aigateway(unified(modelID)) }, @@ -745,10 +767,10 @@ export namespace Provider { output: z.number(), }), status: z.enum(["alpha", "beta", "deprecated", "active"]), - options: z.record(z.string(), z.any()), + options: z.record(z.string(), JsonValue), headers: z.record(z.string(), z.string()), release_date: z.string(), - variants: z.record(z.string(), z.record(z.string(), z.any())).optional(), + variants: z.record(z.string(), z.record(z.string(), JsonValue)).optional(), }) .meta({ ref: "Model", @@ -762,7 +784,7 @@ export namespace Provider { source: z.enum(["env", "config", "custom", "api"]), env: z.string().array(), key: z.string().optional(), - options: z.record(z.string(), z.any()), + options: z.record(z.string(), JsonValue), models: z.record(z.string(), Model), }) .meta({ @@ -1016,7 +1038,10 @@ export namespace Provider { if (!auth) continue if (!plugin.auth.loader) continue - const options = await plugin.auth.loader(() => Auth.get(providerID) as any, database[plugin.auth.provider]) + const options = await plugin.auth.loader( + () => Auth.get(providerID) as Promise, + database[plugin.auth.provider], + ) const opts = options ?? {} const patch: Partial = providers[providerID] ? { options: opts } : { source: "custom", options: opts } mergeProvider(providerID, patch) @@ -1153,7 +1178,7 @@ export namespace Provider { if (options["apiKey"] === undefined && provider.key) options["apiKey"] = provider.key if (model.headers) options["headers"] = { - ...options["headers"], + ...(options["headers"] as Record), ...model.headers, } @@ -1161,11 +1186,12 @@ export namespace Provider { const existing = s.sdk.get(key) if (existing) return existing - const customFetch = options["fetch"] - const chunkTimeout = options["chunkTimeout"] || DEFAULT_CHUNK_TIMEOUT + const customFetch = options["fetch"] as unknown as typeof globalThis.fetch | undefined + const chunkTimeout = (options["chunkTimeout"] as number) || DEFAULT_CHUNK_TIMEOUT delete options["chunkTimeout"] - options["fetch"] = async (input: any, init?: BunFetchRequestInit) => { + // @ts-expect-error fetch function stored in JSON options object + options["fetch"] = async (input: string | URL | Request, init?: BunFetchRequestInit) => { // Preserve custom fetch if it exists, wrap it with timeout logic const fetchFn = customFetch ?? fetch const opts = init ?? {} @@ -1175,7 +1201,7 @@ export namespace Provider { if (opts.signal) signals.push(opts.signal) if (chunkAbortCtl) signals.push(chunkAbortCtl.signal) if (options["timeout"] !== undefined && options["timeout"] !== null && options["timeout"] !== false) - signals.push(AbortSignal.timeout(options["timeout"])) + signals.push(AbortSignal.timeout(options["timeout"] as number)) const combined = signals.length === 0 ? null : signals.length === 1 ? signals[0] : AbortSignal.any(signals) if (combined) opts.signal = combined @@ -1347,7 +1373,7 @@ export namespace Provider { const region = provider.options?.region if (region) { - const regionPrefix = region.split("-")[0] + const regionPrefix = (region as string).split("-")[0] if (regionPrefix === "us" || regionPrefix === "eu") { const regionalMatch = candidates.find((m) => m.startsWith(`${regionPrefix}.`)) if (regionalMatch) return getModel(providerID, ModelID.make(regionalMatch)) diff --git a/packages/opencode/src/provider/sdk/copilot/copilot-provider.ts b/packages/opencode/src/provider/sdk/copilot/copilot-provider.ts index 1dc373ff3..2ebc81a0d 100644 --- a/packages/opencode/src/provider/sdk/copilot/copilot-provider.ts +++ b/packages/opencode/src/provider/sdk/copilot/copilot-provider.ts @@ -41,9 +41,9 @@ export interface OpenaiCompatibleProvider { responses(modelId: OpenaiCompatibleModelId): LanguageModelV2 languageModel(modelId: OpenaiCompatibleModelId): LanguageModelV2 - // embeddingModel(modelId: any): EmbeddingModelV2 + // embeddingModel(modelId: string): EmbeddingModelV2 - // imageModel(modelId: any): ImageModelV2 + // imageModel(modelId: string): ImageModelV2 } /** diff --git a/packages/opencode/src/provider/sdk/copilot/openai-compatible-error.ts b/packages/opencode/src/provider/sdk/copilot/openai-compatible-error.ts index 054c694dd..cf82c0c96 100644 --- a/packages/opencode/src/provider/sdk/copilot/openai-compatible-error.ts +++ b/packages/opencode/src/provider/sdk/copilot/openai-compatible-error.ts @@ -8,6 +8,7 @@ export const openaiCompatibleErrorDataSchema = z.object({ // OpenAI-compatible providers that have slightly different error // responses: type: z.string().nullish(), + // Upstream OpenAI SDK type — param can be any JSON value per OpenAI error spec param: z.any().nullish(), code: z.union([z.string(), z.number()]).nullish(), }), diff --git a/packages/opencode/src/provider/sdk/copilot/responses/openai-error.ts b/packages/opencode/src/provider/sdk/copilot/responses/openai-error.ts index e78824d36..088e3b51f 100644 --- a/packages/opencode/src/provider/sdk/copilot/responses/openai-error.ts +++ b/packages/opencode/src/provider/sdk/copilot/responses/openai-error.ts @@ -9,6 +9,7 @@ export const openaiErrorDataSchema = z.object({ // OpenAI-compatible providers that have slightly different error // responses: type: z.string().nullish(), + // Upstream OpenAI SDK type — param can be any JSON value per OpenAI error spec param: z.any().nullish(), code: z.union([z.string(), z.number()]).nullish(), }), @@ -16,7 +17,8 @@ export const openaiErrorDataSchema = z.object({ export type OpenAIErrorData = z.infer -export const openaiFailedResponseHandler: any = createJsonErrorResponseHandler({ - errorSchema: openaiErrorDataSchema, - errorToMessage: (data) => data.error.message, -}) +export const openaiFailedResponseHandler: ReturnType = + createJsonErrorResponseHandler({ + errorSchema: openaiErrorDataSchema, + errorToMessage: (data) => data.error.message, + }) diff --git a/packages/opencode/src/provider/sdk/copilot/responses/openai-responses-language-model.ts b/packages/opencode/src/provider/sdk/copilot/responses/openai-responses-language-model.ts index 0a575bc02..fb6bf8fcd 100644 --- a/packages/opencode/src/provider/sdk/copilot/responses/openai-responses-language-model.ts +++ b/packages/opencode/src/provider/sdk/copilot/responses/openai-responses-language-model.ts @@ -355,7 +355,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV2 { details: "flex processing is only available for o3, o4-mini, and gpt-5 models", }) // Remove from args if not supported - delete (baseArgs as any).service_tier + baseArgs.service_tier = undefined } // Validate priority processing support @@ -367,7 +367,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV2 { "priority processing is only available for supported models (gpt-4, gpt-5, gpt-5-mini, o3, o4-mini) and requires Enterprise access. gpt-5-nano is not supported", }) // Remove from args if not supported - delete (baseArgs as any).service_tier + baseArgs.service_tier = undefined } const { @@ -1715,6 +1715,7 @@ const openaiResponsesProviderOptionsSchema = z.object({ */ maxToolCalls: z.number().nullish(), + // Upstream OpenAI SDK type — metadata can be any JSON value per OpenAI Responses API metadata: z.any().nullish(), parallelToolCalls: z.boolean().nullish(), previousResponseId: z.string().nullish(), diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 05b9f031f..6f19f797f 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -1,6 +1,6 @@ import type { ModelMessage } from "ai" import { mergeDeep, unique } from "remeda" -import type { JSONSchema7 } from "@ai-sdk/provider" +import type { JSONSchema7, JSONValue } from "@ai-sdk/provider" import type { JSONSchema } from "zod/v4/core" import type { Provider } from "./provider" import type { ModelsDev } from "./models" @@ -137,11 +137,15 @@ export namespace ProviderTransform { const field = model.capabilities.interleaved.field return msgs.map((msg) => { if (msg.role === "assistant" && Array.isArray(msg.content)) { - const reasoningParts = msg.content.filter((part: any) => part.type === "reasoning") - const reasoningText = reasoningParts.map((part: any) => part.text).join("") + const reasoningParts = msg.content.filter( + (part: { type: string; text?: string }) => part.type === "reasoning", + ) + const reasoningText = reasoningParts.map((part: { type: string; text?: string }) => part.text).join("") // Filter out reasoning parts from content - const filteredContent = msg.content.filter((part: any) => part.type !== "reasoning") + const filteredContent = msg.content.filter( + (part: { type: string; text?: string }) => part.type !== "reasoning", + ) // Include reasoning_content | reasoning_details directly on the message for all assistant messages if (reasoningText) { @@ -151,7 +155,7 @@ export namespace ProviderTransform { providerOptions: { ...msg.providerOptions, openaiCompatible: { - ...(msg.providerOptions as any)?.openaiCompatible, + ...(msg.providerOptions as Record> | undefined)?.openaiCompatible, [field]: reasoningText, }, }, @@ -267,7 +271,7 @@ export namespace ProviderTransform { // Remap providerOptions keys from stored providerID to expected SDK key const key = sdkKey(model.api.npm) if (key && key !== model.providerID && model.api.npm !== "@ai-sdk/azure") { - const remap = (opts: Record | undefined) => { + const remap = (opts: Record> | undefined) => { if (!opts) return opts if (!(model.providerID in opts)) return opts const result = { ...opts } @@ -329,7 +333,7 @@ export namespace ProviderTransform { const WIDELY_SUPPORTED_EFFORTS = ["low", "medium", "high"] const OPENAI_EFFORTS = ["none", "minimal", ...WIDELY_SUPPORTED_EFFORTS, "xhigh"] - export function variants(model: Provider.Model): Record> { + export function variants(model: Provider.Model): Record> { if (!model.capabilities.reasoning) return {} const id = model.id.toLowerCase() @@ -714,9 +718,9 @@ export namespace ProviderTransform { export function options(input: { model: Provider.Model sessionID: string - providerOptions?: Record - }): Record { - const result: Record = {} + providerOptions?: Record> + }): Record> { + const result: Record = {} // openai and providers using openai package should set store to false by default. if ( @@ -826,7 +830,7 @@ export namespace ProviderTransform { } } - return result + return result as Record> } export function smallOptions(model: Provider.Model) { @@ -870,7 +874,10 @@ export namespace ProviderTransform { amazon: "bedrock", } - export function providerOptions(model: Provider.Model, options: { [x: string]: any }) { + export function providerOptions( + model: Provider.Model, + options: Record, + ): Record> { if (model.api.npm === "@ai-sdk/gateway") { // Gateway providerOptions are split across two namespaces: // - `gateway`: gateway-native routing/caching controls (order, only, byok, etc.) @@ -884,15 +891,15 @@ export namespace ProviderTransform { const rest = Object.fromEntries(Object.entries(options).filter(([k]) => k !== "gateway")) const has = Object.keys(rest).length > 0 - const result: Record = {} - if (gateway !== undefined) result.gateway = gateway + const result: Record> = {} + if (gateway !== undefined) result.gateway = gateway as Record if (has) { if (slug) { // Route model-specific options under the provider slug result[slug] = rest } else if (gateway && typeof gateway === "object" && !Array.isArray(gateway)) { - result.gateway = { ...gateway, ...rest } + result.gateway = { ...(gateway as Record), ...rest } } else { result.gateway = rest } @@ -930,7 +937,7 @@ export namespace ProviderTransform { // Convert integer enums to string enums for Google/Gemini if (model.providerID === "google" || model.api.id.includes("gemini")) { - const isPlainObject = (node: unknown): node is Record => + const isPlainObject = (node: unknown): node is Record => typeof node === "object" && node !== null && !Array.isArray(node) const hasCombiner = (node: unknown) => isPlainObject(node) && (Array.isArray(node.anyOf) || Array.isArray(node.oneOf) || Array.isArray(node.allOf)) @@ -955,7 +962,7 @@ export namespace ProviderTransform { ].some((key) => key in node) } - const sanitizeGemini = (obj: any): any => { + const sanitizeGemini = (obj: JSONValue): JSONValue => { if (obj === null || typeof obj !== "object") { return obj } @@ -964,7 +971,7 @@ export namespace ProviderTransform { return obj.map(sanitizeGemini) } - const result: any = {} + const result: Record = {} for (const [key, value] of Object.entries(obj)) { if (key === "enum" && Array.isArray(value)) { // Convert all enum values to strings @@ -981,8 +988,15 @@ export namespace ProviderTransform { } // Filter required array to only include fields that exist in properties - if (result.type === "object" && result.properties && Array.isArray(result.required)) { - result.required = result.required.filter((field: any) => field in result.properties) + if ( + result.type === "object" && + result.properties && + typeof result.properties === "object" && + !Array.isArray(result.properties) && + Array.isArray(result.required) + ) { + const props = result.properties as Record + result.required = (result.required as string[]).filter((field: string) => field in props) } if (result.type === "array" && !hasCombiner(result)) { @@ -1004,7 +1018,7 @@ export namespace ProviderTransform { return result } - schema = sanitizeGemini(schema) + schema = sanitizeGemini(schema as JSONValue) as typeof schema } return schema as JSONSchema7 diff --git a/packages/opencode/src/server/error.ts b/packages/opencode/src/server/error.ts index cc5fa9618..cdf275fc2 100644 --- a/packages/opencode/src/server/error.ts +++ b/packages/opencode/src/server/error.ts @@ -1,6 +1,7 @@ import { resolver } from "hono-openapi" import z from "zod" import { NotFoundError } from "../storage/db" +import { JsonValue } from "@/util/json" export const ERRORS = { 400: { @@ -10,8 +11,8 @@ export const ERRORS = { schema: resolver( z .object({ - data: z.any(), - errors: z.array(z.record(z.string(), z.any())), + data: JsonValue, + errors: z.array(z.record(z.string(), JsonValue)), success: z.literal(false), }) .meta({ diff --git a/packages/opencode/src/server/routes/experimental.ts b/packages/opencode/src/server/routes/experimental.ts index 27f65818a..ebb502e8c 100644 --- a/packages/opencode/src/server/routes/experimental.ts +++ b/packages/opencode/src/server/routes/experimental.ts @@ -12,6 +12,8 @@ import { zodToJsonSchema } from "zod-to-json-schema" import { errors } from "../error" import { lazy } from "../../util/lazy" import { WorkspaceRoutes } from "./workspace" +import { JsonValue } from "@/util/json" +import { SideThread } from "@/session/side-thread" export const ExperimentalRoutes = lazy(() => new Hono() @@ -57,7 +59,7 @@ export const ExperimentalRoutes = lazy(() => .object({ id: z.string(), description: z.string(), - parameters: z.any(), + parameters: JsonValue, }) .meta({ ref: "ToolListItem" }), ) @@ -84,6 +86,8 @@ export const ExperimentalRoutes = lazy(() => id: t.id, description: t.description, // Handle both Zod schemas and plain JSON schemas + // SDK boundary: zodToJsonSchema expects Zod v3 ZodType, but parameters may be Zod v4 or plain JSON schema + // biome-ignore lint: Zod v3/v4 type incompatibility at library boundary parameters: (t.parameters as any)?._def ? zodToJsonSchema(t.parameters as any) : t.parameters, })), ) @@ -404,7 +408,7 @@ export const ExperimentalRoutes = lazy(() => schema: resolver( z.object({ projectID: z.string(), - threads: z.array(z.any()), + threads: z.array(SideThread.Info), total: z.number(), hasMore: z.boolean(), }), @@ -428,7 +432,7 @@ export const ExperimentalRoutes = lazy(() => const projectID = InstanceALS.project.id const result = SideThread.list({ projectID, - status: status as any, + status: status as "parked" | "investigating" | "resolved" | "deferred" | "all", limit, offset, }) diff --git a/packages/opencode/src/server/routes/global.ts b/packages/opencode/src/server/routes/global.ts index 21b4fc71a..de58b5351 100644 --- a/packages/opencode/src/server/routes/global.ts +++ b/packages/opencode/src/server/routes/global.ts @@ -77,7 +77,10 @@ export const GlobalRoutes = lazy(() => }, }), }) - async function handler(event: any) { + async function handler(event: { + directory?: string + payload: { type: string; properties: Record } + }) { await stream.writeSSE({ data: JSON.stringify(event), }) diff --git a/packages/opencode/src/server/routes/session.ts b/packages/opencode/src/server/routes/session.ts index c556eaad3..0b4c6a06d 100644 --- a/packages/opencode/src/server/routes/session.ts +++ b/packages/opencode/src/server/routes/session.ts @@ -20,7 +20,7 @@ import { PermissionID } from "@/permission/schema" import { ModelID, ProviderID } from "@/provider/schema" import { errors } from "../error" import { lazy } from "../../util/lazy" -import { aggregateSessionStats } from "../../cli/cmd/stats" +import { aggregateSessionStats, SessionStatsSchema } from "../../cli/cmd/stats" const log = Log.create({ service: "server" }) @@ -106,7 +106,7 @@ export const SessionRoutes = lazy(() => description: "Usage statistics", content: { "application/json": { - schema: resolver(z.any()), + schema: resolver(SessionStatsSchema), }, }, }, diff --git a/packages/opencode/src/server/routes/tui.ts b/packages/opencode/src/server/routes/tui.ts index 5161882c3..88fb1311b 100644 --- a/packages/opencode/src/server/routes/tui.ts +++ b/packages/opencode/src/server/routes/tui.ts @@ -8,16 +8,17 @@ import { AsyncQueue } from "../../util/queue" import { errors } from "../error" import { lazy } from "../../util/lazy" import { InstanceALS } from "../../project/instance-als" +import { JsonValue, type JsonValueType } from "@/util/json" const TuiRequest = z.object({ path: z.string(), - body: z.any(), + body: JsonValue, }) type TuiRequest = z.infer const request = new AsyncQueue() -const response = new AsyncQueue() +const response = new AsyncQueue() export async function callTui(ctx: Context) { const body = await ctx.req.json() @@ -68,7 +69,7 @@ const TuiControlRoutes = new Hono() }, }, }), - validator("json", z.any()), + validator("json", JsonValue), async (c) => { const body = c.req.valid("json") response.push(body) diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 4e6b81113..32283c563 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -47,6 +47,7 @@ import { PermissionRoutes } from "./routes/permission" import { GlobalRoutes } from "./routes/global" import { MDNS } from "./mdns" import { lazy } from "@/util/lazy" +import { JsonValue } from "@/util/json" // @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85 globalThis.AI_SDK_LOG_WARNINGS = false @@ -387,7 +388,7 @@ export namespace Server { level: z.enum(["debug", "info", "error", "warn"]).meta({ description: "Log level" }), message: z.string().meta({ description: "Log message" }), extra: z - .record(z.string(), z.any()) + .record(z.string(), JsonValue) .optional() .meta({ description: "Additional metadata for the log entry" }), }), diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index 0842a2e29..9f938b604 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -681,34 +681,30 @@ export namespace Session { }) export const remove = fn(SessionID.zod, async (sessionID) => { - try { - const session = await get(sessionID) - for (const child of await children(sessionID)) { - try { - await remove(child.id) - } catch (e) { - log.error("failed to remove child session", { childID: child.id, error: e }) - } + const session = await get(sessionID) + for (const child of await children(sessionID)) { + try { + await remove(child.id) + } catch (e) { + log.error("failed to remove child session", { childID: child.id, error: e }) } - await unshare(sessionID).catch(() => {}) - CAS.deleteBySession(sessionID) - EditGraph.deleteBySession(sessionID) - // CASCADE delete handles messages and parts automatically - Database.use((db) => { - db.delete(SessionTable).where(eq(SessionTable.id, sessionID)).run() - Database.effect(() => - Bus.publish( - Event.Deleted, - { - info: session, - }, - session.directory, - ), - ) - }) - } catch (e) { - log.error(e) } + await unshare(sessionID) + CAS.deleteBySession(sessionID) + EditGraph.deleteBySession(sessionID) + // CASCADE delete handles messages and parts automatically + Database.use((db) => { + db.delete(SessionTable).where(eq(SessionTable.id, sessionID)).run() + Database.effect(() => + Bus.publish( + Event.Deleted, + { + info: session, + }, + session.directory, + ), + ) + }) }) export const updateMessage = fn(MessageV2.Info, async (msg) => { diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index 4847f9213..93593ab60 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -1,4 +1,5 @@ import { Installation } from "@/installation" +import type { JSONValue } from "@ai-sdk/provider" import { Provider } from "@/provider/provider" import { Log } from "@/util/log" import { @@ -102,14 +103,9 @@ export namespace LLM { : ProviderTransform.options({ model: input.model, sessionID: input.sessionID, - providerOptions: provider.options, + providerOptions: provider.options as Record> | undefined, }) - const options: Record = pipe( - base, - mergeDeep(input.model.options), - mergeDeep(input.agent.options), - mergeDeep(variant), - ) + const options = pipe(base, mergeDeep(input.model.options), mergeDeep(input.agent.options), mergeDeep(variant)) if (isCodex) { options.instructions = SystemPrompt.instructions() } diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index be3b4e3d2..effee565e 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -13,6 +13,7 @@ import { iife } from "@/util/iife" import { type SystemError } from "bun" import type { Provider } from "@/provider/provider" import { ModelID, ProviderID } from "@/provider/schema" +import { type JsonValueType as _JsonValueType, JsonValue as _JsonValue } from "@/util/json" export namespace MessageV2 { export function isMedia(mime: string) { @@ -52,6 +53,28 @@ export namespace MessageV2 { z.object({ message: z.string(), responseBody: z.string().optional() }), ) + // ── Strong schema types for JSON-serializable data ────────────────────── + // JsonValue defined in util/json.ts to avoid circular imports when used in + // module-level Zod schemas. Re-exported here for backward compatibility. + // SDK boundary crossings (AI SDK ProviderMetadata, UIMessage parts) use explicit casts. + + export type JsonValueType = _JsonValueType + export const JsonValue = _JsonValue + + /** Provider metadata: keyed by provider name, each containing provider-specific key-value pairs */ + export const ProviderMeta = z.record(z.string(), z.record(z.string(), JsonValue)) + export type ProviderMeta = z.infer + + /** Tool input parameters — parsed from Zod-validated tool schemas */ + export const ToolInput = z.record(z.string(), JsonValue) + export type ToolInput = z.infer + + /** Tool metadata — execution metadata (title, output path, truncated flag, etc.) */ + export const ToolMeta = z.record(z.string(), JsonValue) + export type ToolMeta = z.infer + + // ──────────────────────────────────────────────────────────────────────── + export const OutputFormatText = z .object({ type: z.literal("text"), @@ -63,7 +86,7 @@ export namespace MessageV2 { export const OutputFormatJsonSchema = z .object({ type: z.literal("json_schema"), - schema: z.record(z.string(), z.any()).meta({ ref: "JSONSchema" }), + schema: z.record(z.string(), JsonValue).meta({ ref: "JSONSchema" }), retryCount: z.number().int().min(0).default(2), }) .meta({ @@ -139,7 +162,7 @@ export namespace MessageV2 { end: z.number().optional(), }) .optional(), - metadata: z.record(z.string(), z.any()).optional(), + metadata: ProviderMeta.optional(), }).meta({ ref: "TextPart", }) @@ -148,7 +171,7 @@ export namespace MessageV2 { export const ReasoningPart = PartBase.extend({ type: z.literal("reasoning"), text: z.string(), - metadata: z.record(z.string(), z.any()).optional(), + metadata: ProviderMeta.optional(), time: z.object({ start: z.number(), end: z.number().optional(), @@ -294,7 +317,7 @@ export namespace MessageV2 { export const ToolStatePending = z .object({ status: z.literal("pending"), - input: z.record(z.string(), z.any()), + input: ToolInput, raw: z.string(), }) .meta({ @@ -306,9 +329,9 @@ export namespace MessageV2 { export const ToolStateRunning = z .object({ status: z.literal("running"), - input: z.record(z.string(), z.any()), + input: ToolInput, title: z.string().optional(), - metadata: z.record(z.string(), z.any()).optional(), + metadata: ToolMeta.optional(), time: z.object({ start: z.number(), }), @@ -321,10 +344,10 @@ export namespace MessageV2 { export const ToolStateCompleted = z .object({ status: z.literal("completed"), - input: z.record(z.string(), z.any()), + input: ToolInput, output: z.string(), title: z.string(), - metadata: z.record(z.string(), z.any()), + metadata: ToolMeta, time: z.object({ start: z.number(), end: z.number(), @@ -340,9 +363,9 @@ export namespace MessageV2 { export const ToolStateError = z .object({ status: z.literal("error"), - input: z.record(z.string(), z.any()), + input: ToolInput, error: z.string(), - metadata: z.record(z.string(), z.any()).optional(), + metadata: ToolMeta.optional(), time: z.object({ start: z.number(), end: z.number(), @@ -364,7 +387,7 @@ export namespace MessageV2 { callID: z.string(), tool: z.string(), state: ToolState, - metadata: z.record(z.string(), z.any()).optional(), + metadata: ProviderMeta.optional(), }).meta({ ref: "ToolPart", }) @@ -468,7 +491,7 @@ export namespace MessageV2 { write: z.number(), }), }), - structured: z.any().optional(), + structured: JsonValue.optional(), variant: z.string().optional(), finish: z.string().optional(), }).meta({ @@ -721,7 +744,10 @@ export namespace MessageV2 { assistantMessage.parts.push({ type: "text", text: part.text, - ...(differentModel ? {} : { providerMetadata: part.metadata }), + // SDK boundary: ProviderMeta → SharedV2ProviderMetadata + ...(differentModel + ? {} + : { providerMetadata: part.metadata as import("@ai-sdk/provider").SharedV2ProviderMetadata }), }) if (part.type === "step-start") assistantMessage.parts.push({ @@ -756,7 +782,10 @@ export namespace MessageV2 { toolCallId: part.callID, input: part.state.input, output, - ...(differentModel ? {} : { callProviderMetadata: part.metadata }), + // SDK boundary: ProviderMeta → SharedV2ProviderMetadata + ...(differentModel + ? {} + : { callProviderMetadata: part.metadata as import("@ai-sdk/provider").SharedV2ProviderMetadata }), }) } if (part.state.status === "error") @@ -766,7 +795,10 @@ export namespace MessageV2 { toolCallId: part.callID, input: part.state.input, errorText: part.state.error, - ...(differentModel ? {} : { callProviderMetadata: part.metadata }), + // SDK boundary: ProviderMeta → SharedV2ProviderMetadata + ...(differentModel + ? {} + : { callProviderMetadata: part.metadata as import("@ai-sdk/provider").SharedV2ProviderMetadata }), }) // Handle pending/running tool calls to prevent dangling tool_use blocks // Anthropic/Claude APIs require every tool_use to have a corresponding tool_result @@ -777,14 +809,20 @@ export namespace MessageV2 { toolCallId: part.callID, input: part.state.input, errorText: "[Tool execution was interrupted]", - ...(differentModel ? {} : { callProviderMetadata: part.metadata }), + // SDK boundary: ProviderMeta → SharedV2ProviderMetadata + ...(differentModel + ? {} + : { callProviderMetadata: part.metadata as import("@ai-sdk/provider").SharedV2ProviderMetadata }), }) } if (part.type === "reasoning") { assistantMessage.parts.push({ type: "reasoning", text: part.text, - ...(differentModel ? {} : { providerMetadata: part.metadata }), + // SDK boundary: ProviderMeta → SharedV2ProviderMetadata + ...(differentModel + ? {} + : { providerMetadata: part.metadata as import("@ai-sdk/provider").SharedV2ProviderMetadata }), }) } } diff --git a/packages/opencode/src/session/message.ts b/packages/opencode/src/session/message.ts index ee5eac08b..ca6271988 100644 --- a/packages/opencode/src/session/message.ts +++ b/packages/opencode/src/session/message.ts @@ -2,6 +2,8 @@ import z from "zod" import { SessionID } from "./schema" import { ModelID, ProviderID } from "../provider/schema" import { NamedError } from "@opencode-ai/util/error" +import { MessageV2 } from "./message-v2" +import { JsonValue } from "@/util/json" export namespace Message { export const OutputLengthError = NamedError.create("MessageOutputLengthError", z.object({})) @@ -72,7 +74,7 @@ export namespace Message { .object({ type: z.literal("reasoning"), text: z.string(), - providerMetadata: z.record(z.string(), z.any()).optional(), + providerMetadata: MessageV2.ProviderMeta.optional(), }) .meta({ ref: "ReasoningPart", @@ -95,7 +97,7 @@ export namespace Message { sourceId: z.string(), url: z.string(), title: z.string().optional(), - providerMetadata: z.record(z.string(), z.any()).optional(), + providerMetadata: MessageV2.ProviderMeta.optional(), }) .meta({ ref: "SourceUrlPart", @@ -156,7 +158,7 @@ export namespace Message { end: z.number(), }), }) - .catchall(z.any()), + .catchall(JsonValue), ), assistant: z .object({ diff --git a/packages/opencode/src/session/objective.ts b/packages/opencode/src/session/objective.ts index b3030bb99..b20d5b24a 100644 --- a/packages/opencode/src/session/objective.ts +++ b/packages/opencode/src/session/objective.ts @@ -30,10 +30,6 @@ export namespace Objective { * Caches the result so subsequent calls return immediately. */ export async function extract(sessionID: string, messages: MessageV2.WithParts[]): Promise { - // Check cache first - const cached = await get(sessionID) - if (cached) return cached - // Find the first user message with text for (const msg of messages) { if (msg.info.role !== "user") continue diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 912501637..68a2e4829 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -211,7 +211,7 @@ export namespace SessionProcessor { state: { status: "error", input: value.input ?? match.state.input, - error: (value.error as any).toString(), + error: String(value.error), time: { start: match.state.time.start, end: Date.now(), @@ -353,10 +353,10 @@ export namespace SessionProcessor { } if (needsCompaction) break } - } catch (e: any) { + } catch (e) { log.error("process", { error: e, - stack: JSON.stringify(e.stack), + stack: e instanceof Error ? e.stack : undefined, }) const error = MessageV2.fromError(e, { providerID: input.model.providerID }) if (MessageV2.ContextOverflowError.isInstance(error)) { diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 0c14e2c66..b1080a800 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -4,6 +4,7 @@ import fs from "fs/promises" import z from "zod" import { Filesystem } from "../util/filesystem" import { SessionID, MessageID, PartID } from "./schema" +import type { ProjectID } from "../project/schema" import { MessageV2 } from "./message-v2" import { Log } from "../util/log" import { SessionRevert } from "./revert" @@ -72,7 +73,7 @@ type PromptState = Record< abort: AbortController callbacks: { resolve(input: MessageV2.WithParts): void - reject(reason?: any): void + reject(reason?: Error): void }[] } > @@ -423,7 +424,7 @@ export namespace SessionPrompt { prompt: task.prompt, description: task.description, subagent_type: task.agent, - command: task.command, + ...(task.command ? { command: task.command } : {}), }, time: { start: Date.now(), @@ -461,12 +462,15 @@ export namespace SessionPrompt { projectID: _pid, containsPath: _cp, async metadata(input) { + // SDK boundary: Tool.Metadata values are unknown, cast to JsonValueType for our strong schemas + const meta = input.metadata as MessageV2.ToolMeta | undefined part = (await Session.updatePart({ ...part, type: "tool", state: { ...part.state, - ...input, + ...(input.title ? { title: input.title } : {}), + ...(meta ? { metadata: meta } : {}), }, } satisfies MessageV2.ToolPart)) as MessageV2.ToolPart }, @@ -755,7 +759,8 @@ export namespace SessionPrompt { // If structured output was captured, save it and exit immediately // This takes priority because the StructuredOutput tool was called successfully if (structuredOutput !== undefined) { - processor.message.structured = structuredOutput + // SDK boundary: structured output validated by AI SDK against user-provided schema + processor.message.structured = structuredOutput as MessageV2.JsonValueType processor.message.finish = processor.message.finish ?? "stop" await Session.updateMessage(processor.message) break @@ -819,7 +824,7 @@ export namespace SessionPrompt { messages: MessageV2.WithParts[] directory: string worktree: string - projectID: string + projectID: ProjectID }) { using _ = log.time("resolveTools") const tools: Record = {} @@ -829,7 +834,7 @@ export namespace SessionPrompt { const _worktree = input.worktree const _projectID = input.projectID - const context = (args: any, options: ToolCallOptions): Tool.Context => ({ + const context = (args: Record, options: ToolCallOptions): Tool.Context => ({ sessionID: input.session.id, abort: options.abortSignal!, messageID: input.processor.message.id, @@ -845,7 +850,7 @@ export namespace SessionPrompt { if (_worktree === "/") return false return Filesystem.contains(_worktree, filepath) }, - metadata: async (val: { title?: string; metadata?: any }) => { + metadata: async (val: { title?: string; metadata?: Record }) => { const match = input.processor.partFromToolCall(options.toolCallId) if (match && match.state.status === "running") { await Session.updatePart({ @@ -878,9 +883,10 @@ export namespace SessionPrompt { )) { const schema = ProviderTransform.schema(input.model, z.toJSONSchema(item.parameters)) tools[item.id] = tool({ - id: item.id as any, + // SDK boundary: AI SDK tool() expects specific id/schema types + id: item.id as never, description: item.description, - inputSchema: jsonSchema(schema as any), + inputSchema: jsonSchema(schema as Parameters[0]), async execute(args, options) { const ctx = context(args, options) await Plugin.trigger( @@ -1021,16 +1027,17 @@ export namespace SessionPrompt { /** @internal Exported for testing */ export function createStructuredOutputTool(input: { - schema: Record - onSuccess: (output: unknown) => void + schema: Record + onSuccess: (output: MessageV2.JsonValueType) => void }): AITool { // Remove $schema property if present (not needed for tool input) const { $schema, ...toolSchema } = input.schema return tool({ - id: "StructuredOutput" as any, + // SDK boundary: AI SDK tool() expects specific id/schema types + id: "StructuredOutput" as never, description: STRUCTURED_OUTPUT_DESCRIPTION, - inputSchema: jsonSchema(toolSchema as any), + inputSchema: jsonSchema(toolSchema as Parameters[0]), async execute(args) { // AI SDK validates args against inputSchema before calling execute() input.onSuccess(args) diff --git a/packages/opencode/src/session/side-thread.ts b/packages/opencode/src/session/side-thread.ts index b27de195f..ecf05e459 100644 --- a/packages/opencode/src/session/side-thread.ts +++ b/packages/opencode/src/session/side-thread.ts @@ -6,6 +6,7 @@ import { Identifier } from "@/id/id" import { Log } from "@/util/log" import z from "zod" import { InstanceALS } from "@/project/instance-als" +import type { ProjectID } from "@/project/schema" export namespace SideThread { const log = Log.create({ service: "side-thread" }) @@ -55,7 +56,7 @@ export namespace SideThread { } export function create(input: { - projectID: string + projectID: ProjectID title: string description: string priority?: Info["priority"] @@ -73,7 +74,7 @@ export namespace SideThread { db.insert(SideThreadTable) .values({ id, - project_id: input.projectID as any, + project_id: input.projectID, title: input.title, description: input.description, status: "parked", @@ -116,7 +117,7 @@ export namespace SideThread { } export interface ListOptions { - projectID: string + projectID: ProjectID status?: Info["status"] | "all" limit?: number offset?: number @@ -137,9 +138,7 @@ export namespace SideThread { return db .select() .from(SideThreadTable) - .where( - and(eq(SideThreadTable.project_id, options.projectID as any), eq(SideThreadTable.status, options.status)), - ) + .where(and(eq(SideThreadTable.project_id, options.projectID), eq(SideThreadTable.status, options.status))) .orderBy(desc(SideThreadTable.time_updated)) .limit(limit + 1) .offset(offset) @@ -148,7 +147,7 @@ export namespace SideThread { return db .select() .from(SideThreadTable) - .where(eq(SideThreadTable.project_id, options.projectID as any)) + .where(eq(SideThreadTable.project_id, options.projectID)) .orderBy(desc(SideThreadTable.time_updated)) .limit(limit + 1) .offset(offset) @@ -158,8 +157,8 @@ export namespace SideThread { // Get total count (for pagination UI) — must match the same status filter as the rows query const countWhere = options.status && options.status !== "all" - ? and(eq(SideThreadTable.project_id, options.projectID as any), eq(SideThreadTable.status, options.status)) - : eq(SideThreadTable.project_id, options.projectID as any) + ? and(eq(SideThreadTable.project_id, options.projectID), eq(SideThreadTable.status, options.status)) + : eq(SideThreadTable.project_id, options.projectID) const countRow = Database.use((db) => db .select({ count: sql`count(*)` }) @@ -180,7 +179,9 @@ export namespace SideThread { fields: Partial>, ): Info | null { Database.use((db) => { - const updates: Record = {} + const updates: Partial< + Pick + > = {} if (fields.status !== undefined) updates.status = fields.status if (fields.priority !== undefined) updates.priority = fields.priority if (fields.title !== undefined) updates.title = fields.title diff --git a/packages/opencode/src/share/share-next.ts b/packages/opencode/src/share/share-next.ts index 6d6fc4c82..959328d4f 100644 --- a/packages/opencode/src/share/share-next.ts +++ b/packages/opencode/src/share/share-next.ts @@ -84,7 +84,7 @@ export namespace ShareNext { await sync(evt.properties.info.sessionID, [ { type: "message", - data: evt.properties.info, + data: evt.properties.info as SDK.Message, }, ]) if (evt.properties.info.role === "user") { @@ -93,7 +93,7 @@ export namespace ShareNext { type: "model", data: [ await Provider.getModel(evt.properties.info.model.providerID, evt.properties.info.model.modelID).then( - (m) => m, + (m) => m as SDK.Model, ), ], }, @@ -108,7 +108,7 @@ export namespace ShareNext { await sync(evt.properties.part.sessionID, [ { type: "part", - data: evt.properties.part, + data: evt.properties.part as SDK.Part, }, ]) }, @@ -289,16 +289,16 @@ export namespace ShareNext { }, ...messages.map((x) => ({ type: "message" as const, - data: x.info, + data: x.info as SDK.Message, })), - ...messages.flatMap((x) => x.parts.map((y) => ({ type: "part" as const, data: y }))), + ...messages.flatMap((x) => x.parts.map((y) => ({ type: "part" as const, data: y as SDK.Part }))), { type: "session_diff", - data: diffs, + data: diffs as SDK.FileDiff[], }, { type: "model", - data: models, + data: models as SDK.Model[], }, ]) } diff --git a/packages/opencode/src/storage/db.ts b/packages/opencode/src/storage/db.ts index beb8e3eb5..352964d4e 100644 --- a/packages/opencode/src/storage/db.ts +++ b/packages/opencode/src/storage/db.ts @@ -124,6 +124,7 @@ export namespace Database { Client.reset() } + // Drizzle ORM internal generics — schema/table types not exposed for parameterization export type TxOrDb = SQLiteTransaction<"sync", void, any, any> | Client const ctx = Context.create<{ @@ -145,7 +146,7 @@ export namespace Database { } } - export function effect(fn: () => any | Promise) { + export function effect(fn: () => void | Promise) { try { ctx.use().effects.push(fn) } catch { @@ -159,6 +160,7 @@ export namespace Database { } catch (err) { if (err instanceof Context.NotFound) { const effects: (() => void | Promise)[] = [] + // Drizzle ORM API: transaction() generic types don't match our TxOrDb alias const result = (Client().transaction as any)((tx: TxOrDb) => { return ctx.provide({ tx, effects }, () => callback(tx)) }) diff --git a/packages/opencode/src/storage/json-migration.ts b/packages/opencode/src/storage/json-migration.ts index 828ce4799..3cdb72b8f 100644 --- a/packages/opencode/src/storage/json-migration.ts +++ b/packages/opencode/src/storage/json-migration.ts @@ -94,7 +94,11 @@ export namespace JsonMigration { return items } - function insert(values: any[], table: any, label: string) { + function insert( + values: Record[], + table: Parameters[0], + label: string, + ) { if (values.length === 0) return 0 try { db.insert(table).values(values).onConflictDoNothing().run() @@ -151,7 +155,7 @@ export namespace JsonMigration { // Migrate projects first (no FK deps) // Derive all IDs from file paths, not JSON content const projectIds = new Set() - const projectValues = [] as any[] + const projectValues = [] as Record[] for (let i = 0; i < projectFiles.length; i += batchSize) { const end = Math.min(i + batchSize, projectFiles.length) const batch = await read(projectFiles, i, end) @@ -185,7 +189,7 @@ export namespace JsonMigration { // migrations may have moved sessions to new directories without updating the JSON const sessionProjects = sessionFiles.map((file) => path.basename(path.dirname(file))) const sessionIds = new Set() - const sessionValues = [] as any[] + const sessionValues = [] as Record[] for (let i = 0; i < sessionFiles.length; i += batchSize) { const end = Math.min(i + batchSize, sessionFiles.length) const batch = await read(sessionFiles, i, end) @@ -311,7 +315,7 @@ export namespace JsonMigration { for (let i = 0; i < todoFiles.length; i += batchSize) { const end = Math.min(i + batchSize, todoFiles.length) const batch = await read(todoFiles, i, end) - const values = [] as any[] + const values = [] as Record[] for (let j = 0; j < batch.length; j++) { const data = batch[j] if (!data) continue @@ -348,7 +352,7 @@ export namespace JsonMigration { // Migrate permissions const permProjects = permFiles.map((file) => path.basename(file, ".json")) - const permValues = [] as any[] + const permValues = [] as Record[] for (let i = 0; i < permFiles.length; i += batchSize) { const end = Math.min(i + batchSize, permFiles.length) const batch = await read(permFiles, i, end) @@ -373,7 +377,7 @@ export namespace JsonMigration { // Migrate session shares const shareSessions = shareFiles.map((file) => path.basename(file, ".json")) - const shareValues = [] as any[] + const shareValues = [] as Record[] for (let i = 0; i < shareFiles.length; i += batchSize) { const end = Math.min(i + batchSize, shareFiles.length) const batch = await read(shareFiles, i, end) diff --git a/packages/opencode/src/storage/storage.ts b/packages/opencode/src/storage/storage.ts index 7a639c3c7..f6479953a 100644 --- a/packages/opencode/src/storage/storage.ts +++ b/packages/opencode/src/storage/storage.ts @@ -43,9 +43,12 @@ export namespace Storage { cwd: path.join(project, projectDir), absolute: true, })) { - const json = await Filesystem.readJson(msgFile) - worktree = json.path?.root - if (worktree) break + const json = await Filesystem.readJson<{ path?: { root?: string } }>(msgFile) + const root = json.path?.root + if (root) { + worktree = root + break + } } if (!worktree) continue if (!(await Filesystem.isDir(worktree))) continue @@ -81,7 +84,10 @@ export namespace Storage { sessionFile, dest, }) - const session = await Filesystem.readJson(sessionFile) + const session = await Filesystem.readJson<{ + id: string + [key: string]: string | number | boolean | object | null + }>(sessionFile) await Filesystem.writeJson(dest, session) log.info(`migrating messages for session ${session.id}`) for (const msgFile of await Glob.scan(`storage/session/message/${session.id}/*.json`, { @@ -93,7 +99,10 @@ export namespace Storage { msgFile, dest, }) - const message = await Filesystem.readJson(msgFile) + const message = await Filesystem.readJson<{ + id: string + [key: string]: string | number | boolean | object | null + }>(msgFile) await Filesystem.writeJson(dest, message) log.info(`migrating parts for message ${message.id}`) @@ -119,7 +128,12 @@ export namespace Storage { cwd: dir, absolute: true, })) { - const session = await Filesystem.readJson(item) + const session = await Filesystem.readJson<{ + id: string + projectID?: string + summary?: { diffs?: Array<{ additions: number; deletions: number }> } + [key: string]: string | number | boolean | object | null | undefined + }>(item) if (!session.projectID) continue if (!session.summary?.diffs) continue const { diffs } = session.summary @@ -127,8 +141,8 @@ export namespace Storage { await Filesystem.writeJson(path.join(dir, "session", session.projectID, session.id + ".json"), { ...session, summary: { - additions: diffs.reduce((sum: any, x: any) => sum + x.additions, 0), - deletions: diffs.reduce((sum: any, x: any) => sum + x.deletions, 0), + additions: diffs.reduce((sum: number, x: { additions: number }) => sum + x.additions, 0), + deletions: diffs.reduce((sum: number, x: { deletions: number }) => sum + x.deletions, 0), }, }) } diff --git a/packages/opencode/src/tool/apply_patch.ts b/packages/opencode/src/tool/apply_patch.ts index ff30444cb..9596580e3 100644 --- a/packages/opencode/src/tool/apply_patch.ts +++ b/packages/opencode/src/tool/apply_patch.ts @@ -168,7 +168,7 @@ export const ApplyPatchTool = Tool.define("apply_patch", { after: change.newContent, additions: change.additions, deletions: change.deletions, - movePath: change.movePath, + movePath: change.movePath ?? null, })) // Check permissions if needed diff --git a/packages/opencode/src/tool/batch.ts b/packages/opencode/src/tool/batch.ts index 00c22bfe6..033bf1b35 100644 --- a/packages/opencode/src/tool/batch.ts +++ b/packages/opencode/src/tool/batch.ts @@ -1,5 +1,6 @@ import z from "zod" import { Tool } from "./tool" +import { MessageV2 } from "@/session/message-v2" import { ProviderID, ModelID } from "../provider/schema" import DESCRIPTION from "./batch.txt" @@ -70,7 +71,8 @@ export const BatchTool = Tool.define("batch", async () => { callID: partID, state: { status: "running", - input: call.parameters, + // SDK boundary: AI SDK tool call parameters are Record + input: call.parameters as MessageV2.ToolInput, time: { start: callStartTime, }, @@ -94,10 +96,12 @@ export const BatchTool = Tool.define("batch", async () => { callID: partID, state: { status: "completed", - input: call.parameters, + // SDK boundary: AI SDK tool call parameters are Record + input: call.parameters as MessageV2.ToolInput, output: result.output, title: result.title, - metadata: result.metadata, + // SDK boundary: Tool.Metadata is Record + metadata: result.metadata as MessageV2.ToolMeta, attachments, time: { start: callStartTime, @@ -117,7 +121,8 @@ export const BatchTool = Tool.define("batch", async () => { callID: partID, state: { status: "error", - input: call.parameters, + // SDK boundary: AI SDK tool call parameters are Record + input: call.parameters as MessageV2.ToolInput, error: error instanceof Error ? error.message : String(error), time: { start: callStartTime, @@ -145,7 +150,8 @@ export const BatchTool = Tool.define("batch", async () => { callID: partID, state: { status: "error", - input: call.parameters, + // SDK boundary: AI SDK tool call parameters are Record + input: call.parameters as MessageV2.ToolInput, error: "Maximum of 25 tools allowed in batch", time: { start: now, end: now }, }, diff --git a/packages/opencode/src/tool/context-edit.ts b/packages/opencode/src/tool/context-edit.ts index 106370612..bebe18f1b 100644 --- a/packages/opencode/src/tool/context-edit.ts +++ b/packages/opencode/src/tool/context-edit.ts @@ -19,12 +19,13 @@ function resolvePart( if (msg.info.role !== "assistant") continue for (const part of msg.parts) { if (part.edit?.hidden) continue - const content = - part.type === "text" - ? (part as MessageV2.TextPart).text - : part.type === "tool" && (part as MessageV2.ToolPart).state.status === "completed" - ? ((part as any).state.output ?? "") - : "" + let content = "" + if (part.type === "text") { + content = (part as MessageV2.TextPart).text + } else if (part.type === "tool") { + const state = (part as MessageV2.ToolPart).state + if (state.status === "completed") content = state.output ?? "" + } candidates.push({ partID: part.id, messageID: msg.info.id, diff --git a/packages/opencode/src/tool/glob.ts b/packages/opencode/src/tool/glob.ts index ef4842e89..a15862ad2 100644 --- a/packages/opencode/src/tool/glob.ts +++ b/packages/opencode/src/tool/glob.ts @@ -24,7 +24,7 @@ export const GlobTool = Tool.define("glob", { always: ["*"], metadata: { pattern: params.pattern, - path: params.path, + path: params.path ?? null, }, }) diff --git a/packages/opencode/src/tool/grep.ts b/packages/opencode/src/tool/grep.ts index 074bb1eb6..7c4bb9df3 100644 --- a/packages/opencode/src/tool/grep.ts +++ b/packages/opencode/src/tool/grep.ts @@ -29,8 +29,8 @@ export const GrepTool = Tool.define("grep", { always: ["*"], metadata: { pattern: params.pattern, - path: params.path, - include: params.include, + path: params.path ?? null, + include: params.include ?? null, }, }) diff --git a/packages/opencode/src/tool/objective-set.ts b/packages/opencode/src/tool/objective-set.ts index 64e81412a..b5fb1bbe5 100644 --- a/packages/opencode/src/tool/objective-set.ts +++ b/packages/opencode/src/tool/objective-set.ts @@ -27,7 +27,7 @@ Previous objectives are preserved in message metadata, creating an objective tim ctx, ): Promise<{ title: string - metadata: Record + metadata: Record output: string }> { const trimmed = args.objective.trim() diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 06dc762ce..e43f9c9e6 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -105,7 +105,8 @@ export namespace ToolRegistry { directory: ctx.directory, worktree: ctx.worktree, } as unknown as PluginToolContext - const result = await def.execute(args as any, pluginCtx) + // SDK boundary: plugin tool args validated by Zod + const result = await def.execute(args as Record, pluginCtx) const out = await Truncate.output(result, {}, initCtx?.agent) return { title: "", diff --git a/packages/opencode/src/tool/thread-list.ts b/packages/opencode/src/tool/thread-list.ts index 3c5175896..bfd714feb 100644 --- a/packages/opencode/src/tool/thread-list.ts +++ b/packages/opencode/src/tool/thread-list.ts @@ -17,7 +17,7 @@ export const ThreadListTool = Tool.define("thread_list", { async execute(args, ctx) { const result = SideThread.list({ projectID: ctx.projectID, - status: args.status as any, + status: args.status, limit: args.limit, offset: args.offset, }) diff --git a/packages/opencode/src/tool/tool.ts b/packages/opencode/src/tool/tool.ts index ad0b3f2b8..973e80916 100644 --- a/packages/opencode/src/tool/tool.ts +++ b/packages/opencode/src/tool/tool.ts @@ -3,11 +3,15 @@ import type { MessageV2 } from "../session/message-v2" import type { Agent } from "../agent/agent" import type { PermissionNext } from "../permission/next" import type { SessionID, MessageID } from "../session/schema" +import type { ProjectID } from "../project/schema" import { Truncate } from "./truncation" export namespace Tool { + // Metadata flows from typed tool execute() results into the DB (as JSON) and back out + // to TUI components. Since the DB round-trip produces Record, the + // metadata interface must accept unknown to remain compatible with deserialized values. interface Metadata { - [key: string]: any + [key: string]: unknown } export interface InitContext { @@ -22,14 +26,14 @@ export namespace Tool { agent: string abort: AbortSignal callID?: string - extra?: { [key: string]: any } + extra?: { [key: string]: unknown } messages: MessageV2.WithParts[] /** Resolved project directory (absolute path) */ directory: string /** Git worktree or sandbox directory */ worktree: string /** Project ID */ - projectID: string + projectID: ProjectID /** Check if a path is within the project boundary */ containsPath(filepath: string): boolean metadata(input: { title?: string; metadata?: M }): void @@ -54,7 +58,7 @@ export namespace Tool { } export type InferParameters = T extends Info ? z.infer

: never - export type InferMetadata = T extends Info ? M : never + export type InferMetadata = T extends Info ? M : never export function define( id: string, diff --git a/packages/opencode/src/tool/webfetch.ts b/packages/opencode/src/tool/webfetch.ts index 1a5499c6c..dabb45da7 100644 --- a/packages/opencode/src/tool/webfetch.ts +++ b/packages/opencode/src/tool/webfetch.ts @@ -31,7 +31,7 @@ export const WebFetchTool = Tool.define("webfetch", { metadata: { url: params.url, format: params.format, - timeout: params.timeout, + timeout: params.timeout ?? null, }, }) diff --git a/packages/opencode/src/tool/websearch.ts b/packages/opencode/src/tool/websearch.ts index bf16428df..f089a9687 100644 --- a/packages/opencode/src/tool/websearch.ts +++ b/packages/opencode/src/tool/websearch.ts @@ -69,10 +69,10 @@ export const WebSearchTool = Tool.define("websearch", async () => { always: ["*"], metadata: { query: params.query, - numResults: params.numResults, - livecrawl: params.livecrawl, - type: params.type, - contextMaxCharacters: params.contextMaxCharacters, + numResults: params.numResults ?? null, + livecrawl: params.livecrawl ?? null, + type: params.type ?? null, + contextMaxCharacters: params.contextMaxCharacters ?? null, }, }) diff --git a/packages/opencode/src/util/defer.ts b/packages/opencode/src/util/defer.ts index 8de21528c..3d72c82e9 100644 --- a/packages/opencode/src/util/defer.ts +++ b/packages/opencode/src/util/defer.ts @@ -1,6 +1,6 @@ -export function defer void | Promise>( - fn: T, -): T extends () => Promise ? { [Symbol.asyncDispose]: () => Promise } : { [Symbol.dispose]: () => void } { +export function defer(fn: () => Promise): { [Symbol.asyncDispose]: () => Promise } +export function defer(fn: () => void): { [Symbol.dispose]: () => void } +export function defer(fn: () => void | Promise) { return { [Symbol.dispose]() { fn() @@ -8,5 +8,5 @@ export function defer void | Promise>( [Symbol.asyncDispose]() { return Promise.resolve(fn()) }, - } as any + } } diff --git a/packages/opencode/src/util/eventloop.ts b/packages/opencode/src/util/eventloop.ts index 87f6eef41..7560ec861 100644 --- a/packages/opencode/src/util/eventloop.ts +++ b/packages/opencode/src/util/eventloop.ts @@ -1,14 +1,23 @@ import { Log } from "./log" +declare global { + namespace NodeJS { + interface Process { + _getActiveHandles(): object[] + _getActiveRequests(): object[] + } + } +} + export namespace EventLoop { export async function wait() { return new Promise((resolve) => { const check = () => { - const active = [...(process as any)._getActiveHandles(), ...(process as any)._getActiveRequests()] + const active = [...process._getActiveHandles(), ...process._getActiveRequests()] Log.Default.info("eventloop", { active, }) - if ((process as any)._getActiveHandles().length === 0 && (process as any)._getActiveRequests().length === 0) { + if (process._getActiveHandles().length === 0 && process._getActiveRequests().length === 0) { resolve() } else { setImmediate(check) diff --git a/packages/opencode/src/util/filesystem.ts b/packages/opencode/src/util/filesystem.ts index 37f00c6b9..75db311d1 100644 --- a/packages/opencode/src/util/filesystem.ts +++ b/packages/opencode/src/util/filesystem.ts @@ -86,6 +86,9 @@ export namespace Filesystem { await mkdir(dir, { recursive: true }) } + // Bun's ReadableStream type is structurally incompatible with Node's stream/web.ReadableStream + // (Bun adds .values(), .blob(), etc.). Runtime-compatible for fromWeb() — suppress via cast. + // biome-ignore lint: Bun/Node ReadableStream type mismatch at boundary const nodeStream = stream instanceof ReadableStream ? Readable.fromWeb(stream as any) : stream const writeStream = createWriteStream(p) await pipeline(nodeStream, writeStream) diff --git a/packages/opencode/src/util/json.ts b/packages/opencode/src/util/json.ts new file mode 100644 index 000000000..6e7e1c803 --- /dev/null +++ b/packages/opencode/src/util/json.ts @@ -0,0 +1,20 @@ +import z from "zod" + +/** + * JSON-serializable value type and Zod schema. + * + * Defined in its own module to avoid circular imports when used in + * module-level Zod schemas across the codebase (Bun's module evaluation + * order causes circular deps at schema construction time). + * + * The Zod schema uses z.any() with a named ref to avoid generating + * anonymous $ref pointers that break OpenAPI SDK generation. + * The TypeScript type provides the actual constraint. + */ +export type JsonValueType = string | number | boolean | null | { [key: string]: JsonValueType } | JsonValueType[] + +// Zod schema uses z.any() at runtime (accepts all JSON) but the TypeScript type +// constrains it. This avoids both z.lazy() anonymous $ref issues and the +// non-recursive 3-level approximation that diverges from JsonValueType. +// biome-ignore lint: z.any() needed here — z.lazy() breaks OpenAPI, non-recursive approximation breaks tsgo +export const JsonValue: z.ZodType = z.any().meta({ ref: "JsonValue" }) diff --git a/packages/opencode/src/util/log.ts b/packages/opencode/src/util/log.ts index 2ca4c0a3d..e44c7657a 100644 --- a/packages/opencode/src/util/log.ts +++ b/packages/opencode/src/util/log.ts @@ -9,6 +9,14 @@ export namespace Log { export const Level = z.enum(["DEBUG", "INFO", "WARN", "ERROR"]).meta({ ref: "LogLevel", description: "Log level" }) export type Level = z.infer + // The logger is a system boundary: it must accept unknown values because + // catch blocks produce `unknown` and callers pass them as both the message + // and in the extra record (e.g. `log.error("fail", { error: e })`). + // This is one of the few places where `unknown` is correct and intentional. + // biome-ignore lint: logger boundary accepts unknown by design + type LogMessage = unknown + export type LogExtra = Record + const levelPriority: Record = { DEBUG: 0, INFO: 1, @@ -23,15 +31,15 @@ export namespace Log { } export type Logger = { - debug(message?: any, extra?: Record): void - info(message?: any, extra?: Record): void - error(message?: any, extra?: Record): void - warn(message?: any, extra?: Record): void + debug(message?: LogMessage, extra?: LogExtra): void + info(message?: LogMessage, extra?: LogExtra): void + error(message?: LogMessage, extra?: LogExtra): void + warn(message?: LogMessage, extra?: LogExtra): void tag(key: string, value: string): Logger clone(): Logger time( message: string, - extra?: Record, + extra?: LogExtra, ): { stop(): void [Symbol.dispose](): void @@ -52,7 +60,7 @@ export namespace Log { export function file() { return logpath } - let write = (msg: any) => { + let write = (msg: string) => { process.stderr.write(msg) return msg.length } @@ -67,13 +75,9 @@ export namespace Log { ) await fs.truncate(logpath).catch(() => {}) const stream = createWriteStream(logpath, { flags: "a" }) - write = async (msg: any) => { - return new Promise((resolve, reject) => { - stream.write(msg, (err) => { - if (err) reject(err) - else resolve(msg.length) - }) - }) + write = (msg: string) => { + stream.write(msg) + return msg.length } } @@ -97,7 +101,7 @@ export namespace Log { } let last = Date.now() - export function create(tags?: Record) { + export function create(tags?: LogExtra) { tags = tags || {} const service = tags["service"] @@ -108,7 +112,7 @@ export namespace Log { } } - function build(message: any, extra?: Record) { + function build(message: LogMessage, extra?: LogExtra) { const prefix = Object.entries({ ...tags, ...extra, @@ -127,22 +131,22 @@ export namespace Log { return [next.toISOString().split(".")[0], "+" + diff + "ms", prefix, message].filter(Boolean).join(" ") + "\n" } const result: Logger = { - debug(message?: any, extra?: Record) { + debug(message?: LogMessage, extra?: LogExtra) { if (shouldLog("DEBUG")) { write("DEBUG " + build(message, extra)) } }, - info(message?: any, extra?: Record) { + info(message?: LogMessage, extra?: LogExtra) { if (shouldLog("INFO")) { write("INFO " + build(message, extra)) } }, - error(message?: any, extra?: Record) { + error(message?: LogMessage, extra?: LogExtra) { if (shouldLog("ERROR")) { write("ERROR " + build(message, extra)) } }, - warn(message?: any, extra?: Record) { + warn(message?: LogMessage, extra?: LogExtra) { if (shouldLog("WARN")) { write("WARN " + build(message, extra)) } @@ -154,7 +158,7 @@ export namespace Log { clone() { return Log.create({ ...tags }) }, - time(message: string, extra?: Record) { + time(message: string, extra?: LogExtra) { const now = Date.now() result.info(message, { status: "started", ...extra }) function stop() { diff --git a/packages/opencode/src/util/queue.ts b/packages/opencode/src/util/queue.ts index e868c9dce..6260b063c 100644 --- a/packages/opencode/src/util/queue.ts +++ b/packages/opencode/src/util/queue.ts @@ -6,7 +6,7 @@ export class AsyncQueue implements AsyncIterable { private closed = false push(item: T) { - if (this.closed) return + if (this.closed) throw new Error("Cannot push to a closed queue") const resolve = this.resolvers.shift() if (resolve) resolve(item) else this.queue.push(item) diff --git a/packages/opencode/src/util/rpc.ts b/packages/opencode/src/util/rpc.ts index ebd8be40e..01f4248a7 100644 --- a/packages/opencode/src/util/rpc.ts +++ b/packages/opencode/src/util/rpc.ts @@ -1,6 +1,7 @@ export namespace Rpc { type Definition = { - [method: string]: (input: any) => any + // eslint-disable-next-line @typescript-eslint/no-explicit-any + [method: string]: (...args: any[]) => unknown } export function listen(rpc: Definition) { @@ -19,10 +20,10 @@ export namespace Rpc { export function client(target: { postMessage: (data: string) => void | null - onmessage: ((this: Worker, ev: MessageEvent) => any) | null + onmessage: ((this: Worker, ev: MessageEvent) => void) | null }) { - const pending = new Map void>() - const listeners = new Map void>>() + const pending = new Map void>() + const listeners = new Map void>>() let id = 0 target.onmessage = async (evt) => { const parsed = JSON.parse(evt.data) @@ -46,7 +47,7 @@ export namespace Rpc { call(method: Method, input: Parameters[0]): Promise> { const requestId = id++ return new Promise((resolve) => { - pending.set(requestId, resolve) + pending.set(requestId, resolve as (result: unknown) => void) target.postMessage(JSON.stringify({ type: "rpc.request", method, input, id: requestId })) }) }, @@ -56,9 +57,9 @@ export namespace Rpc { handlers = new Set() listeners.set(event, handlers) } - handlers.add(handler) + handlers.add(handler as (data: unknown) => void) return () => { - handlers!.delete(handler) + handlers!.delete(handler as (data: unknown) => void) } }, } diff --git a/packages/opencode/src/util/signal.ts b/packages/opencode/src/util/signal.ts index bc633ecc6..21a7ea7aa 100644 --- a/packages/opencode/src/util/signal.ts +++ b/packages/opencode/src/util/signal.ts @@ -1,6 +1,6 @@ export function signal() { - let resolve: any - const promise = new Promise((r) => (resolve = r)) + let resolve: (value?: void) => void + const promise = new Promise((r) => (resolve = r)) return { trigger() { return resolve() diff --git a/packages/opencode/src/util/wildcard.ts b/packages/opencode/src/util/wildcard.ts index f54b6c85f..ffa58270b 100644 --- a/packages/opencode/src/util/wildcard.ts +++ b/packages/opencode/src/util/wildcard.ts @@ -19,7 +19,7 @@ export namespace Wildcard { return new RegExp("^" + escaped + "$", flags).test(str) } - export function all(input: string, patterns: Record) { + export function all(input: string, patterns: Record): T | undefined { const sorted = pipe(patterns, Object.entries, sortBy([([key]) => key.length, "asc"], [([key]) => key, "asc"])) let result = undefined for (const [pattern, value] of sorted) { @@ -31,7 +31,10 @@ export namespace Wildcard { return result } - export function allStructured(input: { head: string; tail: string[] }, patterns: Record) { + export function allStructured( + input: { head: string; tail: string[] }, + patterns: Record, + ): T | undefined { const sorted = pipe(patterns, Object.entries, sortBy([([key]) => key.length, "asc"], [([key]) => key, "asc"])) let result = undefined for (const [pattern, value] of sorted) { diff --git a/packages/opencode/test/acp/event-subscription.test.ts b/packages/opencode/test/acp/event-subscription.test.ts index 7e3316304..d4e5589d1 100644 --- a/packages/opencode/test/acp/event-subscription.test.ts +++ b/packages/opencode/test/acp/event-subscription.test.ts @@ -44,19 +44,20 @@ function toolEvent( input: Record } & ({ status: "running"; metadata?: Record } | { status: "pending"; raw: string }), ): GlobalEventEnvelope { + // SDK types differ from internal MessageV2 types at the JsonValue boundary — cast at construction const state: ToolStatePending | ToolStateRunning = opts.status === "running" - ? { + ? ({ status: "running", input: opts.input, ...(opts.metadata && { metadata: opts.metadata }), time: { start: Date.now() }, - } - : { + } as ToolStateRunning) + : ({ status: "pending", input: opts.input, raw: opts.raw, - } + } as ToolStatePending) const payload: EventMessagePartUpdated = { type: "message.part.updated", properties: { diff --git a/packages/opencode/test/bus/bus.test.ts b/packages/opencode/test/bus/bus.test.ts index d2cd16b45..6ef7efab6 100644 --- a/packages/opencode/test/bus/bus.test.ts +++ b/packages/opencode/test/bus/bus.test.ts @@ -47,6 +47,31 @@ describe("bus", () => { }) }) + test("throwing subscriber does not block others (B52)", async () => { + await using tmp = await tmpdir() + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const events: number[] = [] + + Bus.subscribe(TestEvent, () => { + events.push(1) + }, Instance.directory) + Bus.subscribe(TestEvent, () => { + throw new Error("subscriber error") + }, Instance.directory) + Bus.subscribe(TestEvent, () => { + events.push(3) + }, Instance.directory) + + // Should not throw, all subscribers should run + await Bus.publish(TestEvent, { value: 42 }, Instance.directory) + expect(events).toEqual([1, 3]) + }, + }) + }) + test("handles unsubscribe with no matching subscription", async () => { await using tmp = await tmpdir() diff --git a/packages/opencode/test/memory/abort-leak.test.ts b/packages/opencode/test/memory/abort-leak.test.ts index bb553030a..1d0da3ef5 100644 --- a/packages/opencode/test/memory/abort-leak.test.ts +++ b/packages/opencode/test/memory/abort-leak.test.ts @@ -3,6 +3,7 @@ import path from "path" import { Instance } from "../fixture/instance-shim" import { WebFetchTool } from "../../src/tool/webfetch" import { SessionID, MessageID } from "../../src/session/schema" +import { ProjectID } from "../../src/project/schema" const projectRoot = path.join(__dirname, "../..") @@ -15,7 +16,7 @@ const ctx = { messages: [], directory: "", worktree: "", - projectID: "", + projectID: ProjectID.make(""), containsPath: () => true, metadata: () => {}, ask: async () => {}, diff --git a/packages/opencode/test/provider/gitlab-duo.test.ts b/packages/opencode/test/provider/gitlab-duo.test.ts index c8054fa98..28ce7c4f2 100644 --- a/packages/opencode/test/provider/gitlab-duo.test.ts +++ b/packages/opencode/test/provider/gitlab-duo.test.ts @@ -217,7 +217,7 @@ test("GitLab Duo: includes context-1m beta header in aiGatewayHeaders", async () fn: async () => { const providers = await Provider.list() expect(providers["gitlab"]).toBeDefined() - expect(providers["gitlab"].options?.aiGatewayHeaders?.["anthropic-beta"]).toContain("context-1m-2025-08-07") + expect((providers["gitlab"].options?.aiGatewayHeaders as Record)?.["anthropic-beta"]).toContain("context-1m-2025-08-07") }, }) }) @@ -252,7 +252,7 @@ test("GitLab Duo: supports feature flags configuration", async () => { const providers = await Provider.list() expect(providers["gitlab"]).toBeDefined() expect(providers["gitlab"].options?.featureFlags).toBeDefined() - expect(providers["gitlab"].options?.featureFlags?.duo_agent_platform_agentic_chat).toBe(true) + expect((providers["gitlab"].options?.featureFlags as Record | undefined)?.duo_agent_platform_agentic_chat).toBe(true) }, }) }) diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index 81a48132d..0053ea258 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -29,7 +29,7 @@ test("provider loaded from env variable", async () => { // Provider should retain its connection source even if custom loaders // merge additional options. expect(providers["anthropic"].source).toBe("env") - expect(providers["anthropic"].options.headers["anthropic-beta"]).toBeDefined() + expect((providers["anthropic"].options.headers as Record)["anthropic-beta"]).toBeDefined() }, }) }) @@ -1726,9 +1726,9 @@ test("provider options are deeply merged", async () => { const providers = await Provider.list() // Custom options should be merged expect(providers["anthropic"].options.timeout).toBe(30000) - expect(providers["anthropic"].options.headers["X-Custom"]).toBe("custom-value") + expect((providers["anthropic"].options.headers as Record)["X-Custom"]).toBe("custom-value") // anthropic custom loader adds its own headers, they should coexist - expect(providers["anthropic"].options.headers["anthropic-beta"]).toBeDefined() + expect((providers["anthropic"].options.headers as Record)["anthropic-beta"]).toBeDefined() }, }) }) @@ -1914,7 +1914,7 @@ test("model variants can be customized via config", async () => { const providers = await Provider.list() const model = providers["anthropic"].models["claude-sonnet-4-20250514"] expect(model.variants!["high"]).toBeDefined() - expect(model.variants!["high"].thinking.budgetTokens).toBe(20000) + expect((model.variants!["high"].thinking as Record).budgetTokens).toBe(20000) }, }) }) diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 917d357ea..db919f661 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -43,16 +43,16 @@ describe("ProviderTransform.options - setCacheKey", () => { const result = ProviderTransform.options({ model: mockModel, sessionID, - providerOptions: { setCacheKey: true }, + providerOptions: { setCacheKey: true } as any, }) - expect(result.promptCacheKey).toBe(sessionID) + expect(result.promptCacheKey).toBe(sessionID as any) }) test("should not set promptCacheKey when providerOptions.setCacheKey is false", () => { const result = ProviderTransform.options({ model: mockModel, sessionID, - providerOptions: { setCacheKey: false }, + providerOptions: { setCacheKey: false } as any, }) expect(result.promptCacheKey).toBeUndefined() }) @@ -82,7 +82,7 @@ describe("ProviderTransform.options - setCacheKey", () => { }, } const result = ProviderTransform.options({ model: openaiModel, sessionID, providerOptions: {} }) - expect(result.promptCacheKey).toBe(sessionID) + expect(result.promptCacheKey).toBe(sessionID as any) }) test("should set store=false for openai provider", () => { @@ -100,7 +100,7 @@ describe("ProviderTransform.options - setCacheKey", () => { sessionID, providerOptions: {}, }) - expect(result.store).toBe(false) + expect(result.store).toBe(false as any) }) }) @@ -136,13 +136,13 @@ describe("ProviderTransform.options - gpt-5 textVerbosity", () => { test("gpt-5.2 should have textVerbosity set to low", () => { const model = createGpt5Model("gpt-5.2") const result = ProviderTransform.options({ model, sessionID, providerOptions: {} }) - expect(result.textVerbosity).toBe("low") + expect(result.textVerbosity).toBe("low" as any) }) test("gpt-5.1 should have textVerbosity set to low", () => { const model = createGpt5Model("gpt-5.1") const result = ProviderTransform.options({ model, sessionID, providerOptions: {} }) - expect(result.textVerbosity).toBe("low") + expect(result.textVerbosity).toBe("low" as any) }) test("gpt-5.2-chat-latest should NOT have textVerbosity set (only supports medium)", () => { diff --git a/packages/opencode/test/session/llm.test.ts b/packages/opencode/test/session/llm.test.ts index 02a3b1b37..7e210d4e7 100644 --- a/packages/opencode/test/session/llm.test.ts +++ b/packages/opencode/test/session/llm.test.ts @@ -14,6 +14,7 @@ import { tmpdir } from "../fixture/fixture" import type { Agent } from "../../src/agent/agent" import type { MessageV2 } from "../../src/session/message-v2" import { SessionID, MessageID } from "../../src/session/schema" +import { ProjectID } from "../../src/project/schema" describe("session.llm.hasToolCalls", () => { test("returns false for empty messages array", () => { @@ -291,7 +292,7 @@ describe("session.llm.stream", () => { const stream = await LLM.stream({ user, sessionID, - projectID: "test", + projectID: ProjectID.make("test"), model: resolved, agent, system: ["You are a helpful assistant."], @@ -391,7 +392,7 @@ describe("session.llm.stream", () => { const stream = await LLM.stream({ user, sessionID, - projectID: "test", + projectID: ProjectID.make("test"), model: resolved, agent, permission: [{ permission: "question", pattern: "*", action: "allow" }], @@ -511,7 +512,7 @@ describe("session.llm.stream", () => { const stream = await LLM.stream({ user, sessionID, - projectID: "test", + projectID: ProjectID.make("test"), model: resolved, agent, system: ["You are a helpful assistant."], @@ -634,7 +635,7 @@ describe("session.llm.stream", () => { const stream = await LLM.stream({ user, sessionID, - projectID: "test", + projectID: ProjectID.make("test"), model: resolved, agent, system: ["You are a helpful assistant."], @@ -736,7 +737,7 @@ describe("session.llm.stream", () => { const stream = await LLM.stream({ user, sessionID, - projectID: "test", + projectID: ProjectID.make("test"), model: resolved, agent, system: ["You are a helpful assistant."], diff --git a/packages/opencode/test/tool/apply_patch.test.ts b/packages/opencode/test/tool/apply_patch.test.ts index 6f5d83513..897b9b97c 100644 --- a/packages/opencode/test/tool/apply_patch.test.ts +++ b/packages/opencode/test/tool/apply_patch.test.ts @@ -5,6 +5,7 @@ import { ApplyPatchTool } from "../../src/tool/apply_patch" import { Instance } from "../fixture/instance-shim" import { tmpdir } from "../fixture/fixture" import { SessionID, MessageID } from "../../src/session/schema" +import type { ProjectID } from "../../src/project/schema" const baseFields = { sessionID: SessionID.make("ses_test"), @@ -40,7 +41,7 @@ type AskInput = { type ToolCtx = typeof baseFields & { directory: string worktree: string - projectID: string + projectID: ProjectID containsPath: (fp: string) => boolean ask: (input: AskInput) => Promise } diff --git a/packages/opencode/test/tool/external-directory.test.ts b/packages/opencode/test/tool/external-directory.test.ts index 526c8e0ac..b82f33b8d 100644 --- a/packages/opencode/test/tool/external-directory.test.ts +++ b/packages/opencode/test/tool/external-directory.test.ts @@ -5,6 +5,7 @@ import { Instance } from "../fixture/instance-shim" import { assertExternalDirectory } from "../../src/tool/external-directory" import type { PermissionNext } from "../../src/permission/next" import { SessionID, MessageID } from "../../src/session/schema" +import { ProjectID } from "../../src/project/schema" const baseCtx: Omit = { sessionID: SessionID.make("ses_test"), @@ -15,7 +16,7 @@ const baseCtx: Omit = { messages: [], directory: "", worktree: "", - projectID: "", + projectID: ProjectID.make(""), containsPath: (fp: string) => Instance.containsPath(fp), metadata: () => {}, } diff --git a/packages/opencode/test/tool/question.test.ts b/packages/opencode/test/tool/question.test.ts index 94e63656b..8d593d80f 100644 --- a/packages/opencode/test/tool/question.test.ts +++ b/packages/opencode/test/tool/question.test.ts @@ -3,6 +3,7 @@ import { z } from "zod" import { QuestionTool } from "../../src/tool/question" import * as QuestionModule from "../../src/question" import { SessionID, MessageID } from "../../src/session/schema" +import { ProjectID } from "../../src/project/schema" const ctx = { sessionID: SessionID.make("ses_test-session"), @@ -13,7 +14,7 @@ const ctx = { messages: [], directory: "", worktree: "", - projectID: "", + projectID: ProjectID.make(""), containsPath: () => true, metadata: () => {}, ask: async () => {}, diff --git a/packages/opencode/test/tool/skill.test.ts b/packages/opencode/test/tool/skill.test.ts index a83e70caf..f6aeba25c 100644 --- a/packages/opencode/test/tool/skill.test.ts +++ b/packages/opencode/test/tool/skill.test.ts @@ -7,6 +7,7 @@ import { Instance } from "../fixture/instance-shim" import { SkillTool } from "../../src/tool/skill" import { tmpdir } from "../fixture/fixture" import { SessionID, MessageID } from "../../src/session/schema" +import { ProjectID } from "../../src/project/schema" const baseCtx: Omit = { sessionID: SessionID.make("ses_test"), @@ -17,7 +18,7 @@ const baseCtx: Omit = { messages: [], directory: "", worktree: "", - projectID: "", + projectID: ProjectID.make(""), containsPath: () => true, metadata: () => {}, } diff --git a/packages/opencode/test/tool/webfetch.test.ts b/packages/opencode/test/tool/webfetch.test.ts index 957ffba7c..aead2ff5a 100644 --- a/packages/opencode/test/tool/webfetch.test.ts +++ b/packages/opencode/test/tool/webfetch.test.ts @@ -3,6 +3,7 @@ import path from "path" import { Instance } from "../fixture/instance-shim" import { WebFetchTool } from "../../src/tool/webfetch" import { SessionID, MessageID } from "../../src/session/schema" +import { ProjectID } from "../../src/project/schema" const projectRoot = path.join(import.meta.dir, "../..") @@ -15,7 +16,7 @@ const ctx = { messages: [], directory: "", worktree: "", - projectID: "", + projectID: ProjectID.make(""), containsPath: () => true, metadata: () => {}, ask: async () => {}, diff --git a/packages/opencode/test/util/queue.test.ts b/packages/opencode/test/util/queue.test.ts index 0babf135c..7d10a0ff1 100644 --- a/packages/opencode/test/util/queue.test.ts +++ b/packages/opencode/test/util/queue.test.ts @@ -45,11 +45,11 @@ describe("util.queue", () => { expect(result).toBeUndefined() }) - test("push after close is ignored", async () => { + test("push after close throws (B50)", async () => { const queue = new AsyncQueue() queue.push(1) queue.close() - queue.push(2) + expect(() => queue.push(2)).toThrow("Cannot push to a closed queue") const first = await queue.next() expect(first).toBe(1) diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 459932498..353a7e976 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -53,6 +53,7 @@ import type { GlobalEventResponses, GlobalHealthResponses, InstanceDisposeResponses, + JsonValue, LifecycleMeta, LspStatusResponses, McpAddErrors, @@ -3354,7 +3355,7 @@ export class Control extends HeyApiClient { parameters?: { directory?: string workspace?: string - body?: unknown + jsonValue?: JsonValue }, options?: Options, ) { @@ -3365,7 +3366,7 @@ export class Control extends HeyApiClient { args: [ { in: "query", key: "directory" }, { in: "query", key: "workspace" }, - { key: "body", map: "body" }, + { key: "jsonValue", map: "body" }, ], }, ], @@ -3903,7 +3904,7 @@ export class App extends HeyApiClient { level?: "debug" | "info" | "error" | "warn" message?: string extra?: { - [key: string]: unknown + [key: string]: JsonValue } }, options?: Options, diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 3ced3ab52..6143d9337 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -18,33 +18,11 @@ export type EventInstallationUpdateAvailable = { } } -export type Project = { - id: string - worktree: string - vcs?: "git" - name?: string - icon?: { - url?: string - override?: string - color?: string - } - commands?: { - /** - * Startup script to run when creating a new workspace (worktree) - */ - start?: string - } - time: { - created: number - updated: number - initialized?: number +export type EventServerInstanceDisposed = { + type: "server.instance.disposed" + properties: { + directory: string } - sandboxes: Array -} - -export type EventProjectUpdated = { - type: "project.updated" - properties: Project } export type EventFileEdited = { @@ -54,13 +32,6 @@ export type EventFileEdited = { } } -export type EventServerInstanceDisposed = { - type: "server.instance.disposed" - properties: { - directory: string - } -} - export type EventFileWatcherUpdated = { type: "file.watcher.updated" properties: { @@ -69,13 +40,15 @@ export type EventFileWatcherUpdated = { } } +export type JsonValue = unknown + export type PermissionRequest = { id: string sessionID: string permission: string patterns: Array metadata: { - [key: string]: unknown + [key: string]: JsonValue } always: Array tool?: { @@ -176,6 +149,64 @@ export type EventQuestionRejected = { } } +export type SessionStatus = + | { + type: "idle" + } + | { + type: "retry" + attempt: number + message: string + next: number + } + | { + type: "busy" + } + +export type EventSessionStatus = { + type: "session.status" + properties: { + sessionID: string + status: SessionStatus + } +} + +export type EventSessionIdle = { + type: "session.idle" + properties: { + sessionID: string + } +} + +export type Project = { + id: string + worktree: string + vcs?: "git" + name?: string + icon?: { + url?: string + override?: string + color?: string + } + commands?: { + /** + * Startup script to run when creating a new workspace (worktree) + */ + start?: string + } + time: { + created: number + updated: number + initialized?: number + } + sandboxes: Array +} + +export type EventProjectUpdated = { + type: "project.updated" + properties: Project +} + export type EventServerConnected = { type: "server.connected" properties: { @@ -210,7 +241,7 @@ export type OutputFormatText = { } export type JsonSchema = { - [key: string]: unknown + [key: string]: JsonValue } export type OutputFormatJsonSchema = { @@ -354,7 +385,7 @@ export type AssistantMessage = { write: number } } - structured?: unknown + structured?: JsonValue variant?: string finish?: string } @@ -411,7 +442,9 @@ export type TextPart = { end?: number } metadata?: { - [key: string]: unknown + [key: string]: { + [key: string]: JsonValue + } } } @@ -441,7 +474,9 @@ export type ReasoningPart = { type: "reasoning" text: string metadata?: { - [key: string]: unknown + [key: string]: { + [key: string]: JsonValue + } } time: { start: number @@ -506,7 +541,7 @@ export type FilePart = { export type ToolStatePending = { status: "pending" input: { - [key: string]: unknown + [key: string]: JsonValue } raw: string } @@ -514,11 +549,11 @@ export type ToolStatePending = { export type ToolStateRunning = { status: "running" input: { - [key: string]: unknown + [key: string]: JsonValue } title?: string metadata?: { - [key: string]: unknown + [key: string]: JsonValue } time: { start: number @@ -528,12 +563,12 @@ export type ToolStateRunning = { export type ToolStateCompleted = { status: "completed" input: { - [key: string]: unknown + [key: string]: JsonValue } output: string title: string metadata: { - [key: string]: unknown + [key: string]: JsonValue } time: { start: number @@ -546,11 +581,11 @@ export type ToolStateCompleted = { export type ToolStateError = { status: "error" input: { - [key: string]: unknown + [key: string]: JsonValue } error: string metadata?: { - [key: string]: unknown + [key: string]: JsonValue } time: { start: number @@ -571,7 +606,9 @@ export type ToolPart = { tool: string state: ToolState metadata?: { - [key: string]: unknown + [key: string]: { + [key: string]: JsonValue + } } } @@ -709,35 +746,6 @@ export type EventMessagePartRemoved = { } } -export type SessionStatus = - | { - type: "idle" - } - | { - type: "retry" - attempt: number - message: string - next: number - } - | { - type: "busy" - } - -export type EventSessionStatus = { - type: "session.status" - properties: { - sessionID: string - status: SessionStatus - } -} - -export type EventSessionIdle = { - type: "session.idle" - properties: { - sessionID: string - } -} - export type EventSessionCompacted = { type: "session.compacted" properties: { @@ -1112,9 +1120,8 @@ export type EventWorktreeFailed = { export type Event = | EventInstallationUpdated | EventInstallationUpdateAvailable - | EventProjectUpdated - | EventFileEdited | EventServerInstanceDisposed + | EventFileEdited | EventFileWatcherUpdated | EventPermissionAsked | EventPermissionReplied @@ -1122,6 +1129,9 @@ export type Event = | EventQuestionAsked | EventQuestionReplied | EventQuestionRejected + | EventSessionStatus + | EventSessionIdle + | EventProjectUpdated | EventServerConnected | EventGlobalDisposed | EventLspClientDiagnostics @@ -1131,8 +1141,6 @@ export type Event = | EventMessagePartUpdated | EventMessagePartDelta | EventMessagePartRemoved - | EventSessionStatus - | EventSessionIdle | EventSessionCompacted | EventTodoUpdated | EventEditGraphCommitted @@ -1260,7 +1268,7 @@ export type AgentConfig = { */ hidden?: boolean options?: { - [key: string]: unknown + [key: string]: JsonValue } /** * Hex color code (e.g., #FF5733) or theme color (e.g., primary) @@ -1276,7 +1284,7 @@ export type AgentConfig = { maxSteps?: number permission?: PermissionConfig [key: string]: - | unknown + | JsonValue | string | number | { @@ -1287,7 +1295,7 @@ export type AgentConfig = { | "primary" | "all" | { - [key: string]: unknown + [key: string]: JsonValue } | string | "primary" @@ -1347,7 +1355,7 @@ export type ProviderConfig = { experimental?: boolean status?: "alpha" | "beta" | "deprecated" options?: { - [key: string]: unknown + [key: string]: JsonValue } headers?: { [key: string]: string @@ -1365,7 +1373,7 @@ export type ProviderConfig = { * Disable this variant for the model */ disabled?: boolean - [key: string]: unknown | boolean | undefined + [key: string]: JsonValue | boolean | undefined } } } @@ -1391,7 +1399,7 @@ export type ProviderConfig = { * Timeout in milliseconds between streamed SSE chunks for this provider. If no chunk arrives within this window, the request is aborted. */ chunkTimeout?: number - [key: string]: unknown | string | boolean | number | false | number | undefined + [key: string]: JsonValue | string | boolean | number | false | number | undefined } } @@ -1614,7 +1622,7 @@ export type Config = { [key: string]: string } initialization?: { - [key: string]: unknown + [key: string]: JsonValue } } } @@ -1717,9 +1725,9 @@ export type Config = { } export type BadRequestError = { - data: unknown + data: JsonValue errors: Array<{ - [key: string]: unknown + [key: string]: JsonValue }> success: false } @@ -1811,7 +1819,7 @@ export type Model = { } status: "alpha" | "beta" | "deprecated" | "active" options: { - [key: string]: unknown + [key: string]: JsonValue } headers: { [key: string]: string @@ -1819,7 +1827,7 @@ export type Model = { release_date: string variants?: { [key: string]: { - [key: string]: unknown + [key: string]: JsonValue } } } @@ -1831,7 +1839,7 @@ export type Provider = { env: Array key?: string options: { - [key: string]: unknown + [key: string]: JsonValue } models: { [key: string]: Model @@ -1843,7 +1851,7 @@ export type ToolIds = Array export type ToolListItem = { id: string description: string - parameters: unknown + parameters: JsonValue } export type ToolList = Array @@ -1941,7 +1949,9 @@ export type TextPartInput = { end?: number } metadata?: { - [key: string]: unknown + [key: string]: { + [key: string]: JsonValue + } } } @@ -2111,7 +2121,7 @@ export type Agent = { variant?: string prompt?: string options: { - [key: string]: unknown + [key: string]: JsonValue } steps?: number } @@ -3037,7 +3047,7 @@ export type ContextThreadsResponses = { */ 200: { projectID: string - threads: Array + threads: Array total: number hasMore: boolean } @@ -3194,9 +3204,49 @@ export type SessionStatsResponses = { /** * Usage statistics */ - 200: unknown + 200: { + totalSessions: number + totalMessages: number + totalCost: number + totalTokens: { + input: number + output: number + reasoning?: number + cache: { + read: number + write: number + } + } + toolUsage: { + [key: string]: number + } + modelUsage: { + [key: string]: { + messages: number + tokens: { + input: number + output: number + cache: { + read: number + write: number + } + } + cost: number + } + } + dateRange: { + earliest: number + latest: number + } + days: number + costPerDay: number + tokensPerSession: number + medianTokensPerSession: number + } } +export type SessionStatsResponse = SessionStatsResponses[keyof SessionStatsResponses] + export type SessionDeleteData = { body?: never path: { @@ -4300,7 +4350,7 @@ export type ProviderListResponses = { experimental?: boolean status?: "alpha" | "beta" | "deprecated" options: { - [key: string]: unknown + [key: string]: JsonValue } headers?: { [key: string]: string @@ -4311,7 +4361,7 @@ export type ProviderListResponses = { } variants?: { [key: string]: { - [key: string]: unknown + [key: string]: JsonValue } } } @@ -5087,14 +5137,14 @@ export type TuiControlNextResponses = { */ 200: { path: string - body: unknown + body: JsonValue } } export type TuiControlNextResponse = TuiControlNextResponses[keyof TuiControlNextResponses] export type TuiControlResponseData = { - body?: unknown + body?: JsonValue path?: never query?: { directory?: string @@ -5206,7 +5256,7 @@ export type AppLogData = { * Additional metadata for the log entry */ extra?: { - [key: string]: unknown + [key: string]: JsonValue } } path?: never