Compare commits

..
Author SHA1 Message Date
Kit Langton c80ec7adc0 chore(plugins): integrate current v2 and SDK readiness fixture 2026-08-28 00:10:34 -04:00
Kit Langton 93042ddaf1 feat(plugins): check, update, and reload plugins in the TUI 2026-08-28 00:05:37 -04:00
Kit Langton d2c307ab30 test(core): remove unused test imports (#45674) 2026-08-27 23:58:41 -04:00
Kit Langton a5193634d0 test(core): use direct test imports (#45683) 2026-08-27 23:58:34 -04:00
Kit Langton 95725d121a chore: remove unused changesets (#45827) 2026-08-27 23:55:57 -04:00
Aiden Cline 56f2559798 fix(codemode): report original tool errors (#45820) 2026-08-27 22:54:36 -05:00
Kit Langton 38bffc9db1 chore(deps): upgrade Effect to rc.112 (#45819) 2026-08-27 23:43:37 -04:00
Kit Langton 1d3308d904 test(core): refresh test vocabulary (#45714) 2026-08-27 23:26:45 -04:00
opencode-agent[bot] 3c9e5adefa chore: update nix node_modules hashes 2026-08-28 03:23:31 +00:00
Kit Langton a065ad4ba7 feat(core): vanilla instance discovery option (#45752) 2026-08-28 03:16:29 +00:00
Kit Langton 048aec32e7 refactor(core): share local provider settings fold (#45645) 2026-08-27 23:13:13 -04:00
Kit Langton a258b17fac test(core): use partial service mocks (#45703) 2026-08-27 23:13:04 -04:00
Kit Langton 07facd2086 refactor(util): share process stop policy (#45725) 2026-08-27 23:12:57 -04:00
Kit Langton 6bf342a861 refactor(core): consolidate HTML inline handling (#45649) 2026-08-27 23:12:50 -04:00
Kit Langton 2cca1ae860 refactor(core): use nonthrowing URL parsing (#45726) 2026-08-27 23:12:43 -04:00
Kit Langton becf5bf482 refactor(core): retain changed worktrees directly (#45709) 2026-08-27 23:12:36 -04:00
Kit Langton afe9e579d6 refactor(core): simplify instruction preview blobs (#45702) 2026-08-27 23:12:28 -04:00
Kit Langton 0b82fe60ea refactor(core): consolidate model error mapping (#45722) 2026-08-27 23:12:19 -04:00
Luke Parker 6fad330efc chore(deps): upgrade Solid and Router (#45808) 2026-08-28 03:03:08 +00:00
Kit Langton 87f21a301d fix(core): forward plugin interrupt options (#45632) 2026-08-27 23:01:57 -04:00
Kit Langton 0123fed65d fix(core): preserve Responses tool strictness (#45663) 2026-08-27 23:01:43 -04:00
Kit Langton 64ef85159d fix(core): preserve assistant text metadata (#45650) 2026-08-27 23:01:33 -04:00
Kit Langton e63779d9e1 fix(core): keep vertex settings serializable (#45704) 2026-08-27 23:01:20 -04:00
Kit Langton ac55c05c30 fix(core): correct compaction history direction (#45698) 2026-08-27 23:01:07 -04:00
Dax Raad 8fa672ed71 docs: document diagnostic profile signals 2026-08-27 22:53:28 -04:00
173 changed files with 7407 additions and 1200 deletions
-5
View File
@@ -1,5 +0,0 @@
---
"@opencode-ai/theme": patch
---
Use the unread accent color by default for question and permission status indicators, while preserving explicit theme overrides.
-5
View File
@@ -1,5 +0,0 @@
---
"@opencode-ai/core": patch
---
Align the built-in review and system prompts with available documentation and code-search tools and the current Plan mode reminder.
-5
View File
@@ -1,5 +0,0 @@
---
"@opencode-ai/core": patch
---
Preserve prompt cache prefixes when sessions move between locations with unchanged instructions.
-5
View File
@@ -1,5 +0,0 @@
---
"@opencode-ai/core": patch
---
Correct the published Newtype documentation to recommend `Schema.decodeUnknownSync` for schema-based synchronous decoding.
-5
View File
@@ -1,5 +0,0 @@
---
"@opencode-ai/core": patch
---
Fix OpenCode Console device authorization URLs when the server returns an origin-rooted verification path.
-5
View File
@@ -1,5 +0,0 @@
---
"@opencode-ai/core": patch
---
Propagate fatal Git ignore-check errors during index refresh so best-effort snapshot capture returns unavailable instead of treating the check as having no matches.
-5
View File
@@ -1,5 +0,0 @@
---
"@opencode-ai/core": patch
---
Prompt and synthetic inbox ID reuse is now idempotent: reusing an ID within the same Session succeeds and returns the first admission, ignoring the retried payload, metadata, and delivery mode. Previously reuse with a differing payload failed with a conflict. Cross-Session and cross-type reuse still fail, and control items keep their operation-specific conflict behavior.
-5
View File
@@ -1,5 +0,0 @@
---
"@opencode-ai/core": major
---
Treat MCP as a word in current Core namespace exports: rename `MCP` to `Mcp`, `MCPClient` to `McpClient`, `MCPStdio` to `McpStdio`, `MCPOAuth` to `McpOAuth`, `ConfigMCPPlugin` to `ConfigMcpPlugin`, and `MCPCodeModeExclusionPlugin` to `McpCodeModeExclusionPlugin`. Direct consumers must update their imports. Module paths, Schema contracts, runtime service keys, error tags, and behavior are unchanged.
-5
View File
@@ -1,5 +0,0 @@
---
"@opencode-ai/core": patch
---
Rename the write, patch, and question tool formatting helpers from `toModelOutput` to `toModelContent` to match the result field they populate. Direct imports of these helpers must use the new name; generated content and declared machine output are unchanged.
-5
View File
@@ -1,5 +0,0 @@
---
"@opencode-ai/core": minor
---
Rename ModelResolver.supported to ModelResolver.hasPackage. Consumers of the old export must update the name; the predicate remains Boolean(model.package), checking only whether a catalog model declares a provider package, not whether it can be loaded. Default-model selection behavior is unchanged.
@@ -1,5 +0,0 @@
---
"@opencode-ai/core": patch
---
Nested AGENTS.md instructions are re-injected after compaction. Previously the in-memory dedup claim outlived the synthetic message that compaction dropped from model-visible history, so nested instructions were silently lost for the rest of the process lifetime. The claim now only guards in-flight loads; the synthetic message metadata in durable history is the sole lasting ledger, so any history truncation (compaction, revert) self-heals on the next read in that subtree.
-5
View File
@@ -1,5 +0,0 @@
---
"@opencode-ai/plugin": patch
---
Derive Promise plugin API request and response conversion from the canonical protocol schemas.
@@ -1,8 +0,0 @@
---
"@opencode-ai/core": minor
"@opencode-ai/schema": minor
"@opencode-ai/protocol": minor
"@opencode-ai/client": minor
---
Remove the unused question request API and use session forms for question tool interactions.
-5
View File
@@ -1,5 +0,0 @@
---
"@opencode-ai/core": patch
---
Wake idle sessions when recovering background shell outcomes after a server restart. Admit shell outcomes before resuming background child sessions, while preserving restart retry budgets and completion-driven parent notifications.
-5
View File
@@ -1,5 +0,0 @@
---
"@opencode-ai/core": patch
---
Include both paths of renamed files in new snapshot change lists so undo restores the original file instead of only deleting the renamed file.
@@ -1,5 +0,0 @@
---
"@opencode-ai/core": patch
---
Apply shared Session model-request preparation to transient generation.
-9
View File
@@ -1,9 +0,0 @@
---
"@opencode-ai/core": minor
"@opencode-ai/schema": minor
"@opencode-ai/protocol": minor
"@opencode-ai/client": minor
---
Add an opt-in portable shell permission scanner. Opaque commands use normal shell authorization without inferring
external directories, while the default tree-sitter path remains unchanged.
-10
View File
@@ -1,10 +0,0 @@
---
"@opencode-ai/core": patch
---
Make the experimental portable shell scanner authoritative, with no Tree-sitter
fallback. Scan common Bash and PowerShell control flow, heredocs, functions,
expressions, quoting, and substitutions natively. Preserve existing redirect and
declaration permission matching, and make PowerShell saved approvals cover the
original command spelling. Parser failures remain visible without changing the
permission engine. The default Tree-sitter path is unchanged.
-5
View File
@@ -1,5 +0,0 @@
---
"@opencode-ai/core": patch
---
Use parent directory names for SKILL.md IDs so individually downloaded skills do not overwrite each other.
-8
View File
@@ -1,8 +0,0 @@
---
"@opencode-ai/core": patch
"@opencode-ai/app": patch
---
Keep project labels stable when opening multiple clones of the same repository, while still refreshing the canonical path when its directory is renamed or removed.
Worktree setup scripts receive the selected source directory as `OPENCODE_WORKTREE_BASE` rather than another clone's shared project path.
-5
View File
@@ -1,5 +0,0 @@
---
"@opencode-ai/core": patch
---
Refresh expired OAuth credentials resolved during batched plugin activation. Integration connection resolution now materializes registrations deferred by the activation batch before consulting refresh implementations, so a just-registered OAuth method is no longer skipped and an expired token no longer used as-is.
-5
View File
@@ -1,5 +0,0 @@
---
"@opencode-ai/core": patch
---
Simplify interrupt continuation: the steer-scoped resume decision now lives in SessionExecution as a post-cleanup inbox check, and the run coordinator drops its continuation state machine. Wakes arriving during cancellation cleanup now restart a normal full drain, and interrupting an idle session with continue now resumes pending steering input. Recovery-applied moves now end with the same full wake as inbox-admitted moves, retrying any stranded inbox work at the new location. Interrupting with continue now also resumes a next-in-line control item: between-turn manual compaction and moves run under any drain scope, while queued prompts remain parked.
-6
View File
@@ -1,6 +0,0 @@
---
"@opencode-ai/client": patch
"@opencode-ai/plugin": patch
---
Add form reply and cancellation operations that reconcile terminal forms in the local TUI projection.
-5
View File
@@ -1,5 +0,0 @@
---
"@opencode-ai/core": patch
---
Title generation and compaction summaries now build their model requests through the shared session request boundary, gaining unsupported-media filtering and image bounds while explicitly opting out of session context hooks: plugins that shape the agent conversation do not observe title or compaction requests. Title requests gain the fork-aware session prompt cache key, and compaction summaries in forked sessions reuse the fork root's prompt cache key instead of the fork's own.
-6
View File
@@ -1,6 +0,0 @@
---
"@opencode-ai/plugin": patch
"@opencode-ai/core": patch
---
Add transport-neutral Session model request hooks and provider-scoped hook registration so eligible OpenAI Responses requests can prefer WebSocket without bypassing HTTP-only middleware.
+42 -23
View File
@@ -185,7 +185,7 @@
"solid-js": "catalog:",
},
"peerDependencies": {
"effect": "4.0.0-rc.111",
"effect": "4.0.0-rc.112",
"solid-js": ">=1.9.0",
},
"optionalPeers": [
@@ -426,7 +426,7 @@
"@sentry/vite-plugin": "catalog:",
"@solid-primitives/storage": "catalog:",
"@solidjs/meta": "catalog:",
"@solidjs/router": "0.15.4",
"@solidjs/router": "catalog:",
"@types/bun": "catalog:",
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
@@ -506,7 +506,7 @@
"name": "@opencode-ai/http-recorder",
"version": "1.18.15",
"dependencies": {
"@effect/platform-node-shared": "4.0.0-rc.111",
"@effect/platform-node-shared": "4.0.0-rc.112",
},
"devDependencies": {
"@effect/platform-node": "catalog:",
@@ -966,6 +966,7 @@
"mime-types": "3.0.2",
"minimatch": "10.2.5",
"npm-package-arg": "13.0.2",
"pacote": "21.5.1",
"resolve.exports": "catalog:",
},
"devDependencies": {
@@ -975,6 +976,7 @@
"@types/node": "catalog:",
"@types/npm-package-arg": "6.1.4",
"@types/npmcli__arborist": "6.3.3",
"@types/pacote": "11.1.8",
"@typescript/native-preview": "catalog:",
},
},
@@ -1043,7 +1045,7 @@
"@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch",
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
"solid-js@1.9.15": "patches/solid-js@1.9.15.patch",
"@ff-labs/fff-bun@0.10.5": "patches/@ff-labs%2Ffff-bun@0.10.5.patch",
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
"@dnd-kit/dom@0.5.0": "patches/@dnd-kit%2Fdom@0.5.0.patch",
@@ -1057,14 +1059,15 @@
"@types/bun": "catalog:",
"@types/node": "catalog:",
"effect": "catalog:",
"solid-js": "catalog:",
},
"catalog": {
"@cloudflare/workers-types": "4.20251008.0",
"@corvu/drawer": "0.2.4",
"@effect/opentelemetry": "4.0.0-rc.111",
"@effect/platform-node": "4.0.0-rc.111",
"@effect/platform-node-shared": "4.0.0-rc.111",
"@effect/sql-sqlite-bun": "4.0.0-rc.111",
"@effect/opentelemetry": "4.0.0-rc.112",
"@effect/platform-node": "4.0.0-rc.112",
"@effect/platform-node-shared": "4.0.0-rc.112",
"@effect/sql-sqlite-bun": "4.0.0-rc.112",
"@hono/standard-validator": "0.2.0",
"@hono/zod-validator": "0.4.2",
"@kobalte/core": "0.13.13",
@@ -1085,7 +1088,7 @@
"@solid-primitives/resize-observer": "2.2.0",
"@solid-primitives/storage": "4.4.0",
"@solidjs/meta": "0.29.4",
"@solidjs/router": "0.15.4",
"@solidjs/router": "1.0.0",
"@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020",
"@standard-schema/spec": "1.1.0",
"@tailwindcss/vite": "4.1.11",
@@ -1104,7 +1107,7 @@
"dompurify": "3.4.14",
"drizzle-kit": "1.0.0-rc.5-ab785fc",
"drizzle-orm": "1.0.0-rc.5-169397b",
"effect": "4.0.0-rc.111",
"effect": "4.0.0-rc.112",
"fuzzysort": "3.1.0",
"get-east-asian-width": "1.6.0",
"hono": "4.10.7",
@@ -1118,7 +1121,7 @@
"resolve.exports": "2.0.3",
"semver": "7.7.4",
"shiki": "4.4.3",
"solid-js": "1.9.10",
"solid-js": "1.9.15",
"solid-list": "0.3.0",
"solid-sonner": "0.3.1",
"sst": "4.13.1",
@@ -1663,13 +1666,13 @@
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.12.0", "", {}, "sha512-mlUE+rZ8CatQekLhnaiN91Iemdd+e2gFKooGlnRB3oPTL3VghLfX24dx7HrzMNeC1JrIB/0kpsfyty3f5HNfxQ=="],
"@effect/opentelemetry": ["@effect/opentelemetry@4.0.0-rc.111", "", { "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <2.0.0", "@opentelemetry/api-logs": ">=0.203.0 <0.300.0", "@opentelemetry/resources": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", "@opentelemetry/sdk-metrics": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-base": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-node": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-web": ">=2.0.0 <3.0.0", "@opentelemetry/semantic-conventions": ">=1.33.0 <2.0.0", "effect": "^4.0.0-rc.111" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/api-logs", "@opentelemetry/resources", "@opentelemetry/sdk-logs", "@opentelemetry/sdk-metrics", "@opentelemetry/sdk-trace-base", "@opentelemetry/sdk-trace-node", "@opentelemetry/sdk-trace-web"] }, "sha512-bztAYpWoipn/a4tFjECMNvtWLkf2TS+Qn23PBI+f4q5DP9cBKfV00uhX8cwEVcfAtZd+lflSbGURHa4xlSewOA=="],
"@effect/opentelemetry": ["@effect/opentelemetry@4.0.0-rc.112", "", { "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <2.0.0", "@opentelemetry/api-logs": ">=0.203.0 <0.300.0", "@opentelemetry/resources": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", "@opentelemetry/sdk-metrics": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-base": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-node": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-web": ">=2.0.0 <3.0.0", "@opentelemetry/semantic-conventions": ">=1.33.0 <2.0.0", "effect": "^4.0.0-rc.112" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/api-logs", "@opentelemetry/resources", "@opentelemetry/sdk-logs", "@opentelemetry/sdk-metrics", "@opentelemetry/sdk-trace-base", "@opentelemetry/sdk-trace-node", "@opentelemetry/sdk-trace-web"] }, "sha512-OTRv1DxTHUmnakgJ6XVM8wVgF1KgZH4UXnOemwSLUwUjXO+RCikzF8oR/rlVmOGq81KtQzj1URM4M4nchlQOuQ=="],
"@effect/platform-node": ["@effect/platform-node@4.0.0-rc.111", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-rc.111", "mime": "^4.1.0", "undici": "^8.10.0" }, "peerDependencies": { "effect": "^4.0.0-rc.111", "redis": ">=5.0.0 <7.0.0" } }, "sha512-oy1i7HsOGg/5r+DuBe5+ddmnUhnXmyZFPFPXZCBdO/RQpHK3PIFe1/2UMGxZE+ngDymrSksuQTSgQLF6P+MLqw=="],
"@effect/platform-node": ["@effect/platform-node@4.0.0-rc.112", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-rc.112", "mime": "^4.1.0", "undici": "^8.10.0" }, "peerDependencies": { "effect": "^4.0.0-rc.112", "redis": ">=5.0.0 <7.0.0" } }, "sha512-/BMAcdNGQQskLmI0Zoa95KfTZkr9HV9N4NSxaSrusG6GeW6Ulp9KvZ+Rlaiw8lnOt43CXjFLdfll5/k5rxL4hQ=="],
"@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-rc.111", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.21.3" }, "peerDependencies": { "effect": "^4.0.0-rc.111" } }, "sha512-iES0Q9vmjhaUKqeW9ceonuD45MUg/Ouk08LzRSptZ+B5qB0w9WlRjDmUz5TJmY2betNop5FRI5k4AD4mtQt3Bw=="],
"@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-rc.112", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.21.3" }, "peerDependencies": { "effect": "^4.0.0-rc.112" } }, "sha512-ttjz0xKamFN7vL8pNDYVwddJLjZvqKePc05djlz2VcdaKbLsnYbtMnL1rbOfHgEnIUSHGh7FkjaN4DM1Ov81sQ=="],
"@effect/sql-sqlite-bun": ["@effect/sql-sqlite-bun@4.0.0-rc.111", "", { "peerDependencies": { "effect": "^4.0.0-rc.111" } }, "sha512-u5HqWLYISTH5ydsCr45nOGmkspmAeNzK4q+plMrJntDoG8sQttwizC4akGfbQDgoSxyYfyBzi3YyVW/NvhvSQQ=="],
"@effect/sql-sqlite-bun": ["@effect/sql-sqlite-bun@4.0.0-rc.112", "", { "peerDependencies": { "effect": "^4.0.0-rc.112" } }, "sha512-EqR8pWZo3VzedRvP6qo5HcfqNmFvSa+sQLWQl3oBMx2KY1FCc8NYjep79wYjNkW0qABnffrPUXCMTOncd1A+5g=="],
"@electron-internal/extract-zip": ["@electron-internal/extract-zip@1.0.5", "", {}, "sha512-+bqFCP98pLI0Tt0XQo1TmlXtwjWchISndDOxCkEcIuUgXWpBnLyRI+2DU+mesvnMMX6L1XDqYNA0lXNDHd/yiA=="],
@@ -2893,7 +2896,7 @@
"@solidjs/meta": ["@solidjs/meta@0.29.4", "", { "peerDependencies": { "solid-js": ">=1.8.4" } }, "sha512-zdIWBGpR9zGx1p1bzIPqF5Gs+Ks/BH8R6fWhmUa/dcK1L2rUC8BAcZJzNRYBQv74kScf1TSOs0EY//Vd/I0V8g=="],
"@solidjs/router": ["@solidjs/router@0.15.4", "", { "peerDependencies": { "solid-js": "^1.8.6" } }, "sha512-WOpgg9a9T638cR+5FGbFi/IV4l2FpmBs1GpIMSPa0Ce9vyJN7Wts+X2PqMf9IYn0zUj2MlSJtm1gp7/HI/n5TQ=="],
"@solidjs/router": ["@solidjs/router@1.0.0", "", { "peerDependencies": { "solid-js": "^1.8.6" } }, "sha512-cCSk1hvgCowiMa9bzzYWHiLu1U4E22+DfJe6/rOwAyECKrxc3jrd5QnoW3sDDJtW+e077cz/M67bPl3DqOBw1Q=="],
"@solidjs/start": ["@solidjs/start@https://pkg.pr.new/@solidjs/start@dfb2020", { "dependencies": { "@babel/core": "^7.28.3", "@babel/traverse": "^7.28.3", "@babel/types": "^7.28.5", "@solidjs/meta": "^0.29.4", "@tanstack/server-functions-plugin": "1.134.5", "@types/babel__traverse": "^7.28.0", "@types/micromatch": "^4.0.9", "cookie-es": "^2.0.0", "defu": "^6.1.4", "error-stack-parser": "^2.1.4", "es-module-lexer": "^1.7.0", "esbuild": "^0.25.3", "fast-glob": "^3.3.3", "h3": "npm:h3@2.0.1-rc.4", "html-to-image": "^1.11.13", "micromatch": "^4.0.8", "path-to-regexp": "^8.2.0", "pathe": "^2.0.3", "radix3": "^1.1.2", "seroval": "^1.3.2", "seroval-plugins": "^1.2.1", "shiki": "^1.26.1", "solid-js": "^1.9.9", "source-map-js": "^1.2.1", "srvx": "^0.9.1", "terracotta": "^1.0.6", "vite": "7.1.10", "vite-plugin-solid": "^2.11.9", "vitest": "^4.0.10" } }, "sha512-7JjjA49VGNOsMRI8QRUhVudZmv0CnJ18SliSgK1ojszs/c3ijftgVkzvXdkSLN4miDTzbkXewf65D6ZBo6W+GQ=="],
@@ -3833,7 +3836,7 @@
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
"effect": ["effect@4.0.0-rc.111", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.9.0", "msgpackr": "^2.0.5" } }, "sha512-ASd5L58EIR0CUNueZNKKjSsyOCd+2alxOAIaTcHaqkJkPsaYSsw5Cg/cfANk5K4Jr2YsX756xvX11shzqsreWA=="],
"effect": ["effect@4.0.0-rc.112", "", { "dependencies": { "fast-check": "^4.9.0", "msgpackr": "^2.0.5" } }, "sha512-wXxwuh1Ywnv4cPRM3Wfa0vDwuOHnZ1TsTgHJkG9XgzND6inhBH9n1vBxhg3iIXOia/OrpmvVmd3lrD4vq6bF3A=="],
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
@@ -5235,9 +5238,9 @@
"serialize-javascript": ["serialize-javascript@7.1.0", "", {}, "sha512-RNEqWOyhhUQYN9V1GfHwu9AR/g+NTciH6Z5u3/no6X3/w+04J2lVDL+svFQVXgXrEGBMG2puMVN3gq2SNGuTGw=="],
"seroval": ["seroval@1.3.2", "", {}, "sha512-RbcPH1n5cfwKrru7v7+zrZvjLurgHhGyso3HTyGtRivGWgYjbOmGuivCQaORNELjNONoK35nj28EoWul9sb1zQ=="],
"seroval": ["seroval@1.5.6", "", {}, "sha512-rVQVWjjSvlINzaQPZH5JFqsqEsIWdTxY3iJZCnTL/5gQbXIRooVZKI60tVCkOVfzcRPejboxO2t0P89dg5mQaA=="],
"seroval-plugins": ["seroval-plugins@1.3.3", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-16OL3NnUBw8JG1jBLUoZJsLnQq0n5Ua6aHalhJK4fMQkz1lqR7Osz1sA30trBtd9VUDc2NgkuRCn8+/pBwqZ+w=="],
"seroval-plugins": ["seroval-plugins@1.5.6", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-HXuLAX2pu/UByPpaeo/TaMfvMIi+1QqIoPJYCcAtU8QkVNwgR6MPlGuCQTErV1JwraaMbYaWVIBX7mppzGLATQ=="],
"serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="],
@@ -5299,7 +5302,7 @@
"socks-proxy-agent": ["socks-proxy-agent@8.0.5", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "socks": "^2.8.3" } }, "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw=="],
"solid-js": ["solid-js@1.9.10", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.3.0", "seroval-plugins": "~1.3.0" } }, "sha512-Coz956cos/EPDlhs6+jsdTxKuJDPT7B5SVIWgABwROyxjY7Xbr8wkzD68Et+NxnV7DLJ3nJdAC2r9InuV/4Jew=="],
"solid-js": ["solid-js@1.9.15", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.5.4", "seroval-plugins": "~1.5.4" } }, "sha512-EeiY2xfpZJqPLjXspVEKjAII4yv8NyG//NxZ3IpOFHdUNnnTyL0uJOeS9LWGvA7cFCz5y94cjFwYlmw5Luncsg=="],
"solid-list": ["solid-list@0.3.0", "", { "dependencies": { "@corvu/utils": "~0.4.0" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-t4hx/F/l8Vmq+ib9HtZYl7Z9F1eKxq3eKJTXlvcm7P7yI4Z8O7QSOOEVHb/K6DD7M0RxzVRobK/BS5aSfLRwKg=="],
@@ -5811,7 +5814,7 @@
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
"yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
"yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
"yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="],
@@ -6307,10 +6310,18 @@
"@solidjs/start/path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="],
"@solidjs/start/seroval": ["seroval@1.3.2", "", {}, "sha512-RbcPH1n5cfwKrru7v7+zrZvjLurgHhGyso3HTyGtRivGWgYjbOmGuivCQaORNELjNONoK35nj28EoWul9sb1zQ=="],
"@solidjs/start/seroval-plugins": ["seroval-plugins@1.3.3", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-16OL3NnUBw8JG1jBLUoZJsLnQq0n5Ua6aHalhJK4fMQkz1lqR7Osz1sA30trBtd9VUDc2NgkuRCn8+/pBwqZ+w=="],
"@solidjs/start/shiki": ["shiki@1.29.2", "", { "dependencies": { "@shikijs/core": "1.29.2", "@shikijs/engine-javascript": "1.29.2", "@shikijs/engine-oniguruma": "1.29.2", "@shikijs/langs": "1.29.2", "@shikijs/themes": "1.29.2", "@shikijs/types": "1.29.2", "@shikijs/vscode-textmate": "^10.0.1", "@types/hast": "^3.0.4" } }, "sha512-njXuliz/cP+67jU2hukkxCNuH1yUi4QfdZZY+sMr5PPrIyXSu5iTb/qYC4BiWWB0vZ+7TbdvYUCeL23zpwCfbg=="],
"@solidjs/start/vite": ["vite@7.1.10", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-CmuvUBzVJ/e3HGxhg6cYk88NGgTnBoOo7ogtfJJ0fefUWAxN/WDSUa50o+oVBxuIhO8FoEZW0j2eW7sfjs5EtA=="],
"@standard-community/standard-json/effect": ["effect@4.0.0-rc.111", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.9.0", "msgpackr": "^2.0.5" } }, "sha512-ASd5L58EIR0CUNueZNKKjSsyOCd+2alxOAIaTcHaqkJkPsaYSsw5Cg/cfANk5K4Jr2YsX756xvX11shzqsreWA=="],
"@standard-community/standard-openapi/effect": ["effect@4.0.0-rc.111", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.9.0", "msgpackr": "^2.0.5" } }, "sha512-ASd5L58EIR0CUNueZNKKjSsyOCd+2alxOAIaTcHaqkJkPsaYSsw5Cg/cfANk5K4Jr2YsX756xvX11shzqsreWA=="],
"@tailwindcss/node/lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.3", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.3", "tslib": "^2.4.0" }, "bundled": true }, "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg=="],
@@ -6633,8 +6644,6 @@
"svgo/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="],
"tar/yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
"temp/rimraf": ["rimraf@2.6.3", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "./bin.js" } }, "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA=="],
"tempy/type-fest": ["type-fest@0.16.0", "", {}, "sha512-eaBzG6MxNzEn9kiwvtre90cXaNLkmadMWa1zQMs3XORCXNbsH/OewwbxC5ia9dCxIxnTAsSxXJaa/p5y8DlvJg=="],
@@ -6891,6 +6900,8 @@
"@brendonovich/vite-plugin-opencode/@opencode-ai/client/@opencode-ai/schema": ["@opencode-ai/schema@0.0.0-beta-18050", "", { "dependencies": { "@standard-schema/spec": "1.1.0", "effect": "4.0.0-rc.111" } }, "sha512-/D6VXaWlytTXR3IOiMLIKuPcfp7FQNUzRPm9z3K7UBFd1Bw4q/WZksaf5RVcBGz+0YRxYMc1V4D7MFlceSgtyg=="],
"@brendonovich/vite-plugin-opencode/@opencode-ai/client/effect": ["effect@4.0.0-rc.111", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.9.0", "msgpackr": "^2.0.5" } }, "sha512-ASd5L58EIR0CUNueZNKKjSsyOCd+2alxOAIaTcHaqkJkPsaYSsw5Cg/cfANk5K4Jr2YsX756xvX11shzqsreWA=="],
"@bruits/satteri-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="],
"@electron/asar/minimatch/brace-expansion": ["brace-expansion@1.1.18", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw=="],
@@ -7439,6 +7450,12 @@
"miniflare/sharp/@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.33.5", "", { "os": "win32", "cpu": "x64" }, "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg=="],
"minipass-flush/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
"minipass-pipeline/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
"openid-client/lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
"p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
"pkg-dir/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
@@ -8145,6 +8162,8 @@
"app-builder-lib/@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
"app-builder-lib/hosted-git-info/lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
"archiver-utils/glob/jackspeak/@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="],
"archiver-utils/glob/minimatch/brace-expansion": ["brace-expansion@2.1.4", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-vn4haT0uuD5tCbMD2F5IuDj+/XS/U/sVQ0bk5xJCbIM=",
"aarch64-linux": "sha256-XUGJ6hE7xvIyT53k5uCqaF/eGU4d4tIbuVuGrHU12Fg=",
"aarch64-darwin": "sha256-o3Os6rO8nGVVYs1aJBJAdYQaLbDsYNtGub4Gg1gHRQU=",
"x86_64-darwin": "sha256-YB00nH7WmgqRXvzA+zYEbaaik/vKheu3n/vJ6Ombx9k="
"x86_64-linux": "sha256-Fmwqp/fjTMX7gQW01Zgta51reLHp1S53GSQgBcJePrY=",
"aarch64-linux": "sha256-KLI6OIbvycMePKxt66nNKeILYJSEkufOIaSawZ/gotc=",
"aarch64-darwin": "sha256-go1wmrsHfYfJ3ukUIR/fveobyVba4E9G/rdsB/J2cQQ=",
"x86_64-darwin": "sha256-UnVYMijlG12k4RLFkTwf4Or0yXqFSwe1Ji6QCGufjzg="
}
}
+10 -9
View File
@@ -39,10 +39,10 @@
"packages/stats/*"
],
"catalog": {
"@effect/opentelemetry": "4.0.0-rc.111",
"@effect/platform-node": "4.0.0-rc.111",
"@effect/platform-node-shared": "4.0.0-rc.111",
"@effect/sql-sqlite-bun": "4.0.0-rc.111",
"@effect/opentelemetry": "4.0.0-rc.112",
"@effect/platform-node": "4.0.0-rc.112",
"@effect/platform-node-shared": "4.0.0-rc.112",
"@effect/sql-sqlite-bun": "4.0.0-rc.112",
"@npmcli/arborist": "9.4.0",
"@types/bun": "1.3.13",
"@types/cross-spawn": "6.0.6",
@@ -76,7 +76,7 @@
"dompurify": "3.4.14",
"drizzle-kit": "1.0.0-rc.5-ab785fc",
"drizzle-orm": "1.0.0-rc.5-169397b",
"effect": "4.0.0-rc.111",
"effect": "4.0.0-rc.112",
"ai": "6.0.168",
"cross-spawn": "7.0.6",
"hono": "4.10.7",
@@ -101,11 +101,11 @@
"tailwindcss": "4.1.11",
"vite": "7.3.6",
"@solidjs/meta": "0.29.4",
"@solidjs/router": "0.15.4",
"@solidjs/router": "1.0.0",
"@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020",
"@sentry/solid": "10.71.0",
"@sentry/vite-plugin": "5.4.0",
"solid-js": "1.9.10",
"solid-js": "1.9.15",
"solid-sonner": "0.3.1",
"vite-plugin-solid": "2.11.10",
"@lydell/node-pty": "1.2.0-beta.12"
@@ -161,7 +161,8 @@
"@effect/platform-node-shared": "catalog:",
"@types/bun": "catalog:",
"@types/node": "catalog:",
"effect": "catalog:"
"effect": "catalog:",
"solid-js": "catalog:"
},
"patchedDependencies": {
"@ai-sdk/openai-compatible@2.0.41": "patches/@ai-sdk%2Fopenai-compatible@2.0.41.patch",
@@ -169,7 +170,7 @@
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
"solid-js@1.9.15": "patches/solid-js@1.9.15.patch",
"@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch",
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
"pacote@21.5.0": "patches/pacote@21.5.0.patch",
@@ -0,0 +1,85 @@
import { expect, test } from "bun:test"
import { MemoryRouter, Route, createMemoryHistory, useNavigate, useParams } from "@solidjs/router"
import { createEffect, onCleanup } from "solid-js"
import { createComponent, render } from "solid-js/web"
import { createSessionOwnership } from "@/session/session-ownership"
test("session ownership follows navigation, back, forward, replacement, and route disposal", async () => {
const host = document.createElement("div")
const history = createMemoryHistory()
history.set({ value: "/session/A", replace: true, scroll: false })
const captured: ReturnType<ReturnType<typeof createSessionOwnership>["capture"]>[] = []
const navigate: ReturnType<typeof useNavigate>[] = []
const cleaned: string[] = []
const settle = () => new Promise((resolve) => setTimeout(resolve, 0))
const Session = () => {
const params = useParams<{ id: string }>()
const ownership = createSessionOwnership(() => params.id)
const label = document.createElement("div")
navigate.push(useNavigate())
createEffect(() => {
label.textContent = params.id
captured.push(ownership.capture())
})
onCleanup(() => cleaned.push("session"))
return label
}
const dispose = render(
() =>
createComponent(MemoryRouter, {
history,
get children() {
return [
createComponent(Route, { path: "/", component: () => "Home" }),
createComponent(Route, { path: "/session/:id", component: Session }),
]
},
}),
host,
)
try {
expect(host.textContent).toBe("A")
expect(captured[0].current()).toBe(true)
navigate[0]("/session/B")
await settle()
expect(host.textContent).toBe("B")
expect(captured[0].current()).toBe(false)
expect(captured[1].current()).toBe(true)
history.back()
await settle()
expect(host.textContent).toBe("A")
expect(captured[0].current()).toBe(false)
expect(captured[1].current()).toBe(false)
expect(captured[2].current()).toBe(true)
history.forward()
await settle()
expect(host.textContent).toBe("B")
expect(captured[2].current()).toBe(false)
expect(captured[3].current()).toBe(true)
expect(navigate).toHaveLength(1)
expect(cleaned).toEqual([])
navigate[0]("/", { replace: true })
await settle()
expect(host.textContent).toBe("Home")
expect(captured.every((owner) => !owner.current())).toBe(true)
expect(cleaned).toEqual(["session"])
history.back()
await settle()
expect(host.textContent).toBe("A")
expect(captured[4].current()).toBe(true)
expect(navigate).toHaveLength(2)
} finally {
dispose()
}
expect(captured.every((owner) => !owner.current())).toBe(true)
expect(cleaned).toEqual(["session", "session"])
})
@@ -0,0 +1,89 @@
import { expect, test } from "bun:test"
import {
createComputed,
createMemo,
createResource,
createRoot,
createSignal,
onCleanup,
startTransition,
Suspense,
type Accessor,
type JSX,
} from "solid-js"
import { createComponent, render } from "solid-js/web"
// Solid 1.9.15 includes the former transition patch for solidjs/solid#2046.
test("a memo created during a paused transition has a committed value", async () => {
const host = document.createElement("div")
const loaded = Promise.withResolvers<string>()
const started = Promise.withResolvers<void>()
const memos: Accessor<string>[] = []
const [session, setSession] = createSignal(false)
const dispose = render(
() =>
createComponent(Suspense, {
fallback: "Loading",
get children() {
return createMemo(() => {
if (!session()) return "Home"
memos.push(createMemo(() => "Session"))
const [data] = createResource(() => loaded.promise)
started.resolve()
return createMemo(() => data() ?? "")
}) as unknown as JSX.Element
},
}),
host,
)
try {
expect(host.textContent).toBe("Home")
const pending = startTransition(() => setSession(true))
await started.promise
expect(host.textContent).toBe("Home")
expect(memos).toHaveLength(1)
expect(memos[0]()).toBe("Session")
loaded.resolve("Session loaded")
await pending
expect(host.textContent).toBe("Session loaded")
expect(memos[0]()).toBe("Session")
} finally {
loaded.resolve("Session loaded")
dispose()
}
})
// Exercise each patched bundle independently; importing both dev formats emits Solid's duplicate-runtime warning.
test.each(["solid.js", "solid.cjs", "dev.js", "dev.cjs", "server.js", "server.cjs"])(
"%s reentrant disposal cleans owned computations and callbacks exactly once",
async (file) => {
const runtime: {
createRoot: typeof createRoot
createComputed: typeof createComputed
onCleanup: typeof onCleanup
} = await import(`solid-js/dist/${file}`)
const cleaned: string[] = []
const dispose = runtime.createRoot((dispose) => {
runtime.onCleanup(() => cleaned.push("root"))
runtime.createComputed(() => {
runtime.onCleanup(() => cleaned.push("first"))
})
runtime.createComputed(() => {
runtime.onCleanup(() => cleaned.push("second"))
runtime.onCleanup(() => {
cleaned.push("reentrant")
// Bound the regression so an unpatched runtime fails without overflowing the stack.
if (cleaned.filter((item) => item === "reentrant").length === 1) dispose()
})
})
return dispose
})
expect(dispose).not.toThrow()
expect(cleaned.toSorted()).toEqual(["first", "reentrant", "root", "second"])
dispose()
expect(cleaned).toHaveLength(4)
},
)
@@ -84,12 +84,15 @@ export default Runtime.handler(Commands, (input) =>
update: (update) => runPromise(config.update(update)),
},
packages: {
resolve: (spec, install = true) =>
resolve: (spec, install = true, revision) =>
runPromise(
(install ? npm.add(spec, { subpaths: ["tui"] }) : npm.resolve(spec, { subpaths: ["tui"] })).pipe(
Effect.map((result) => result.entrypoint),
),
install
? npm.add(spec, { subpaths: ["tui"], revision })
: npm.resolve(spec, { subpaths: ["tui"], revision }),
),
check: (spec) => runPromise(npm.check(spec)),
update: (spec) => runPromise(npm.update(spec, { subpaths: ["tui"] })),
reload: (spec, options) => runPromise(npm.reload(spec, { subpaths: ["tui"], ...options })),
},
environment: requestedServer === undefined ? Env.session() : undefined,
terminalHandoff: () => preflight.finish(),
+1 -1
View File
@@ -37,7 +37,7 @@
"@opencode-ai/protocol": "workspace:*"
},
"peerDependencies": {
"effect": "4.0.0-rc.111",
"effect": "4.0.0-rc.112",
"solid-js": ">=1.9.0"
},
"peerDependenciesMeta": {
+24
View File
@@ -88,8 +88,32 @@ export type PluginListInput = {
export type PluginListOutput = { readonly location: Location.Info; readonly data: ReadonlyArray<Plugin.Info> }
export type PluginListOperation<E = never> = (input?: PluginListInput) => Effect.Effect<PluginListOutput, E>
export type PluginCheckInput = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly target: string
}
export type PluginCheckOutput = { readonly location: Location.Info; readonly data: Plugin.PackageStatus }
export type PluginCheckOperation<E = never> = (input: PluginCheckInput) => Effect.Effect<PluginCheckOutput, E>
export type PluginUpdateInput = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly target: string
}
export type PluginUpdateOutput = { readonly location: Location.Info; readonly data: ReadonlyArray<Plugin.Info> }
export type PluginUpdateOperation<E = never> = (input: PluginUpdateInput) => Effect.Effect<PluginUpdateOutput, E>
export type PluginReloadInput = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly target: string
}
export type PluginReloadOutput = { readonly location: Location.Info; readonly data: ReadonlyArray<Plugin.Info> }
export type PluginReloadOperation<E = never> = (input: PluginReloadInput) => Effect.Effect<PluginReloadOutput, E>
export interface PluginApi<E = never> {
readonly list: PluginListOperation<E>
readonly check: PluginCheckOperation<E>
readonly update: PluginUpdateOperation<E>
readonly reload: PluginReloadOperation<E>
}
export type SessionListInput = {
+33 -1
View File
@@ -15,6 +15,12 @@ import type {
AgentGetOutput,
PluginListInput,
PluginListOutput,
PluginCheckInput,
PluginCheckOutput,
PluginUpdateInput,
PluginUpdateOutput,
PluginReloadInput,
PluginReloadOutput,
SessionListInput,
SessionListOutput,
SessionStatsInput,
@@ -316,7 +322,33 @@ const EndpointPluginList = (raw: RawClient["server.plugin"]) => (input?: PluginL
raw["plugin.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
)
const adaptGroupPlugin = (raw: RawClient["server.plugin"]) => ({ list: EndpointPluginList(raw) })
const EndpointPluginCheck = (raw: RawClient["server.plugin"]) => (input: PluginCheckInput) =>
preserveEffect<PluginCheckOutput>()(
raw["plugin.check"]({ query: { location: input["location"] }, payload: { target: input["target"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const EndpointPluginUpdate = (raw: RawClient["server.plugin"]) => (input: PluginUpdateInput) =>
preserveEffect<PluginUpdateOutput>()(
raw["plugin.update"]({ query: { location: input["location"] }, payload: { target: input["target"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const EndpointPluginReload = (raw: RawClient["server.plugin"]) => (input: PluginReloadInput) =>
preserveEffect<PluginReloadOutput>()(
raw["plugin.reload"]({ query: { location: input["location"] }, payload: { target: input["target"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const adaptGroupPlugin = (raw: RawClient["server.plugin"]) => ({
list: EndpointPluginList(raw),
check: EndpointPluginCheck(raw),
update: EndpointPluginUpdate(raw),
reload: EndpointPluginReload(raw),
})
const EndpointSessionList = (raw: RawClient["server.session"]) => (input?: SessionListInput) =>
preserveEffect<SessionListOutput>()(
@@ -9,6 +9,12 @@ import type {
AgentGetOutput,
PluginListInput,
PluginListOutput,
PluginCheckInput,
PluginCheckOutput,
PluginUpdateInput,
PluginUpdateOutput,
PluginReloadInput,
PluginReloadOutput,
SessionListInput,
SessionListOutput,
SessionStatsInput,
@@ -460,6 +466,45 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
check: (input: PluginCheckInput, requestOptions?: RequestOptions) =>
request<PluginCheckOutput>(
{
method: "POST",
path: `/api/plugin/check`,
query: { location: input["location"] },
body: { target: input["target"] },
successStatus: 200,
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
),
update: (input: PluginUpdateInput, requestOptions?: RequestOptions) =>
request<PluginUpdateOutput>(
{
method: "POST",
path: `/api/plugin/update`,
query: { location: input["location"] },
body: { target: input["target"] },
successStatus: 200,
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
),
reload: (input: PluginReloadInput, requestOptions?: RequestOptions) =>
request<PluginReloadOutput>(
{
method: "POST",
path: `/api/plugin/reload`,
query: { location: input["location"] },
body: { target: input["target"] },
successStatus: 200,
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
),
},
session: {
list: (input?: SessionListInput, requestOptions?: RequestOptions) =>
+60 -2
View File
@@ -16,6 +16,8 @@ export type PluginSource =
| { type: "local"; path: string }
| { type: "sdk" }
export type PluginPackageStatus = { installed?: string; available?: string; mutable: boolean }
export type SessionForkBoundary = { type: "before"; messageID: string } | { type: "through"; messageID: string }
export type MoneyUSD = number
@@ -433,8 +435,24 @@ export type ProviderRequest = {
export type PermissionRule = { action: string; resource: string; effect: PermissionEffect }
export type PluginInfo =
| { id: string; source: PluginSource; status: "active"; tui: boolean }
| { id?: string; source: PluginSource; status: "failed"; error: string; tui: boolean }
| {
id: string
source: PluginSource
status: "active"
tui: boolean
revision?: string
generation?: string
error?: string
}
| {
id?: string
source: PluginSource
status: "failed"
error: string
tui: boolean
revision?: string
generation?: string
}
export type SessionMessageLocationSwitched = {
id: string
@@ -2330,6 +2348,10 @@ export type AgentNotFoundError = {
export const isAgentNotFoundError = (value: unknown): value is AgentNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "AgentNotFoundError"
export type PluginOperationError = { readonly _tag: "PluginOperationError"; readonly message: string }
export const isPluginOperationError = (value: unknown): value is PluginOperationError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "PluginOperationError"
export type InvalidCursorError = { readonly _tag: "InvalidCursorError"; readonly message: string }
export const isInvalidCursorError = (value: unknown): value is InvalidCursorError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "InvalidCursorError"
@@ -2540,6 +2562,42 @@ export type PluginListOutput = {
data: Array<PluginInfo>
}
export type PluginCheckInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly target: { readonly target: string }["target"]
}
export type PluginCheckOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: PluginPackageStatus
}
export type PluginUpdateInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly target: { readonly target: string }["target"]
}
export type PluginUpdateOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: Array<PluginInfo>
}
export type PluginReloadInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly target: { readonly target: string }["target"]
}
export type PluginReloadOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: Array<PluginInfo>
}
export type SessionListInput = {
readonly workspace?: {
readonly workspace?: string | undefined
+1 -1
View File
@@ -19,7 +19,7 @@
- If a captured user-visible output channel returns (an earlier `output.text`/`output.file`/`output.image` API was removed from v1), keep `output` as its name, distinct from the program return value: `return` stays the structured result for the model, while `output.*` describes artifacts the host may render into a conversation or UI after execution. Keep this host-neutral and let applications decide how captured output is delivered. In v1, hosts collect media host-side (outside CodeMode) instead.
- Improve the failure taxonomy. Distinguish parse/compile mistakes, unsupported syntax, user-thrown errors, invalid returned data, tool refusal, tool internal failure, timeout, and genuine runtime defects so agents can recover accurately instead of treating everything as a generic execution failure.
- Preserve the public/private error split. Tool authors should be able to return a safe model-visible message while retaining a private cause for host diagnostics. Unknown host failures must remain sanitized by default.
- Report host failure messages and underlying causes rather than replacing them with generic diagnostics. Preserve interruption behavior.
- Think deliberately about richer binary boundaries before allowing `Blob`, `File`, `ArrayBuffer`, streams, or typed arrays beyond today's JSON-like values. If CodeMode supports binary tool args/results, use explicit tagged data shapes and clear size limits rather than relying on ambient runtime serialization.
- Keep host capabilities explicit. Globals such as `fetch`, `crypto`, filesystem handles, extra modules, or network clients should be opt-in runtime capabilities with obvious policy defaults, not ambient authority. Default to unavailable unless a host deliberately provides the capability.
- If `fetch` is added, model it as a host-provided outbound capability with policy controls: allowed origins, methods, headers, response size, timeout, and whether response bodies may be returned, emitted, or only summarized through a tool.
+2 -2
View File
@@ -139,8 +139,8 @@ Diagnostic kinds:
| `ExecutionFailure` | The program threw or another execution error occurred. |
| `Truncated` | Warning only: additional warnings were omitted by `maxOutputBytes`. |
Unknown host failures, defects, and invalid outputs are sanitized. `toolError("safe message")` explicitly exposes a
safe refusal to the model; its optional cause remains private.
Host failures and defects report their messages and underlying causes. Invalid outputs include the validation or
copying error. Interruption propagates without becoming an error diagnostic.
## Discovery
+2 -2
View File
@@ -365,7 +365,7 @@ ultimate source of truth.
`catch`.
- [x] Source locations on unsupported-syntax diagnostics for JavaScript-shaped input; TypeScript transpilation may
shift them.
- [x] Sanitized model-visible diagnostics and explicit safe `ToolError` messages.
- [ ] Distinguish user-thrown failures from interpreter defects and explicit tool refusals from sanitized internal tool
- [x] Model-visible host failure messages and underlying causes, including output-validation errors.
- [ ] Distinguish user-thrown failures from interpreter defects and explicit tool refusals from internal tool
failures; preserve those categories in caught errors, promise rejection handlers, and `Promise.allSettled`
reasons.
+2 -2
View File
@@ -1,11 +1,11 @@
import { Schema } from "effect"
/** Safe operational refusal from a standard tool pack, reported as `ToolFailure`. */
/** Tool failure reported as `ToolFailure`. */
export class ToolError extends Schema.TaggedError<ToolError>()("ToolError", {
message: Schema.String,
cause: Schema.optionalKey(Schema.Defect()),
}) {}
/** Creates a tool refusal whose message is safe to include in an execution diagnostic. */
/** Creates a tool failure with an optional underlying cause. */
export const toolError = (message: string, cause?: unknown): ToolError =>
new ToolError({ message, ...(cause === undefined ? {} : { cause }) })
+18 -24
View File
@@ -1,5 +1,5 @@
import { Cause, Effect, Exit, Schema } from "effect"
import { ToolError, toolError } from "./tool-error.js"
import { Cause, Effect, Exit, Formatter, Schema } from "effect"
import { toolError } from "./tool-error.js"
import {
decodeInput as decodeToolInput,
decodeOutput as decodeToolOutput,
@@ -117,15 +117,6 @@ export class ToolRuntimeError extends Error {
}
}
const runHost = <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, ToolError, R> =>
effect.pipe(
Effect.catchCause((cause) => {
if (Cause.hasInterruptsOnly(cause)) return Effect.interrupt
const error = Cause.squash(cause)
return Effect.fail(error instanceof ToolError ? error : toolError("Tool execution failed", error))
}),
)
const blockedMemberNames = new Set(["__proto__", "constructor", "prototype"])
export const isBlockedMember = (name: string): boolean => blockedMemberNames.has(name)
@@ -507,19 +498,12 @@ export const make = <R>(
if (Exit.isSuccess(exit)) return onEnd({ ...call, durationMs, outcome: "success" })
if (Cause.hasInterruptsOnly(exit.cause)) return onEnd({ ...call, durationMs, outcome: "interrupted" })
const error = Cause.squash(exit.cause)
const message =
error instanceof ToolError || error instanceof ToolRuntimeError ? error.message : "Tool execution failed"
const message = error instanceof Error ? error.message : Cause.pretty(exit.cause)
return onEnd({ ...call, durationMs, outcome: "failure", message })
}),
)
}
const decodeOutput = (value: unknown, name: string) =>
Effect.try({
try: () => copyIn(value, `Result from tool '${name}'`),
catch: () => new ToolRuntimeError("InvalidToolOutput", `Invalid output from tool '${name}'.`),
})
const recordCall = (call: ToolCall): void => {
if (maxToolCalls !== undefined && calls.length >= maxToolCalls) {
throw new ToolRuntimeError("ToolCallLimitExceeded", `Execution exceeded its tool-call limit of ${maxToolCalls}.`)
@@ -548,12 +532,22 @@ export const make = <R>(
return yield* observeEnd(
Effect.gen(function* () {
if (hooks?.onToolCallStart !== undefined) yield* hooks.onToolCallStart(call)
const raw = yield* runHost(Effect.suspend(() => tool.execute(input)))
const result = yield* Effect.try({
try: () => decodeToolOutput(tool, raw),
catch: () => new ToolRuntimeError("InvalidToolOutput", `Invalid output from tool '${name}'.`),
const raw = yield* Effect.suspend(() => tool.execute(input)).pipe(
Effect.catchCause((cause) => {
if (Cause.hasInterruptsOnly(cause)) return Effect.interrupt
return Effect.fail(
toolError(
Cause.prettyErrors(cause)
.map((error) => (error.cause ? Formatter.format(error) : error.message || error.name))
.join("\n"),
),
)
}),
)
return yield* Effect.try({
try: () => copyIn(decodeToolOutput(tool, raw), `Result from tool '${name}'`),
catch: (cause) => new ToolRuntimeError("InvalidToolOutput", `Invalid output from tool '${name}': ${cause}`),
})
return yield* decodeOutput(result, name)
}),
call,
)
+23 -22
View File
@@ -5,15 +5,15 @@ import { CodeMode, Tool, toolError } from "../src/index.js"
const run = (tool: Tool.Tool<never>) =>
Effect.runPromise(CodeMode.make({ tools: { host: { call: tool } } }).execute("return await tools.host.call({})"))
class UnsafeHostError extends Schema.TaggedError<UnsafeHostError>()("UnsafeHostError", {
reason: Schema.String,
class HostError extends Schema.TaggedError<HostError>()("HostError", {
message: Schema.String,
}) {}
describe("CodeMode host failure boundary", () => {
test("preserves explicit safe tool failures", async () => {
test("preserves explicit tool failures", async () => {
const result = await run(
Tool.make({
description: "Fail safely",
description: "Fail",
input: Schema.Struct({}),
output: Schema.String,
execute: () => Effect.fail(toolError("Authorized request was refused")),
@@ -26,10 +26,10 @@ describe("CodeMode host failure boundary", () => {
})
})
test("does not rewrite explicit safe tool failures", async () => {
test("does not rewrite explicit tool failures", async () => {
const result = await run(
Tool.make({
description: "Fail safely",
description: "Fail",
input: Schema.Struct({}),
output: Schema.String,
execute: () => Effect.fail(toolError("File not found: /tmp/report.json")),
@@ -42,10 +42,15 @@ describe("CodeMode host failure boundary", () => {
})
})
test("sanitizes unknown host failures and defects", async () => {
test("reports failures, defects, rejected Promises, and nested causes", async () => {
for (const failure of [
Effect.fail(new UnsafeHostError({ reason: "Authorization: Bearer typed-secret" })),
Effect.die(new Error("postgres://user:defect-secret@example.invalid")),
Effect.fail(new HostError({ message: "Connection refused" })),
Effect.die(new Error("Connection refused")),
Effect.promise(async () => {
throw new Error("Connection refused")
}),
Effect.fail(toolError("Request failed", new Error("Connection refused"))),
Effect.failCause(Cause.combine(Cause.fail("Request failed"), Cause.die("Connection refused"))),
]) {
const result = await run(
Tool.make({
@@ -58,31 +63,28 @@ describe("CodeMode host failure boundary", () => {
expect(result.ok ? undefined : result.error).toStrictEqual({
kind: "ToolFailure",
message: "Tool execution failed",
message: expect.stringContaining("Connection refused"),
})
expect(JSON.stringify(result)).not.toMatch(/typed-secret|defect-secret|Authorization: Bearer/)
}
})
test("sanitizes invalid host output", async () => {
const secret = "invalid-output-secret"
test("reports invalid host output", async () => {
const result = await run(
Tool.make({
description: "Return invalid output",
input: Schema.Struct({}),
output: Schema.Struct({ safe: Schema.String }),
execute: () => Effect.succeed({ safe: 1, secret } as unknown as { readonly safe: string }),
output: Schema.Struct({ value: Schema.String }),
execute: () => Effect.succeed({ value: 1 } as unknown as { readonly value: string }),
}),
)
expect(result.ok ? undefined : result.error).toStrictEqual({
kind: "InvalidToolOutput",
message: "Invalid output from tool 'host.call'.",
message: "Invalid output from tool 'host.call': SchemaError(Expected string\n at [\"value\"])",
})
expect(JSON.stringify(result)).not.toMatch(/invalid-output-secret/)
})
test("sanitizes host output that throws while being copied", async () => {
test("reports host output copying errors", async () => {
const result = await run(
Tool.make({
description: "Return hostile output",
@@ -94,7 +96,7 @@ describe("CodeMode host failure boundary", () => {
{},
{
ownKeys: () => {
throw new Error("host-output-secret")
throw new Error("Cannot enumerate output")
},
},
),
@@ -104,9 +106,8 @@ describe("CodeMode host failure boundary", () => {
expect(result.ok ? undefined : result.error).toStrictEqual({
kind: "InvalidToolOutput",
message: "Invalid output from tool 'host.call'.",
message: "Invalid output from tool 'host.call': Error: Cannot enumerate output",
})
expect(JSON.stringify(result)).not.toMatch(/host-output-secret/)
})
test("caught tool failures are Error values in-program", async () => {
@@ -229,7 +230,7 @@ describe("CodeMode tool-call observation", () => {
{ phase: "start", index: 0, name: "context.lookup" },
{ phase: "end", index: 0, name: "context.lookup", outcome: "failure", message: "Lookup refused" },
{ phase: "start", index: 0, name: "context.lookup" },
{ phase: "end", index: 0, name: "context.lookup", outcome: "failure", message: "Tool execution failed" },
{ phase: "end", index: 0, name: "context.lookup", outcome: "failure", message: "broken" },
])
})
+1 -1
View File
@@ -514,7 +514,7 @@ function userPart(part: ContentPart): UserContent {
function assistantPart(part: ContentPart): AssistantContent {
switch (part.type) {
case "text":
return [{ type: "text", text: part.text }]
return [{ type: "text", text: part.text, providerOptions: metadataProviderOptions(part.providerMetadata) }]
case "media":
return [{ type: "file", mediaType: part.mediaType, data: part.data, filename: part.filename }]
case "reasoning":
+1 -3
View File
@@ -65,9 +65,7 @@ export const create = (
(name, tool, input) =>
Effect.gen(function* () {
const index = yield* Ref.getAndUpdate(callIndex, (index) => index + 1)
const executed = yield* executeTool(name, tool, input, context).pipe(
Effect.mapError((failure) => toolError(failure.message, failure)),
)
const executed = yield* executeTool(name, tool, input, context)
const content =
typeof executed.content === "string"
? [{ type: "text" as const, text: executed.content }]
+35 -15
View File
@@ -53,6 +53,9 @@ export class UpdateError extends Schema.TaggedError<UpdateError>()("Config.Updat
export const Options = Schema.Struct({
project: Schema.optional(Schema.Boolean),
// false skips the global config dir, ~/.claude, and ~/.agents; wellknown,
// file, and content entries still load.
global: Schema.optional(Schema.Boolean),
file: Schema.optional(Schema.String),
content: Schema.optional(Schema.String),
})
@@ -220,28 +223,39 @@ export const layer = (options?: Options) =>
})
.pipe(Effect.orDie)
const globalEnabled = options?.global !== false
// A walked path that resolves into a global root is global config
// however the walk reached it (home above the project, or a location
// beneath the global config dir), so global: false excludes it
// uniformly — classified once here, not per consumer below.
const globalRoots = [globalDirectory, globalClaudeDirectory, globalAgentsDirectory].map((item) =>
path.resolve(item),
)
const visible = globalEnabled
? discovered
: discovered.filter((item) => {
const resolved = path.resolve(item)
return !globalRoots.some((root) => resolved === root || resolved.startsWith(root + path.sep))
})
// We load certain files from a few other folders in the ecosystem
const claude = [
...new Set([
...((yield* fs.isDir(globalClaudeDirectory)) ? [globalClaudeDirectory] : []),
...discovered.filter((item) => path.basename(item) === ".claude").toReversed(),
...(globalEnabled && (yield* fs.isDir(globalClaudeDirectory)) ? [globalClaudeDirectory] : []),
...visible.filter((item) => path.basename(item) === ".claude").toReversed(),
]),
].map((directory) => new ClaudeDirectory({ type: "claude", path: AbsolutePath.make(directory) }))
const agents = [
...new Set([
...((yield* fs.isDir(globalAgentsDirectory)) ? [globalAgentsDirectory] : []),
...discovered.filter((item) => path.basename(item) === ".agents").toReversed(),
...(globalEnabled && (yield* fs.isDir(globalAgentsDirectory)) ? [globalAgentsDirectory] : []),
...visible.filter((item) => path.basename(item) === ".agents").toReversed(),
]),
].map((directory) => new AgentsDirectory({ type: "agents", path: AbsolutePath.make(directory) }))
const directories = [
globalDirectory,
...discovered
.filter((item) => path.basename(item) === ".opencode")
.toReversed()
.map((directory) => AbsolutePath.make(directory)),
]
const directPaths = discovered
const projectDirectories = visible
.filter((item) => path.basename(item) === ".opencode")
.toReversed()
.map((directory) => AbsolutePath.make(directory))
const directPaths = visible
.filter((item) => ![".agents", ".claude", ".opencode"].includes(path.basename(item)))
.toReversed()
fileTargets.clear()
@@ -273,15 +287,21 @@ export const layer = (options?: Options) =>
)
: []
const supplementary = yield* Effect.forEach(directories, loadDirectory).pipe(Effect.orDie)
// Global entries sit below explicit and direct files; project
// directories rank above them.
const globalSupplementary = globalEnabled ? yield* loadDirectory(globalDirectory).pipe(Effect.orDie) : []
const projectSupplementary = yield* Effect.forEach(projectDirectories, loadDirectory).pipe(
Effect.orDie,
Effect.map((entries) => entries.flat()),
)
return [
...(yield* loadWellknown().pipe(Effect.orDie)),
...claude,
...agents,
...(supplementary[0] ?? []),
...globalSupplementary,
...explicit,
...direct,
...supplementary.slice(1).flat(),
...projectSupplementary,
...content,
]
})
+14 -5
View File
@@ -19,6 +19,9 @@ export const Plugin = define({
id: "opencode.config.instruction",
effect: Effect.fn(function* () {
const discovery = yield* InstructionDiscovery.Service
// Nothing this plugin watches or loads can contribute when both scopes
// are disabled; skip the resolves, the watcher fiber, and the transform.
if (!discovery.project && !discovery.global) return
yield* Effect.gen(function* () {
const fs = yield* FSUtil.Service
const global = yield* Global.Service
@@ -35,9 +38,15 @@ export const Plugin = define({
const loaded: { current: Loaded } = { current: { type: "available", files: [] } }
const publish = (update: Watcher.Update) => PubSub.publish(changes, update.path).pipe(Effect.asVoid)
// The ancestor walk can reach the global file when the location sits
// beneath the global config dir; global: false excludes it there too.
const candidates = [
globalFile,
...(project ? ancestorDirectories(start, stop).map((directory) => join(directory, "AGENTS.md")) : []),
...(discovery.global ? [globalFile] : []),
...(project
? ancestorDirectories(start, stop)
.map((directory) => join(directory, "AGENTS.md"))
.filter((file) => discovery.global || file !== globalFile)
: []),
]
for (const path of new Set(candidates)) {
const updates = yield* watcher.subscribe({ path, type: "file" })
@@ -51,15 +60,15 @@ export const Plugin = define({
})
const globalSource = Effect.fn("ConfigInstructionPlugin.globalSource")(function* () {
if (!discovery.global) return []
const file = yield* read(globalFile)
return file ? [file] : []
})
const projectSource = Effect.fn("ConfigInstructionPlugin.projectSource")(function* () {
if (!project) return []
const discovered = new Set(
yield* Effect.forEach(yield* fs.up({ targets: ["AGENTS.md"], start, stop }), fs.resolve),
)
const walked = yield* Effect.forEach(yield* fs.up({ targets: ["AGENTS.md"], start, stop }), fs.resolve)
const discovered = new Set(walked.filter((file) => discovery.global || file !== globalFile))
const files = yield* Effect.forEach(discovered, read, { concurrency: "unbounded" })
if (files.some((file) => file === undefined)) return Instructions.unavailable
return files.filter((file): file is InstructionDiscovery.File => file !== undefined)
+1 -10
View File
@@ -311,7 +311,7 @@ function validateField(field: InputField, value: Form.Value): string | undefined
}
if (field.format === "email" && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value))
return `Expected email for form field: ${field.key}`
if (field.format === "uri" && !isUri(value)) return `Expected URI for form field: ${field.key}`
if (field.format === "uri" && !URL.canParse(value)) return `Expected URI for form field: ${field.key}`
if (field.format === "date" && !isDate(value)) return `Expected date for form field: ${field.key}`
if (field.format === "date-time" && !isDateTime(value)) return `Expected date-time for form field: ${field.key}`
if (field.options && !field.custom && !field.options.some((option) => option.value === value)) {
@@ -347,15 +347,6 @@ function isStringArray(value: Form.Value): value is ReadonlyArray<string> {
return Array.isArray(value) && value.every((item): item is string => typeof item === "string")
}
function isUri(value: string) {
try {
new URL(value)
return true
} catch {
return false
}
}
function isDate(value: string) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false
const date = new Date(`${value}T00:00:00.000Z`)
+12 -13
View File
@@ -37,20 +37,19 @@ export const layer = Layer.effect(
const runText = Effect.fn("Generate.text")(function* (input: TextInput) {
const resolved = yield* resolver.resolve(input.model).pipe(
Effect.catchTags({
"SessionRunnerModel.VariantUnavailableError": (error) =>
input.model
Effect.catchTag(
[
"SessionRunnerModel.VariantUnavailableError",
"SessionRunnerModel.UnsupportedPackageError",
"SessionRunnerModel.UnresolvedProviderVariablesError",
],
(error) => {
const mapped: Error = input.model
? new ModelSelectionError({ message: error.message })
: new UnavailableError({ message: error.message, service: error.providerID }),
"SessionRunnerModel.UnsupportedPackageError": (error) =>
input.model
? new ModelSelectionError({ message: error.message })
: new UnavailableError({ message: error.message, service: error.providerID }),
"SessionRunnerModel.UnresolvedProviderVariablesError": (error) =>
input.model
? new ModelSelectionError({ message: error.message })
: new UnavailableError({ message: error.message, service: error.providerID }),
}),
: new UnavailableError({ message: error.message, service: error.providerID })
return Effect.fail(mapped)
},
),
)
if (!resolved)
return yield* new ModelSelectionError({
@@ -47,7 +47,7 @@ export function prepareResponsesTools({
name: tool.name,
description: tool.description,
parameters: tool.inputSchema,
strict: strictJsonSchema,
strict: tool.strict ?? strictJsonSchema,
})
break
case "provider": {
+31 -4
View File
@@ -23,6 +23,7 @@ import { Mcp } from "./mcp/index.js"
import { Permission } from "./permission.js"
import { Plugin } from "./plugin.js"
import { PluginHooks } from "./plugin/hooks.js"
import { PluginExecution } from "./plugin/execution.js"
import { InstancePlugins } from "./plugin/instance.js"
import { PluginSupervisor } from "./plugin/supervisor.js"
import { Worktree } from "./worktree.js"
@@ -67,6 +68,7 @@ const nodes = [
AISDK.node,
Plugin.node,
PluginHooks.node,
PluginExecution.node,
InstancePlugins.node,
PluginSupervisor.node,
Worktree.refreshNode,
@@ -114,17 +116,42 @@ export type Error = LayerNode.Error<typeof graph>
export interface Options {
// Plugins this instance is born with; empty and absent are equivalent.
readonly plugins?: InstancePlugins.List
// Filesystem config discovery; true (default) is today's behavior. When
// false the instance boots vanilla: no upward config scan, no global config
// dir, no plugin-dir loading or ambient plugin module imports, no boot-time
// AGENTS.md discovery. Wellknown integration config and host-injected values
// still apply, and a config value that explicitly names something
// file-backed (a skill path, an MCP command) is a request, not discovery.
// Use-triggered behavior also stays: reading a file still injects nested
// AGENTS.md instructions — that is session-time context, not boot-time
// population.
readonly discovery?: boolean
readonly replacements?: LayerNode.Replacements
}
// Vanilla neutralizes the discovery inputs; the plugin list stays untouched.
// These are defaults ahead of caller replacements: a host that replaces a
// gated node itself owns that node's discovery flags. Plugin-directory
// discovery needs no swap here — implicit scanning only triggers on Directory
// config entries, which a no-scan Config never produces, and the default
// source still honors explicit plugin operations from wellknown and
// host-injected config.
const vanillaReplacements: LayerNode.Replacements = [
[Config.node, Config.configured({ project: false, global: false })],
[InstructionDiscovery.node, InstructionDiscovery.configured({ project: false, global: false })],
]
// One instance is one compiled, fresh copy of the graph standing on a directory.
export function layer(ref: Location.Ref, options: Options = {}) {
const startedAt = performance.now()
// Bound pairs come last, so they win over caller replacements of the same nodes.
const allReplacements = (options.replacements ?? []).concat([
[Location.node, Location.boundNode(ref)],
// Ordered: vanilla defaults, then caller replacements (which win over the
// defaults), then bound pairs (which win over everything).
const allReplacements: LayerNode.Replacements = [
...(options.discovery === false ? vanillaReplacements : []),
...(options.replacements ?? []),
[Location.node, Location.boundNode(ref, { discovery: options.discovery })],
[InstancePlugins.node, InstancePlugins.bound(options.plugins ?? [])],
])
]
// Apply replacements during hoist, not afterward: replacements can
// introduce new tagged dependencies (Location.boundNode depends on
// Project), and the hoist walk is the only pass that can still slice
@@ -39,12 +39,14 @@ export interface Interface extends State.Transformable<Draft> {
// Discovery policy lives here because internal plugins have no per-composition options channel.
// Move it into plugin config once plugins can consume their own options.
readonly project: boolean
readonly global: boolean
readonly list: () => Effect.Effect<File[] | Instructions.Unavailable>
readonly load: () => Effect.Effect<Instructions.List>
}
export const Options = Schema.Struct({
project: Schema.optional(Schema.Boolean),
global: Schema.optional(Schema.Boolean),
})
export type Options = typeof Options.Type
@@ -95,6 +97,7 @@ export const layer = (options?: Options) =>
return Service.of({
project: options?.project !== false,
global: options?.global !== false,
transform: state.transform,
reload: state.reload,
list,
-4
View File
@@ -684,10 +684,6 @@ const layer = Layer.effect(
const credential = yield* credentials.get(connection.id)
if (!credential) return undefined
if (credential.value.type === "key") return credential.value
// Plugin activation batches registrations: a plugin resolving during its own
// setup must see the refresh implementation it just registered, or an expired
// credential is silently returned without refreshing.
yield* state.flush()
const implementation = state
.get()
.integrations.get(credential.integrationID)
+4 -4
View File
@@ -17,12 +17,12 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Lo
export const node = LayerNode.unbound(Service, tags.values.location)
const layer = (ref: Ref) =>
const layer = (ref: Ref, options?: { readonly discovery?: boolean }) =>
Layer.effect(
Service,
Effect.gen(function* () {
const project = yield* Project.Service
const resolved = yield* project.resolve(ref.directory)
const resolved = yield* project.resolve(ref.directory, options)
return Service.of({
directory: ref.directory,
workspaceID: ref.workspaceID,
@@ -33,9 +33,9 @@ const layer = (ref: Ref) =>
}),
)
export const boundNode = (ref: Ref) =>
export const boundNode = (ref: Ref, options?: { readonly discovery?: boolean }) =>
makeLocationNode({
service: Service,
layer: layer(ref),
layer: layer(ref, options),
deps: [Project.node],
})
+107 -44
View File
@@ -1,5 +1,5 @@
export * as Plugin from "./plugin.js"
export { Event, ID, Info, Source } from "@opencode-ai/schema/plugin"
export { Event, ID, Info, Source, PackageStatus, OperationError } from "@opencode-ai/schema/plugin"
import { Plugin } from "@opencode-ai/schema/plugin"
import type { Plugin as PluginDefinition } from "@opencode-ai/plugin/effect/plugin"
@@ -26,6 +26,7 @@ import { Vcs } from "./vcs.js"
import { PluginHooks } from "./plugin/hooks.js"
import { Generate } from "./generate.js"
import { Permission } from "./permission.js"
import { PluginExecution } from "./plugin/execution.js"
export interface Interface {
readonly activate: (
@@ -38,6 +39,8 @@ export interface Interface {
export type Versioned = PluginDefinition & {
readonly version: string
readonly source?: Plugin.Source
readonly revision?: string
readonly generation?: string
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Plugin") {}
@@ -47,8 +50,12 @@ const layer = Layer.effect(
Effect.gen(function* () {
const bus = yield* Bus.Service
const kv = yield* KV.Service
const execution = yield* PluginExecution.Service
const scope = yield* Scope.make()
const active = new Map<Plugin.ID, { readonly plugin: Versioned; readonly scope: Scope.Closeable }>()
const active = new Map<
Plugin.ID,
{ readonly plugin: Versioned; readonly scope: Scope.Closeable; readonly error?: string }
>()
const lock = Semaphore.makeUnsafe(1)
let inventory: Plugin.Info[] = []
let host: Parameters<PluginDefinition["effect"]>[0]
@@ -75,7 +82,7 @@ const layer = Layer.effect(
"plugin.id": plugin.id,
cause: loaded.cause,
})
return { error: Cause.pretty(loaded.cause) } as const
return { error: errorMessage(loaded.cause) } as const
})
const activate = Effect.fn("Plugin.activate")(function* (
@@ -98,55 +105,84 @@ const layer = Layer.effect(
return entry.plugin.id === definition?.id && entry.plugin.version === definition.version
})
) {
const nextInventory = [...Array.from(active.values(), (entry) => activeInfo(entry.plugin)), ...failures]
const nextInventory = withFailures(
Array.from(active.values(), (entry) => activeInfo(entry.plugin, entry.error)),
failures,
)
if (JSON.stringify(inventory) === JSON.stringify(nextInventory)) return
inventory = nextInventory
yield* bus.publish(Plugin.Event.Updated, {})
return
}
yield* State.batch(
Effect.gen(function* () {
const nextInventory: Plugin.Info[] = []
for (const definition of definitions) {
const previous = active.get(definition.id)
active.delete(definition.id)
if (previous) yield* Scope.close(previous.scope, Exit.void).pipe(Effect.ignore)
yield* execution.exclusive(
State.batch(
Effect.gen(function* () {
const nextInventory: Plugin.Info[] = []
// Registrations are append-ordered. Preserve the unchanged prefix, then rebuild the suffix.
const current = Array.from(active.values(), (entry) => entry.plugin)
const prefix = definitions.findIndex(
(definition, index) =>
definition.id !== current[index]?.id || definition.version !== current[index]?.version,
)
const keep = prefix === -1 ? definitions.length : prefix
const previousEntries = new Map(active)
const closing = Array.from(active.entries()).slice(keep).toReversed()
yield* Effect.forEach(
closing,
([id, entry]) =>
Effect.gen(function* () {
yield* Scope.close(entry.scope, Exit.void).pipe(
Effect.catchCause((cause) =>
Effect.logWarning("failed to clean up plugin", { "plugin.id": id, cause }),
),
)
active.delete(id)
}),
{ discard: true },
)
for (const [index, definition] of definitions.entries()) {
if (index < keep) {
nextInventory.push(activeInfo(definition, active.get(definition.id)?.error))
continue
}
const previous = previousEntries.get(definition.id)
const loaded = yield* load(definition)
if (loaded.scope !== undefined) {
active.set(definition.id, { plugin: definition, scope: loaded.scope })
nextInventory.push(activeInfo(definition))
continue
const loaded = yield* load(definition)
if (loaded.scope !== undefined) {
active.set(definition.id, { plugin: definition, scope: loaded.scope })
nextInventory.push(activeInfo(definition))
continue
}
const failed: Plugin.Info = {
id: definition.id,
source: definition.source ?? { type: "builtin" },
status: "failed",
error: loaded.error,
tui: definition.tui ?? false,
...(definition.revision === undefined ? {} : { revision: definition.revision }),
...(definition.generation === undefined ? {} : { generation: definition.generation }),
}
if (!previous) {
nextInventory.push(failed)
continue
}
const restored = yield* load(previous.plugin)
if (restored.scope !== undefined) {
active.set(definition.id, { plugin: previous.plugin, scope: restored.scope, error: loaded.error })
nextInventory.push({ ...activeInfo(previous.plugin), error: loaded.error })
continue
}
yield* Effect.logError("failed to restore plugin; deactivating", {
"plugin.id": definition.id,
})
nextInventory.push(failed)
}
nextInventory.push({
id: definition.id,
source: definition.source ?? { type: "builtin" },
status: "failed",
error: loaded.error,
tui: definition.tui ?? false,
})
if (!previous) continue
const restored = yield* load(previous.plugin)
if (restored.scope !== undefined) {
active.set(definition.id, { plugin: previous.plugin, scope: restored.scope })
continue
}
yield* Effect.logError("failed to restore plugin; deactivating", {
"plugin.id": definition.id,
})
}
const removed = Array.from(active.entries())
.filter(([id]) => !ids.has(id))
.toReversed()
removed.forEach(([id]) => active.delete(id))
yield* Effect.forEach(removed, ([, entry]) => Scope.close(entry.scope, Exit.void).pipe(Effect.ignore), {
discard: true,
})
inventory = [...nextInventory, ...failures]
}),
inventory = withFailures(nextInventory, failures)
}),
).pipe(Effect.uninterruptible),
)
yield* bus.publish(Plugin.Event.Updated, {})
}),
@@ -171,15 +207,41 @@ const layer = Layer.effect(
}),
)
function activeInfo(plugin: Versioned): Plugin.Info {
function activeInfo(plugin: Versioned, error?: string): Plugin.Info {
return {
id: Plugin.ID.make(plugin.id),
source: plugin.source ?? { type: "builtin" },
status: "active",
tui: plugin.tui ?? false,
...(plugin.revision === undefined ? {} : { revision: plugin.revision }),
...(plugin.generation === undefined ? {} : { generation: plugin.generation }),
...(error === undefined ? {} : { error }),
}
}
/** Inventory errors must not expose credentials or unbounded import/setup traces. */
export function errorMessage(cause: Cause.Cause<unknown>) {
return Cause.pretty(cause)
.replace(/https?:\/\/[^\s"'<>]+/g, (value) => {
if (!URL.canParse(value)) return "[URL]"
const url = new URL(value)
url.username = ""
url.password = ""
url.search = ""
url.hash = ""
return url.href
})
.slice(0, 2000)
}
function withFailures(active: Plugin.Info[], failures: readonly Extract<Plugin.Info, { readonly status: "failed" }>[]) {
return failures.reduce((inventory, failure) => {
const index = inventory.findIndex((entry) => JSON.stringify(entry.source) === JSON.stringify(failure.source))
if (index === -1) return [...inventory, failure]
return inventory.map((entry, current) => (current === index ? { ...entry, error: failure.error } : entry))
}, active)
}
export const node = makeLocationNode({
service: Service,
layer,
@@ -203,5 +265,6 @@ export const node = makeLocationNode({
WebSearch.node,
Generate.node,
Permission.node,
PluginExecution.node,
],
})
+74
View File
@@ -0,0 +1,74 @@
export * as PluginExecution from "./execution.js"
import { Context, Effect, Latch, Layer, Semaphore } from "effect"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
export interface Interface {
readonly lease: <A, E, R>(
session: { readonly id: string; readonly parentID?: string; readonly admission?: boolean },
effect: Effect.Effect<A, E, R>,
) => Effect.Effect<A, E, R>
readonly exclusive: <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E, R>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/PluginExecution") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const admission = yield* Latch.make(true)
const idle = yield* Latch.make(true)
const mutations = yield* Semaphore.make(1)
const readers = new Map<string, number>()
let leases = 0
const acquire = (session: {
readonly id: string
readonly parentID?: string
readonly admission?: boolean
}): Effect.Effect<void> =>
Effect.suspend(() => {
// Child execution must finish the parent's tool call even while activation waits.
if (
!admission.isOpen() &&
!readers.has(session.id) &&
!(session.parentID && readers.has(session.parentID)) &&
!(session.admission && leases > 0)
)
return Effect.interruptible(admission.await).pipe(Effect.andThen(acquire(session)))
// Admission protects callbacks but does not establish Session execution ownership.
if (!session.admission) readers.set(session.id, (readers.get(session.id) ?? 0) + 1)
leases++
return idle.close
})
return Service.of({
lease: (session, effect) =>
Effect.acquireUseRelease(
acquire(session),
() => effect,
() =>
Effect.suspend(() => {
leases--
if (!session.admission) {
const count = (readers.get(session.id) ?? 1) - 1
if (count === 0) readers.delete(session.id)
else readers.set(session.id, count)
}
return leases === 0 ? idle.open : Effect.void
}),
),
exclusive: (effect) =>
mutations.withPermit(
Effect.uninterruptibleMask((restore) =>
admission.close.pipe(
Effect.andThen(restore(idle.await).pipe(Effect.andThen(restore(effect)))),
Effect.ensuring(admission.open),
),
),
),
})
}),
)
export const node = makeLocationNode({ service: Service, layer, deps: [] })
+5
View File
@@ -4,6 +4,7 @@ import { Plugin } from "@opencode-ai/plugin/effect"
import type { IntegrationMethodRegistration } from "@opencode-ai/plugin/effect/integration"
import { EventManifest } from "@opencode-ai/schema/event-manifest"
import { ServerConfig } from "@opencode-ai/schema/mcp"
import { OperationError } from "@opencode-ai/schema/plugin"
import { App } from "../app.js"
import { Effect, Schema, Stream } from "effect"
import { Agent } from "../agent.js"
@@ -327,6 +328,10 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
},
plugin: {
list: () => response(plugin.list()),
// In-process management can wait on its own setup/tool lease. Use an external client instead.
check: () => Effect.fail(new OperationError({ message: "Check plugin updates through an external client." })),
update: () => Effect.fail(new OperationError({ message: "Update plugins through an external client." })),
reload: () => Effect.fail(new OperationError({ message: "Reload plugins through an external client." })),
},
reference: {
list: () => response(reference.list()),
+6 -5
View File
@@ -13,10 +13,11 @@ import type { Versioned } from "../plugin.js"
* for the instance's lifetime; runtime dynamism lives inside plugins through
* the container transform/reload APIs.
*
* Limitations, both shared with `SdkPlugins`: `vcs` marker declarations are
* not seen by `ProjectMarkers` (it is global and runs during project
* resolution, before the instance exists), and config plugin operations may
* disable instance plugins by id.
* Limitations: `vcs` marker declarations in an instance list are not seen by
* `ProjectMarkers` (it is global and runs during project resolution, before
* the instance exists unlike `SdkPlugins`, whose declarations it consumes
* directly), and config plugin operations may disable instance plugins by id,
* matching `SdkPlugins` behavior.
*/
export type List = readonly Plugin[]
@@ -28,7 +29,7 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/In
export const node = makeLocationNode({
service: Service,
layer: Layer.succeed(Service, Service.of({ all: () => [] })),
layer: bound([]),
deps: [],
})
+15 -6
View File
@@ -36,15 +36,22 @@ const Definition = Schema.Struct({
export const load = Effect.fn("PluginModule.load")(function* (
operation: Extract<ConfigPluginSource.Operation, { type: "add" }>,
fresh?: Npm.EntryPoint,
) {
const npm = yield* Npm.Service
const entrypoint = path.isAbsolute(operation.target)
? pathToFileURL(operation.target).href
: (yield* npm.add(operation.target, { subpaths: ["server", ""], refresh: true })).entrypoint
const resolved =
fresh ??
(path.isAbsolute(operation.target)
? { entrypoint: pathToFileURL(operation.target).href, revision: undefined, generation: undefined }
: yield* npm.add(operation.target, { subpaths: ["server", ""], refresh: true }))
const entrypoint = resolved.entrypoint
if (!entrypoint) return yield* Effect.fail(new Error(`Plugin entrypoint not found: ${operation.target}`))
// Bun currently ignores query parameters when caching file:// imports.
const target = typeof Bun !== "undefined" ? operation.target.replaceAll("\\", "/") : entrypoint
const source = operation.mtime === undefined ? entrypoint : `${target}?mtime=${operation.mtime}`
const target =
typeof Bun !== "undefined" && path.isAbsolute(operation.target)
? operation.target.replaceAll("\\", "/")
: entrypoint
const source = fresh || operation.mtime === undefined ? entrypoint : `${target}?mtime=${operation.mtime}`
yield* Effect.log({ msg: "loading plugin", id: operation.target, entrypoint: source })
const mod = yield* Effect.promise(() => importModule(source))
const value = (yield* Schema.decodeUnknownEffect(Definition)(mod)).default
@@ -53,7 +60,9 @@ export const load = Effect.fn("PluginModule.load")(function* (
id: plugin.id,
tui: plugin.tui,
vcs: plugin.vcs,
version: JSON.stringify(operation),
version: JSON.stringify([operation, resolved.generation]),
revision: resolved.revision,
generation: resolved.generation,
source: path.isAbsolute(operation.target)
? { type: "local" as const, path: operation.target }
: { type: "package" as const, package: operation.target },
@@ -1,5 +1,5 @@
import { Effect, Option } from "effect"
import type { Document } from "@opencode-ai/schema/config"
import type { Document, Entry } from "@opencode-ai/schema/config"
import { Catalog } from "../../catalog.js"
import { Config } from "../../config.js"
import { Provider } from "../../provider.js"
@@ -9,7 +9,11 @@ export const configuredSettings = Effect.fn("ProviderPlugin.configuredSettings")
const current = (yield* catalog.provider.get(id))?.settings
const service = yield* Effect.serviceOption(Config.Service)
const entries = Option.isSome(service) ? yield* service.value.entries() : []
return foldSettings(entries, id, current)
})
export function foldSettings(entries: readonly Entry[], id: string, initial: Provider.Settings | undefined) {
return entries
.filter((entry): entry is Document => entry.type === "document")
.reduce((settings, entry) => Provider.mergeOverlay(settings, entry.info.providers?.[id]?.settings), current)
})
.reduce((settings, entry) => Provider.mergeOverlay(settings, entry.info.providers?.[id]?.settings), initial)
}
@@ -81,9 +81,6 @@ export const GoogleVertexPlugin = define({
...(typeof provider.settings?.baseURL === "string"
? { baseURL: replaceVertexVars(provider.settings.baseURL, project, location) }
: {}),
...(Provider.packageName(provider.package)?.includes("@ai-sdk/openai-compatible")
? { fetch: authFetch(provider.settings?.fetch) }
: {}),
}
})
}
@@ -1,11 +1,11 @@
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Document, type Entry } from "@opencode-ai/schema/config"
import type { Entry } from "@opencode-ai/schema/config"
import { Duration, Effect, Schedule, Schema, Semaphore, Stream } from "effect"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { Config } from "../../config.js"
import { Model } from "../../model.js"
import { Provider } from "../../provider.js"
import type { PluginInternal } from "../internal.js"
import { foldSettings } from "./configured.js"
const providerID = "lmstudio"
@@ -150,13 +150,7 @@ export function make(origin = "http://127.0.0.1:1234", interval: Duration.Input
export const LMStudioPlugin = make()
function configured(entries: readonly Entry[], origin: string) {
const settings = entries
.filter((entry): entry is Document => entry.type === "document")
.flatMap((entry) => {
const settings = entry.info.providers?.[providerID]?.settings
return settings ? [settings] : []
})
.reduce<Provider.Settings | undefined>((result, item) => Provider.mergeOverlay(result, item), undefined)
const settings = foldSettings(entries, providerID, undefined)
const baseURL = (
typeof settings?.baseURL === "string" ? settings.baseURL : `${origin.replace(/\/+$/, "")}/v1`
).replace(/\/+$/, "")
+3 -9
View File
@@ -1,11 +1,11 @@
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Document, type Entry } from "@opencode-ai/schema/config"
import type { Entry } from "@opencode-ai/schema/config"
import { Duration, Effect, Schedule, Schema, Semaphore, Stream } from "effect"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { Config } from "../../config.js"
import { Model } from "../../model.js"
import { Provider } from "../../provider.js"
import type { PluginInternal } from "../internal.js"
import { foldSettings } from "./configured.js"
const providerID = "ollama"
@@ -206,13 +206,7 @@ export function make(origin = "http://127.0.0.1:11434", interval: Duration.Input
export const OllamaPlugin = make()
function configured(entries: readonly Entry[], origin: string) {
const settings = entries
.filter((entry): entry is Document => entry.type === "document")
.flatMap((entry) => {
const settings = entry.info.providers?.[providerID]?.settings
return settings ? [settings] : []
})
.reduce<Provider.Settings | undefined>((result, item) => Provider.mergeOverlay(result, item), undefined)
const settings = foldSettings(entries, providerID, undefined)
const baseURL = (
typeof settings?.baseURL === "string" ? settings.baseURL : `${origin.replace(/\/+$/, "")}/v1`
).replace(/\/+$/, "")
+3 -9
View File
@@ -1,11 +1,11 @@
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Document, type Entry } from "@opencode-ai/schema/config"
import type { Entry } from "@opencode-ai/schema/config"
import { Duration, Effect, Schedule, Schema, Semaphore, Stream } from "effect"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { Config } from "../../config.js"
import { Model } from "../../model.js"
import { Provider } from "../../provider.js"
import type { PluginInternal } from "../internal.js"
import { foldSettings } from "./configured.js"
const providerID = "vllm"
@@ -136,13 +136,7 @@ export function make(origin = "http://127.0.0.1:8000", interval: Duration.Input
export const VLLMPlugin = make()
function configured(entries: readonly Entry[], origin: string) {
const settings = entries
.filter((entry): entry is Document => entry.type === "document")
.flatMap((entry) => {
const settings = entry.info.providers?.[providerID]?.settings
return settings ? [settings] : []
})
.reduce<Provider.Settings | undefined>((result, item) => Provider.mergeOverlay(result, item), undefined)
const settings = foldSettings(entries, providerID, undefined)
const baseURL = (
typeof settings?.baseURL === "string" ? settings.baseURL : `${origin.replace(/\/+$/, "")}/v1`
).replace(/\/+$/, "")
+1 -1
View File
@@ -76,7 +76,7 @@ export const layerWithCell = (cell: Cell) =>
resume: (sessionID) => require(cell, (runtime) => runtime.session.resume(sessionID)),
switchAgent: (input) => require(cell, (runtime) => runtime.session.switchAgent(input)),
switchModel: (input) => require(cell, (runtime) => runtime.session.switchModel(input)),
interrupt: (sessionID) => require(cell, (runtime) => runtime.session.interrupt(sessionID)),
interrupt: (sessionID, options) => require(cell, (runtime) => runtime.session.interrupt(sessionID, options)),
synthetic: (input) => require(cell, (runtime) => runtime.session.synthetic(input)),
wait: (sessionID) => require(cell, (runtime) => runtime.session.wait(sessionID)),
context: (sessionID) => require(cell, (runtime) => runtime.session.context(sessionID)),
@@ -1,6 +1,7 @@
export * as PluginSupervisor from "./supervisor-service.js"
import { Context, Effect } from "effect"
import type { Plugin } from "@opencode-ai/schema/plugin"
/**
* Dependency-only supervisor seam. Keep this module free of implementation
@@ -9,6 +10,11 @@ import { Context, Effect } from "effect"
export interface Interface {
/** Wait for the initial plugin generation and startup updates to settle. */
readonly flush: Effect.Effect<void>
/** Initial reconciliation barrier; it never closes again after startup settles. */
readonly initialized: Effect.Effect<void>
readonly check: (target: string) => Effect.Effect<Plugin.PackageStatus, Plugin.OperationError>
readonly update: (target: string) => Effect.Effect<Plugin.Info[], Plugin.OperationError>
readonly reload: (target: string) => Effect.Effect<Plugin.Info[], Plugin.OperationError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/PluginSupervisor") {}
+203 -19
View File
@@ -2,8 +2,10 @@ export * as PluginSupervisor from "./supervisor.js"
export { Service, type Interface } from "./supervisor-service.js"
import { Event } from "@opencode-ai/schema/config"
import { Cause, Effect, Latch, Layer, Stream } from "effect"
import { Cause, Effect, Latch, Layer, Option, Scope, Semaphore, Stream } from "effect"
import path from "path"
import { randomUUID } from "node:crypto"
import { pathToFileURL } from "node:url"
import { ConfigPluginSource } from "../config/plugin/source.js"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Bus } from "../bus.js"
@@ -15,10 +17,13 @@ import { PluginModule } from "./module.js"
import { SdkPlugins } from "./sdk.js"
import { Service } from "./supervisor-service.js"
type Cached = { readonly operation: string; readonly plugin: Plugin.Versioned; readonly error?: string }
const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
pre: readonly Plugin.Versioned[],
post: readonly Plugin.Versioned[],
operations: readonly ConfigPluginSource.Operation[],
cache: Map<string, Cached>,
) {
const matches = (selector: string, target: string) =>
selector === "*" || (selector.endsWith(".*") ? target.startsWith(selector.slice(0, -1)) : selector === target)
@@ -26,6 +31,7 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
const enabled = new Set(definitions.map((plugin) => plugin.id))
const packages = new Map<string, Plugin.Versioned>()
const failures = new Map<string, Extract<Plugin.Info, { readonly status: "failed" }>>()
const previousSources = new Map(cache)
const plugins = () => [...definitions, ...packages.values()]
for (const operation of operations) {
@@ -48,13 +54,23 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
continue
}
const plugin = yield* PluginModule.load(operation).pipe(
Effect.catchCause((cause) =>
Effect.logWarning("failed to load plugin", { target: operation.target, cause }).pipe(
Effect.as({ error: Cause.pretty(cause) }),
),
),
)
const cached = previousSources.get(operation.target)
const key = JSON.stringify(operation)
const plugin =
cached?.operation === key
? cached.plugin
: yield* PluginModule.load(operation).pipe(
Effect.flatMap((plugin) =>
cached && plugin.id !== cached.plugin.id
? Effect.fail(new Error(`Plugin ID changed from ${cached.plugin.id} to ${plugin.id}`))
: Effect.succeed(plugin),
),
Effect.catchCause((cause) =>
Effect.logWarning("failed to load plugin", { target: operation.target, cause }).pipe(
Effect.as({ error: Plugin.errorMessage(cause) }),
),
),
)
if ("error" in plugin) {
failures.set(operation.target, {
source: pluginSource(operation.target),
@@ -62,9 +78,24 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
error: plugin.error,
tui: false,
})
if (cached) {
cache.set(operation.target, { ...cached, operation: key, error: plugin.error })
packages.set(operation.target, cached.plugin)
enabled.add(cached.plugin.id)
}
continue
}
failures.delete(operation.target)
if (cached?.operation === key && cached.error) {
failures.set(operation.target, {
source: pluginSource(operation.target),
status: "failed",
error: cached.error,
tui: plugin.tui ?? false,
})
} else {
failures.delete(operation.target)
cache.set(operation.target, { operation: key, plugin })
}
const previous = packages.get(operation.target)
if (previous) enabled.delete(previous.id)
packages.set(operation.target, plugin)
@@ -77,7 +108,12 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
...[...packages.values()].filter((plugin) => enabled.has(plugin.id)),
...post.filter((plugin) => enabled.has(plugin.id)),
],
failures: [...failures.values()],
failures: [...failures.entries()]
.filter(([target]) => {
const plugin = packages.get(target)
return !plugin || enabled.has(plugin.id)
})
.map(([, failure]) => failure),
}
})
@@ -89,12 +125,20 @@ export const layer = Layer.effect(
const instance = yield* InstancePlugins.Service
const sources = yield* ConfigPluginSource.Service
const bus = yield* Bus.Service
const npm = yield* Npm.Service
const scope = yield* Scope.Scope
const ready = yield* Latch.make()
const initialized = yield* Latch.make()
const mutations = yield* Semaphore.make(1)
const cache = new Map<string, Cached>()
// Internal definitions capture stable Location services; SDK contributions remain dynamic.
const internal = yield* PluginInternal.list()
let observed = 0
const activate = Effect.fn("PluginSupervisor.activate")(function* () {
// Resolve OpenCode's internal plugins with their privileged Location services.
const internal = yield* PluginInternal.list()
const activate = Effect.fn("PluginSupervisor.activate")(function* (
operations: readonly ConfigPluginSource.Operation[],
previous: ReadonlyMap<string, Cached> = new Map(cache),
) {
// Combine internal plugins with host-contributed plugins in boot order.
// Instance-bound plugins come last: later activation can override earlier
// container writes, so the instance's explicit choices win over globals.
@@ -108,12 +152,117 @@ export const layer = Layer.effect(
version: "internal",
source: { type: "builtin" as const },
}))
const operations = yield* sources.operations()
// Apply config operations and load enabled package plugins into one ordered generation.
const resolved = yield* resolve(pre, post, operations)
const resolved = yield* resolve(pre, post, operations, cache).pipe(Effect.provideService(Npm.Service, npm))
// Replace the active generation in one scoped, batched activation.
yield* registry.activate(resolved.plugins, resolved.failures)
const activated = yield* registry.activate(resolved.plugins, resolved.failures).pipe(Effect.exit)
if (activated._tag === "Failure") {
cache.clear()
previous.forEach((entry, target) => cache.set(target, entry))
return yield* Effect.failCause(activated.cause)
}
const inventory = yield* registry.list()
for (const [target, entry] of cache) {
if (!operations.some((operation) => operation.type === "add" && operation.target === target)) {
cache.delete(target)
continue
}
const active = inventory.find((item) =>
item.source.type === "local"
? item.source.path === target
: item.source.type === "package" && item.source.package === target,
)
if (active?.status !== "active" || !active.error) continue
const old = previous.get(target)
if (old) cache.set(target, { ...old, operation: entry.operation, error: active.error })
}
return inventory
})
const operation = Effect.fnUntraced(function* (target: string) {
const operations = yield* sources.operations().pipe(Effect.provideService(Scope.Scope, scope))
const entry = operations.findLast((entry) => entry.type === "add" && entry.target === target)
const inventory = yield* registry.list()
if (
!entry ||
entry.type !== "add" ||
target === "*" ||
target.endsWith(".*") ||
target.startsWith("opencode.") ||
(!cache.has(target) &&
!inventory.some((item) =>
item.source.type === "local"
? item.source.path === target
: item.source.type === "package" && item.source.package === target,
))
)
return yield* new Plugin.OperationError({ message: `Not a configured plugin source: ${target}` })
return { entry, operations }
})
const mutate = (target: string, action: "update" | "reload") =>
initialized.await.pipe(
Effect.andThen(
mutations.withPermit(
Effect.gen(function* () {
const selected = yield* operation(target)
if (action === "update") {
if (path.isAbsolute(target) || !(yield* npm.inspect(target)).mutable)
return yield* new Plugin.OperationError({ message: `Plugin source cannot be updated: ${target}` })
}
const previous = new Map(cache)
const generation = randomUUID()
const staged = path.isAbsolute(target)
? {
directory: path.dirname(target),
generation,
entrypoint: `${typeof Bun !== "undefined" ? target.replaceAll("\\", "/") : pathToFileURL(target).href}?reload=${generation}`,
}
: yield* action === "update"
? npm.update(target, { subpaths: ["server", ""] })
: npm.reload(target, {
subpaths: ["server", ""],
revision: cache.get(target)?.plugin.revision,
generation: cache.get(target)?.plugin.generation,
})
const loaded = yield* PluginModule.load(selected.entry, staged).pipe(
Effect.provideService(Npm.Service, npm),
Effect.exit,
)
if (loaded._tag === "Failure") {
const old = cache.get(target)
if (!old) return yield* Effect.failCause(loaded.cause)
cache.set(target, {
...old,
operation: JSON.stringify(selected.entry),
error: Plugin.errorMessage(loaded.cause),
})
return yield* activate(selected.operations, previous)
}
const old = cache.get(target)
if (old && loaded.value.id !== old.plugin.id) {
cache.set(target, {
...old,
operation: JSON.stringify(selected.entry),
error: `Plugin ID changed from ${old.plugin.id} to ${loaded.value.id}`,
})
return yield* activate(selected.operations, previous)
}
cache.set(target, { operation: JSON.stringify(selected.entry), plugin: loaded.value })
return yield* activate(selected.operations, previous)
}),
),
),
Effect.catchCause((cause) => {
if (Cause.hasInterruptsOnly(cause)) return Effect.interrupt
const error = Option.getOrUndefined(Cause.findErrorOption(cause))
return Effect.fail(
error instanceof Plugin.OperationError
? error
: new Plugin.OperationError({ message: `Unable to ${action} plugin source.` }),
)
}),
)
const updates = Stream.merge(sources.changes(), bus.subscribe([Event.Updated, SdkPlugins.Updated])).pipe(
// Make accepted work visible to flush before coalescing the burst.
Stream.mapEffect(() =>
@@ -130,13 +279,48 @@ export const layer = Layer.effect(
Stream.debounce("100 millis"),
Stream.runForEach((target) =>
Effect.gen(function* () {
yield* activate().pipe(Effect.catchCause((cause) => Effect.logError("failed to reload plugins", { cause })))
if (observed === target) yield* ready.open
yield* mutations
.withPermit(
Effect.gen(function* () {
return yield* activate(yield* sources.operations().pipe(Effect.provideService(Scope.Scope, scope)))
}),
)
.pipe(Effect.catchCause((cause) => Effect.logError("failed to reload plugins", { cause })))
if (observed === target) {
yield* initialized.open
yield* ready.open
}
}),
),
Effect.forkScoped({ startImmediately: true }),
)
return Service.of({ flush: ready.await })
return Service.of({
flush: ready.await,
initialized: initialized.await,
check: (target) =>
initialized.await.pipe(
Effect.andThen(
mutations.withPermit(
Effect.gen(function* () {
yield* operation(target)
if (path.isAbsolute(target)) return { mutable: false }
return yield* npm.check(target)
}),
),
),
Effect.catchCause((cause) => {
if (Cause.hasInterruptsOnly(cause)) return Effect.interrupt
const error = Option.getOrUndefined(Cause.findErrorOption(cause))
return Effect.fail(
error instanceof Plugin.OperationError
? error
: new Plugin.OperationError({ message: "Unable to check plugin source." }),
)
}),
),
update: (target) => mutate(target, "update"),
reload: (target) => mutate(target, "reload"),
})
}),
)
+11 -9
View File
@@ -62,7 +62,7 @@ export const root = Effect.fn("Project.root")(function* (
export interface Interface {
readonly list: () => Effect.Effect<ReadonlyArray<Info>>
readonly update: (input: UpdateInput) => Effect.Effect<Info, NotFoundError>
readonly resolve: (input: AbsolutePath) => Effect.Effect<Resolved>
readonly resolve: (input: AbsolutePath, options?: { readonly discovery?: boolean }) => Effect.Effect<Resolved>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Project") {}
@@ -256,15 +256,14 @@ const layer = Layer.effect(
const value = input.trim()
if (!value) return undefined
try {
const parsed = new URL(value)
const parsed = URL.parse(value)
if (parsed) {
if (parsed.protocol === "file:") return undefined
return parts(parsed.hostname, parsed.pathname)
} catch {
const scp = value.match(/^([^@/:]+@)?([^/:]+):(.+)$/)
if (scp) return parts(scp[2], scp[3])
return undefined
}
const scp = value.match(/^([^@/:]+@)?([^/:]+):(.+)$/)
if (scp) return parts(scp[2], scp[3])
return undefined
}
function parts(host: string, name: string) {
@@ -317,9 +316,12 @@ const layer = Layer.effect(
}
})
const resolve = Effect.fn("Project.resolve")(function* (input: AbsolutePath) {
const resolve = Effect.fn("Project.resolve")(function* (
input: AbsolutePath,
options?: { readonly discovery?: boolean },
) {
const directory = AbsolutePath.make(yield* fs.resolve(input))
const marker = yield* markers.discover(directory)
const marker = yield* markers.discover(directory, options)
const native = yield* fs.up({ targets: [".git", ".hg"], start: directory, mode: "first" }).pipe(
Effect.map((matches) => matches[0]),
Effect.orElseSucceed(() => undefined),
+18 -3
View File
@@ -22,7 +22,10 @@ export interface Match {
}
export interface Interface {
readonly discover: (directory: AbsolutePath) => Effect.Effect<Match | undefined>
readonly discover: (
directory: AbsolutePath,
options?: { readonly discovery?: boolean },
) => Effect.Effect<Match | undefined>
readonly targets: () => readonly string[]
}
@@ -38,7 +41,10 @@ const layer = Layer.effect(
const known = new Set([".git", ".hg"])
const loaded = new Map<string, Versioned | undefined>()
const discover = Effect.fn("ProjectMarkers.discover")(function* (directory: AbsolutePath) {
// The filesystem half of discovery: walk up for config, scan plugin
// directories, and read configured plugin operations. This is the part a
// no-discovery caller must skip — it imports plugin modules.
const scanOperations = Effect.fnUntraced(function* (directory: AbsolutePath) {
const found = yield* fs
.up({ targets: [".opencode", "opencode.json", "opencode.jsonc"], start: directory })
.pipe(Effect.orElseSucceed(() => []))
@@ -54,7 +60,7 @@ const layer = Layer.effect(
const configured = yield* Effect.forEach([...new Set(files)], (file) => read(fs, file)).pipe(
Effect.map((entries) => entries.flat()),
)
const operations = yield* Effect.forEach(
return yield* Effect.forEach(
[
...automatic.map((target): ConfigPluginSource.Operation => ({ type: "add", target, options: {} })),
...configured,
@@ -70,6 +76,15 @@ const layer = Layer.effect(
)
},
)
})
const discover = Effect.fn("ProjectMarkers.discover")(function* (
directory: AbsolutePath,
options?: { readonly discovery?: boolean },
) {
// discovery: false skips the config scan and its plugin module loading;
// sdk-declared vcs markers are host-explicit, not ambient, so they stay.
const operations = options?.discovery === false ? [] : yield* scanOperations(directory)
const declarations = new Map<string, { readonly id: string; readonly markers: readonly string[] }>()
for (const plugin of sdk.all()) {
+114 -86
View File
@@ -41,6 +41,7 @@ import { Session } from "@opencode-ai/schema/session"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Image } from "./image.js"
import { PluginSupervisor } from "./plugin/supervisor-service.js"
import { PluginExecution } from "./plugin/execution.js"
import { PluginHooks } from "./plugin/hooks.js"
import { Mime } from "./mime.js"
import type { EventLog } from "@opencode-ai/schema/event-log"
@@ -690,30 +691,39 @@ const layer = Layer.effect(
),
generate: Effect.fn("Session.generate")(function* (input) {
const session = yield* result.get(input.sessionID)
const generate = yield* SessionGenerate.Service.pipe(Effect.provide(locations.get(session.location)))
return yield* generate.generate(input)
return yield* Effect.gen(function* () {
const plugins = yield* PluginSupervisor.Service
yield* plugins.initialized
const gate = yield* PluginExecution.Service
const generate = yield* SessionGenerate.Service
return yield* gate.lease({ id: session.id, admission: true }, generate.generate(input))
}).pipe(Effect.provide(locations.get(session.location)))
}),
command: Effect.fn("Session.command")(function* (input) {
const session = yield* result.get(input.sessionID)
const commands = yield* Effect.gen(function* () {
yield* Effect.gen(function* () {
const plugins = yield* PluginSupervisor.Service
yield* plugins.flush
return yield* Command.Service
yield* plugins.initialized
const gate = yield* PluginExecution.Service
const commands = yield* Command.Service
// Commands may await child Sessions, so they retain ownership that lets those children drain.
return yield* gate.lease(
session,
commands.execute({
name: input.command,
invocation: {
sessionID: input.sessionID,
prompt: {
text: input.text,
files: input.files,
agents: input.agents,
skills: input.skills,
},
delivery: input.delivery ?? "steer",
},
}),
)
}).pipe(Effect.provide(locations.get(session.location)))
const delivery = input.delivery ?? "steer"
yield* commands.execute({
name: input.command,
invocation: {
sessionID: input.sessionID,
prompt: {
text: input.text,
files: input.files,
agents: input.agents,
skills: input.skills,
},
delivery,
},
})
}),
shell: Effect.fn("Session.shell")(function* (input) {
const session = yield* result.get(input.sessionID)
@@ -723,16 +733,20 @@ const layer = Layer.effect(
yield* execution.awaitIdle(input.sessionID)
const started = yield* Effect.gen(function* () {
const plugins = yield* PluginSupervisor.Service
yield* plugins.flush
yield* plugins.initialized
const gate = yield* PluginExecution.Service
const shell = yield* Shell.Service
return yield* shell
.create({
command: input.command,
cwd: session.location.directory,
timeout: 0,
metadata: { sessionID: input.sessionID },
})
.pipe(Effect.orDie)
return yield* gate.lease(
{ id: input.sessionID, admission: true },
shell
.create({
command: input.command,
cwd: session.location.directory,
timeout: 0,
metadata: { sessionID: input.sessionID },
})
.pipe(Effect.orDie),
)
}).pipe(Effect.provide(locations.get(session.location)))
yield* bus.publish(
SessionEvent.Shell.Started,
@@ -964,10 +978,14 @@ const layer = Layer.effect(
return yield* new BusyError({ sessionID: input.sessionID })
return yield* Effect.gen(function* () {
const plugins = yield* PluginSupervisor.Service
yield* plugins.flush
return yield* SessionRevert.stage({ session, messageID: input.messageID, files: input.files }).pipe(
Effect.provideService(Database.Service, database),
Effect.provideService(Bus.Service, bus),
yield* plugins.initialized
const gate = yield* PluginExecution.Service
return yield* gate.lease(
{ id: session.id, admission: true },
SessionRevert.stage({ session, messageID: input.messageID, files: input.files }).pipe(
Effect.provideService(Database.Service, database),
Effect.provideService(Bus.Service, bus),
),
)
}).pipe(Effect.provide(locations.get(session.location)))
}),
@@ -976,8 +994,12 @@ const layer = Layer.effect(
if ((yield* execution.active).has(sessionID)) return yield* new BusyError({ sessionID })
const revert = yield* Effect.gen(function* () {
const plugins = yield* PluginSupervisor.Service
yield* plugins.flush
return yield* SessionRevert.clear(session).pipe(Effect.provideService(Bus.Service, bus))
yield* plugins.initialized
const gate = yield* PluginExecution.Service
return yield* gate.lease(
{ id: session.id, admission: true },
SessionRevert.clear(session).pipe(Effect.provideService(Bus.Service, bus)),
)
}).pipe(Effect.provide(locations.get(session.location)))
yield* execution.wake(sessionID)
return revert
@@ -1019,59 +1041,65 @@ const preparePrompt = Effect.fn("Session.preparePrompt")(function* (
messageID: SessionMessage.ID,
) {
const plugins = yield* PluginSupervisor.Service
yield* plugins.flush
const hooks = yield* PluginHooks.Service
const event = yield* hooks.trigger("session", "prompt", {
sessionID: request.sessionID,
messageID,
prompt: structuredClone({
text: request.text,
files: request.files?.slice(),
agents: request.agents?.slice(),
skills: request.skills?.slice(),
yield* plugins.initialized
const gate = yield* PluginExecution.Service
return yield* gate.lease(
{ id: request.sessionID, admission: true },
Effect.gen(function* () {
const hooks = yield* PluginHooks.Service
const event = yield* hooks.trigger("session", "prompt", {
sessionID: request.sessionID,
messageID,
prompt: structuredClone({
text: request.text,
files: request.files?.slice(),
agents: request.agents?.slice(),
skills: request.skills?.slice(),
}),
metadata: structuredClone(request.metadata),
delivery: request.delivery ?? "steer",
})
const input = event.prompt
const fs = yield* FSUtil.Service
const files = input.files
? yield* Effect.forEach(input.files, (file) => materializeAttachment(fs, file), { concurrency: 8 })
: undefined
const requested = input.skills
const selected = yield* Effect.gen(function* () {
if (!requested?.length) return undefined
const skillService = yield* Skill.Service
const prepared = new Map<Skill.ID, Skill.Name>()
return yield* Effect.forEach(requested, (attachment) =>
Effect.gen(function* () {
const name = prepared.get(attachment.id)
if (name !== undefined) return { id: attachment.id, name, mention: attachment.mention }
const skill = yield* skillService.get(attachment.id)
if (!skill) return yield* new SkillNotFoundError({ skill: attachment.id })
prepared.set(skill.id, skill.name)
return {
id: skill.id,
name: skill.name,
text: (yield* Skill.prepare(fs, skill).pipe(Effect.orDie)).output,
mention: attachment.mention,
}
}),
)
})
return SessionInbox.Item.make({
type: "user",
payload: {
...Prompt.make({
text: input.text,
agents: input.agents,
files,
skills: selected?.length ? selected : undefined,
}),
metadata: event.metadata,
},
delivery: event.delivery,
})
}),
metadata: structuredClone(request.metadata),
delivery: request.delivery ?? "steer",
})
const input = event.prompt
const fs = yield* FSUtil.Service
const files = input.files
? yield* Effect.forEach(input.files, (file) => materializeAttachment(fs, file), { concurrency: 8 })
: undefined
const requested = input.skills
const selected = yield* Effect.gen(function* () {
if (!requested?.length) return undefined
const skillService = yield* Skill.Service
const prepared = new Map<Skill.ID, Skill.Name>()
return yield* Effect.forEach(requested, (attachment) =>
Effect.gen(function* () {
const name = prepared.get(attachment.id)
if (name !== undefined) return { id: attachment.id, name, mention: attachment.mention }
const skill = yield* skillService.get(attachment.id)
if (!skill) return yield* new SkillNotFoundError({ skill: attachment.id })
prepared.set(skill.id, skill.name)
return {
id: skill.id,
name: skill.name,
text: (yield* Skill.prepare(fs, skill).pipe(Effect.orDie)).output,
mention: attachment.mention,
}
}),
)
})
return SessionInbox.Item.make({
type: "user",
payload: {
...Prompt.make({
text: input.text,
agents: input.agents,
files,
skills: selected?.length ? selected : undefined,
}),
metadata: event.metadata,
},
delivery: event.delivery,
})
)
})
const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024
+1 -1
View File
@@ -215,7 +215,7 @@ const select = (
export const buildPrompt = (input: { readonly previousSummary?: string; readonly context: readonly string[] }) =>
[
input.previousSummary
? `Update the anchored summary below using the conversation history above.\nPreserve still-true details, remove stale details, and merge in the new facts.\n<previous-summary>\n${input.previousSummary}\n</previous-summary>`
? `Update the anchored summary below using the conversation history below.\nPreserve still-true details, remove stale details, and merge in the new facts.\n<previous-summary>\n${input.previousSummary}\n</previous-summary>`
: "Create a new anchored summary from the conversation history.",
SUMMARY_TEMPLATE,
"The following is the conversation history:",
+1 -1
View File
@@ -125,7 +125,7 @@ const layer = Layer.effect(
if (session.location.directory !== location.directory || session.location.workspaceID !== location.workspaceID)
return yield* Effect.interrupt
yield* plugins.flush
yield* plugins.initialized
yield* mcpTools.flush
const agent = yield* agents.select(session.agent)
if (!agent.info) return yield* new AgentNotFoundError({ sessionID: session.id, agent: session.agent ?? agent.id })
@@ -202,7 +202,7 @@ export const preview = Effect.fn("InstructionState.preview")(function* (
update: Instructions.renderUpdate(
instructions,
dereference(state.current_values, stored),
dereferenceDelta(result.delta, new Map([...stored, ...observedBlobs])),
dereferenceDelta(result.delta, observedBlobs),
),
}
})
+11 -2
View File
@@ -26,6 +26,7 @@ import { ToolOutput } from "../../tool-output.js"
import { PluginSupervisor } from "../../plugin/supervisor.js"
import { PromptCacheDiagnostics } from "../prompt-cache-diagnostics.js"
import { MAX_STEPS_PROMPT } from "./max-steps.js"
import { PluginExecution } from "../../plugin/execution.js"
const CONTINUE_AFTER_INCOMPLETE_STREAM =
"The previous response was interrupted. Continue from where you left off without repeating completed content."
@@ -40,6 +41,7 @@ const layer = Layer.effect(
const db = (yield* Database.Service).db
const compaction = yield* SessionCompaction.Service
const plugins = yield* PluginSupervisor.Service
const pluginExecution = yield* PluginExecution.Service
const title = yield* SessionTitle.Service
const steps = yield* SessionStep.make
const diagnostics = yield* Config.boolean("OPENCODE_PROMPT_CACHE_DIAGNOSTICS").pipe(
@@ -89,7 +91,6 @@ const layer = Layer.effect(
const control = pending.type === "compaction" || pending.type === "move"
if (promotable === "steer" && pending.delivery === "queue" && !control) return DrainResult.Complete()
}
yield* plugins.flush
yield* settleStaleToolCalls(sessionID)
const advanceToStep = Effect.fn("SessionRunner.advanceToStep")(() =>
@@ -313,7 +314,14 @@ const layer = Layer.effect(
}
})
return Service.of({ drain })
return Service.of({
drain: Effect.fnUntraced(function* (input) {
yield* plugins.initialized
const session = yield* store.get(input.sessionID)
if (!session) return yield* Effect.die(new Error(`Session not found: ${input.sessionID}`))
return yield* pluginExecution.lease(session, drain(input))
}),
})
}),
)
@@ -332,5 +340,6 @@ export const node = makeLocationNode({
Snapshot.node,
ToolOutput.node,
Database.node,
PluginExecution.node,
],
})
+8 -2
View File
@@ -17,6 +17,7 @@ import type { SessionRunnerModel } from "./runner/model.js"
import { SessionSchema } from "./schema.js"
import { SessionUsage } from "./usage.js"
import { SessionStore } from "./store.js"
import { PluginExecution } from "../plugin/execution.js"
const MAX_LENGTH = 100
const MAX_CONTEXT_LENGTH = 8_000
@@ -165,9 +166,14 @@ export const layer = Layer.effect(
const context = yield* SessionContext.Service
const store = yield* SessionStore.Service
const database = yield* Database.Service
const gate = yield* PluginExecution.Service
const title = make({ bus, llm, context, store })
return Service.of({
generate: (sessionID) => title.generate(database.db, sessionID),
generate: Effect.fnUntraced(function* (sessionID) {
const session = yield* store.get(sessionID)
if (!session) return
return yield* gate.lease({ id: session.id, admission: true }, title.generate(database.db, sessionID))
}),
})
}),
)
@@ -175,5 +181,5 @@ export const layer = Layer.effect(
export const node = makeLocationNode({
service: Service,
layer,
deps: [Bus.node, llmClient, SessionContext.node, SessionStore.node, Database.node],
deps: [Bus.node, llmClient, SessionContext.node, SessionStore.node, Database.node, PluginExecution.node],
})
+2 -20
View File
@@ -75,12 +75,6 @@ export interface Options<State, DraftApi> {
export interface Interface<State, DraftApi> extends Transformable<DraftApi> {
readonly get: () => State
/**
* Materializes registrations and disposals deferred by an active batch so
* subsequent reads observe them. No-op when nothing is pending; never waits
* out the reload debounce.
*/
readonly flush: () => Effect.Effect<void>
}
export function create<State, DraftApi>(options: Options<State, DraftApi>): Interface<State, DraftApi> {
@@ -90,7 +84,6 @@ export function create<State, DraftApi>(options: Options<State, DraftApi>): Inte
let requestedAt = 0
let running = false
let closed = false
let dirty = false
let waiters: { generation: number; done: Deferred.Deferred<void> }[] = []
const semaphore = Semaphore.makeUnsafe(1)
@@ -100,7 +93,6 @@ export function create<State, DraftApi>(options: Options<State, DraftApi>): Inte
})
const materialize = Effect.fnUntraced(function* () {
dirty = false
if (closed) return
const next = options.initial()
const api = options.draft(next)
@@ -170,7 +162,6 @@ export function create<State, DraftApi>(options: Options<State, DraftApi>): Inte
closed = true
return
}
dirty = true
batch.reloads.add(materializeReload)
return
}
@@ -186,21 +177,12 @@ export function create<State, DraftApi>(options: Options<State, DraftApi>): Inte
)
yield* Scope.addFinalizer(scope, dispose)
const batch = yield* CurrentBatch
if (batch?.active) {
dirty = true
batch.reloads.add(materializeReload)
} else yield* materializeReload()
if (batch?.active) batch.reloads.add(materializeReload)
else yield* materializeReload()
return { dispose }
}),
)
}),
reload,
flush: Effect.fnUntraced(function* () {
if (!dirty) return
// Flushing from inside the deferring batch replaces its queued rebuild.
const batch = yield* CurrentBatch
batch?.reloads.delete(materializeReload)
yield* materializeReload()
}),
}
}
+14 -27
View File
@@ -197,6 +197,12 @@ export function convertHTMLToMarkdown(html: string) {
.trim()
.replace(/([\\"])/g, "\\$1")}"`
: ""
const inlineMarker = (name: string) => {
if (name === "strong" || name === "b") return "**"
if (name === "em" || name === "i") return "*"
if (name === "s" || name === "strike" || name === "del") return "~~"
return undefined
}
const finishCode = (code: NonNullable<Frame["code"]>) => {
if (code.inline && !code.text) return
let backticks = 0
@@ -321,20 +327,9 @@ export function convertHTMLToMarkdown(html: string) {
block()
return
}
if (name === "strong" || name === "b") {
inline("**", true)
frame.marker = { index: output.length - 1, block: blockCount, previous: activeMarker }
activeMarker = frame.marker
return
}
if (name === "em" || name === "i") {
inline("*", true)
frame.marker = { index: output.length - 1, block: blockCount, previous: activeMarker }
activeMarker = frame.marker
return
}
if (name === "s" || name === "strike" || name === "del") {
inline("~~", true)
const marker = inlineMarker(name)
if (marker) {
inline(marker, true)
frame.marker = { index: output.length - 1, block: blockCount, previous: activeMarker }
activeMarker = frame.marker
return
@@ -509,16 +504,8 @@ export function convertHTMLToMarkdown(html: string) {
return
}
if (name === "dd") return block()
if (
name === "strong" ||
name === "b" ||
name === "em" ||
name === "i" ||
name === "s" ||
name === "strike" ||
name === "del"
) {
const value = name === "strong" || name === "b" ? "**" : name === "em" || name === "i" ? "*" : "~~"
const marker = inlineMarker(name)
if (marker) {
const trailingSpace = pendingSpace
pendingSpace = false
if (frame.marker) activeMarker = frame.marker.previous
@@ -527,15 +514,15 @@ export function convertHTMLToMarkdown(html: string) {
pendingSpace = trailingSpace || frame.marker.leadingSpace === true
return
}
inline(value)
inline(marker)
pendingSpace = trailingSpace || frame.marker?.leadingSpace === true
return
}
if (name === "a") {
if (frame.link && (activeLink === frame.link || !activeLink)) {
activeLink = frame.link
if (linkOpen) append(`](${destination(frame.link?.href ?? "")}${title(frame.link?.title)})`)
else if (last && last !== "\n") append(`](${destination(frame.link?.href ?? "")}${title(frame.link?.title)})`)
if (linkOpen || (last && last !== "\n"))
append(`](${destination(frame.link.href)}${title(frame.link.title)})`)
linkOpen = false
activeLink = undefined
}
+4 -8
View File
@@ -334,10 +334,10 @@ const layer = Layer.effect(
Effect.map((sets) => new Map(sets.flat(2).map((item) => [item.directory, item] as const)).values().toArray()),
)
const removed = checked.filter((item) => !item.exists).map((item) => item.directory)
const result = yield* db
const changes = yield* db
.transaction((tx) =>
Effect.all({
updated: Effect.forEach(discovered, (item) =>
updated: Effect.filter(discovered, (item) =>
ops.create(
{
projectID: input.projectID,
@@ -346,15 +346,11 @@ const layer = Layer.effect(
},
tx,
),
),
removed: Effect.forEach(removed, (directory) => ops.remove(input.projectID, directory, tx)),
).pipe(Effect.map((items) => items.map((item) => item.directory))),
removed: Effect.filter(removed, (directory) => ops.remove(input.projectID, directory, tx)),
}),
)
.pipe(Effect.orDie)
const changes = {
updated: discovered.filter((_, index) => result.updated[index]).map((item) => item.directory),
removed: removed.filter((_, index) => result.removed[index]),
}
yield* changed(input.projectID, changes.updated.length > 0 || changes.removed.length > 0)
return changes
})
+12
View File
@@ -326,6 +326,8 @@ it.effect("projects replay metadata onto AI SDK prompt parts", () =>
model: resolved,
messages: [
Message.assistant([
{ type: "text", text: "Answer", providerMetadata: { anthropic: { cacheControl: { type: "ephemeral" } } } },
{ type: "text", text: " without metadata" },
{ type: "reasoning", text: "Think", providerMetadata: { anthropic: { signature: "signed" } } },
{
type: "tool-call",
@@ -344,6 +346,16 @@ it.effect("projects replay metadata onto AI SDK prompt parts", () =>
{
role: "assistant",
content: [
{
type: "text",
text: "Answer",
providerOptions: { anthropic: { cacheControl: { type: "ephemeral" } } },
},
{
type: "text",
text: " without metadata",
providerOptions: undefined,
},
{
type: "reasoning",
text: "Think",
+3 -3
View File
@@ -1,5 +1,5 @@
import { describe, expect } from "bun:test"
import { Cause, Deferred, Effect, Exit, Fiber, Layer, Option, Ref, Schema, Stream } from "effect"
import { Cause, Deferred, Effect, Exit, Fiber, Layer, Ref, Schema, Stream } from "effect"
import { Bus } from "@opencode-ai/core/bus"
import { Event } from "@opencode-ai/schema/event"
import { Session } from "@opencode-ai/schema/session"
@@ -228,10 +228,10 @@ describe("Bus", () => {
)
const event = yield* bus.publish(SyncMessage, { id: "one", text: "hello" })
yield* bus.publish(SyncMessage, { id: "one", text: "after unsubscribe" })
yield* bus.publish(SyncMessage, { id: "one", text: "second event" })
expect(received[0]).toEqual(event)
expect(received[1]?.data).toEqual({ id: "one", text: "after unsubscribe" })
expect(received[1]?.data).toEqual({ id: "one", text: "second event" })
}),
)
+79 -1
View File
@@ -13,7 +13,6 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Credential } from "@opencode-ai/core/credential"
import { ConfigMigrateV1 } from "@opencode-ai/core/v1/config/migrate"
import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
import { Bus } from "@opencode-ai/core/bus"
import { Global } from "@opencode-ai/util/global"
@@ -128,6 +127,85 @@ describe("Config", () => {
),
)
it.live("excludes home-level claude and agents directories when global is disabled", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) => {
const global = path.join(tmp.path, "global")
const home = path.join(global, "home")
const project = path.join(home, "project")
const ambient = (entries: readonly { type: string }[]) =>
entries.filter((entry) => entry.type === "claude" || entry.type === "agents")
return Effect.promise(() =>
Promise.all([
fs.mkdir(project, { recursive: true }),
fs.mkdir(path.join(home, ".claude"), { recursive: true }),
fs.mkdir(path.join(home, ".agents"), { recursive: true }),
]),
).pipe(
Effect.andThen(
Effect.gen(function* () {
// The fixture is real: with global enabled the walk finds both.
const config = yield* Config.Service
expect(ambient(yield* config.entries()).length).toBe(2)
}).pipe(Effect.provide(testLayer(project, global))),
),
Effect.andThen(
// Home-level directories are global config however the walk
// reaches them, so global: false excludes them even with the
// project walk enabled.
Effect.gen(function* () {
const config = yield* Config.Service
expect(ambient(yield* config.entries())).toEqual([])
}).pipe(
Effect.provide(
testLayer(project, global, project, undefined, undefined, undefined, undefined, { global: false }),
),
),
),
)
}),
),
)
it.live("excludes global config reached through the project walk when global is disabled", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) => {
// The location sits BENEATH the global config dir, so the upward walk
// reaches the global opencode.json as a direct file.
const global = path.join(tmp.path, "global")
const project = path.join(global, "plugins", "demo")
return Effect.promise(async () => {
await fs.mkdir(project, { recursive: true })
await fs.writeFile(path.join(global, "opencode.json"), JSON.stringify({ shell: "global-sentinel" }))
}).pipe(
Effect.andThen(
// Fixture control: with global enabled the file loads.
Effect.gen(function* () {
const config = yield* Config.Service
expect(Config.latest(yield* config.entries(), "shell")).toBe("global-sentinel")
}).pipe(Effect.provide(testLayer(project, global))),
),
Effect.andThen(
Effect.gen(function* () {
const config = yield* Config.Service
expect(Config.latest(yield* config.entries(), "shell")).toBeUndefined()
}).pipe(
Effect.provide(
testLayer(project, global, project, undefined, undefined, undefined, undefined, { global: false }),
),
),
),
)
}),
),
)
it.effect("fails updates when no file-backed document exists", () =>
Effect.gen(function* () {
const config = yield* Config.Service
+106 -1
View File
@@ -19,13 +19,15 @@ import { Model } from "@opencode-ai/core/model"
import { Provider } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Effect, Fiber, Logger, Stream } from "effect"
import { Npm } from "@opencode-ai/util/npm"
import { Database } from "../../src/database/database"
import { tmpdir } from "../fixture/tmpdir"
import { git } from "../fixture/git"
import { tempGlobalLayer } from "../fixture/global"
import { testEffect } from "../lib/effect"
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), [
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node, Npm.node]), [
[Global.node, tempGlobalLayer],
]),
)
@@ -37,6 +39,85 @@ const staticIt = testEffect(
)
describe("PluginSupervisor config", () => {
it.live(
"updates a real package graph and retains its active revision after failed import and setup",
() =>
withLocation(
undefined,
Effect.gen(function* () {
yield* ready()
const location = yield* Location.Service
const registry = yield* Plugin.Service
const supervisor = yield* PluginSupervisor.Service
const agents = yield* Agent.Service
const npm = yield* Npm.Service
const repository = path.join(location.directory, "package")
const target = `git+${pathToFileURL(repository).href}#fixture-branch`
const initial = (yield* registry.list())[0]
expect(initial?.revision).toMatch(/^[0-9a-f]{40}$/)
expect((yield* agents.get(Agent.ID.make("package-agent")))?.description).toBe("first")
yield* Effect.promise(() => fs.writeFile(path.join(repository, "value.js"), `export const value = "second"`))
yield* commitPackage(repository)
const check = yield* supervisor.check(target)
expect(check.mutable).toBe(true)
expect(check.installed).toBe(initial?.revision)
expect(check.available).not.toBe(initial?.revision)
const updated = (yield* supervisor.update(target))[0]
expect(updated?.revision).toBe(check.available)
expect(updated?.generation).not.toBe(initial?.generation)
expect((yield* agents.get(Agent.ID.make("package-agent")))?.description).toBe("second")
yield* Effect.promise(() => fs.writeFile(path.join(repository, "index.js"), packagePlugin(true)))
yield* commitPackage(repository)
const failedSetup = (yield* supervisor.update(target))[0]
expect(failedSetup).toEqual({ ...updated, error: expect.stringContaining("package setup failed") })
expect((yield* agents.get(Agent.ID.make("package-agent")))?.description).toBe("second")
expect((yield* npm.inspect(target)).installed).not.toBe(updated?.revision)
yield* Effect.promise(() =>
fs.writeFile(path.join(repository, "index.js"), `import "./missing.js"; ${packagePlugin()}`),
)
yield* commitPackage(repository)
const failedImport = (yield* supervisor.update(target))[0]
expect(failedImport).toEqual({ ...updated, error: expect.stringContaining("missing.js") })
expect((yield* agents.get(Agent.ID.make("package-agent")))?.description).toBe("second")
const reloaded = (yield* supervisor.reload(target))[0]
expect(reloaded?.status).toBe("active")
expect(reloaded?.revision).toBe(updated?.revision)
expect(reloaded?.error).toBeUndefined()
expect(reloaded?.generation).not.toBe(updated?.generation)
}),
false,
async (directory) => {
const repository = path.join(directory, "package")
await fs.mkdir(repository)
await fs.writeFile(
path.join(repository, "package.json"),
JSON.stringify({
name: "fixture-core-plugin",
version: "1.0.0",
type: "module",
exports: "./index.js",
}),
)
await fs.writeFile(path.join(repository, "index.js"), packagePlugin())
await fs.writeFile(path.join(repository, "value.js"), `export const value = "first"`)
await git(repository, "init", "-q", "-b", "fixture-branch")
await Effect.runPromise(commitPackage(repository))
await fs.writeFile(
path.join(directory, "opencode.json"),
JSON.stringify({
plugins: [
"-*",
{ package: `git+${pathToFileURL(repository).href}#fixture-branch`, options: { selection: "first" } },
{ package: `git+${pathToFileURL(repository).href}#fixture-branch`, options: { selection: "last" } },
],
}),
)
},
),
30_000,
)
it.live("applies selectors in order", () =>
withLocation(
{ plugins: ["-opencode.provider.*", "opencode.provider.openai"] },
@@ -505,6 +586,30 @@ export default Plugin.define({
`
}
function packagePlugin(fail = false) {
return `import { value } from "./value.js"
export default { id: "package-plugin", tui: true, async setup(ctx) {
await ctx.agent.transform((draft) => draft.update("package-agent", (agent) => { agent.description = value }))
${fail ? 'throw new Error("package setup failed")' : ""}
} }`
}
const commitPackage = (repository: string) =>
Effect.promise(async () => {
await git(repository, "add", ".")
await git(
repository,
"-c",
"user.name=fixture",
"-c",
"user.email=fixture@example.com",
"commit",
"-q",
"-m",
"fixture",
)
})
function discoveredPlugin(id: string) {
return `export default { id: ${JSON.stringify(id)}, setup() {} }`
}
+2 -2
View File
@@ -7,7 +7,7 @@ import { SqliteClient } from "@effect/sql-sqlite-bun"
import { eq, sql } from "drizzle-orm"
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"
import { Effect, Tracer } from "effect"
import type { SqlClient as SqlClientService } from "effect/unstable/sql/SqlClient"
import type { SqlClient } from "effect/unstable/sql/SqlClient"
import { isSqlError } from "effect/unstable/sql/SqlError"
import { EffectDrizzleSqlite } from "@opencode-ai/core/database/drizzle"
@@ -16,7 +16,7 @@ const users = sqliteTable("users", {
name: text().notNull(),
})
const run = <A, E>(effect: Effect.Effect<A, E, SqlClientService>) =>
const run = <A, E>(effect: Effect.Effect<A, E, SqlClient>) =>
Effect.runPromise(
effect.pipe(Effect.provide(SqliteClient.layer({ filename: ":memory:", disableWAL: true })), Effect.scoped),
)
@@ -2,8 +2,7 @@ import { describe, expect } from "bun:test"
import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import { Effect, Exit, Stream } from "effect"
import type * as PlatformError from "effect/PlatformError"
import { Effect, Exit, PlatformError, Stream } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { CrossSpawnSpawner } from "@opencode-ai/util/cross-spawn-spawner"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
@@ -244,11 +243,11 @@ describe("cross-spawn spawner", () => {
)
fx.effect(
"kills a child when scope exits",
"uses the configured kill signal when scope exits",
Effect.gen(function* () {
const pid = yield* Effect.scoped(
Effect.gen(function* () {
const handle = yield* js("setInterval(() => {}, 10_000)")
const handle = yield* js("setInterval(() => {}, 10_000)", { killSignal: "SIGKILL" })
return Number(handle.pid)
}),
)
+1 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { Effect, Layer, Logger } from "effect"
import { Effect, Logger } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Database } from "@opencode-ai/core/database/database"
@@ -22,6 +22,7 @@ import { location } from "../fixture/location"
import { tmpdir } from "../fixture/tmpdir"
import { testEffect } from "../lib/effect"
import { host } from "../plugin/host"
import { noopPluginSupervisor } from "../fixture/plugin-supervisor"
type WatcherEvent = { file: string; event: "add" | "change" | "unlink" }
const describeNative = process.env.CI ? describe.skip : describe
@@ -31,7 +32,7 @@ const it = testEffect(AppNodeBuilder.build(LayerNode.group([FSUtil.node, Bus.nod
const configLayer = Config.testLayer()
const pluginNode = makeLocationNode({
service: PluginSupervisor.Service,
layer: Layer.succeed(PluginSupervisor.Service, PluginSupervisor.Service.of({ flush: Effect.void })),
layer: Layer.succeed(PluginSupervisor.Service, noopPluginSupervisor()),
deps: [],
})
@@ -314,7 +315,7 @@ describe("LocationWatcher subscriptions", () => {
Effect.gen(function* () {
const policy = yield* LocationWatcherPolicy.Service
yield* policy.transform((draft) => draft.add([".git"]))
return PluginSupervisor.Service.of({ flush: Effect.void })
return noopPluginSupervisor()
}),
),
deps: [LocationWatcherPolicy.node],
+1 -1
View File
@@ -1,4 +1,4 @@
import { Effect, Layer, Stream } from "effect"
import { Effect, Layer } from "effect"
import { Config } from "@opencode-ai/core/config"
import { Location } from "@opencode-ai/core/location"
import { Mcp } from "@opencode-ai/core/mcp/index"
@@ -0,0 +1,11 @@
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor-service"
import { Effect } from "effect"
export const noopPluginSupervisor = (flush: Effect.Effect<unknown> = Effect.void) =>
PluginSupervisor.Service.of({
flush: Effect.asVoid(flush),
initialized: Effect.asVoid(flush),
check: () => Effect.die("unused plugin.check"),
update: () => Effect.die("unused plugin.update"),
reload: () => Effect.die("unused plugin.reload"),
})
@@ -2,17 +2,20 @@ import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
import type { LocationServices } from "@opencode-ai/core/location-services"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor-service"
import { PluginExecution } from "@opencode-ai/core/plugin/execution"
import { noopPluginSupervisor } from "./plugin-supervisor"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Effect, Layer, LayerMap } from "effect"
import { Layer, LayerMap } from "effect"
// Plain-prompt unit fixtures use virtual directories and need only the admission hook services.
export const promptLocationLayer = Layer.effect(
LocationServiceMap.Service,
LayerMap.make(
() =>
Layer.merge(
Layer.mergeAll(
LayerNode.compile(PluginHooks.node),
Layer.succeed(PluginSupervisor.Service, { flush: Effect.void }),
PluginExecution.layer,
Layer.succeed(PluginSupervisor.Service, noopPluginSupervisor()),
) as Layer.Layer<LocationServices>,
),
)
+2 -2
View File
@@ -1,10 +1,10 @@
import fs from "fs/promises"
import { tmpdir as osTmpdir } from "os"
import os from "os"
import path from "path"
import { Effect } from "effect"
export const tmpdir = async (prefix = "opencode-core-test-") => {
const dir = await fs.realpath(await fs.mkdtemp(path.join(osTmpdir(), prefix)))
const dir = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), prefix)))
return {
path: dir,
async [Symbol.asyncDispose]() {
+11
View File
@@ -19,6 +19,17 @@ const input = {
} satisfies Form.CreateInput
describe("Form", () => {
it.effect("validates absolute URI formats without restricting schemes", () =>
Effect.sync(() => {
const fields = [{ key: "uri", type: "string", format: "uri" }] satisfies ReadonlyArray<Form.Field>
expect(Form.validateAnswer(fields, { uri: "https://example.com/path" })).toBeUndefined()
expect(Form.validateAnswer(fields, { uri: "mailto:user@example.com" })).toBeUndefined()
expect(Form.validateAnswer(fields, { uri: "relative/path" })).toBe("Expected URI for form field: uri")
expect(Form.validateAnswer(fields, { uri: "://invalid" })).toBe("Expected URI for form field: uri")
}),
)
it.effect("returns a terminal cancelled state from ask", () =>
Effect.gen(function* () {
const service = yield* Form.Service
@@ -0,0 +1,20 @@
import { expect, test } from "bun:test"
import type { LanguageModelV3FunctionTool } from "@ai-sdk/provider"
import { prepareResponsesTools } from "@opencode-ai/core/github-copilot/responses/openai-responses-prepare-tools"
function prepare(strict: boolean | undefined, strictJsonSchema: boolean) {
const tool: LanguageModelV3FunctionTool = {
type: "function",
name: "lookup",
inputSchema: { type: "object", properties: {} },
strict,
}
return prepareResponsesTools({ tools: [tool], strictJsonSchema }).tools?.[0]
}
test("function tools prefer explicit strictness over the global fallback", () => {
expect(prepare(true, false)).toMatchObject({ type: "function", strict: true })
expect(prepare(false, true)).toMatchObject({ type: "function", strict: false })
expect(prepare(undefined, true)).toMatchObject({ type: "function", strict: true })
expect(prepare(undefined, false)).toMatchObject({ type: "function", strict: false })
})
+160
View File
@@ -0,0 +1,160 @@
import fs from "fs/promises"
import path from "path"
import { describe, expect } from "bun:test"
import { Duration, Effect, Layer, LayerMap } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Global } from "@opencode-ai/util/global"
import { Config } from "@opencode-ai/core/config"
import { Instance } from "@opencode-ai/core/instance"
import { InstructionDiscovery } from "@opencode-ai/core/instruction-discovery"
import { LocationServiceMap } from "@opencode-ai/core/location-services"
import { Location } from "@opencode-ai/core/location"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Tool } from "@opencode-ai/core/tool"
import { tmpdir } from "./fixture/tmpdir"
import { tempGlobalLayer } from "./fixture/global"
import { testEffect } from "./lib/effect"
import { Database } from "../src/database/database"
import { Bus } from "../src/bus"
// Config the host hands the vanilla instance explicitly: a value and an
// explicit plugin removal, both of which must survive discovery: false.
const hostConfig: LayerNode.Replacements = [
[
Config.node,
Config.configured({
project: false,
global: false,
content: JSON.stringify({ shell: "vanilla-host", plugins: ["-opencode.tool.shell"] }),
}),
],
]
// Same directory contents, two instances: one vanilla, one with discovery.
const instances = Layer.effect(
LocationServiceMap.Service,
LayerMap.make(
(ref: Location.Ref) => {
const name = path.basename(ref.directory)
return Instance.layer(ref, {
// "bare" exercises the vanilla defaults themselves: no caller Config.
discovery: name !== "vanilla" && name !== "bare",
// Caller replacements win over the vanilla defaults.
replacements: [[Global.node, tempGlobalLayer], ...(name === "vanilla" ? hostConfig : [])],
})
},
{ idleTimeToLive: Duration.infinity },
),
)
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), [
[Global.node, tempGlobalLayer],
[LocationServiceMap.node, instances],
]),
)
describe("Instance vanilla", () => {
it.live("boots without filesystem config discovery", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((dir) =>
Effect.gen(function* () {
const locations = yield* LocationServiceMap.Service
const plant = (name: string) =>
Effect.promise(async () => {
const directory = path.join(dir.path, name)
await fs.mkdir(directory)
await fs.writeFile(path.join(directory, "opencode.json"), "{}")
await fs.writeFile(path.join(directory, "AGENTS.md"), "planted instructions")
return Location.Ref.make({ directory: AbsolutePath.make(directory) })
})
const read = (ref: Location.Ref) =>
Effect.gen(function* () {
const supervisor = yield* PluginSupervisor.Service
yield* supervisor.flush
const config = yield* Config.Service
const discovery = yield* InstructionDiscovery.Service
const tools = yield* Tool.Service
const entries = yield* config.entries()
return {
documents: entries.filter(
(entry) => "path" in entry && typeof entry.path === "string" && entry.path.startsWith(ref.directory),
),
instructions: yield* discovery.list(),
shell: Config.latest(entries, "shell"),
toolNames: (yield* tools.snapshot()).definitions.map((definition) => definition.name),
}
}).pipe(Effect.scoped, Effect.provide(locations.get(ref)))
const vanilla = yield* read(yield* plant("vanilla"))
expect(vanilla.documents).toEqual([])
expect(vanilla.instructions).toEqual([])
// Host-injected content survives discovery: false, including its
// explicit plugin operations.
expect(vanilla.shell).toBe("vanilla-host")
expect(vanilla.toolNames).not.toContain("shell")
// Bare vanilla: the defaults themselves, with no caller Config.
const bare = yield* read(yield* plant("bare"))
expect(bare.documents).toEqual([])
expect(bare.instructions).toEqual([])
expect(bare.shell).toBeUndefined()
expect(bare.toolNames).toContain("shell")
const discovery = yield* read(yield* plant("discovery"))
expect(discovery.documents.length).toBeGreaterThan(0)
expect(
Array.isArray(discovery.instructions) &&
discovery.instructions.some((file) => file.path.endsWith("AGENTS.md")),
).toBe(true)
expect(discovery.toolNames).toContain("shell")
}),
),
),
)
it.live("does not execute ambient plugin modules", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((dir) =>
Effect.gen(function* () {
const locations = yield* LocationServiceMap.Service
const directory = path.join(dir.path, "vanilla")
const marker = path.join(directory, "ambient-loaded.txt")
// A plugin module whose import writes a sentinel: project-marker
// discovery used to import it during vanilla boot.
yield* Effect.promise(async () => {
await fs.mkdir(path.join(directory, ".opencode", "plugins"), { recursive: true })
await fs.writeFile(
path.join(directory, ".opencode", "plugins", "ambient.ts"),
[
'import { writeFile } from "node:fs/promises"',
`await writeFile(${JSON.stringify(marker)}, "loaded")`,
'export default { id: "ambient-plugin", setup() {} }',
].join("\n"),
)
})
const ref = Location.Ref.make({ directory: AbsolutePath.make(directory) })
yield* Effect.gen(function* () {
const supervisor = yield* PluginSupervisor.Service
yield* supervisor.flush
const config = yield* Config.Service
// Only the pathless host-injected document; nothing file-backed.
const entries = yield* config.entries()
expect(entries.filter((entry) => "path" in entry && typeof entry.path === "string")).toEqual([])
}).pipe(Effect.scoped, Effect.provide(locations.get(ref)))
expect(yield* Effect.promise(() => Bun.file(marker).exists())).toBe(false)
}),
),
),
)
})
@@ -7,7 +7,6 @@ import { Bus } from "@opencode-ai/core/bus"
import { ConfigInstructionPlugin } from "@opencode-ai/core/config/plugin/instruction"
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
import { InstructionDiscovery } from "@opencode-ai/core/instruction-discovery"
import { Instructions } from "@opencode-ai/core/instructions/index"
import { Location } from "@opencode-ai/core/location"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { FSUtil } from "@opencode-ai/util/fs-util"
@@ -322,6 +322,35 @@ describe("InstructionState", () => {
}),
)
it.effect("previews changed and removed instructions from observed blobs", () =>
Effect.gen(function* () {
const sessionID = SessionSchema.ID.make("ses_instruction_generate_delta")
const { db, events } = yield* setup(sessionID)
let current = "Initial context"
let retired: string | Instructions.Removed = "Retired context"
const instructions = Instructions.combine([
source(
"test/current",
Effect.sync(() => current),
),
source(
"test/retired",
Effect.sync(() => retired),
),
])
yield* InstructionState.prepare(db, events, instructions, sessionID)
current = "Changed context"
retired = Instructions.removed
const assembled = yield* preview(db, sessionID, instructions)
expect(assembled.initial).toContain("Initial context")
expect(assembled.initial).toContain("Retired context")
expect(assembled.update).toContain("Changed context")
expect(assembled.update).toContain("Removed Retired context")
}),
)
it.effect("persists chronological updates as system messages", () =>
Effect.gen(function* () {
const sessionID = SessionSchema.ID.make("ses_instruction_messages")
@@ -1,7 +1,7 @@
import { describe, expect } from "bun:test"
import os from "os"
import { Effect, Layer } from "effect"
import * as TestClock from "effect/testing/TestClock"
import { TestClock } from "effect/testing"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Location } from "@opencode-ai/core/location"
import { FSUtil } from "@opencode-ai/util/fs-util"
+1 -37
View File
@@ -1,13 +1,12 @@
import { describe, expect } from "bun:test"
import { Cause, Clock, Duration, Effect, Exit, Fiber, Layer, Scope, Stream } from "effect"
import * as TestClock from "effect/testing/TestClock"
import { TestClock } from "effect/testing"
import { Credential } from "@opencode-ai/core/credential"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Bus } from "@opencode-ai/core/bus"
import { Integration } from "@opencode-ai/core/integration"
import { State } from "@opencode-ai/core/state"
import { testEffect } from "./lib/effect"
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Integration.node, Credential.node, Bus.node])))
@@ -263,41 +262,6 @@ describe("Integration", () => {
}),
)
it.effect("refreshes an expired OAuth credential registered in the same batch", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
const credentials = yield* Credential.Service
const integrationID = Integration.ID.make("opencode")
const methodID = Integration.MethodID.make("device")
const stored = yield* credentials.create({
integrationID,
label: "Work",
value: Credential.OAuth.make({ type: "oauth", methodID, access: "expired", refresh: "refresh", expires: 1 }),
})
// Plugin activation batches setup, deferring method registration until
// every plugin finishes. A plugin resolving its connection during setup
// must still reach the refresh implementation it just registered.
const resolved = yield* State.batch(
Effect.gen(function* () {
yield* integrations.transform((editor) =>
editor.method.update({
integrationID,
method: { id: methodID, type: "oauth", label: "Device" },
authorize: () => Effect.die(new Error("unused authorize")),
refresh: (credential) =>
Effect.succeed({ ...credential, access: "fresh", expires: Number.MAX_SAFE_INTEGER }),
}),
)
return yield* integrations.connection.resolve({ type: "credential", id: stored.id, label: "Work" })
}),
)
expect(resolved).toMatchObject({ type: "oauth", access: "fresh" })
expect((yield* credentials.get(stored.id))?.value).toMatchObject({ access: "fresh" })
}),
)
it.effect("completes code OAuth once and stores the credential", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
+2 -3
View File
@@ -1,7 +1,6 @@
import { test, type TestOptions } from "bun:test"
import { Cause, Effect, Exit, Layer, Scope } from "effect"
import * as TestClock from "effect/testing/TestClock"
import * as TestConsole from "effect/testing/TestConsole"
import { Cause, Effect, Exit, Layer, type Scope } from "effect"
import { TestClock, TestConsole } from "effect/testing"
type Body<A, E, R> = Effect.Effect<A, E, R> | (() => Effect.Effect<A, E, R>)
+2 -2
View File
@@ -4,7 +4,7 @@ import { SessionMessage } from "@opencode-ai/core/session/message"
import { toSessionError } from "@opencode-ai/core/session/to-session-error"
import type { SessionError } from "@opencode-ai/schema/session-error"
import { Tool } from "@opencode-ai/core/tool"
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
import type { Context } from "@opencode-ai/plugin/effect/plugin"
import { Effect, type Scope } from "effect"
import { host } from "../plugin/host"
@@ -52,7 +52,7 @@ export function waitForCodeModeTool(
export const registerToolPlugin = <R>(
plugin: {
readonly id: string
readonly effect: (context: PluginContext) => Effect.Effect<void, never, R>
readonly effect: (context: Context) => Effect.Effect<void, never, R>
},
overrides: Parameters<typeof host>[0] = {},
): Effect.Effect<void, never, R | Tool.Service | Scope.Scope> =>
+12 -11
View File
@@ -304,7 +304,7 @@ describe("LocationServiceMap", () => {
),
)
itWithSdk.live("reruns activation for Config updates during startup", () =>
itWithSdk.live("reconciles Config updates during startup without restarting unchanged plugins", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
@@ -316,18 +316,14 @@ describe("LocationServiceMap", () => {
yield* Effect.promise(() => fs.writeFile(file, "{}"))
const firstStarted = yield* Deferred.make<void>()
const releaseFirst = yield* Deferred.make<void>()
const secondStarted = yield* Deferred.make<void>()
const releaseSecond = yield* Deferred.make<void>()
const sdk = yield* SdkPlugins.Service
yield* sdk.register(
EffectPlugin.define({
id: "blocked-config-reload",
effect: () =>
Effect.sync(() => ++activations.count).pipe(
Effect.flatMap((activation) =>
activation === 1
? Deferred.succeed(firstStarted, undefined).pipe(Effect.andThen(Deferred.await(releaseFirst)))
: Deferred.succeed(secondStarted, undefined).pipe(Effect.andThen(Deferred.await(releaseSecond))),
Effect.andThen(
Deferred.succeed(firstStarted, undefined).pipe(Effect.andThen(Deferred.await(releaseFirst))),
),
),
}),
@@ -355,12 +351,17 @@ describe("LocationServiceMap", () => {
Effect.provide(context),
Effect.forkChild,
)
const initialFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.initialized).pipe(
Effect.provide(context),
Effect.forkChild({ startImmediately: true }),
)
expect(initialFiber.pollUnsafe()).toBeUndefined()
yield* Deferred.succeed(releaseFirst, undefined)
yield* Deferred.await(secondStarted)
expect(flushFiber.pollUnsafe()).toBeUndefined()
yield* Deferred.succeed(releaseSecond, undefined)
yield* Fiber.join(flushFiber)
expect(activations.count).toBe(2)
yield* Fiber.join(initialFiber)
expect(activations.count).toBe(1)
const inventory = yield* Plugin.Service.use((plugins) => plugins.list()).pipe(Effect.provide(context))
expect(inventory.map((plugin) => plugin.id)).toContain(Plugin.ID.make("config-effect-plugin"))
}),
),
),
+1 -1
View File
@@ -35,7 +35,7 @@ import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { McpTool } from "@opencode-ai/core/tool/mcp"
import { Tool } from "@opencode-ai/core/tool"
import { DateTime, Deferred, Effect, Exit, Fiber, Layer, PubSub, Ref, Schedule, Schema, Sink, Stream } from "effect"
import { Deferred, Effect, Exit, Fiber, Layer, PubSub, Ref, Schedule, Schema, Sink, Stream } from "effect"
import { TestClock } from "effect/testing"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { ExitCode, makeHandle, ProcessId } from "effect/unstable/process/ChildProcessSpawner"

Some files were not shown because too many files have changed in this diff Show More