mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-28 20:46:14 +00:00
Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c80ec7adc0 | ||
|
|
93042ddaf1 | ||
|
|
d2c307ab30 | ||
|
|
a5193634d0 | ||
|
|
95725d121a | ||
|
|
56f2559798 | ||
|
|
38bffc9db1 | ||
|
|
1d3308d904 | ||
|
|
3c9e5adefa | ||
|
|
a065ad4ba7 | ||
|
|
048aec32e7 | ||
|
|
a258b17fac | ||
|
|
07facd2086 | ||
|
|
6bf342a861 | ||
|
|
2cca1ae860 | ||
|
|
becf5bf482 | ||
|
|
afe9e579d6 | ||
|
|
0b82fe60ea | ||
|
|
6fad330efc | ||
|
|
87f21a301d | ||
|
|
0123fed65d | ||
|
|
64ef85159d | ||
|
|
e63779d9e1 | ||
|
|
ac55c05c30 | ||
|
|
8fa672ed71 |
@@ -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.
|
||||
@@ -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.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@opencode-ai/core": patch
|
||||
---
|
||||
|
||||
Preserve prompt cache prefixes when sessions move between locations with unchanged instructions.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@opencode-ai/core": patch
|
||||
---
|
||||
|
||||
Correct the published Newtype documentation to recommend `Schema.decodeUnknownSync` for schema-based synchronous decoding.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@opencode-ai/core": patch
|
||||
---
|
||||
|
||||
Fix OpenCode Console device authorization URLs when the server returns an origin-rooted verification path.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
@@ -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(),
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 }) })
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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" },
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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
@@ -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,
|
||||
]
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -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: [] })
|
||||
@@ -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()),
|
||||
|
||||
@@ -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: [],
|
||||
})
|
||||
|
||||
|
||||
@@ -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(/\/+$/, "")
|
||||
|
||||
@@ -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(/\/+$/, "")
|
||||
|
||||
@@ -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(/\/+$/, "")
|
||||
|
||||
@@ -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") {}
|
||||
|
||||
@@ -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"),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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" })
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {} }`
|
||||
}
|
||||
|
||||
@@ -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,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,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>,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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]() {
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
@@ -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,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
|
||||
|
||||
@@ -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>)
|
||||
|
||||
|
||||
@@ -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> =>
|
||||
|
||||
@@ -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"))
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user