mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-28 04:26:11 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b436244729 |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@opencode-ai/theme": patch
|
||||
---
|
||||
|
||||
Use the unread accent color by default for question and permission status indicators, while preserving explicit theme overrides.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@opencode-ai/core": patch
|
||||
---
|
||||
|
||||
Preserve prompt cache prefixes when sessions move between locations with unchanged instructions.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@opencode-ai/core": patch
|
||||
---
|
||||
|
||||
Fix OpenCode Console device authorization URLs when the server returns an origin-rooted verification path.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@opencode-ai/plugin": patch
|
||||
---
|
||||
|
||||
Derive Promise plugin API request and response conversion from the canonical protocol schemas.
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
"@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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@opencode-ai/core": patch
|
||||
---
|
||||
|
||||
Apply shared Session model-request preparation to transient generation.
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
"@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.
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
"@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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@opencode-ai/core": patch
|
||||
---
|
||||
|
||||
Use parent directory names for SKILL.md IDs so individually downloaded skills do not overwrite each other.
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
"@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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@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.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@opencode-ai/client": patch
|
||||
"@opencode-ai/plugin": patch
|
||||
---
|
||||
|
||||
Add form reply and cancellation operations that reconcile terminal forms in the local TUI projection.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@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.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@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.
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
exact = true
|
||||
# Only install newly resolved package versions published at least 3 days ago.
|
||||
minimumReleaseAge = 259200
|
||||
minimumReleaseAgeExcludes = ["@ai-sdk/amazon-bedrock", "@ai-sdk/anthropic", "@brendonovich/vite-plugin-opencode", "@opencode-ai/sdk", "@opencode-ai/pty", "@opencode-ai/pty-darwin-arm64", "@opencode-ai/pty-darwin-x64", "@opencode-ai/pty-linux-arm64-gnu", "@opencode-ai/pty-linux-arm64-musl", "@opencode-ai/pty-linux-x64-gnu", "@opencode-ai/pty-linux-x64-musl", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "opentui-spinner", "gitlab-ai-provider", "opencode-gitlab-auth", "@ff-labs/fff-node", "@ff-labs/fff-bun", "@ff-labs/fff-bin-darwin-arm64", "@ff-labs/fff-bin-darwin-x64", "@ff-labs/fff-bin-linux-arm64-gnu", "@ff-labs/fff-bin-linux-arm64-musl", "@ff-labs/fff-bin-linux-x64-gnu", "@ff-labs/fff-bin-linux-x64-musl", "@ff-labs/fff-bin-win32-arm64", "@ff-labs/fff-bin-win32-x64", "@pierre/diffs", "@pierre/theming", "app-builder-lib", "dmg-builder", "electron", "electron-builder", "electron-publish", "blume", "mermaid"]
|
||||
minimumReleaseAgeExcludes = ["@ai-sdk/amazon-bedrock", "@ai-sdk/anthropic", "@brendonovich/vite-plugin-opencode", "@opencode-ai/sdk", "@opencode-ai/pty", "@opencode-ai/pty-darwin-arm64", "@opencode-ai/pty-darwin-x64", "@opencode-ai/pty-linux-arm64-gnu", "@opencode-ai/pty-linux-arm64-musl", "@opencode-ai/pty-linux-x64-gnu", "@opencode-ai/pty-linux-x64-musl", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "opentui-spinner", "gitlab-ai-provider", "opencode-gitlab-auth", "@ff-labs/fff-node", "@ff-labs/fff-bun", "@ff-labs/fff-bin-darwin-arm64", "@ff-labs/fff-bin-darwin-x64", "@ff-labs/fff-bin-linux-arm64-gnu", "@ff-labs/fff-bin-linux-arm64-musl", "@ff-labs/fff-bin-linux-x64-gnu", "@ff-labs/fff-bin-linux-x64-musl", "@ff-labs/fff-bin-win32-arm64", "@ff-labs/fff-bin-win32-x64", "@pierre/diffs", "@pierre/theming", "app-builder-lib", "dmg-builder", "electron", "electron-builder", "electron-publish", "blume"]
|
||||
|
||||
[test]
|
||||
root = "./do-not-run-tests-from-root"
|
||||
|
||||
+1
-2
@@ -63,8 +63,7 @@ new sst.cloudflare.StaticSite("WebApp", {
|
||||
domain: "app." + domain,
|
||||
path: "packages/app",
|
||||
build: {
|
||||
// Preserve Sentry credentials and run source-map uploads on every deployment.
|
||||
command: "bun run build",
|
||||
command: "bun turbo build",
|
||||
output: "./dist",
|
||||
},
|
||||
})
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-Fmwqp/fjTMX7gQW01Zgta51reLHp1S53GSQgBcJePrY=",
|
||||
"aarch64-linux": "sha256-KLI6OIbvycMePKxt66nNKeILYJSEkufOIaSawZ/gotc=",
|
||||
"aarch64-darwin": "sha256-go1wmrsHfYfJ3ukUIR/fveobyVba4E9G/rdsB/J2cQQ=",
|
||||
"x86_64-darwin": "sha256-UnVYMijlG12k4RLFkTwf4Or0yXqFSwe1Ji6QCGufjzg="
|
||||
"x86_64-linux": "sha256-agxF1Ab8P84tijHvw7jaG+9UZm/dJZhHw8CGNlQQqYc=",
|
||||
"aarch64-linux": "sha256-Kg6CkhoEEsb+ZOGPN/rnNoirLdGSyQl6HU/Fy8t9Mqs=",
|
||||
"aarch64-darwin": "sha256-SnxBpmcfPL2p3OYmSuG5Y/aFW8PeEOLhfQrxAs/53fs=",
|
||||
"x86_64-darwin": "sha256-Dnxl1EcfiE7/rlHmPkko0BKc6hHVk6rQ/2h/PWuLfNo="
|
||||
}
|
||||
}
|
||||
|
||||
+22
-26
@@ -39,24 +39,24 @@
|
||||
"packages/stats/*"
|
||||
],
|
||||
"catalog": {
|
||||
"@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",
|
||||
"@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",
|
||||
"@npmcli/arborist": "9.4.0",
|
||||
"@types/bun": "1.3.13",
|
||||
"@types/cross-spawn": "6.0.6",
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@hono/standard-validator": "0.2.0",
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
"@opentui/core": "0.5.9",
|
||||
"@opentui/keymap": "0.5.9",
|
||||
"@opentui/solid": "0.5.9",
|
||||
"@opentui/core": "0.5.8",
|
||||
"@opentui/keymap": "0.5.8",
|
||||
"@opentui/solid": "0.5.8",
|
||||
"@tanstack/solid-virtual": "3.13.37",
|
||||
"@shikijs/stream": "4.4.3",
|
||||
"@shikijs/stream": "4.2.0",
|
||||
"@standard-schema/spec": "1.1.0",
|
||||
"ulid": "3.0.1",
|
||||
"@kobalte/core": "0.13.13",
|
||||
"@kobalte/core": "0.13.11",
|
||||
"@corvu/drawer": "0.2.4",
|
||||
"@types/luxon": "3.7.1",
|
||||
"@types/node": "24.12.2",
|
||||
@@ -67,16 +67,13 @@
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@pierre/diffs": "1.2.10",
|
||||
"opentui-spinner": "0.0.7",
|
||||
"@solid-primitives/event-listener": "2.4.6",
|
||||
"@solid-primitives/media": "2.3.6",
|
||||
"@solid-primitives/resize-observer": "2.2.0",
|
||||
"@solid-primitives/storage": "4.4.0",
|
||||
"@solid-primitives/storage": "4.3.3",
|
||||
"@tailwindcss/vite": "4.1.11",
|
||||
"diff": "8.0.4",
|
||||
"dompurify": "3.4.14",
|
||||
"dompurify": "3.3.1",
|
||||
"drizzle-kit": "1.0.0-rc.5-ab785fc",
|
||||
"drizzle-orm": "1.0.0-rc.5-169397b",
|
||||
"effect": "4.0.0-rc.112",
|
||||
"effect": "4.0.0-rc.111",
|
||||
"ai": "6.0.168",
|
||||
"cross-spawn": "7.0.6",
|
||||
"hono": "4.10.7",
|
||||
@@ -84,9 +81,9 @@
|
||||
"fuzzysort": "3.1.0",
|
||||
"get-east-asian-width": "1.6.0",
|
||||
"luxon": "3.6.1",
|
||||
"marked": "18.0.11",
|
||||
"marked": "18.0.7",
|
||||
"marked-shiki": "1.2.1",
|
||||
"remend": "1.3.1",
|
||||
"remend": "1.3.0",
|
||||
"@playwright/test": "1.59.1",
|
||||
"semver": "7.7.4",
|
||||
"typescript": "5.8.2",
|
||||
@@ -95,17 +92,17 @@
|
||||
"remeda": "2.26.0",
|
||||
"resolve.exports": "2.0.3",
|
||||
"sst": "4.13.1",
|
||||
"shiki": "4.4.3",
|
||||
"shiki": "4.2.0",
|
||||
"solid-list": "0.3.0",
|
||||
"string-width": "7.2.0",
|
||||
"tailwindcss": "4.1.11",
|
||||
"vite": "7.3.6",
|
||||
"vite": "7.1.4",
|
||||
"@solidjs/meta": "0.29.4",
|
||||
"@solidjs/router": "1.0.0",
|
||||
"@solidjs/router": "0.15.4",
|
||||
"@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020",
|
||||
"@sentry/solid": "10.71.0",
|
||||
"@sentry/vite-plugin": "5.4.0",
|
||||
"solid-js": "1.9.15",
|
||||
"@sentry/solid": "10.36.0",
|
||||
"@sentry/vite-plugin": "4.6.0",
|
||||
"solid-js": "1.9.10",
|
||||
"solid-sonner": "0.3.1",
|
||||
"vite-plugin-solid": "2.11.10",
|
||||
"@lydell/node-pty": "1.2.0-beta.12"
|
||||
@@ -161,8 +158,7 @@
|
||||
"@effect/platform-node-shared": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"solid-js": "catalog:"
|
||||
"effect": "catalog:"
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"@ai-sdk/openai-compatible@2.0.41": "patches/@ai-sdk%2Fopenai-compatible@2.0.41.patch",
|
||||
@@ -170,7 +166,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.15": "patches/solid-js@1.9.15.patch",
|
||||
"solid-js@1.9.10": "patches/solid-js@1.9.10.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",
|
||||
|
||||
@@ -209,29 +209,6 @@ test("appearance experimental setting switches tab orientation", async ({ page }
|
||||
await page.setViewportSize({ width: 390, height: 360 })
|
||||
await version.scrollIntoViewIfNeeded()
|
||||
await expect(version).toBeInViewport()
|
||||
|
||||
// Reload the UI-selected preference without seeding settings storage.
|
||||
await page.reload()
|
||||
const href = `/server/${base64Encode(server)}/session/${sessionA.id}`
|
||||
await expect(
|
||||
page
|
||||
.locator('[data-slot="titlebar-tabs"]')
|
||||
.locator(`[data-titlebar-tab-link][href="${href}"]`)
|
||||
.getByText(sessionA.title, { exact: true }),
|
||||
).toBeVisible()
|
||||
await expect(page.locator('[data-slot="vertical-tabs-sidebar"]')).toHaveCount(0)
|
||||
|
||||
await page.setViewportSize({ width: 1280, height: 720 })
|
||||
await expect(
|
||||
page
|
||||
.locator('[data-slot="vertical-tabs-sidebar"]')
|
||||
.locator(`[data-titlebar-tab-link][href="${href}"]`)
|
||||
.getByText(sessionA.title, { exact: true }),
|
||||
).toBeVisible()
|
||||
await expect(page.locator('[data-slot="titlebar-tabs"]')).toHaveCount(0)
|
||||
await page.keyboard.press("Control+,")
|
||||
await settings.getByRole("tab", { name: "Appearance" }).click()
|
||||
await expect(layout).toContainText("Vertical")
|
||||
})
|
||||
|
||||
test("vertical tab preference falls back to horizontal on mobile", async ({ page }) => {
|
||||
|
||||
@@ -198,10 +198,6 @@ fixture(
|
||||
fixture(
|
||||
"precaches public files of every type and size, excluding deployment metadata and source maps",
|
||||
async ({ page, site, builds, context }) => {
|
||||
for (const output of Object.values(builds)) {
|
||||
expect(output["/sw.js.map"]).toBeUndefined()
|
||||
expect(Object.keys(output).some((path) => path.startsWith("/_assets/") && path.endsWith(".map"))).toBe(true)
|
||||
}
|
||||
await install(page, site.url)
|
||||
const files = ["/nested/data.json", "/nested/font.woff2", "/nested/module.wasm", "/large.bin"]
|
||||
await context.setOffline(true)
|
||||
|
||||
@@ -42,18 +42,21 @@
|
||||
"@playwright/test": "catalog:",
|
||||
"@sentry/vite-plugin": "catalog:",
|
||||
"@tailwindcss/vite": "4.3.3",
|
||||
"@tsconfig/bun": "1.0.9",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/luxon": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"diff": "catalog:",
|
||||
"happy-dom": "20.11.1",
|
||||
"tw-animate-css": "1.4.0",
|
||||
"vite": "8.2.2",
|
||||
"typescript": "catalog:",
|
||||
"vite": "8.2.1",
|
||||
"vite-plugin-icons-spritesheet": "3.0.1",
|
||||
"vite-plugin-pwa": "1.3.0",
|
||||
"vite-plugin-solid": "2.11.14"
|
||||
},
|
||||
"dependencies": {
|
||||
"@corvu/drawer": "catalog:",
|
||||
"@dnd-kit/abstract": "0.5.0",
|
||||
"@dnd-kit/dom": "0.5.0",
|
||||
"@dnd-kit/helpers": "0.5.0",
|
||||
@@ -68,23 +71,28 @@
|
||||
"@pierre/trees": "1.0.0-beta.4",
|
||||
"@sentry/solid": "catalog:",
|
||||
"@solid-primitives/event-bus": "1.1.2",
|
||||
"@solid-primitives/event-listener": "catalog:",
|
||||
"@solid-primitives/event-listener": "2.4.5",
|
||||
"@solid-primitives/i18n": "2.2.1",
|
||||
"@solid-primitives/keyed": "1.5.3",
|
||||
"@solid-primitives/media": "catalog:",
|
||||
"@solid-primitives/resize-observer": "catalog:",
|
||||
"@solid-primitives/media": "2.3.3",
|
||||
"@solid-primitives/resize-observer": "2.1.5",
|
||||
"@solid-primitives/scheduled": "1.5.3",
|
||||
"@solid-primitives/storage": "catalog:",
|
||||
"@solidjs/meta": "catalog:",
|
||||
"@solidjs/router": "catalog:",
|
||||
"@tanstack/solid-query": "5.91.4",
|
||||
"@tanstack/solid-virtual": "catalog:",
|
||||
"diff": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"fuzzysort": "catalog:",
|
||||
"ghostty-web": "github:anomalyco/ghostty-web#83c0a07b8628b748aed073b232cb4b52a6ca11c1",
|
||||
"luxon": "catalog:",
|
||||
"marked": "catalog:",
|
||||
"marked-shiki": "catalog:",
|
||||
"remeda": "catalog:",
|
||||
"shiki": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
"solid-list": "catalog:",
|
||||
"solid-presence": "0.2.0",
|
||||
"tailwindcss": "4.3.3"
|
||||
}
|
||||
|
||||
@@ -892,6 +892,23 @@ export const dict = {
|
||||
"settings.tab.notifications": "Notifications",
|
||||
"settings.tab.projects": "Projects",
|
||||
"settings.tab.extensions": "Extensions",
|
||||
"settings.tab.about": "About",
|
||||
"settings.about.version": "Version {{version}}",
|
||||
"settings.about.devVersion": "development",
|
||||
"settings.about.license": "Released under the MIT License",
|
||||
"settings.about.writtenBy": "Written by",
|
||||
"settings.about.illustratedBy": "Illustrated by",
|
||||
"settings.about.and": "and",
|
||||
"settings.about.otherContributor.one": "{{count}} other",
|
||||
"settings.about.otherContributor.other": "{{count}} others",
|
||||
"settings.about.firstPublished": "First published in Missouri, USA",
|
||||
"settings.about.firstIllustrated": "First illustrated in London, England",
|
||||
"settings.about.website": "www.opencode.ai",
|
||||
"settings.about.description": "OpenCode, the open source coding agent",
|
||||
"settings.about.trademark": "OpenCode is a registered trademark of Anomaly Innovations, Inc.",
|
||||
"settings.about.typeset": "Typeset in Inter and IBM Plex Mono",
|
||||
"settings.about.tagline": "AI can’t build great software, without you",
|
||||
"settings.about.copyright": "© Anomaly Innovations, Inc.",
|
||||
"settings.preferences.description": "Customize preferences and theme and default behavior",
|
||||
"settings.appearance.description": "Customize theme and fonts",
|
||||
"settings.appearance.section.experimental": "Experimental",
|
||||
|
||||
@@ -644,7 +644,7 @@ export function persisted<T>(
|
||||
: undefined
|
||||
if (channel) onCleanup(() => channel.close())
|
||||
|
||||
const [state, setState, init] = makePersisted<T, typeof store>(store, {
|
||||
const [state, setState, init] = makePersisted(store, {
|
||||
name: config.key,
|
||||
storage,
|
||||
sync: channel ? messageSync(channel) : undefined,
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createApiForServer } from "./api"
|
||||
import { readLocalImage } from "./image"
|
||||
|
||||
function setup(
|
||||
respond: (init?: RequestInit) => Response | Promise<Response> = () => new Response(new Uint8Array([0, 127, 255])),
|
||||
) {
|
||||
const requests: Array<{ url: URL; init?: RequestInit }> = []
|
||||
const api = createApiForServer({
|
||||
server: { url: "https://server.example:4096", username: "image-user", password: "secret" },
|
||||
fetch: (async (input: string | URL | Request, init?: RequestInit) => {
|
||||
requests.push({ url: new URL(input instanceof Request ? input.url : input), init })
|
||||
return respond(init)
|
||||
}) as typeof fetch,
|
||||
})
|
||||
return { api, requests }
|
||||
}
|
||||
|
||||
describe("readLocalImage", () => {
|
||||
test.each([
|
||||
["images/screen #1 + 50% ?.PNG", "/workspace/project", "images/screen%20%231%20%2B%2050%25%20%3F.PNG"],
|
||||
["C:/tmp/opencode/screen #1.png", "C:/tmp/opencode/", "screen%20%231.png"],
|
||||
["d:/screen.png", "d:/", "screen.png"],
|
||||
["D:/charts/screen.png", "D:/charts/", "screen.png"],
|
||||
["Z:/charts/screen.png", "Z:/charts/", "screen.png"],
|
||||
["/tmp/opencode/screen #1.png", "/tmp/opencode/", "screen%20%231.png"],
|
||||
["/screen.png", "/", "screen.png"],
|
||||
])("reads %s with the correct location and authentication", async (path, directory, encoded) => {
|
||||
const { api, requests } = setup()
|
||||
const signal = new AbortController().signal
|
||||
const blob = await readLocalImage(api, "/workspace/project", path, signal)
|
||||
|
||||
expect(blob).toBeInstanceOf(Blob)
|
||||
expect(blob?.type).toBe("image/png")
|
||||
expect(new Uint8Array(await blob!.arrayBuffer())).toEqual(new Uint8Array([0, 127, 255]))
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(requests[0].url.origin).toBe("https://server.example:4096")
|
||||
expect(requests[0].url.pathname).toBe(`/api/fs/read/${encoded}`)
|
||||
expect([...requests[0].url.searchParams]).toEqual([["location[directory]", directory]])
|
||||
expect(requests[0].init?.method).toBe("GET")
|
||||
expect(new Headers(requests[0].init?.headers).get("authorization")).toBe(`Basic ${btoa("image-user:secret")}`)
|
||||
expect(requests[0].init?.signal).toBe(signal)
|
||||
})
|
||||
|
||||
test("preserves literal percent escapes and encodes the supplied relative location", async () => {
|
||||
const { api, requests } = setup()
|
||||
await readLocalImage(api, "C:/project #1 + 50%", "images/literal%20name.png", new AbortController().signal)
|
||||
|
||||
expect(requests[0].url.href).toBe(
|
||||
"https://server.example:4096/api/fs/read/images/literal%2520name.png?location%5Bdirectory%5D=C%3A%2Fproject+%231+%2B+50%25",
|
||||
)
|
||||
})
|
||||
|
||||
test.each([
|
||||
["png", "image/png"],
|
||||
["jpeg", "image/jpeg"],
|
||||
["JPG", "image/jpeg"],
|
||||
["gif", "image/gif"],
|
||||
["webp", "image/webp"],
|
||||
["SVG", "image/svg+xml"],
|
||||
["avif", "image/avif"],
|
||||
["bmp", "image/bmp"],
|
||||
["ico", "image/x-icon"],
|
||||
])("uses the MIME type for .%s rather than the response header", async (extension, type) => {
|
||||
const { api } = setup(
|
||||
() => new Response("image bytes", { headers: { "content-type": "application/octet-stream" } }),
|
||||
)
|
||||
const blob = await readLocalImage(api, "/repo", `image.${extension}`, new AbortController().signal)
|
||||
expect(blob?.type).toBe(type)
|
||||
expect(await blob?.text()).toBe("image bytes")
|
||||
})
|
||||
|
||||
test.each([
|
||||
"https://example.com/image.png",
|
||||
"HTTP://example.com/image.png",
|
||||
"file:///tmp/image.png",
|
||||
"data:image/png;base64,image.png",
|
||||
"blob:https://example.com/image.png",
|
||||
"custom+scheme:image.png",
|
||||
"C:image.png",
|
||||
"//server/share/image.png",
|
||||
"\\\\server\\share\\image.png",
|
||||
"//?/C:/image.png",
|
||||
"///server/share/image.png",
|
||||
"image.txt",
|
||||
"image.html",
|
||||
"image.constructor",
|
||||
"image.png.txt",
|
||||
"image",
|
||||
"images.png/image",
|
||||
"image.png/",
|
||||
"",
|
||||
])("does not request unsupported input %s", async (path) => {
|
||||
const { api, requests } = setup()
|
||||
expect(await readLocalImage(api, "/repo", path, new AbortController().signal)).toBeUndefined()
|
||||
expect(requests).toHaveLength(0)
|
||||
})
|
||||
|
||||
test.each([400, 401])("propagates declared API errors (%s)", async (status) => {
|
||||
const error = { _tag: "RequestError", message: "Cannot read image" }
|
||||
const { api } = setup(() => Response.json(error, { status }))
|
||||
await expect(readLocalImage(api, "/repo", "image.png", new AbortController().signal)).rejects.toEqual(error)
|
||||
})
|
||||
|
||||
test.each([404, 500])("does not turn an unexpected HTTP status (%s) into a Blob", async (status) => {
|
||||
const { api } = setup(() => new Response("Not an image", { status }))
|
||||
await expect(readLocalImage(api, "/repo", "image.png", new AbortController().signal)).rejects.toMatchObject({
|
||||
reason: "UnexpectedStatus",
|
||||
cause: { status },
|
||||
})
|
||||
})
|
||||
|
||||
test("propagates transport failures", async () => {
|
||||
const error = new Error("Connection lost")
|
||||
const { api } = setup(() => Promise.reject(error))
|
||||
await expect(readLocalImage(api, "/repo", "image.png", new AbortController().signal)).rejects.toMatchObject({
|
||||
reason: "Transport",
|
||||
cause: error,
|
||||
})
|
||||
})
|
||||
|
||||
test("forwards cancellation to an in-flight fetch", async () => {
|
||||
const controller = new AbortController()
|
||||
const { api, requests } = setup(
|
||||
(init) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
init?.signal?.addEventListener("abort", () => reject(init.signal?.reason), { once: true })
|
||||
}),
|
||||
)
|
||||
const result = readLocalImage(api, "/repo", "image.png", controller.signal)
|
||||
expect(requests[0].init?.signal).toBe(controller.signal)
|
||||
controller.abort()
|
||||
await expect(result).rejects.toMatchObject({ reason: "Transport", cause: controller.signal.reason })
|
||||
})
|
||||
})
|
||||
@@ -1,36 +0,0 @@
|
||||
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
||||
import type { OpenCodeClient } from "@opencode-ai/client/promise"
|
||||
|
||||
const types = new Map([
|
||||
["png", "image/png"],
|
||||
["jpeg", "image/jpeg"],
|
||||
["jpg", "image/jpeg"],
|
||||
["gif", "image/gif"],
|
||||
["webp", "image/webp"],
|
||||
["svg", "image/svg+xml"],
|
||||
["avif", "image/avif"],
|
||||
["bmp", "image/bmp"],
|
||||
["ico", "image/x-icon"],
|
||||
])
|
||||
|
||||
export async function readLocalImage(
|
||||
api: Pick<OpenCodeClient, "file">,
|
||||
directory: string,
|
||||
path: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<Blob | undefined> {
|
||||
const drive = /^[a-z]:\//i.test(path)
|
||||
if (/^[\\/]{2}/.test(path) || (!drive && /^[a-z][a-z\d+.-]*:/i.test(path))) return
|
||||
const type = types.get(path.match(/\.([^./]+)$/)?.[1]?.toLowerCase() ?? "")
|
||||
if (!type) return
|
||||
const absolute = drive || path.startsWith("/")
|
||||
// Scope absolute images to their parent, including files outside the project.
|
||||
const bytes = await api.file.read(
|
||||
{
|
||||
path: absolute ? getFilename(path) : path,
|
||||
location: { directory: absolute ? getDirectory(path) : directory },
|
||||
},
|
||||
{ signal },
|
||||
)
|
||||
return new Blob([new Uint8Array(bytes)], { type })
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { For, createResource, type JSX } from "solid-js"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { ExternalLink } from "@/runtime/platform/external-link"
|
||||
import legal from "./legal.svg"
|
||||
import anomalyBrush from "./anomaly-brush.svg"
|
||||
import { AnimatedWordmark } from "./animated-wordmark"
|
||||
import { FALLBACK_OTHER_CONTRIBUTORS, loadOtherContributorCount } from "./contributors"
|
||||
|
||||
const writers = [
|
||||
"thdxr",
|
||||
"adamdotdev",
|
||||
"rekram1-node",
|
||||
"kitlangton",
|
||||
"iamdavidhill",
|
||||
"jayair",
|
||||
"fwang",
|
||||
"brendonovich",
|
||||
"nexxeln",
|
||||
"hona",
|
||||
"kommander",
|
||||
"jlongster",
|
||||
"vimtor",
|
||||
"r44vcorp",
|
||||
"simonklee",
|
||||
"arvsrn",
|
||||
] as const
|
||||
const illustrators = ["usrnk1", "ludvigrask_", "arvsrn", "iamdavidhill"] as const
|
||||
|
||||
export function SettingsAbout(props: { active: boolean }) {
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const [otherContributors] = createResource(
|
||||
() => props.active || undefined,
|
||||
() => loadOtherContributorCount(platform.fetch ?? fetch),
|
||||
{ initialValue: FALLBACK_OTHER_CONTRIBUTORS },
|
||||
)
|
||||
|
||||
return (
|
||||
<div class="settings-about-content">
|
||||
<div class="settings-about-intro">
|
||||
<p>{language.t("settings.about.version", { version: platform.version ?? language.t("settings.about.devVersion") })}</p>
|
||||
<p>{language.t("settings.about.license")}</p>
|
||||
</div>
|
||||
|
||||
<AnimatedWordmark active={props.active} />
|
||||
|
||||
<div class="settings-about-credits">
|
||||
<CreditLine
|
||||
label={language.t("settings.about.writtenBy")}
|
||||
names={writers}
|
||||
and={language.t("settings.about.and")}
|
||||
tail={
|
||||
<ExternalLink href="https://github.com/anomalyco/opencode/graphs/contributors">
|
||||
{language.plural("settings.about.otherContributor", otherContributors(), {
|
||||
count: otherContributors(),
|
||||
})}
|
||||
</ExternalLink>
|
||||
}
|
||||
/>
|
||||
<CreditLine
|
||||
label={language.t("settings.about.illustratedBy")}
|
||||
names={illustrators}
|
||||
and={language.t("settings.about.and")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="settings-about-publication">
|
||||
<p>{language.t("settings.about.firstPublished")}</p>
|
||||
<p>{language.t("settings.about.firstIllustrated")}</p>
|
||||
</div>
|
||||
|
||||
<p class="settings-about-faint">
|
||||
<ExternalLink href="https://opencode.ai">
|
||||
<bdi dir="ltr">{language.t("settings.about.website")}</bdi>
|
||||
</ExternalLink>
|
||||
</p>
|
||||
|
||||
<div class="settings-about-details">
|
||||
<p>{language.t("settings.about.description")}</p>
|
||||
<p>{language.t("settings.about.trademark")}</p>
|
||||
<p>{language.t("settings.about.typeset")}</p>
|
||||
</div>
|
||||
|
||||
<p>{language.t("settings.about.tagline")}</p>
|
||||
<img class="settings-about-legal" src={legal} alt="" />
|
||||
<div class="settings-about-copyright">
|
||||
<p>{language.t("settings.about.copyright")}</p>
|
||||
<img class="settings-about-anomaly-brush" src={anomalyBrush} alt="" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CreditLine(props: {
|
||||
label: string
|
||||
names: readonly string[]
|
||||
and: string
|
||||
tail?: JSX.Element
|
||||
}) {
|
||||
return (
|
||||
<p>
|
||||
{props.label}{" "}
|
||||
<For each={props.names}>
|
||||
{(name, index) => (
|
||||
<>
|
||||
{index() === 0 ? "" : index() === props.names.length - 1 && !props.tail ? `, ${props.and} ` : ", "}
|
||||
<bdi dir="ltr">
|
||||
<ExternalLink href={profile(name)}>{name}</ExternalLink>
|
||||
</bdi>
|
||||
</>
|
||||
)}
|
||||
</For>
|
||||
{props.tail ? <>, {props.and} {props.tail}</> : null}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
function profile(name: string) {
|
||||
if (name === "r44vcorp") return "https://github.com/R44VC0RP"
|
||||
if (name === "ludvigrask_") return "https://x.com/ludvigrask_"
|
||||
return `https://github.com/${name}`
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { createEffect, For, on, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
|
||||
const target = ["o", "p", "e", "n", "c", "o", "d", "e"] as const
|
||||
const choices = ["o", "p", "e", "n", "c", "d"] as const
|
||||
|
||||
export function AnimatedWordmark(props: { active: boolean }) {
|
||||
const [state, setState] = createStore({ letters: [...target] })
|
||||
const timers = new Set<ReturnType<typeof setTimeout>>()
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => props.active,
|
||||
(active) => {
|
||||
timers.forEach(clearTimeout)
|
||||
timers.clear()
|
||||
if (!active || matchMedia("(prefers-reduced-motion: reduce)").matches) {
|
||||
setState("letters", [...target])
|
||||
return
|
||||
}
|
||||
|
||||
const starts = target.map(() => choices[Math.floor(Math.random() * choices.length)])
|
||||
const settles = target.map(() => 6 + Math.floor(Math.random() * 8))
|
||||
const last = Math.max(...settles)
|
||||
setState("letters", starts)
|
||||
|
||||
Array.from({ length: last }, (_, index) => index + 1).forEach((tick) => {
|
||||
const timer = setTimeout(() => {
|
||||
setState(
|
||||
"letters",
|
||||
target.map((letter, index) =>
|
||||
tick >= settles[index] ? letter : choices[Math.floor(Math.random() * choices.length)],
|
||||
),
|
||||
)
|
||||
timers.delete(timer)
|
||||
}, tick * 75)
|
||||
timers.add(timer)
|
||||
})
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
onCleanup(() => timers.forEach(clearTimeout))
|
||||
|
||||
return (
|
||||
<svg class="settings-about-wordmark" viewBox="0 0 234 42" aria-hidden="true">
|
||||
<defs>
|
||||
<symbol id="settings-about-letter-o" viewBox="0 0 24 42">
|
||||
<path class="settings-about-letter-shadow" d="M18 30H6V18H18V30Z" />
|
||||
<path d="M18 12H6V30H18V12ZM24 36H0V6H24V36Z" />
|
||||
</symbol>
|
||||
<symbol id="settings-about-letter-p" viewBox="0 0 24 42">
|
||||
<path class="settings-about-letter-shadow" d="M18 30H6V18H18V30Z" />
|
||||
<path d="M6 30H18V12H6V30ZM24 36H6V42H0V6H24V36Z" />
|
||||
</symbol>
|
||||
<symbol id="settings-about-letter-e" viewBox="0 0 24 42">
|
||||
<path class="settings-about-letter-shadow" d="M24 24V30H6V24H24Z" />
|
||||
<path d="M24 24H6V30H24V36H0V6H24V24ZM6 18H18V12H6V18Z" />
|
||||
</symbol>
|
||||
<symbol id="settings-about-letter-n" viewBox="0 0 24 42">
|
||||
<path class="settings-about-letter-shadow" d="M18 36H6V18H18V36Z" />
|
||||
<path d="M18 12H6V36H0V6H18V12ZM24 36H18V12H24V36Z" />
|
||||
</symbol>
|
||||
<symbol id="settings-about-letter-c" viewBox="0 0 24 42">
|
||||
<path class="settings-about-letter-shadow" d="M24 30H6V18H24V30Z" />
|
||||
<path d="M24 12H6V30H24V36H0V6H24V12Z" />
|
||||
</symbol>
|
||||
<symbol id="settings-about-letter-d" viewBox="0 0 24 42">
|
||||
<path class="settings-about-letter-shadow" d="M18 30H6V18H18V30Z" />
|
||||
<path d="M18 12H6V30H18V12ZM24 36H0V6H18V0H24V36Z" />
|
||||
</symbol>
|
||||
</defs>
|
||||
<For each={state.letters}>
|
||||
{(letter, index) => (
|
||||
<use href={`#settings-about-letter-${letter}`} x={index() * 30} width="24" height="42" />
|
||||
)}
|
||||
</For>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 90 KiB |
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { FALLBACK_OTHER_CONTRIBUTORS, otherContributorCount } from "./contributors"
|
||||
|
||||
describe("otherContributorCount", () => {
|
||||
test("subtracts the contributors named in the colophon", () => {
|
||||
expect(
|
||||
otherContributorCount(
|
||||
'<https://api.github.com/repositories/975734319/contributors?anon=1&per_page=1&page=2>; rel="next", <https://api.github.com/repositories/975734319/contributors?anon=1&per_page=1&page=1004>; rel="last"',
|
||||
),
|
||||
).toBe(988)
|
||||
})
|
||||
|
||||
test("falls back when pagination metadata is unavailable", () => {
|
||||
expect(otherContributorCount(null)).toBe(FALLBACK_OTHER_CONTRIBUTORS)
|
||||
expect(otherContributorCount("invalid")).toBe(FALLBACK_OTHER_CONTRIBUTORS)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
const CREDITED_CONTRIBUTORS = 16
|
||||
let request: Promise<number> | undefined
|
||||
|
||||
export const FALLBACK_OTHER_CONTRIBUTORS = 935
|
||||
|
||||
export function otherContributorCount(link: string | null) {
|
||||
const total = Number.parseInt(link?.match(/[?&]page=(\d+)[^>]*>;\s*rel="last"/)?.[1] ?? "", 10)
|
||||
if (!Number.isFinite(total) || total <= CREDITED_CONTRIBUTORS) return FALLBACK_OTHER_CONTRIBUTORS
|
||||
return total - CREDITED_CONTRIBUTORS
|
||||
}
|
||||
|
||||
export function loadOtherContributorCount(fetcher: typeof fetch) {
|
||||
request ??= fetcher("https://api.github.com/repos/anomalyco/opencode/contributors?anon=1&per_page=1", {
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
},
|
||||
}).then(
|
||||
(response) => (response.ok ? otherContributorCount(response.headers.get("Link")) : FALLBACK_OTHER_CONTRIBUTORS),
|
||||
() => FALLBACK_OTHER_CONTRIBUTORS,
|
||||
)
|
||||
return request
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<svg width="91" height="16" viewBox="0 0 91 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g opacity=".5" fill="#808080">
|
||||
<path d="M22.463 16H11.231c6.203 0 11.232-3.582 11.232-8s-5.029-8-11.232-8S0 3.582 0 8s5.028 8 11.231 8H0V0h22.463v16Z"/>
|
||||
<path d="M13.672 4.799h5.354v.988h-2.102v5.54h-1.158v-5.54h-2.094v-.988ZM11.724 4.799h1.158v6.528h-1.158V4.799ZM3.474 4.799h1.63l1.825 5.11h.018l1.779-5.11h1.612v6.528H9.235V6.289h-.018l-1.834 5.038h-.954L4.595 6.289h-.019v5.038H3.474V4.799Z"/>
|
||||
<path fill-rule="evenodd" d="M50.363 1a7.363 7.363 0 1 1 0 14.726 7.363 7.363 0 0 1 0-14.726Zm-1.938 1.04a6.615 6.615 0 0 0-3.338 2.34c.483.014.783.079.783.37 0 .375.375.625 1.123-.625.543-.906 1.02-1.681 1.432-2.086Zm8.547 6.585h-2.241c-.999 0-1.123 1-1.623 1.75-.499.75-.748 1.5-.748 2.625s-.749 1.25-1.124 1.375c-.374.125-.623-.375-1.123-.75-.499-.375-.25-2.375-.25-2.75s0-.625-.374-.75c-.374-.125-1.997-.375-2.121-.75-.125-.375 0-.75.748-1.75.44-.625 1.123-.25 1.373-.25.25 0 .999.25 1.498.25.499 0 .624.25 1.373.25.748 0 .25-.25.25-1 0-.75-.5-.375-.999-.25-.499.125-.624.25-1.747.25s-.25-.375.25-.625c.498-.25.498-.25.873-.875.374-.625 0-.25-.624-.375-.624-.125-.25.375-.874.375s-.124-.625.375-1c.499-.375.374-.5.499-1.25.125-.75.25-.25.873-.5.624-.25.874 0 1.123-.25.088-.088.098-.191.092-.288a6.616 6.616 0 0 0-8.702 6.288A6.614 6.614 0 0 0 50.363 15c3.569 0 6.478-2.832 6.61-6.375Z" clip-rule="evenodd"/>
|
||||
<path fill-rule="evenodd" d="M66.836 1a7.363 7.363 0 1 1 0 14.726 7.363 7.363 0 0 1 0-14.726Zm2.496 3.5c-.624 0-.999 1.125-1.623 1.75-.624.625-.499 1.375-.25 2.375.25 1-.623 1.625-1.372 1.625s-.874-.375-1.248-.75c-.375-.375-1.248-.375-2.247-.25-.998.125-.624 1.625-.748 2.375-.125.75-.624-.25-.749-1-.079-.475-.459-1-.822-1.417A6.614 6.614 0 0 0 66.836 15a6.59 6.59 0 0 0 2.518-.498c-.378-.049-.972-.333-1.395-.502-.624-.25-.874 0-1.622-.5-.749-.5-.25-.875-.25-1.875.25-.625.998-.25 1.373-.25h2.246c.874 0 .874 1.25.874 1.875 0 .604-.7.741-.86 1.088a6.625 6.625 0 0 0 2.489-9.827A8.822 8.822 0 0 0 71.952 4.5h-2.62Z" clip-rule="evenodd"/>
|
||||
<path fill-rule="evenodd" d="M83.308 1a7.363 7.363 0 1 1 0 14.726 7.363 7.363 0 0 1 0-14.726Zm.656.782c-.752.31-.387 1.473.093 1.593.5.125.5-.625.874-.625s1.123.375 1.123.625-.25.375-.5.75c-.249.375.375.375 1.124 0 .748-.375.624.375.499 1.25s-.749 1-1.622 1h-1.498c-.328 0-.391 1.633-.25 1.75.141.117.345.375.5.375h1.871c.624 0 .749.25 1.248.375s.375.75.5 1.125c.124.375-.375.75-.75 1.25-.374.5-.748 1-.998 1.375-.25.375-.374.875-.374 1.375s-.375.5-.624.5c-.25 0-.374-.125-.374-.625s-.125-.625-.25-1.375c-.125-.75-.374-1.375-.374-1.75 0-.375-.25-.25-.669-.25s-.454-.125-.454-.75.809-.305.873-.75c.014-.097 0-.25 0-.25 0-.279-.25-.375-.419-.468-.17-.093-.598-.214-.704-.532-.125-.375-.5-.625-1.248-.625-.749 0-.998-.125-.998-.75V4.5c0-.875-.5-.5-1.498-.75.076-.114.282-.435.544-.868A6.614 6.614 0 1 0 83.964 1.782Z" clip-rule="evenodd"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.9 KiB |
@@ -74,6 +74,102 @@
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.settings-about {
|
||||
align-items: center;
|
||||
padding-inline: 24px;
|
||||
}
|
||||
|
||||
.settings-about-content {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
max-width: 560px;
|
||||
min-height: 462px;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 24px;
|
||||
padding-block: 80px 32px;
|
||||
color: var(--v2-text-text-muted);
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
line-height: var(--line-height-base);
|
||||
letter-spacing: -0.04px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.settings-about-content p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.settings-about-intro,
|
||||
.settings-about-credits,
|
||||
.settings-about-publication,
|
||||
.settings-about-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.settings-about-intro {
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.settings-about-credits,
|
||||
.settings-about-publication,
|
||||
.settings-about-details {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.settings-about-wordmark {
|
||||
width: 178px;
|
||||
height: 32px;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.2;
|
||||
color: var(--v2-text-text-muted);
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
.settings-about-letter-shadow {
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.settings-about-faint,
|
||||
.settings-about-credits {
|
||||
color: var(--v2-text-text-faint);
|
||||
}
|
||||
|
||||
.settings-about-legal {
|
||||
width: 109.2px;
|
||||
height: 19.2px;
|
||||
}
|
||||
|
||||
.settings-about-copyright {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
flex-shrink: 0;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.settings-about-anomaly-brush {
|
||||
width: 126.72px;
|
||||
height: 48.384px;
|
||||
flex-shrink: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.settings-about-content a {
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.settings-about-content a:hover,
|
||||
.settings-about-content a:focus-visible {
|
||||
color: var(--v2-text-text-base);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.settings-back {
|
||||
display: flex;
|
||||
height: 28px;
|
||||
|
||||
@@ -13,6 +13,7 @@ import { SettingsServers } from "./servers/servers"
|
||||
import { SettingsWorkspaces } from "./workspaces/workspaces"
|
||||
import { SettingsProjects } from "./workspaces/projects"
|
||||
import { SettingsExtensions } from "./providers/extensions"
|
||||
import { SettingsAbout } from "./about/about"
|
||||
import { SettingsServerScope } from "./server-scope"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useLayout } from "@/shell/state/layout"
|
||||
@@ -161,6 +162,21 @@ export const SettingsScreen: Component<{
|
||||
{language.t("settings.tab.extensions")}
|
||||
</Tabs.Trigger>
|
||||
</div>
|
||||
|
||||
{/* Group 4: About */}
|
||||
<div class="flex flex-col gap-1 w-full">
|
||||
<Tabs.Trigger value="about">
|
||||
<svg data-slot="icon-svg" width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||
<g transform="translate(2 2)">
|
||||
<path
|
||||
d="M12 12H0V0H12V12ZM1 1V11H11V1H1ZM6.5791 4.81641V9.37207H5.5791V5.81641H4.46777V4.81641H6.5791ZM6.85645 2.62891V3.62891H5.30078V2.62891H6.85645Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
{language.t("settings.tab.about")}
|
||||
</Tabs.Trigger>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-nav-footer">
|
||||
@@ -203,6 +219,9 @@ export const SettingsScreen: Component<{
|
||||
<SettingsExtensions />
|
||||
</Tabs.Content>
|
||||
</SettingsServerScope>
|
||||
<Tabs.Content value="about" class="settings-panel settings-about">
|
||||
<SettingsAbout active={tab() === "about"} />
|
||||
</Tabs.Content>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { DataProvider } from "@opencode-ai/session-ui/context"
|
||||
import { MarkdownProvider, type ReadMarkdownImage } from "@opencode-ai/session-ui/context/markdown"
|
||||
import { useNavigate, useParams } from "@solidjs/router"
|
||||
import { createMemo, type ParentProps } from "solid-js"
|
||||
import { useProviders } from "@/providers/catalog/providers"
|
||||
@@ -9,7 +8,6 @@ import { sessionHref } from "@/shell/routes/session"
|
||||
import { useData } from "@/runtime/server/current"
|
||||
import { useServerSDK } from "@/runtime/server/client"
|
||||
import { useTabs } from "@/shell/tabs/tabs"
|
||||
import { readLocalImage } from "@/runtime/server/image"
|
||||
|
||||
export function SessionUIProvider(
|
||||
props: ParentProps<{
|
||||
@@ -23,10 +21,6 @@ export function SessionUIProvider(
|
||||
const serverSDK = useServerSDK()
|
||||
const tabs = useTabs()
|
||||
const directory = () => props.directory
|
||||
const readImage = createMemo<ReadMarkdownImage>(() => {
|
||||
const dir = directory()
|
||||
return (path, signal) => readLocalImage(serverSDK.api, dir, path, signal)
|
||||
})
|
||||
const href = (sessionID: string) => sessionHref(props.server, sessionID)
|
||||
const navigateToSession = async (sessionID: string) => {
|
||||
const tab = tabs.store.find(
|
||||
@@ -66,9 +60,7 @@ export function SessionUIProvider(
|
||||
onNavigateToSession={navigateToSession}
|
||||
onSessionHref={href}
|
||||
>
|
||||
<MarkdownProvider readImage={readImage()}>
|
||||
<LocalProvider>{props.children}</LocalProvider>
|
||||
</MarkdownProvider>
|
||||
<LocalProvider>{props.children}</LocalProvider>
|
||||
</DataProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
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"])
|
||||
})
|
||||
@@ -1,89 +0,0 @@
|
||||
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)
|
||||
},
|
||||
)
|
||||
@@ -10,8 +10,6 @@ export function serviceWorker(directory: string) {
|
||||
injectRegister: false,
|
||||
manifest: false,
|
||||
workbox: {
|
||||
// Workbox runs after Sentry's upload and cleanup, so do not publish an unuploaded map.
|
||||
sourcemap: false,
|
||||
globDirectory: directory,
|
||||
clientsClaim: false,
|
||||
// Keep each open tab on its complete build until all old clients close.
|
||||
|
||||
@@ -84,15 +84,12 @@ export default Runtime.handler(Commands, (input) =>
|
||||
update: (update) => runPromise(config.update(update)),
|
||||
},
|
||||
packages: {
|
||||
resolve: (spec, install = true, revision) =>
|
||||
resolve: (spec, install = true) =>
|
||||
runPromise(
|
||||
install
|
||||
? npm.add(spec, { subpaths: ["tui"], revision })
|
||||
: npm.resolve(spec, { subpaths: ["tui"], revision }),
|
||||
(install ? npm.add(spec, { subpaths: ["tui"] }) : npm.resolve(spec, { subpaths: ["tui"] })).pipe(
|
||||
Effect.map((result) => result.entrypoint),
|
||||
),
|
||||
),
|
||||
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.112",
|
||||
"effect": "4.0.0-rc.111",
|
||||
"solid-js": ">=1.9.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
|
||||
@@ -88,32 +88,8 @@ 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 = {
|
||||
@@ -207,7 +183,6 @@ export type SessionCreateInput = {
|
||||
readonly agent?: Agent.ID | undefined
|
||||
readonly model?: Model.Ref | undefined
|
||||
readonly location?: Location.Ref | undefined
|
||||
readonly metadata?: Session.Metadata | undefined
|
||||
}
|
||||
export type SessionCreateOutput = Session.Info
|
||||
export type SessionCreateOperation<E = never> = (input?: SessionCreateInput) => Effect.Effect<SessionCreateOutput, E>
|
||||
@@ -435,7 +410,6 @@ export type SessionLogOutput =
|
||||
readonly title?: string | undefined
|
||||
readonly agent?: Agent.ID | undefined
|
||||
readonly model?: Model.Ref | undefined
|
||||
readonly metadata?: Session.Metadata | undefined
|
||||
readonly version: string
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,12 +15,6 @@ import type {
|
||||
AgentGetOutput,
|
||||
PluginListInput,
|
||||
PluginListOutput,
|
||||
PluginCheckInput,
|
||||
PluginCheckOutput,
|
||||
PluginUpdateInput,
|
||||
PluginUpdateOutput,
|
||||
PluginReloadInput,
|
||||
PluginReloadOutput,
|
||||
SessionListInput,
|
||||
SessionListOutput,
|
||||
SessionStatsInput,
|
||||
@@ -322,33 +316,7 @@ const EndpointPluginList = (raw: RawClient["server.plugin"]) => (input?: PluginL
|
||||
raw["plugin.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
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 adaptGroupPlugin = (raw: RawClient["server.plugin"]) => ({ list: EndpointPluginList(raw) })
|
||||
|
||||
const EndpointSessionList = (raw: RawClient["server.session"]) => (input?: SessionListInput) =>
|
||||
preserveEffect<SessionListOutput>()(
|
||||
@@ -392,7 +360,6 @@ const EndpointSessionCreate = (raw: RawClient["server.session"]) => (input?: Ses
|
||||
agent: input?.["agent"],
|
||||
model: input?.["model"],
|
||||
location: input?.["location"],
|
||||
metadata: input?.["metadata"],
|
||||
},
|
||||
}).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
|
||||
@@ -9,12 +9,6 @@ import type {
|
||||
AgentGetOutput,
|
||||
PluginListInput,
|
||||
PluginListOutput,
|
||||
PluginCheckInput,
|
||||
PluginCheckOutput,
|
||||
PluginUpdateInput,
|
||||
PluginUpdateOutput,
|
||||
PluginReloadInput,
|
||||
PluginReloadOutput,
|
||||
SessionListInput,
|
||||
SessionListOutput,
|
||||
SessionStatsInput,
|
||||
@@ -466,45 +460,6 @@ 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) =>
|
||||
@@ -558,7 +513,6 @@ export function make(options: ClientOptions) {
|
||||
agent: input?.["agent"],
|
||||
model: input?.["model"],
|
||||
location: input?.["location"],
|
||||
metadata: input?.["metadata"],
|
||||
},
|
||||
successStatus: 200,
|
||||
declaredStatuses: [401, 400],
|
||||
|
||||
@@ -16,8 +16,6 @@ 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
|
||||
@@ -31,8 +29,6 @@ export type TokenUsageInfo = {
|
||||
|
||||
export type LocationRef = { directory: string; workspaceID?: string }
|
||||
|
||||
export type SessionMetadata = { [x: string]: JsonValue }
|
||||
|
||||
export type FileDiffInfo = {
|
||||
file: string
|
||||
patch: string
|
||||
@@ -435,24 +431,8 @@ export type ProviderRequest = {
|
||||
export type PermissionRule = { action: string; resource: string; effect: PermissionEffect }
|
||||
|
||||
export type PluginInfo =
|
||||
| {
|
||||
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
|
||||
}
|
||||
| { id: string; source: PluginSource; status: "active"; tui: boolean }
|
||||
| { id?: string; source: PluginSource; status: "failed"; error: string; tui: boolean }
|
||||
|
||||
export type SessionMessageLocationSwitched = {
|
||||
id: string
|
||||
@@ -568,7 +548,6 @@ export type SessionCreated = {
|
||||
title?: string
|
||||
agent?: string
|
||||
model?: ModelRef
|
||||
metadata?: SessionMetadata
|
||||
version: string
|
||||
}
|
||||
}
|
||||
@@ -1663,7 +1642,6 @@ export type SessionInfo = {
|
||||
title?: string
|
||||
location: LocationRef
|
||||
subpath?: string
|
||||
metadata?: SessionMetadata
|
||||
revert?: SessionRevert
|
||||
}
|
||||
|
||||
@@ -2348,10 +2326,6 @@ 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"
|
||||
@@ -2562,42 +2536,6 @@ 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
|
||||
@@ -2749,7 +2687,6 @@ export type SessionCreateInput = {
|
||||
readonly agent?: string | null
|
||||
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
|
||||
readonly metadata?: { readonly [x: string]: JsonValue } | null
|
||||
}["id"]
|
||||
readonly title?: {
|
||||
readonly id?: string | null
|
||||
@@ -2757,7 +2694,6 @@ export type SessionCreateInput = {
|
||||
readonly agent?: string | null
|
||||
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
|
||||
readonly metadata?: { readonly [x: string]: JsonValue } | null
|
||||
}["title"]
|
||||
readonly agent?: {
|
||||
readonly id?: string | null
|
||||
@@ -2765,7 +2701,6 @@ export type SessionCreateInput = {
|
||||
readonly agent?: string | null
|
||||
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
|
||||
readonly metadata?: { readonly [x: string]: JsonValue } | null
|
||||
}["agent"]
|
||||
readonly model?: {
|
||||
readonly id?: string | null
|
||||
@@ -2773,7 +2708,6 @@ export type SessionCreateInput = {
|
||||
readonly agent?: string | null
|
||||
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
|
||||
readonly metadata?: { readonly [x: string]: JsonValue } | null
|
||||
}["model"]
|
||||
readonly location?: {
|
||||
readonly id?: string | null
|
||||
@@ -2781,16 +2715,7 @@ export type SessionCreateInput = {
|
||||
readonly agent?: string | null
|
||||
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
|
||||
readonly metadata?: { readonly [x: string]: JsonValue } | null
|
||||
}["location"]
|
||||
readonly metadata?: {
|
||||
readonly id?: string | null
|
||||
readonly title?: string | null
|
||||
readonly agent?: string | null
|
||||
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
|
||||
readonly metadata?: { readonly [x: string]: JsonValue } | null
|
||||
}["metadata"]
|
||||
}
|
||||
|
||||
export type SessionCreateOutput = { data: SessionInfo }["data"]
|
||||
@@ -2827,7 +2752,6 @@ export type SessionImportInput = {
|
||||
readonly title?: string
|
||||
readonly location: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly subpath?: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly revert?: {
|
||||
readonly messageID: string
|
||||
readonly partID?: string
|
||||
@@ -3104,7 +3028,6 @@ export type SessionImportInput = {
|
||||
readonly title?: string
|
||||
readonly location: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly subpath?: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly revert?: {
|
||||
readonly messageID: string
|
||||
readonly partID?: string
|
||||
@@ -3381,7 +3304,6 @@ export type SessionImportInput = {
|
||||
readonly title?: string
|
||||
readonly location: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly subpath?: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly revert?: {
|
||||
readonly messageID: string
|
||||
readonly partID?: string
|
||||
|
||||
@@ -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.
|
||||
- Report host failure messages and underlying causes rather than replacing them with generic diagnostics. Preserve interruption behavior.
|
||||
- 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.
|
||||
- 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`. |
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## 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] 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
|
||||
- [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
|
||||
failures; preserve those categories in caught errors, promise rejection handlers, and `Promise.allSettled`
|
||||
reasons.
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Schema } from "effect"
|
||||
|
||||
/** Tool failure reported as `ToolFailure`. */
|
||||
/** Safe operational refusal from a standard tool pack, reported as `ToolFailure`. */
|
||||
export class ToolError extends Schema.TaggedError<ToolError>()("ToolError", {
|
||||
message: Schema.String,
|
||||
cause: Schema.optionalKey(Schema.Defect()),
|
||||
}) {}
|
||||
|
||||
/** Creates a tool failure with an optional underlying cause. */
|
||||
/** Creates a tool refusal whose message is safe to include in an execution diagnostic. */
|
||||
export const toolError = (message: string, cause?: unknown): ToolError =>
|
||||
new ToolError({ message, ...(cause === undefined ? {} : { cause }) })
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Cause, Effect, Exit, Formatter, Schema } from "effect"
|
||||
import { toolError } from "./tool-error.js"
|
||||
import { Cause, Effect, Exit, Schema } from "effect"
|
||||
import { ToolError, toolError } from "./tool-error.js"
|
||||
import {
|
||||
decodeInput as decodeToolInput,
|
||||
decodeOutput as decodeToolOutput,
|
||||
@@ -117,6 +117,15 @@ 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)
|
||||
@@ -498,12 +507,19 @@ 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 Error ? error.message : Cause.pretty(exit.cause)
|
||||
const message =
|
||||
error instanceof ToolError || error instanceof ToolRuntimeError ? error.message : "Tool execution failed"
|
||||
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}.`)
|
||||
@@ -532,22 +548,12 @@ export const make = <R>(
|
||||
return yield* observeEnd(
|
||||
Effect.gen(function* () {
|
||||
if (hooks?.onToolCallStart !== undefined) yield* hooks.onToolCallStart(call)
|
||||
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}`),
|
||||
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}'.`),
|
||||
})
|
||||
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 HostError extends Schema.TaggedError<HostError>()("HostError", {
|
||||
message: Schema.String,
|
||||
class UnsafeHostError extends Schema.TaggedError<UnsafeHostError>()("UnsafeHostError", {
|
||||
reason: Schema.String,
|
||||
}) {}
|
||||
|
||||
describe("CodeMode host failure boundary", () => {
|
||||
test("preserves explicit tool failures", async () => {
|
||||
test("preserves explicit safe tool failures", async () => {
|
||||
const result = await run(
|
||||
Tool.make({
|
||||
description: "Fail",
|
||||
description: "Fail safely",
|
||||
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 tool failures", async () => {
|
||||
test("does not rewrite explicit safe tool failures", async () => {
|
||||
const result = await run(
|
||||
Tool.make({
|
||||
description: "Fail",
|
||||
description: "Fail safely",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.String,
|
||||
execute: () => Effect.fail(toolError("File not found: /tmp/report.json")),
|
||||
@@ -42,15 +42,10 @@ describe("CodeMode host failure boundary", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("reports failures, defects, rejected Promises, and nested causes", async () => {
|
||||
test("sanitizes unknown host failures and defects", async () => {
|
||||
for (const failure of [
|
||||
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"))),
|
||||
Effect.fail(new UnsafeHostError({ reason: "Authorization: Bearer typed-secret" })),
|
||||
Effect.die(new Error("postgres://user:defect-secret@example.invalid")),
|
||||
]) {
|
||||
const result = await run(
|
||||
Tool.make({
|
||||
@@ -63,28 +58,31 @@ describe("CodeMode host failure boundary", () => {
|
||||
|
||||
expect(result.ok ? undefined : result.error).toStrictEqual({
|
||||
kind: "ToolFailure",
|
||||
message: expect.stringContaining("Connection refused"),
|
||||
message: "Tool execution failed",
|
||||
})
|
||||
expect(JSON.stringify(result)).not.toMatch(/typed-secret|defect-secret|Authorization: Bearer/)
|
||||
}
|
||||
})
|
||||
|
||||
test("reports invalid host output", async () => {
|
||||
test("sanitizes invalid host output", async () => {
|
||||
const secret = "invalid-output-secret"
|
||||
const result = await run(
|
||||
Tool.make({
|
||||
description: "Return invalid output",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.Struct({ value: Schema.String }),
|
||||
execute: () => Effect.succeed({ value: 1 } as unknown as { readonly value: string }),
|
||||
output: Schema.Struct({ safe: Schema.String }),
|
||||
execute: () => Effect.succeed({ safe: 1, secret } as unknown as { readonly safe: string }),
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result.ok ? undefined : result.error).toStrictEqual({
|
||||
kind: "InvalidToolOutput",
|
||||
message: "Invalid output from tool 'host.call': SchemaError(Expected string\n at [\"value\"])",
|
||||
message: "Invalid output from tool 'host.call'.",
|
||||
})
|
||||
expect(JSON.stringify(result)).not.toMatch(/invalid-output-secret/)
|
||||
})
|
||||
|
||||
test("reports host output copying errors", async () => {
|
||||
test("sanitizes host output that throws while being copied", async () => {
|
||||
const result = await run(
|
||||
Tool.make({
|
||||
description: "Return hostile output",
|
||||
@@ -96,7 +94,7 @@ describe("CodeMode host failure boundary", () => {
|
||||
{},
|
||||
{
|
||||
ownKeys: () => {
|
||||
throw new Error("Cannot enumerate output")
|
||||
throw new Error("host-output-secret")
|
||||
},
|
||||
},
|
||||
),
|
||||
@@ -106,8 +104,9 @@ describe("CodeMode host failure boundary", () => {
|
||||
|
||||
expect(result.ok ? undefined : result.error).toStrictEqual({
|
||||
kind: "InvalidToolOutput",
|
||||
message: "Invalid output from tool 'host.call': Error: Cannot enumerate output",
|
||||
message: "Invalid output from tool 'host.call'.",
|
||||
})
|
||||
expect(JSON.stringify(result)).not.toMatch(/host-output-secret/)
|
||||
})
|
||||
|
||||
test("caught tool failures are Error values in-program", async () => {
|
||||
@@ -230,7 +229,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: "broken" },
|
||||
{ phase: "end", index: 0, name: "context.lookup", outcome: "failure", message: "Tool execution failed" },
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
@@ -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, providerOptions: metadataProviderOptions(part.providerMetadata) }]
|
||||
return [{ type: "text", text: part.text }]
|
||||
case "media":
|
||||
return [{ type: "file", mediaType: part.mediaType, data: part.data, filename: part.filename }]
|
||||
case "reasoning":
|
||||
|
||||
@@ -3,7 +3,7 @@ export * as CodeModeTool from "./tool.js"
|
||||
import { CodeMode, Tool, toolError } from "@opencode-ai/codemode"
|
||||
import type { Content, Context, Error, Info, Metadata, Result } from "@opencode-ai/schema/tool"
|
||||
import { Effect, Ref, Schema, Semaphore } from "effect"
|
||||
import { definition, normalizedName } from "../tool/runtime.js"
|
||||
import { definition } from "../tool/runtime.js"
|
||||
|
||||
const ExecuteFile = Schema.Struct({
|
||||
data: Schema.String,
|
||||
@@ -65,7 +65,9 @@ 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)
|
||||
const executed = yield* executeTool(name, tool, input, context).pipe(
|
||||
Effect.mapError((failure) => toolError(failure.message, failure)),
|
||||
)
|
||||
const content =
|
||||
typeof executed.content === "string"
|
||||
? [{ type: "text" as const, text: executed.content }]
|
||||
@@ -163,7 +165,7 @@ function runtime(
|
||||
}
|
||||
|
||||
function qualifiedName(registration: Info) {
|
||||
const normalized = normalizedName(registration)
|
||||
const normalized = registration.name.replace(/[^a-zA-Z0-9_-]/g, "_")
|
||||
if (registration.options?.namespace === undefined) return normalized
|
||||
return `${registration.options.namespace}.${normalized}`
|
||||
}
|
||||
|
||||
+16
-39
@@ -53,9 +53,6 @@ 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),
|
||||
})
|
||||
@@ -87,10 +84,7 @@ export const testLayer = (initial: Entry[] = []) =>
|
||||
const entry = current[index]
|
||||
if (!entry || entry.type !== "document")
|
||||
return yield* Effect.fail(new UpdateError({ message: "No editable config document found" }))
|
||||
const info = yield* Effect.try({
|
||||
try: () => produce(entry.info, update),
|
||||
catch: (cause) => new UpdateError({ message: "Config update failed", cause }),
|
||||
})
|
||||
const info = produce(entry.info, update)
|
||||
yield* Ref.set(entries, current.with(index, new Document({ type: "document", path: entry.path, info })))
|
||||
return info
|
||||
}),
|
||||
@@ -223,39 +217,28 @@ 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([
|
||||
...(globalEnabled && (yield* fs.isDir(globalClaudeDirectory)) ? [globalClaudeDirectory] : []),
|
||||
...visible.filter((item) => path.basename(item) === ".claude").toReversed(),
|
||||
...((yield* fs.isDir(globalClaudeDirectory)) ? [globalClaudeDirectory] : []),
|
||||
...discovered.filter((item) => path.basename(item) === ".claude").toReversed(),
|
||||
]),
|
||||
].map((directory) => new ClaudeDirectory({ type: "claude", path: AbsolutePath.make(directory) }))
|
||||
const agents = [
|
||||
...new Set([
|
||||
...(globalEnabled && (yield* fs.isDir(globalAgentsDirectory)) ? [globalAgentsDirectory] : []),
|
||||
...visible.filter((item) => path.basename(item) === ".agents").toReversed(),
|
||||
...((yield* fs.isDir(globalAgentsDirectory)) ? [globalAgentsDirectory] : []),
|
||||
...discovered.filter((item) => path.basename(item) === ".agents").toReversed(),
|
||||
]),
|
||||
].map((directory) => new AgentsDirectory({ type: "agents", path: AbsolutePath.make(directory) }))
|
||||
|
||||
const projectDirectories = visible
|
||||
.filter((item) => path.basename(item) === ".opencode")
|
||||
.toReversed()
|
||||
.map((directory) => AbsolutePath.make(directory))
|
||||
const directPaths = visible
|
||||
const directories = [
|
||||
globalDirectory,
|
||||
...discovered
|
||||
.filter((item) => path.basename(item) === ".opencode")
|
||||
.toReversed()
|
||||
.map((directory) => AbsolutePath.make(directory)),
|
||||
]
|
||||
const directPaths = discovered
|
||||
.filter((item) => ![".agents", ".claude", ".opencode"].includes(path.basename(item)))
|
||||
.toReversed()
|
||||
fileTargets.clear()
|
||||
@@ -287,21 +270,15 @@ export const layer = (options?: Options) =>
|
||||
)
|
||||
: []
|
||||
|
||||
// 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()),
|
||||
)
|
||||
const supplementary = yield* Effect.forEach(directories, loadDirectory).pipe(Effect.orDie)
|
||||
return [
|
||||
...(yield* loadWellknown().pipe(Effect.orDie)),
|
||||
...claude,
|
||||
...agents,
|
||||
...globalSupplementary,
|
||||
...(supplementary[0] ?? []),
|
||||
...explicit,
|
||||
...direct,
|
||||
...projectSupplementary,
|
||||
...supplementary.slice(1).flat(),
|
||||
...content,
|
||||
]
|
||||
})
|
||||
|
||||
@@ -19,9 +19,6 @@ 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
|
||||
@@ -38,15 +35,9 @@ 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 = [
|
||||
...(discovery.global ? [globalFile] : []),
|
||||
...(project
|
||||
? ancestorDirectories(start, stop)
|
||||
.map((directory) => join(directory, "AGENTS.md"))
|
||||
.filter((file) => discovery.global || file !== globalFile)
|
||||
: []),
|
||||
globalFile,
|
||||
...(project ? ancestorDirectories(start, stop).map((directory) => join(directory, "AGENTS.md")) : []),
|
||||
]
|
||||
for (const path of new Set(candidates)) {
|
||||
const updates = yield* watcher.subscribe({ path, type: "file" })
|
||||
@@ -60,15 +51,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 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 discovered = new Set(
|
||||
yield* Effect.forEach(yield* fs.up({ targets: ["AGENTS.md"], start, stop }), fs.resolve),
|
||||
)
|
||||
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)
|
||||
|
||||
@@ -7,16 +7,16 @@ import { FileSystem } from "@opencode-ai/schema/filesystem"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Cause, Context, Effect, Layer, PubSub, RcMap, Schema, Stream } from "effect"
|
||||
import { lazy } from "../util/lazy.js"
|
||||
import { watch } from "node:fs"
|
||||
import { watch as watchFileSystem } from "node:fs"
|
||||
import path from "path"
|
||||
import loadBinding from "./watcher-binding.js"
|
||||
|
||||
const SUBSCRIBE_TIMEOUT_MS = 10_000
|
||||
export const Event = { Updated: FileSystem.Event.Changed }
|
||||
|
||||
const watcher = lazy((): typeof ParcelWatcher | undefined => {
|
||||
const watcher = lazy((): typeof import("@parcel/watcher") | undefined => {
|
||||
try {
|
||||
return createWrapper(loadBinding()) as typeof ParcelWatcher
|
||||
return createWrapper(loadBinding()) as typeof import("@parcel/watcher")
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
@@ -192,7 +192,7 @@ export const nativeLayer = Layer.succeed(
|
||||
if (input.type === "file") {
|
||||
return Effect.sync(() => {
|
||||
const directory = path.dirname(input.target)
|
||||
const subscription = watch(directory, { recursive: false }, (_event, file) => {
|
||||
const subscription = watchFileSystem(directory, { recursive: false }, (_event, file) => {
|
||||
if (file && path.resolve(directory, file.toString()) !== input.target) return
|
||||
input.publish({ path: input.target, type: "update" } satisfies Update)
|
||||
})
|
||||
@@ -218,7 +218,7 @@ export function configured(options?: Options) {
|
||||
export const node = configured()
|
||||
|
||||
function subscribeDirectory(
|
||||
native: typeof ParcelWatcher | undefined,
|
||||
native: typeof import("@parcel/watcher") | undefined,
|
||||
backend: ParcelWatcher.BackendType | undefined,
|
||||
directory: string,
|
||||
ignore: readonly string[],
|
||||
|
||||
@@ -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" && !URL.canParse(value)) return `Expected URI for form field: ${field.key}`
|
||||
if (field.format === "uri" && !isUri(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,6 +347,15 @@ 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,19 +37,20 @@ export const layer = Layer.effect(
|
||||
|
||||
const runText = Effect.fn("Generate.text")(function* (input: TextInput) {
|
||||
const resolved = yield* resolver.resolve(input.model).pipe(
|
||||
Effect.catchTag(
|
||||
[
|
||||
"SessionRunnerModel.VariantUnavailableError",
|
||||
"SessionRunnerModel.UnsupportedPackageError",
|
||||
"SessionRunnerModel.UnresolvedProviderVariablesError",
|
||||
],
|
||||
(error) => {
|
||||
const mapped: Error = input.model
|
||||
Effect.catchTags({
|
||||
"SessionRunnerModel.VariantUnavailableError": (error) =>
|
||||
input.model
|
||||
? new ModelSelectionError({ message: error.message })
|
||||
: new UnavailableError({ message: error.message, service: error.providerID })
|
||||
return Effect.fail(mapped)
|
||||
},
|
||||
),
|
||||
: 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 }),
|
||||
}),
|
||||
)
|
||||
if (!resolved)
|
||||
return yield* new ModelSelectionError({
|
||||
|
||||
@@ -47,7 +47,7 @@ export function prepareResponsesTools({
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: tool.inputSchema,
|
||||
strict: tool.strict ?? strictJsonSchema,
|
||||
strict: strictJsonSchema,
|
||||
})
|
||||
break
|
||||
case "provider": {
|
||||
|
||||
@@ -1,172 +0,0 @@
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Agent } from "./agent.js"
|
||||
import { AISDK } from "./aisdk.js"
|
||||
import { Catalog } from "./catalog.js"
|
||||
import { Command } from "./command.js"
|
||||
import { Config } from "./config.js"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Node } from "@opencode-ai/util/effect/app-node"
|
||||
import { FileMutation } from "./file-mutation.js"
|
||||
import { Environment } from "./environment/index.js"
|
||||
import { Formatter } from "./formatter.js"
|
||||
import { FileSystem } from "./filesystem.js"
|
||||
import { FileSystemSearch } from "./filesystem/search.js"
|
||||
import { Generate } from "./generate.js"
|
||||
import { Form } from "./form.js"
|
||||
import { Image } from "./image.js"
|
||||
import { LocationWatcher } from "./filesystem/location-watcher.js"
|
||||
import { Integration } from "./integration.js"
|
||||
import { Location } from "./location.js"
|
||||
import { LocationMutation } from "./location-mutation.js"
|
||||
import { ModelResolver } from "./model-resolver.js"
|
||||
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"
|
||||
import { Pty } from "./pty.js"
|
||||
import { Shell } from "./shell.js"
|
||||
import { ShellSelect } from "./shell/select.js"
|
||||
import { Reference } from "./reference.js"
|
||||
import { WebSearch } from "./websearch.js"
|
||||
import { ReferenceInstructions } from "./reference/instructions.js"
|
||||
import { SessionRunnerLLM } from "./session/runner/llm.js"
|
||||
import { SessionRunnerModel } from "./session/runner/model.js"
|
||||
import { SessionModelTransport } from "./session/model-transport.js"
|
||||
import { SessionCompaction } from "./session/compaction.js"
|
||||
import { SessionTitle } from "./session/title.js"
|
||||
import { Skill } from "./skill.js"
|
||||
import { SkillInstructions } from "./skill/instructions.js"
|
||||
import { Snapshot } from "./snapshot.js"
|
||||
import { InstructionDiscovery } from "./instruction-discovery.js"
|
||||
import { InstructionBuiltIns } from "./instructions/builtins.js"
|
||||
import { InstructionEntry } from "./session/instruction-entry.js"
|
||||
import { SessionInstructions } from "./session/instructions.js"
|
||||
import { SessionGenerateNode } from "./session/generate-node.js"
|
||||
import { McpTool } from "./tool/mcp.js"
|
||||
import { ReadToolFileSystem } from "./tool/read-filesystem.js"
|
||||
import { Tool } from "./tool.js"
|
||||
import { ToolOutput } from "./tool-output.js"
|
||||
import { Vcs } from "./vcs.js"
|
||||
|
||||
export * as Instance from "./instance.js"
|
||||
|
||||
const nodes = [
|
||||
Location.node,
|
||||
Environment.node,
|
||||
Config.node,
|
||||
Agent.node,
|
||||
Command.node,
|
||||
Reference.node,
|
||||
WebSearch.node,
|
||||
Integration.node,
|
||||
Catalog.node,
|
||||
ModelResolver.node,
|
||||
AISDK.node,
|
||||
Plugin.node,
|
||||
PluginHooks.node,
|
||||
PluginExecution.node,
|
||||
InstancePlugins.node,
|
||||
PluginSupervisor.node,
|
||||
Worktree.refreshNode,
|
||||
FileSystemSearch.node,
|
||||
FileSystem.node,
|
||||
ShellSelect.node,
|
||||
Pty.node,
|
||||
Shell.node,
|
||||
Skill.node,
|
||||
InstructionBuiltIns.node,
|
||||
InstructionDiscovery.node,
|
||||
LocationMutation.node,
|
||||
FileMutation.node,
|
||||
Formatter.node,
|
||||
Mcp.node,
|
||||
Permission.node,
|
||||
Tool.node,
|
||||
ToolOutput.node,
|
||||
Image.node,
|
||||
SkillInstructions.node,
|
||||
ReferenceInstructions.node,
|
||||
InstructionEntry.node,
|
||||
Form.node,
|
||||
Generate.node,
|
||||
SessionGenerateNode.node,
|
||||
ReadToolFileSystem.node,
|
||||
McpTool.node,
|
||||
SessionInstructions.node,
|
||||
SessionRunnerModel.node,
|
||||
SessionModelTransport.node,
|
||||
SessionCompaction.node,
|
||||
SessionTitle.node,
|
||||
Snapshot.node,
|
||||
SessionRunnerLLM.node,
|
||||
Vcs.node,
|
||||
// Start repository watches only after boot-critical filesystem and Git work.
|
||||
LocationWatcher.node,
|
||||
] as const satisfies readonly Node.LocationNode<unknown, unknown>[]
|
||||
|
||||
export const graph = LayerNode.group<typeof nodes>(nodes)
|
||||
|
||||
export type Services = LayerNode.Output<typeof graph>
|
||||
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()
|
||||
// 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
|
||||
// those back out.
|
||||
const location = LayerNode.hoist(graph, Node.tags.values.global, allReplacements)
|
||||
|
||||
return LayerNode.compile(location.node).pipe(
|
||||
Layer.fresh,
|
||||
Layer.tap(() =>
|
||||
Effect.logInfo("location services booted", {
|
||||
directory: ref.directory,
|
||||
workspaceID: ref.workspaceID,
|
||||
durationMs: Math.round(performance.now() - startedAt),
|
||||
}),
|
||||
),
|
||||
Layer.provide(LayerNode.compile(location.hoisted)),
|
||||
)
|
||||
}
|
||||
@@ -39,14 +39,12 @@ 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
|
||||
|
||||
@@ -97,7 +95,6 @@ export const layer = (options?: Options) =>
|
||||
|
||||
return Service.of({
|
||||
project: options?.project !== false,
|
||||
global: options?.global !== false,
|
||||
transform: state.transform,
|
||||
reload: state.reload,
|
||||
list,
|
||||
|
||||
@@ -16,8 +16,7 @@ export type Kind = typeof Kind.Type
|
||||
/**
|
||||
* Mutation paths do not accept project references. A leading `~` expands to
|
||||
* the home directory; other relative paths resolve from the active Location.
|
||||
* Paths outside it and its non-root project worktree require separate
|
||||
* `external_directory` approval.
|
||||
* Paths outside it require separate `external_directory` approval.
|
||||
*/
|
||||
export const ResolveInput = Schema.Struct({
|
||||
path: Schema.String,
|
||||
@@ -53,24 +52,22 @@ export interface Interface {
|
||||
/**
|
||||
* Resolve a path and derive its permission resources. A leading `~` expands
|
||||
* to the home directory; other relative paths resolve from the Location.
|
||||
* Paths outside it and its non-root project worktree require separate
|
||||
* `external_directory` approval. This does not approve the mutation.
|
||||
* Paths outside it require separate `external_directory` approval. This does
|
||||
* not approve the mutation.
|
||||
*/
|
||||
readonly resolve: (input: ResolveInput) => Effect.Effect<Target, FSUtil.Error>
|
||||
}
|
||||
|
||||
/** Lexical absolute path, normalizing Windows shell paths and expanding `~` before resolution. */
|
||||
export const resolvePath = (directory: string, input: string, home = Global.Path.home) => {
|
||||
const normalized = FSUtil.windowsPath(input)
|
||||
return path.resolve(
|
||||
/** Lexical absolute path, expanding a leading `~` before resolving against `directory`. */
|
||||
export const resolvePath = (directory: string, input: string, home = Global.Path.home) =>
|
||||
path.resolve(
|
||||
directory,
|
||||
normalized === "~"
|
||||
input === "~"
|
||||
? home
|
||||
: normalized.startsWith("~/") || (process.platform === "win32" && normalized.startsWith("~\\"))
|
||||
? path.join(home, normalized.slice(2))
|
||||
: normalized,
|
||||
: input.startsWith("~/") || (process.platform === "win32" && input.startsWith("~\\"))
|
||||
? path.join(home, input.slice(2))
|
||||
: input,
|
||||
)
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/LocationMutation") {}
|
||||
|
||||
@@ -85,11 +82,7 @@ const layer = Layer.effect(
|
||||
|
||||
const resolve = Effect.fnUntraced(function* (input: ResolveInput) {
|
||||
const absolute = resolvePath(location.directory, input.path)
|
||||
const worktree = path.resolve(location.project.directory)
|
||||
const internal =
|
||||
FSUtil.contains(location.directory, absolute) ||
|
||||
(worktree !== path.parse(worktree).root && FSUtil.contains(worktree, absolute))
|
||||
if (internal) {
|
||||
if (FSUtil.contains(location.directory, absolute)) {
|
||||
return {
|
||||
absolute,
|
||||
resource: slash(path.relative(location.directory, absolute) || "."),
|
||||
@@ -127,6 +120,6 @@ const layer = Layer.effect(
|
||||
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
layer: layer.pipe(Layer.orDie),
|
||||
deps: [FSUtil.node, Location.node, ProjectMarkers.node],
|
||||
})
|
||||
|
||||
@@ -2,11 +2,11 @@ import { Context, Effect, Layer, LayerMap } from "effect"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Node } from "@opencode-ai/util/effect/app-node"
|
||||
import { Location } from "./location.js"
|
||||
import type { Instance } from "./instance.js"
|
||||
import type { LocationError, LocationServices } from "./location-services.js"
|
||||
|
||||
export class Service extends Context.Service<
|
||||
Service,
|
||||
LayerMap.LayerMap<Location.Ref, Instance.Services, Instance.Error>
|
||||
LayerMap.LayerMap<Location.Ref, LocationServices, LocationError>
|
||||
>()("@opencode/example/LocationServiceMap") {
|
||||
static get(ref: Location.Ref) {
|
||||
return Layer.unwrap(Effect.map(Service, (locations) => locations.get(ref)))
|
||||
|
||||
@@ -1,16 +1,118 @@
|
||||
import { Duration, Effect, Layer, LayerMap } from "effect"
|
||||
import { existsSync } from "fs"
|
||||
import path from "path"
|
||||
import { Agent } from "./agent.js"
|
||||
import { AISDK } from "./aisdk.js"
|
||||
import { Catalog } from "./catalog.js"
|
||||
import { Command } from "./command.js"
|
||||
import { Config } from "./config.js"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Instance } from "./instance.js"
|
||||
import { Node } from "@opencode-ai/util/effect/app-node"
|
||||
import { Bus } from "./bus.js"
|
||||
import { FileMutation } from "./file-mutation.js"
|
||||
import { Environment } from "./environment/index.js"
|
||||
import { Formatter } from "./formatter.js"
|
||||
import { FileSystem } from "./filesystem.js"
|
||||
import { FileSystemSearch } from "./filesystem/search.js"
|
||||
import { Generate } from "./generate.js"
|
||||
import { Form } from "./form.js"
|
||||
import { Image } from "./image.js"
|
||||
import { LocationWatcher } from "./filesystem/location-watcher.js"
|
||||
import { Integration } from "./integration.js"
|
||||
import { Location } from "./location.js"
|
||||
import { LocationMutation } from "./location-mutation.js"
|
||||
import { LocationServiceMap } from "./location-service-map.js"
|
||||
import { ModelResolver } from "./model-resolver.js"
|
||||
import { Mcp } from "./mcp/index.js"
|
||||
import { Permission } from "./permission.js"
|
||||
import { Plugin } from "./plugin.js"
|
||||
import { PluginHooks } from "./plugin/hooks.js"
|
||||
import { PluginSupervisor } from "./plugin/supervisor.js"
|
||||
import { Worktree } from "./worktree.js"
|
||||
import { Pty } from "./pty.js"
|
||||
import { Shell } from "./shell.js"
|
||||
import { ShellSelect } from "./shell/select.js"
|
||||
import { Reference } from "./reference.js"
|
||||
import { WebSearch } from "./websearch.js"
|
||||
import { ReferenceInstructions } from "./reference/instructions.js"
|
||||
import { SessionRunnerLLM } from "./session/runner/llm.js"
|
||||
import { SessionRunnerModel } from "./session/runner/model.js"
|
||||
import { SessionModelTransport } from "./session/model-transport.js"
|
||||
import { SessionCompaction } from "./session/compaction.js"
|
||||
import { SessionTitle } from "./session/title.js"
|
||||
import { Skill } from "./skill.js"
|
||||
import { SkillInstructions } from "./skill/instructions.js"
|
||||
import { Snapshot } from "./snapshot.js"
|
||||
import { InstructionDiscovery } from "./instruction-discovery.js"
|
||||
import { InstructionBuiltIns } from "./instructions/builtins.js"
|
||||
import { InstructionEntry } from "./session/instruction-entry.js"
|
||||
import { SessionInstructions } from "./session/instructions.js"
|
||||
import { SessionGenerateNode } from "./session/generate-node.js"
|
||||
import { McpTool } from "./tool/mcp.js"
|
||||
import { ReadToolFileSystem } from "./tool/read-filesystem.js"
|
||||
import { Tool } from "./tool.js"
|
||||
import { ToolOutput } from "./tool-output.js"
|
||||
import { Vcs } from "./vcs.js"
|
||||
import { AbsolutePath } from "./schema.js"
|
||||
|
||||
export { LocationServiceMap } from "./location-service-map.js"
|
||||
|
||||
export type LocationServices = Instance.Services
|
||||
export type LocationError = Instance.Error
|
||||
const locationServiceNodes = [
|
||||
Location.node,
|
||||
Environment.node,
|
||||
Config.node,
|
||||
Agent.node,
|
||||
Command.node,
|
||||
Reference.node,
|
||||
WebSearch.node,
|
||||
Integration.node,
|
||||
Catalog.node,
|
||||
ModelResolver.node,
|
||||
AISDK.node,
|
||||
Plugin.node,
|
||||
PluginHooks.node,
|
||||
PluginSupervisor.node,
|
||||
Worktree.refreshNode,
|
||||
FileSystemSearch.node,
|
||||
FileSystem.node,
|
||||
ShellSelect.node,
|
||||
Pty.node,
|
||||
Shell.node,
|
||||
Skill.node,
|
||||
InstructionBuiltIns.node,
|
||||
InstructionDiscovery.node,
|
||||
LocationMutation.node,
|
||||
FileMutation.node,
|
||||
Formatter.node,
|
||||
Mcp.node,
|
||||
Permission.node,
|
||||
Tool.node,
|
||||
ToolOutput.node,
|
||||
Image.node,
|
||||
SkillInstructions.node,
|
||||
ReferenceInstructions.node,
|
||||
InstructionEntry.node,
|
||||
Form.node,
|
||||
Generate.node,
|
||||
SessionGenerateNode.node,
|
||||
ReadToolFileSystem.node,
|
||||
McpTool.node,
|
||||
SessionInstructions.node,
|
||||
SessionRunnerModel.node,
|
||||
SessionModelTransport.node,
|
||||
SessionCompaction.node,
|
||||
SessionTitle.node,
|
||||
Snapshot.node,
|
||||
SessionRunnerLLM.node,
|
||||
Vcs.node,
|
||||
// Start repository watches only after boot-critical filesystem and Git work.
|
||||
LocationWatcher.node,
|
||||
] as const satisfies readonly Node.LocationNode<unknown, unknown>[]
|
||||
|
||||
export const locationServices = LayerNode.group<typeof locationServiceNodes>(locationServiceNodes)
|
||||
|
||||
export type LocationServices = LayerNode.Output<typeof locationServices>
|
||||
export type LocationError = LayerNode.Error<typeof locationServices>
|
||||
|
||||
export function buildLocationServiceMap(
|
||||
replacements: LayerNode.Replacements = [],
|
||||
@@ -25,14 +127,37 @@ export function buildLocationServiceMap(
|
||||
return Layer.effect(
|
||||
LocationServiceMap.Service,
|
||||
Effect.map(
|
||||
LayerMap.make((ref: Location.Ref) => Instance.layer(ref, { replacements }), {
|
||||
// Workspace-placed directories exist only inside the workspace, so a
|
||||
// local stat consults the wrong filesystem. Workspace liveness is
|
||||
// owned by placement; do not probe the sandbox here, which would
|
||||
// provision lazily-idle workspaces.
|
||||
idleTimeToLive: (ref) =>
|
||||
ref.workspaceID !== undefined || existsSync(ref.directory) ? Duration.infinity : Duration.zero,
|
||||
}),
|
||||
LayerMap.make(
|
||||
(ref: Location.Ref) => {
|
||||
const startedAt = performance.now()
|
||||
const allReplacements = replacements.concat([[Location.node, Location.boundNode(ref)]])
|
||||
// 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
|
||||
// those back out.
|
||||
const location = LayerNode.hoist(locationServices, Node.tags.values.global, allReplacements)
|
||||
|
||||
return LayerNode.compile(location.node).pipe(
|
||||
Layer.fresh,
|
||||
Layer.tap(() =>
|
||||
Effect.logInfo("location services booted", {
|
||||
directory: ref.directory,
|
||||
workspaceID: ref.workspaceID,
|
||||
durationMs: Math.round(performance.now() - startedAt),
|
||||
}),
|
||||
),
|
||||
Layer.provide(LayerNode.compile(location.hoisted)),
|
||||
)
|
||||
},
|
||||
{
|
||||
// Workspace-placed directories exist only inside the workspace, so a
|
||||
// local stat consults the wrong filesystem. Workspace liveness is
|
||||
// owned by placement; do not probe the sandbox here, which would
|
||||
// provision lazily-idle workspaces.
|
||||
idleTimeToLive: (ref) =>
|
||||
ref.workspaceID !== undefined || existsSync(ref.directory) ? Duration.infinity : Duration.zero,
|
||||
},
|
||||
),
|
||||
(inner) => ({
|
||||
...inner,
|
||||
get: (ref: Location.Ref) => inner.get(canonical(ref)),
|
||||
|
||||
@@ -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, options?: { readonly discovery?: boolean }) =>
|
||||
const layer = (ref: Ref) =>
|
||||
Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const project = yield* Project.Service
|
||||
const resolved = yield* project.resolve(ref.directory, options)
|
||||
const resolved = yield* project.resolve(ref.directory)
|
||||
return Service.of({
|
||||
directory: ref.directory,
|
||||
workspaceID: ref.workspaceID,
|
||||
@@ -33,9 +33,9 @@ const layer = (ref: Ref, options?: { readonly discovery?: boolean }) =>
|
||||
}),
|
||||
)
|
||||
|
||||
export const boundNode = (ref: Ref, options?: { readonly discovery?: boolean }) =>
|
||||
export const boundNode = (ref: Ref) =>
|
||||
makeLocationNode({
|
||||
service: Service,
|
||||
layer: layer(ref, options),
|
||||
layer: layer(ref),
|
||||
deps: [Project.node],
|
||||
})
|
||||
|
||||
@@ -19,7 +19,7 @@ async function install(
|
||||
const directory = path.join(root, `${input.version}-${input.sha256.slice(0, 16)}`)
|
||||
await privateDirectory(directory)
|
||||
const destination = path.join(directory, "opencode-pty")
|
||||
if (await validateIfPresent(destination, input.sha256)) return destination
|
||||
if (await exists(destination, input.sha256)) return destination
|
||||
|
||||
const bytes = new Uint8Array(await Bun.file(input.path).arrayBuffer())
|
||||
if (sha256(bytes) !== input.sha256) throw new Error("Embedded opencode-pty checksum mismatch")
|
||||
@@ -34,7 +34,7 @@ async function install(
|
||||
}
|
||||
await chmod(temporary, 0o755)
|
||||
await rename(temporary, destination).catch(async (error) => {
|
||||
if (!(await validateIfPresent(destination, input.sha256))) throw error
|
||||
if (!(await exists(destination, input.sha256))) throw error
|
||||
})
|
||||
} finally {
|
||||
await rm(temporary, { force: true })
|
||||
@@ -52,7 +52,7 @@ async function privateDirectory(directory: string) {
|
||||
await chmod(directory, 0o700)
|
||||
}
|
||||
|
||||
async function validateIfPresent(file: string, expected: string) {
|
||||
async function exists(file: string, expected: string) {
|
||||
try {
|
||||
await validate(file, expected)
|
||||
return true
|
||||
@@ -62,13 +62,14 @@ async function validateIfPresent(file: string, expected: string) {
|
||||
}
|
||||
}
|
||||
|
||||
async function validate(file: string, expected: string) {
|
||||
async function validate(file: string, expected?: string) {
|
||||
const info = await lstat(file)
|
||||
if (!info.isFile() || info.isSymbolicLink()) throw new Error(`Unsafe opencode-pty executable: ${file}`)
|
||||
const uid = typeof process.getuid === "function" ? process.getuid() : undefined
|
||||
if (uid !== undefined && info.uid !== uid)
|
||||
throw new Error(`opencode-pty executable is owned by another user: ${file}`)
|
||||
if (sha256(await readFile(file)) !== expected) throw new Error(`Cached opencode-pty checksum mismatch: ${file}`)
|
||||
if (expected && sha256(await readFile(file)) !== expected)
|
||||
throw new Error(`Cached opencode-pty checksum mismatch: ${file}`)
|
||||
await chmod(file, 0o755)
|
||||
return file
|
||||
}
|
||||
|
||||
+53
-111
@@ -1,5 +1,5 @@
|
||||
export * as Plugin from "./plugin.js"
|
||||
export { Event, ID, Info, Source, PackageStatus, OperationError } from "@opencode-ai/schema/plugin"
|
||||
export { Event, ID, Info, Source } from "@opencode-ai/schema/plugin"
|
||||
|
||||
import { Plugin } from "@opencode-ai/schema/plugin"
|
||||
import type { Plugin as PluginDefinition } from "@opencode-ai/plugin/effect/plugin"
|
||||
@@ -26,7 +26,6 @@ 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: (
|
||||
@@ -39,8 +38,6 @@ 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") {}
|
||||
@@ -50,12 +47,8 @@ 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; readonly error?: string }
|
||||
>()
|
||||
const active = new Map<Plugin.ID, { readonly plugin: Versioned; readonly scope: Scope.Closeable }>()
|
||||
const lock = Semaphore.makeUnsafe(1)
|
||||
let inventory: Plugin.Info[] = []
|
||||
let host: Parameters<PluginDefinition["effect"]>[0]
|
||||
@@ -82,7 +75,7 @@ const layer = Layer.effect(
|
||||
"plugin.id": plugin.id,
|
||||
cause: loaded.cause,
|
||||
})
|
||||
return { error: errorMessage(loaded.cause) } as const
|
||||
return { error: Cause.pretty(loaded.cause) } as const
|
||||
})
|
||||
|
||||
const activate = Effect.fn("Plugin.activate")(function* (
|
||||
@@ -98,91 +91,67 @@ const layer = Layer.effect(
|
||||
|
||||
yield* lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
const next = definitions.map((definition) => ({ id: definition.id, version: definition.version }))
|
||||
const current = Array.from(active.values(), (entry) => ({
|
||||
id: entry.plugin.id,
|
||||
version: entry.plugin.version,
|
||||
}))
|
||||
if (
|
||||
active.size === definitions.length &&
|
||||
Array.from(active.values()).every((entry, index) => {
|
||||
const definition = definitions[index]
|
||||
return entry.plugin.id === definition?.id && entry.plugin.version === definition.version
|
||||
current.length === next.length &&
|
||||
current.every((definition, index) => {
|
||||
const candidate = next[index]
|
||||
return definition.id === candidate?.id && definition.version === candidate.version
|
||||
})
|
||||
) {
|
||||
const nextInventory = withFailures(
|
||||
Array.from(active.values(), (entry) => activeInfo(entry.plugin, entry.error)),
|
||||
failures,
|
||||
)
|
||||
const nextInventory = [...Array.from(active.values(), (entry) => activeInfo(entry.plugin)), ...failures]
|
||||
if (JSON.stringify(inventory) === JSON.stringify(nextInventory)) return
|
||||
inventory = nextInventory
|
||||
yield* bus.publish(Plugin.Event.Updated, {})
|
||||
return
|
||||
}
|
||||
|
||||
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)
|
||||
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)
|
||||
|
||||
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)
|
||||
const loaded = yield* load(definition)
|
||||
if (loaded.scope !== undefined) {
|
||||
active.set(definition.id, { plugin: definition, scope: loaded.scope })
|
||||
nextInventory.push(activeInfo(definition))
|
||||
continue
|
||||
}
|
||||
nextInventory.push({
|
||||
id: definition.id,
|
||||
source: definition.source ?? { type: "builtin" },
|
||||
status: "failed",
|
||||
error: loaded.error,
|
||||
tui: definition.tui ?? false,
|
||||
})
|
||||
|
||||
inventory = withFailures(nextInventory, failures)
|
||||
}),
|
||||
).pipe(Effect.uninterruptible),
|
||||
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]
|
||||
}),
|
||||
)
|
||||
yield* bus.publish(Plugin.Event.Updated, {})
|
||||
}),
|
||||
@@ -207,41 +176,15 @@ const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
function activeInfo(plugin: Versioned, error?: string): Plugin.Info {
|
||||
function activeInfo(plugin: Versioned): 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,
|
||||
@@ -265,6 +208,5 @@ export const node = makeLocationNode({
|
||||
WebSearch.node,
|
||||
Generate.node,
|
||||
Permission.node,
|
||||
PluginExecution.node,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -83,7 +83,7 @@ Use best judgement when processing input.
|
||||
Use these to inform your review:
|
||||
|
||||
- **Explore agent** - Find how existing code handles similar problems. Check patterns, conventions, and prior art before claiming something doesn't fit.
|
||||
- **Available documentation and code-search tools** - Verify correct usage of libraries/APIs before flagging something as wrong.
|
||||
- **Exa Code Context** - Verify correct usage of libraries/APIs before flagging something as wrong.
|
||||
- **Web Search** - Research best practices if you're unsure about a pattern.
|
||||
|
||||
If you're uncertain about something and can't verify it with these tools, say "I'm not sure about X" rather than flagging it as a definite issue.
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
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,7 +4,6 @@ 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"
|
||||
@@ -328,10 +327,6 @@ 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()),
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
export * as InstancePlugins from "./instance.js"
|
||||
|
||||
import type { Plugin } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Context, Layer } from "effect"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import type { Versioned } from "../plugin.js"
|
||||
|
||||
/**
|
||||
* Holds the plugins one instance is born with. Unlike the host-global
|
||||
* `SdkPlugins` store, this list is a birth argument of a single instance:
|
||||
* `Instance.layer` binds it through the replacement mechanism, so two
|
||||
* instances in one process can carry different plugins. The list is immutable
|
||||
* for the instance's lifetime; runtime dynamism lives inside plugins through
|
||||
* the container transform/reload APIs.
|
||||
*
|
||||
* 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[]
|
||||
|
||||
export interface Interface {
|
||||
readonly all: () => readonly Versioned[]
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/InstancePlugins") {}
|
||||
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer: bound([]),
|
||||
deps: [],
|
||||
})
|
||||
|
||||
// The constant version is load-bearing: the plugin registry treats an
|
||||
// unchanged (id, version) pair as the same plugin across activations, which
|
||||
// is only correct because a bound list never changes after creation.
|
||||
// `source: "sdk"` means host-contributed; an instance list is the
|
||||
// per-instance form of the same channel.
|
||||
export function bound(plugins: List) {
|
||||
const duplicates = plugins.filter((plugin, index) => plugins.findIndex((other) => other.id === plugin.id) !== index)
|
||||
if (duplicates.length > 0) {
|
||||
throw new Error(`duplicate instance plugin ids: ${duplicates.map((plugin) => plugin.id).join(", ")}`)
|
||||
}
|
||||
const stamped = plugins.map(
|
||||
(plugin): Versioned => ({ ...plugin, version: "instance", source: { type: "sdk" } }),
|
||||
)
|
||||
return Layer.succeed(Service, Service.of({ all: () => stamped }))
|
||||
}
|
||||
@@ -36,22 +36,15 @@ 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 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
|
||||
const entrypoint = path.isAbsolute(operation.target)
|
||||
? pathToFileURL(operation.target).href
|
||||
: (yield* npm.add(operation.target, { subpaths: ["server", ""], refresh: true })).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" && path.isAbsolute(operation.target)
|
||||
? operation.target.replaceAll("\\", "/")
|
||||
: entrypoint
|
||||
const source = fresh || operation.mtime === undefined ? entrypoint : `${target}?mtime=${operation.mtime}`
|
||||
const target = typeof Bun !== "undefined" ? operation.target.replaceAll("\\", "/") : entrypoint
|
||||
const source = 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
|
||||
@@ -60,9 +53,7 @@ export const load = Effect.fn("PluginModule.load")(function* (
|
||||
id: plugin.id,
|
||||
tui: plugin.tui,
|
||||
vcs: plugin.vcs,
|
||||
version: JSON.stringify([operation, resolved.generation]),
|
||||
revision: resolved.revision,
|
||||
generation: resolved.generation,
|
||||
version: JSON.stringify(operation),
|
||||
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, Entry } from "@opencode-ai/schema/config"
|
||||
import type { Document } from "@opencode-ai/schema/config"
|
||||
import { Catalog } from "../../catalog.js"
|
||||
import { Config } from "../../config.js"
|
||||
import { Provider } from "../../provider.js"
|
||||
@@ -9,11 +9,7 @@ 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), initial)
|
||||
}
|
||||
.reduce((settings, entry) => Provider.mergeOverlay(settings, entry.info.providers?.[id]?.settings), current)
|
||||
})
|
||||
|
||||
@@ -81,6 +81,9 @@ 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 type { Entry } from "@opencode-ai/schema/config"
|
||||
import { Document, 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,7 +150,13 @@ 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 = foldSettings(entries, providerID, undefined)
|
||||
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 baseURL = (
|
||||
typeof settings?.baseURL === "string" ? settings.baseURL : `${origin.replace(/\/+$/, "")}/v1`
|
||||
).replace(/\/+$/, "")
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import type { Entry } from "@opencode-ai/schema/config"
|
||||
import { Document, 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,7 +206,13 @@ 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 = foldSettings(entries, providerID, undefined)
|
||||
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 baseURL = (
|
||||
typeof settings?.baseURL === "string" ? settings.baseURL : `${origin.replace(/\/+$/, "")}/v1`
|
||||
).replace(/\/+$/, "")
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import type { Entry } from "@opencode-ai/schema/config"
|
||||
import { Document, 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,7 +136,13 @@ 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 = foldSettings(entries, providerID, undefined)
|
||||
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 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, options) => require(cell, (runtime) => runtime.session.interrupt(sessionID, options)),
|
||||
interrupt: (sessionID) => require(cell, (runtime) => runtime.session.interrupt(sessionID)),
|
||||
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,7 +1,6 @@
|
||||
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
|
||||
@@ -10,11 +9,6 @@ import type { Plugin } from "@opencode-ai/schema/plugin"
|
||||
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,28 +2,22 @@ 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, Option, Scope, Semaphore, Stream } from "effect"
|
||||
import { Cause, Effect, Latch, Layer, 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"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { Plugin } from "../plugin.js"
|
||||
import { InstancePlugins } from "./instance.js"
|
||||
import { PluginInternal } from "./internal.js"
|
||||
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)
|
||||
@@ -31,7 +25,6 @@ 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) {
|
||||
@@ -54,23 +47,13 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
|
||||
continue
|
||||
}
|
||||
|
||||
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) }),
|
||||
),
|
||||
),
|
||||
)
|
||||
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) }),
|
||||
),
|
||||
),
|
||||
)
|
||||
if ("error" in plugin) {
|
||||
failures.set(operation.target, {
|
||||
source: pluginSource(operation.target),
|
||||
@@ -78,24 +61,9 @@ 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
|
||||
}
|
||||
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 })
|
||||
}
|
||||
failures.delete(operation.target)
|
||||
const previous = packages.get(operation.target)
|
||||
if (previous) enabled.delete(previous.id)
|
||||
packages.set(operation.target, plugin)
|
||||
@@ -108,12 +76,7 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
|
||||
...[...packages.values()].filter((plugin) => enabled.has(plugin.id)),
|
||||
...post.filter((plugin) => enabled.has(plugin.id)),
|
||||
],
|
||||
failures: [...failures.entries()]
|
||||
.filter(([target]) => {
|
||||
const plugin = packages.get(target)
|
||||
return !plugin || enabled.has(plugin.id)
|
||||
})
|
||||
.map(([, failure]) => failure),
|
||||
failures: [...failures.values()],
|
||||
}
|
||||
})
|
||||
|
||||
@@ -122,147 +85,30 @@ export const layer = Layer.effect(
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* Plugin.Service
|
||||
const sdk = yield* SdkPlugins.Service
|
||||
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* (
|
||||
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.
|
||||
const activate = Effect.fn("PluginSupervisor.activate")(function* () {
|
||||
// Resolve OpenCode's internal plugins with their privileged Location services.
|
||||
const internal = yield* PluginInternal.list()
|
||||
// Combine internal plugins with host-contributed SDK plugins in boot order.
|
||||
const pre = [
|
||||
...internal.pre.map((plugin) => ({ ...plugin, version: "internal", source: { type: "builtin" as const } })),
|
||||
...sdk.all(),
|
||||
...instance.all(),
|
||||
]
|
||||
const post = internal.post.map((plugin) => ({
|
||||
...plugin,
|
||||
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, cache).pipe(Effect.provideService(Npm.Service, npm))
|
||||
const resolved = yield* resolve(pre, post, operations)
|
||||
// Replace the active generation in one scoped, batched activation.
|
||||
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
|
||||
yield* registry.activate(resolved.plugins, resolved.failures)
|
||||
})
|
||||
|
||||
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(() =>
|
||||
@@ -279,55 +125,19 @@ export const layer = Layer.effect(
|
||||
Stream.debounce("100 millis"),
|
||||
Stream.runForEach((target) =>
|
||||
Effect.gen(function* () {
|
||||
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
|
||||
}
|
||||
}),
|
||||
yield* activate()
|
||||
if (observed === target) yield* ready.open
|
||||
}).pipe(Effect.catchCause((cause) => Effect.logError("failed to reload plugins", { cause }))),
|
||||
),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
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"),
|
||||
})
|
||||
return Service.of({ flush: ready.await })
|
||||
}),
|
||||
)
|
||||
|
||||
const nodeDeps = [
|
||||
Plugin.node,
|
||||
SdkPlugins.node,
|
||||
InstancePlugins.node,
|
||||
ConfigPluginSource.node,
|
||||
Bus.node,
|
||||
Npm.node,
|
||||
|
||||
@@ -49,7 +49,7 @@ Use the instructions below and the tools available to assist the user.
|
||||
|
||||
# Tool Use - OpenCode Specifics
|
||||
- When `webfetch` returns a message about a redirect to a different host, you should immediately make a new `webfetch` request with the redirect URL provided in the response.
|
||||
- When `plan` mode is active, you will see a <system-reminder> about this. Follow that reminder for the files you may edit and all other Plan mode restrictions. If the user asks you to implement changes, inform them that `plan` mode is active and that they need to switch to build mode.
|
||||
- When `plan` mode is active, you will see a <system-reminder> about this. `plan` mode is for planning, not editing. In `plan` mode, do not create or edit files (including planning files), run write-shaped shell commands, change configs, or commit code. If the user is asking you to perform edit operations in `plan` mode, inform them that `plan` mode is active and that they need to switch to build mode.
|
||||
|
||||
# Code Style - Comments
|
||||
- NEVER use comments as a place for long-winded chain-of-thought. Long thinking texts must be generated as private reasoning. Comments in code must be appropriately concise.
|
||||
|
||||
@@ -109,14 +109,25 @@ function make(
|
||||
return []
|
||||
}),
|
||||
status: Effect.fn("VcsHg.status")(function* () {
|
||||
return (yield* diffAgainst(undefined, { context: 0 })).map(
|
||||
const [items, batch] = yield* Effect.all(
|
||||
// Zero-context patches are enough to count changed lines.
|
||||
[hg.status(undefined, scope), hg.diff(undefined, scope, { context: 0 })],
|
||||
{ concurrency: 2 },
|
||||
)
|
||||
const chunks = chunksByFile(batch, () => undefined)
|
||||
return yield* Effect.forEach(
|
||||
items.toSorted((a, b) => a.file.localeCompare(b.file)),
|
||||
(item) =>
|
||||
({
|
||||
file: item.file,
|
||||
additions: item.additions,
|
||||
deletions: item.deletions,
|
||||
status: item.status,
|
||||
}) satisfies FileStatus,
|
||||
Effect.gen(function* () {
|
||||
const patch = yield* patchFor(item, undefined, chunks.get(item.file))
|
||||
const counts = countPatch(patch)
|
||||
return {
|
||||
file: item.file,
|
||||
additions: counts.additions,
|
||||
deletions: counts.deletions,
|
||||
status: item.status,
|
||||
} satisfies FileStatus
|
||||
}),
|
||||
)
|
||||
}),
|
||||
diff: Effect.fn("VcsHg.diff")(function* (mode: Mode, options?: DiffOptions) {
|
||||
|
||||
@@ -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, options?: { readonly discovery?: boolean }) => Effect.Effect<Resolved>
|
||||
readonly resolve: (input: AbsolutePath) => Effect.Effect<Resolved>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Project") {}
|
||||
@@ -256,14 +256,15 @@ const layer = Layer.effect(
|
||||
const value = input.trim()
|
||||
if (!value) return undefined
|
||||
|
||||
const parsed = URL.parse(value)
|
||||
if (parsed) {
|
||||
try {
|
||||
const parsed = new URL(value)
|
||||
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) {
|
||||
@@ -316,12 +317,9 @@ const layer = Layer.effect(
|
||||
}
|
||||
})
|
||||
|
||||
const resolve = Effect.fn("Project.resolve")(function* (
|
||||
input: AbsolutePath,
|
||||
options?: { readonly discovery?: boolean },
|
||||
) {
|
||||
const resolve = Effect.fn("Project.resolve")(function* (input: AbsolutePath) {
|
||||
const directory = AbsolutePath.make(yield* fs.resolve(input))
|
||||
const marker = yield* markers.discover(directory, options)
|
||||
const marker = yield* markers.discover(directory)
|
||||
const native = yield* fs.up({ targets: [".git", ".hg"], start: directory, mode: "first" }).pipe(
|
||||
Effect.map((matches) => matches[0]),
|
||||
Effect.orElseSucceed(() => undefined),
|
||||
|
||||
@@ -22,10 +22,7 @@ export interface Match {
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly discover: (
|
||||
directory: AbsolutePath,
|
||||
options?: { readonly discovery?: boolean },
|
||||
) => Effect.Effect<Match | undefined>
|
||||
readonly discover: (directory: AbsolutePath) => Effect.Effect<Match | undefined>
|
||||
readonly targets: () => readonly string[]
|
||||
}
|
||||
|
||||
@@ -41,10 +38,7 @@ const layer = Layer.effect(
|
||||
const known = new Set([".git", ".hg"])
|
||||
const loaded = new Map<string, Versioned | undefined>()
|
||||
|
||||
// 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 discover = Effect.fn("ProjectMarkers.discover")(function* (directory: AbsolutePath) {
|
||||
const found = yield* fs
|
||||
.up({ targets: [".opencode", "opencode.json", "opencode.jsonc"], start: directory })
|
||||
.pipe(Effect.orElseSucceed(() => []))
|
||||
@@ -60,7 +54,7 @@ const layer = Layer.effect(
|
||||
const configured = yield* Effect.forEach([...new Set(files)], (file) => read(fs, file)).pipe(
|
||||
Effect.map((entries) => entries.flat()),
|
||||
)
|
||||
return yield* Effect.forEach(
|
||||
const operations = yield* Effect.forEach(
|
||||
[
|
||||
...automatic.map((target): ConfigPluginSource.Operation => ({ type: "add", target, options: {} })),
|
||||
...configured,
|
||||
@@ -76,15 +70,6 @@ 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()) {
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import { createRequire } from "node:module"
|
||||
import { isSea } from "node:sea"
|
||||
import type { spawn } from "@lydell/node-pty"
|
||||
import type { Opts, Proc } from "./pty.js"
|
||||
|
||||
export type { Disp, Exit, Opts, Proc } from "./pty.js"
|
||||
|
||||
const pty = createRequire(import.meta.url)(process.env.OPENCODE_NODE_PTY_PATH ?? "@lydell/node-pty") as {
|
||||
spawn: typeof spawn
|
||||
}
|
||||
const pty = createRequire(import.meta.url)(
|
||||
process.env.OPENCODE_NODE_PTY_PATH ?? "@lydell/node-pty",
|
||||
) as typeof import("@lydell/node-pty")
|
||||
|
||||
function spawnPty(file: string, args: string[], opts: Opts): Proc {
|
||||
export function spawn(file: string, args: string[], opts: Opts): Proc {
|
||||
const proc = pty.spawn(file, args, process.platform === "win32" && isSea() ? { ...opts, useConptyDll: true } : opts)
|
||||
return {
|
||||
pid: proc.pid,
|
||||
@@ -30,5 +29,3 @@ function spawnPty(file: string, args: string[], opts: Opts): Proc {
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export { spawnPty as spawn }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import path from "path"
|
||||
import { Context, Effect, Layer, Stream } from "effect"
|
||||
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
|
||||
import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/util/cross-spawn-spawner"
|
||||
|
||||
@@ -41,7 +41,7 @@ export type DeepMutable<T> = T extends string | number | boolean | bigint | symb
|
||||
|
||||
/**
|
||||
* Nominal wrapper for scalar types. The class itself is a valid schema —
|
||||
* pass it directly to `Schema.decodeUnknownSync`, `Schema.decodeEffect`, etc.
|
||||
* pass it directly to `Schema.decode`, `Schema.decodeEffect`, etc.
|
||||
*
|
||||
* The runtime value remains an unwrapped primitive. `Schema.brand` supplies
|
||||
* the primitive schema behavior and constructor validation, while the class
|
||||
|
||||
+87
-119
@@ -41,7 +41,6 @@ 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"
|
||||
@@ -99,7 +98,6 @@ type CreateBaseInput = {
|
||||
title?: string
|
||||
agent?: Agent.ID
|
||||
model?: Model.Ref
|
||||
metadata?: SessionSchema.Metadata
|
||||
}
|
||||
type CreateInput = CreateBaseInput &
|
||||
({ location: Location.Ref; parentID?: never } | { parentID: SessionSchema.ID; location?: never })
|
||||
@@ -411,9 +409,6 @@ const layer = Layer.effect(
|
||||
subpath: RelativePath.make(path.relative(project.directory, location.directory).replaceAll("\\", "/")),
|
||||
title: input.title,
|
||||
agent: input.agent,
|
||||
// Children inherit metadata the way they inherit location, so
|
||||
// host policies that read it treat the family uniformly.
|
||||
metadata: input.metadata ?? parent?.metadata,
|
||||
model: input.model
|
||||
? {
|
||||
id: Model.ID.make(input.model.id),
|
||||
@@ -691,39 +686,30 @@ const layer = Layer.effect(
|
||||
),
|
||||
generate: Effect.fn("Session.generate")(function* (input) {
|
||||
const session = yield* result.get(input.sessionID)
|
||||
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)))
|
||||
const generate = yield* SessionGenerate.Service.pipe(Effect.provide(locations.get(session.location)))
|
||||
return yield* generate.generate(input)
|
||||
}),
|
||||
command: Effect.fn("Session.command")(function* (input) {
|
||||
const session = yield* result.get(input.sessionID)
|
||||
yield* Effect.gen(function* () {
|
||||
const commands = yield* Effect.gen(function* () {
|
||||
const plugins = yield* PluginSupervisor.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",
|
||||
},
|
||||
}),
|
||||
)
|
||||
yield* plugins.flush
|
||||
return yield* Command.Service
|
||||
}).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)
|
||||
@@ -733,20 +719,16 @@ const layer = Layer.effect(
|
||||
yield* execution.awaitIdle(input.sessionID)
|
||||
const started = yield* Effect.gen(function* () {
|
||||
const plugins = yield* PluginSupervisor.Service
|
||||
yield* plugins.initialized
|
||||
const gate = yield* PluginExecution.Service
|
||||
yield* plugins.flush
|
||||
const shell = yield* Shell.Service
|
||||
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),
|
||||
)
|
||||
return yield* 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,
|
||||
@@ -978,14 +960,10 @@ const layer = Layer.effect(
|
||||
return yield* new BusyError({ sessionID: input.sessionID })
|
||||
return yield* Effect.gen(function* () {
|
||||
const plugins = yield* PluginSupervisor.Service
|
||||
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),
|
||||
),
|
||||
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),
|
||||
)
|
||||
}).pipe(Effect.provide(locations.get(session.location)))
|
||||
}),
|
||||
@@ -994,12 +972,8 @@ 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.initialized
|
||||
const gate = yield* PluginExecution.Service
|
||||
return yield* gate.lease(
|
||||
{ id: session.id, admission: true },
|
||||
SessionRevert.clear(session).pipe(Effect.provideService(Bus.Service, bus)),
|
||||
)
|
||||
yield* plugins.flush
|
||||
return yield* SessionRevert.clear(session).pipe(Effect.provideService(Bus.Service, bus))
|
||||
}).pipe(Effect.provide(locations.get(session.location)))
|
||||
yield* execution.wake(sessionID)
|
||||
return revert
|
||||
@@ -1041,65 +1015,59 @@ const preparePrompt = Effect.fn("Session.preparePrompt")(function* (
|
||||
messageID: SessionMessage.ID,
|
||||
) {
|
||||
const plugins = yield* PluginSupervisor.Service
|
||||
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,
|
||||
})
|
||||
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(),
|
||||
}),
|
||||
)
|
||||
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
|
||||
@@ -1241,7 +1209,7 @@ const SHELL_MAX_CAPTURE_BYTES = 1024 * 1024
|
||||
|
||||
export const node = makeGlobalNode({
|
||||
service: Service,
|
||||
layer,
|
||||
layer: layer.pipe(Layer.orDie),
|
||||
deps: [
|
||||
Job.node,
|
||||
SessionEnvironment.node,
|
||||
|
||||
@@ -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 below.\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 above.\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.initialized
|
||||
yield* plugins.flush
|
||||
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 })
|
||||
|
||||
@@ -98,7 +98,6 @@ export const layer = (options?: Options) =>
|
||||
const recoverShell = Effect.fnUntraced(function* (
|
||||
background: Job.Background,
|
||||
recovery: Extract<Job.Recovery, { kind: "shell" }>,
|
||||
suspended: ReadonlySet<SessionSchema.ID>,
|
||||
) {
|
||||
const state = background.status === "running" ? "cancelled" : background.status
|
||||
const text =
|
||||
@@ -122,7 +121,7 @@ export const layer = (options?: Options) =>
|
||||
shellID: recovery.shellID,
|
||||
state,
|
||||
},
|
||||
...(suspended.has(recovery.sessionID) ? { resume: false } : {}),
|
||||
resume: false,
|
||||
})
|
||||
.pipe(
|
||||
Effect.catchTag("Session.NotFoundError", () => Effect.void),
|
||||
@@ -212,25 +211,23 @@ export const layer = (options?: Options) =>
|
||||
return Service.of({
|
||||
resumeSuspendedSessions: Effect.gen(function* () {
|
||||
const active = yield* execution.active
|
||||
// Early notices wait for root recovery's accounting, including roots that exhaust their budget.
|
||||
const suspended = new Set((yield* store.listSuspended()).filter((sessionID) => !active.has(sessionID)))
|
||||
const pending = yield* jobs.pendingBackground
|
||||
const children = pending.flatMap((background) =>
|
||||
background.status === "running" && background.recovery.kind === "subagent"
|
||||
? [background.recovery.childSessionID]
|
||||
: [],
|
||||
yield* store.releaseChildClaims(
|
||||
pending.flatMap((background) =>
|
||||
background.status === "running" && background.recovery.kind === "subagent"
|
||||
? [background.recovery.childSessionID]
|
||||
: [],
|
||||
),
|
||||
)
|
||||
// Early notices wait for recovery's accounting, including Sessions that exhaust their budget.
|
||||
const suspended = new Set(
|
||||
[...(yield* store.listSuspended()), ...children].filter((sessionID) => !active.has(sessionID)),
|
||||
)
|
||||
yield* store.releaseChildClaims(children)
|
||||
yield* Effect.forEach(
|
||||
// Admit shell outcomes before a recovered child can start its first model request.
|
||||
pending.toSorted((a, b) => Number(a.recovery.kind === "subagent") - Number(b.recovery.kind === "subagent")),
|
||||
pending,
|
||||
Effect.fnUntraced(function* (background) {
|
||||
if ((yield* jobs.get(background.id))?.status === "running") return
|
||||
const recovery = background.recovery
|
||||
yield* recovery.kind === "shell"
|
||||
? recoverShell(background, recovery, suspended)
|
||||
? recoverShell(background, recovery)
|
||||
: recoverSubagent(background, recovery, suspended)
|
||||
}),
|
||||
{ discard: true },
|
||||
|
||||
@@ -49,7 +49,6 @@ export function fromRow(row: typeof SessionTable.$inferSelect): SessionSchema.In
|
||||
workspaceID: row.workspace_id ? Workspace.ID.make(row.workspace_id) : undefined,
|
||||
}),
|
||||
subpath: row.path ? RelativePath.make(row.path) : undefined,
|
||||
metadata: row.metadata ?? undefined,
|
||||
revert: row.revert ? decodeRevert(row.revert) : undefined,
|
||||
outcome: row.idle_outcome ?? undefined,
|
||||
time: {
|
||||
|
||||
@@ -202,7 +202,7 @@ export const preview = Effect.fn("InstructionState.preview")(function* (
|
||||
update: Instructions.renderUpdate(
|
||||
instructions,
|
||||
dereference(state.current_values, stored),
|
||||
dereferenceDelta(result.delta, observedBlobs),
|
||||
dereferenceDelta(result.delta, new Map([...stored, ...observedBlobs])),
|
||||
),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -41,6 +41,7 @@ interface Channel {
|
||||
readonly openedAt: number
|
||||
active?: Active
|
||||
closing: boolean
|
||||
poisoned: boolean
|
||||
checkpoint?: ChannelCheckpoint
|
||||
pending?: { readonly token: object; readonly checkpoint: ChannelCheckpoint }
|
||||
reader?: Fiber.Fiber<unknown, unknown>
|
||||
@@ -149,6 +150,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
channel: Channel,
|
||||
error: AIError,
|
||||
) {
|
||||
channel.poisoned = true
|
||||
if (owner.channel === channel) owner.channel = undefined
|
||||
if (channel.closing) return
|
||||
channel.closing = true
|
||||
@@ -185,6 +187,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
connection,
|
||||
openedAt: yield* Clock.currentTimeMillis,
|
||||
closing: false,
|
||||
poisoned: false,
|
||||
}
|
||||
owner.channel = channel
|
||||
channel.reader = yield* connection.messages.pipe(
|
||||
@@ -271,11 +274,13 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
const current = owner.channel
|
||||
const rotateAfterMs = exchange.connect.rotateAfterMs ?? ROTATE_AFTER_MS
|
||||
const rotation = current
|
||||
? current.affinity !== key
|
||||
? "affinity"
|
||||
: now - current.openedAt >= rotateAfterMs
|
||||
? "age"
|
||||
: undefined
|
||||
? current.poisoned
|
||||
? "poisoned"
|
||||
: current.affinity !== key
|
||||
? "affinity"
|
||||
: now - current.openedAt >= rotateAfterMs
|
||||
? "age"
|
||||
: undefined
|
||||
: undefined
|
||||
if (current && rotation) {
|
||||
yield* Effect.logDebug("session websocket rotating", {
|
||||
@@ -356,6 +361,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
|
||||
let terminal: ChannelObservation | undefined
|
||||
const token = {}
|
||||
let staged: ChannelCheckpoint | undefined
|
||||
const frames = Stream.fromQueue(active.queue).pipe(
|
||||
Stream.timeoutOrElse({
|
||||
duration: IDLE_TIMEOUT,
|
||||
@@ -376,7 +382,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
if (!observationTerminal(observation)) return
|
||||
terminal = observation
|
||||
lifecycle.delivery = "terminal"
|
||||
const staged = observation.type === "completed" ? observation.checkpoint : undefined
|
||||
staged = observation.type === "completed" ? observation.checkpoint : undefined
|
||||
if (staged) channel.pending = { token, checkpoint: staged }
|
||||
if (observation.type !== "completed" || !staged) channel.checkpoint = undefined
|
||||
}),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user