Compare commits

..

7 Commits

Author SHA1 Message Date
James Long 48a7685db6 feat(tui): wire simulation control server 2026-07-01 21:45:53 +00:00
James Long 842e6b77ee fix: avoid simulation hook in mini mode 2026-07-01 21:45:53 +00:00
James Long c4e9f3d91a feat: add simulation control surface 2026-07-01 21:45:53 +00:00
James Long d0a75c5df3 docs: track simulation phase 1 tasks 2026-07-01 21:45:53 +00:00
James Long 61278daa02 docs: add simulation implementation phases 2026-07-01 21:45:53 +00:00
James Long 6437f8d615 docs: add simulation config generation 2026-07-01 21:45:53 +00:00
James Long 22c8bd9c6a docs: add simulation architecture spec 2026-07-01 21:45:53 +00:00
366 changed files with 52764 additions and 34565 deletions
-7
View File
@@ -8,13 +8,6 @@ inputs:
runs: runs:
using: "composite" using: "composite"
steps: steps:
# node-gyp@latest (invoked via bunx for native install scripts) requires Node >=22;
# some runner images ship an older system Node on PATH
- name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: "24"
- name: Get baseline download URL - name: Get baseline download URL
id: bun-url id: bun-url
shell: bash shell: bash
+2 -2
View File
@@ -2,9 +2,9 @@ name: typecheck
on: on:
push: push:
branches: [dev, v2] branches: [dev]
pull_request: pull_request:
branches: [dev, v2] branches: [dev]
workflow_dispatch: workflow_dispatch:
jobs: jobs:
-36
View File
@@ -1,36 +0,0 @@
export default {
id: "Orchestrator",
setup: async (ctx) => {
await ctx.agent.transform((agents) => {
agents.update("orchestrator", (agent) => {
agent.description = "Coordinates work by delegating implementation tasks to the minion subagent."
agent.mode = "primary"
agent.system = [
"You are Orchestrator, the primary coordinating agent for this repository. You do meta work only: you coordinate, brief, and synthesize — you do not perform the work itself.",
"Delegate ALL actual work to the minion subagent — implementation, exploration, discovery, searching the codebase, reading files to understand a problem, and even trivial one-line edits. Task size is never a reason to do it yourself, and there is no 'final integration' exception.",
"You are not hard-banned from tools, but direct tool use is reserved for coordination overhead: a quick peek to phrase a better brief, a fast read-only check to verify a minion's reported result, or answering a question about coordination state. If a tool call is producing the answer or the artifact the user asked for, that call belongs to a minion, not you.",
"Exploration is work. If the user asks how something works or where something lives, delegate the investigation to a minion rather than exploring yourself.",
"Always start minion subagents in the background. Even if you have nothing else to coordinate right now, the user may assign you new work while a Minion runs, and you must stay free to receive it. Never poll; you will be notified when they finish.",
"Give each minion a clear, self-contained brief: the goal, constraints, expected output, and any files or context already known from the user or previous minion reports.",
"Synthesize minion results, decide next steps, and report back concisely.",
].join("\n")
})
agents.update("minion", (agent) => {
agent.description = "Subagent that executes focused tasks delegated by Orchestrator."
agent.mode = "subagent"
agent.model = { providerID: "opencode", id: "glm-5.2" }
agent.system = [
"You are minion, a focused execution subagent for this repository.",
"Complete the specific task delegated to you by Orchestrator using the available tools.",
"Inspect the codebase before making assumptions, make targeted changes when requested, and verify your work when feasible.",
"Follow the repository's AGENTS.md conventions: respect the style guide, run `bun typecheck` from the affected package directory after code changes, never run tests from the repo root, and do not modify packages/opencode unless the task explicitly says V1 work.",
"If the task is ambiguous or you hit a blocker, stop and report your findings instead of guessing.",
"Keep your final response concise: summarize what you did, list important files changed or findings, and call out blockers or verification gaps.",
"Do not delegate to other subagents; execute the assigned work yourself.",
].join("\n")
agent.permissions.push({ action: "subagent", resource: "*", effect: "deny" })
})
})
},
}
+16
View File
@@ -0,0 +1,16 @@
export default {
id: "sample-agent-plugin",
setup: async (ctx) => {
await ctx.agent.transform((agents) => {
agents.update("sample-plugin-agent", (agent) => {
agent.description = "Example primary agent registered by .opencode/plugins/sample-agent.ts"
agent.mode = "primary"
agent.system = [
"You are the sample plugin agent for this repository.",
"Use this agent to verify that local plugin auto-discovery can add agents.",
"Keep responses concise and explain which plugin registered you when asked.",
].join("\n")
})
})
},
}
+1 -2
View File
@@ -159,5 +159,4 @@ const table = sqliteTable("session", {
- Keep local Session drains process-local until clustering is implemented. `SessionRunCoordinator` joins explicit same-Session resumes, coalesces prompt wakeups, and allows different Sessions to run concurrently. Advisory wakes drain eligible durable inbox rows only; post-crash continuation recovery requires a separate explicit design before it may retry provider work. A drain has no durable identity or transcript boundary. - Keep local Session drains process-local until clustering is implemented. `SessionRunCoordinator` joins explicit same-Session resumes, coalesces prompt wakeups, and allows different Sessions to run concurrently. Advisory wakes drain eligible durable inbox rows only; post-crash continuation recovery requires a separate explicit design before it may retry provider work. A drain has no durable identity or transcript boundary.
- Keep delivery vocabulary explicit. Prompts steer by default and promote at the next safe provider-turn boundary while the current drain requires continuation. An explicit `queue` input remains pending until the Session would otherwise become idle; promote one queued input at that boundary, then reevaluate continuation before promoting another. Promoting any new user input resets the selected agent's provider-turn allowance; a batch of steers resets it once. - Keep delivery vocabulary explicit. Prompts steer by default and promote at the next safe provider-turn boundary while the current drain requires continuation. An explicit `queue` input remains pending until the Session would otherwise become idle; promote one queued input at that boundary, then reevaluate continuation before promoting another. Promoting any new user input resets the selected agent's provider-turn allowance; a batch of steers resets it once.
- Keep EventV2 replay owner claims separate from clustered Session execution ownership. - Keep EventV2 replay owner claims separate from clustered Session execution ownership.
- Keep the System Context algebra and built-ins in `src/system-context`; keep Context Source producers with their observed domains, and keep Session History selection plus Context Checkpoint persistence Session-owned. The runner composes all context producers explicitly in `loadSystemContext`; there is no context registry. - Keep the System Context algebra, registry, and built-ins in `src/system-context`; keep Context Source producers with their observed domains, and keep Session History selection plus Context Epoch persistence Session-owned.
- The durable Applied record is what the model was last told, per source. Reconcile narrates drift as chronological System updates and never rewrites the baseline; only completed compaction rebaselines, and move or committed revert resets the checkpoint. Unavailable sources keep the model's prior belief, blocking only a session's first baseline.
+2 -38
View File
@@ -126,6 +126,7 @@
"@opencode-ai/schema": "workspace:*", "@opencode-ai/schema": "workspace:*",
}, },
"devDependencies": { "devDependencies": {
"@effect/platform-node": "catalog:",
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
"@opencode-ai/httpapi-codegen": "workspace:*", "@opencode-ai/httpapi-codegen": "workspace:*",
"@opencode-ai/server": "workspace:*", "@opencode-ai/server": "workspace:*",
@@ -141,20 +142,6 @@
"effect", "effect",
], ],
}, },
"packages/codemode": {
"name": "@opencode-ai/codemode",
"version": "0.0.1",
"dependencies": {
"acorn": "8.15.0",
"effect": "catalog:",
"typescript": "catalog:",
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
},
},
"packages/console/app": { "packages/console/app": {
"name": "@opencode-ai/console-app", "name": "@opencode-ai/console-app",
"version": "1.17.13", "version": "1.17.13",
@@ -597,7 +584,6 @@
"@octokit/rest": "catalog:", "@octokit/rest": "catalog:",
"@openauthjs/openauth": "catalog:", "@openauthjs/openauth": "catalog:",
"@opencode-ai/client": "workspace:*", "@opencode-ai/client": "workspace:*",
"@opencode-ai/codemode": "workspace:*",
"@opencode-ai/llm": "workspace:*", "@opencode-ai/llm": "workspace:*",
"@opencode-ai/plugin": "workspace:*", "@opencode-ai/plugin": "workspace:*",
"@opencode-ai/protocol": "workspace:*", "@opencode-ai/protocol": "workspace:*",
@@ -1954,8 +1940,6 @@
"@opencode-ai/client": ["@opencode-ai/client@workspace:packages/client"], "@opencode-ai/client": ["@opencode-ai/client@workspace:packages/client"],
"@opencode-ai/codemode": ["@opencode-ai/codemode@workspace:packages/codemode"],
"@opencode-ai/console-app": ["@opencode-ai/console-app@workspace:packages/console/app"], "@opencode-ai/console-app": ["@opencode-ai/console-app@workspace:packages/console/app"],
"@opencode-ai/console-core": ["@opencode-ai/console-core@workspace:packages/console/core"], "@opencode-ai/console-core": ["@opencode-ai/console-core@workspace:packages/console/core"],
@@ -3042,7 +3026,7 @@
"accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
@@ -5724,8 +5708,6 @@
"@astrojs/mdx/@astrojs/markdown-remark": ["@astrojs/markdown-remark@6.3.11", "", { "dependencies": { "@astrojs/internal-helpers": "0.7.6", "@astrojs/prism": "3.3.0", "github-slugger": "^2.0.0", "hast-util-from-html": "^2.0.3", "hast-util-to-text": "^4.0.2", "import-meta-resolve": "^4.2.0", "js-yaml": "^4.1.1", "mdast-util-definitions": "^6.0.0", "rehype-raw": "^7.0.0", "rehype-stringify": "^10.0.1", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remark-smartypants": "^3.0.2", "shiki": "^3.21.0", "smol-toml": "^1.6.0", "unified": "^11.0.5", "unist-util-remove-position": "^5.0.0", "unist-util-visit": "^5.0.0", "unist-util-visit-parents": "^6.0.2", "vfile": "^6.0.3" } }, "sha512-hcaxX/5aC6lQgHeGh1i+aauvSwIT6cfyFjKWvExYSxUhZZBBdvCliOtu06gbQyhbe0pGJNoNmqNlQZ5zYUuIyQ=="], "@astrojs/mdx/@astrojs/markdown-remark": ["@astrojs/markdown-remark@6.3.11", "", { "dependencies": { "@astrojs/internal-helpers": "0.7.6", "@astrojs/prism": "3.3.0", "github-slugger": "^2.0.0", "hast-util-from-html": "^2.0.3", "hast-util-to-text": "^4.0.2", "import-meta-resolve": "^4.2.0", "js-yaml": "^4.1.1", "mdast-util-definitions": "^6.0.0", "rehype-raw": "^7.0.0", "rehype-stringify": "^10.0.1", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remark-smartypants": "^3.0.2", "shiki": "^3.21.0", "smol-toml": "^1.6.0", "unified": "^11.0.5", "unist-util-remove-position": "^5.0.0", "unist-util-visit": "^5.0.0", "unist-util-visit-parents": "^6.0.2", "vfile": "^6.0.3" } }, "sha512-hcaxX/5aC6lQgHeGh1i+aauvSwIT6cfyFjKWvExYSxUhZZBBdvCliOtu06gbQyhbe0pGJNoNmqNlQZ5zYUuIyQ=="],
"@astrojs/mdx/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"@astrojs/mdx/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], "@astrojs/mdx/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
"@astrojs/sitemap/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], "@astrojs/sitemap/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
@@ -5936,8 +5918,6 @@
"@malept/flatpak-bundler/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="], "@malept/flatpak-bundler/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="],
"@mdx-js/mdx/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"@mdx-js/mdx/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], "@mdx-js/mdx/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
"@modelcontextprotocol/sdk/hono": ["hono@4.12.23", "", {}, "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA=="], "@modelcontextprotocol/sdk/hono": ["hono@4.12.23", "", {}, "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA=="],
@@ -6186,8 +6166,6 @@
"astro/@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.6.1", "", {}, "sha512-l5Pqf6uZu31aG+3Lv8nl/3s4DbUzdlxTWDof4pEpto6GUJNhhCbelVi9dEyurOVyqaelwmS9oSyOWOENSfgo9A=="], "astro/@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.6.1", "", {}, "sha512-l5Pqf6uZu31aG+3Lv8nl/3s4DbUzdlxTWDof4pEpto6GUJNhhCbelVi9dEyurOVyqaelwmS9oSyOWOENSfgo9A=="],
"astro/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"astro/common-ancestor-path": ["common-ancestor-path@1.0.1", "", {}, "sha512-L3sHRo1pXXEqX8VU28kfgUY+YGsk09hPqZiZmLacNib6XNTCM8ubYeT7ryXQw8asB1sKgcU5lkB7ONug08aB8w=="], "astro/common-ancestor-path": ["common-ancestor-path@1.0.1", "", {}, "sha512-L3sHRo1pXXEqX8VU28kfgUY+YGsk09hPqZiZmLacNib6XNTCM8ubYeT7ryXQw8asB1sKgcU5lkB7ONug08aB8w=="],
"astro/diff": ["diff@5.2.2", "", {}, "sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A=="], "astro/diff": ["diff@5.2.2", "", {}, "sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A=="],
@@ -6274,8 +6252,6 @@
"engine.io-client/ws": ["ws@8.20.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w=="], "engine.io-client/ws": ["ws@8.20.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w=="],
"esast-util-from-js/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"esbuild-plugin-copy/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "esbuild-plugin-copy/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"esbuild-plugin-copy/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], "esbuild-plugin-copy/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
@@ -6334,8 +6310,6 @@
"md-to-react-email/marked": ["marked@7.0.4", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-t8eP0dXRJMtMvBojtkcsA7n48BkauktUKzfkPSCq85ZMTJ0v76Rke4DYz01omYpPTUh4p/f7HePgRo3ebG8+QQ=="], "md-to-react-email/marked": ["marked@7.0.4", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-t8eP0dXRJMtMvBojtkcsA7n48BkauktUKzfkPSCq85ZMTJ0v76Rke4DYz01omYpPTUh4p/f7HePgRo3ebG8+QQ=="],
"micromark-extension-mdxjs/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], "micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
"miniflare/acorn": ["acorn@8.14.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA=="], "miniflare/acorn": ["acorn@8.14.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA=="],
@@ -6456,8 +6430,6 @@
"tar/yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="], "tar/yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
"terser/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], "terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],
"thread-stream/real-require": ["real-require@1.0.0", "", {}, "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g=="], "thread-stream/real-require": ["real-require@1.0.0", "", {}, "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g=="],
@@ -6476,8 +6448,6 @@
"unifont/ofetch": ["ofetch@1.5.1", "", { "dependencies": { "destr": "^2.0.5", "node-fetch-native": "^1.6.7", "ufo": "^1.6.1" } }, "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA=="], "unifont/ofetch": ["ofetch@1.5.1", "", { "dependencies": { "destr": "^2.0.5", "node-fetch-native": "^1.6.7", "ufo": "^1.6.1" } }, "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA=="],
"unplugin/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"unplugin/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], "unplugin/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
"unused-filename/path-exists": ["path-exists@5.0.0", "", {}, "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ=="], "unused-filename/path-exists": ["path-exists@5.0.0", "", {}, "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ=="],
@@ -6492,8 +6462,6 @@
"verror/core-util-is": ["core-util-is@1.0.2", "", {}, "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ=="], "verror/core-util-is": ["core-util-is@1.0.2", "", {}, "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ=="],
"vite-plugin-dynamic-import/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"vite-plugin-icons-spritesheet/glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="], "vite-plugin-icons-spritesheet/glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="],
"vitest/@vitest/expect": ["@vitest/expect@4.1.7", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.7", "@vitest/utils": "4.1.7", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w=="], "vitest/@vitest/expect": ["@vitest/expect@4.1.7", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.7", "@vitest/utils": "4.1.7", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w=="],
@@ -6918,8 +6886,6 @@
"@standard-community/standard-openapi/effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "@standard-community/standard-openapi/effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@storybook/csf-plugin/unplugin/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"@storybook/csf-plugin/unplugin/webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], "@storybook/csf-plugin/unplugin/webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="],
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="], "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="],
@@ -7078,8 +7044,6 @@
"opencode-gitlab-auth/open/wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="], "opencode-gitlab-auth/open/wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="],
"openid-client/lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
"p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], "p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
"pkg-dir/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], "pkg-dir/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
-4
View File
@@ -14,10 +14,6 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
Flag.withDescription("Run with a private server instead of the background service"), Flag.withDescription("Run with a private server instead of the background service"),
Flag.withDefault(false), Flag.withDefault(false),
), ),
server: Flag.string("server").pipe(
Flag.withDescription("Connect to a server URL instead of the background service"),
Flag.optional,
),
continue: Flag.boolean("continue").pipe( continue: Flag.boolean("continue").pipe(
Flag.withAlias("c"), Flag.withAlias("c"),
Flag.withDescription("Continue the last session"), Flag.withDescription("Continue the last session"),
+4 -7
View File
@@ -2,9 +2,7 @@ import { EOL } from "node:os"
import { Effect, Option } from "effect" import { Effect, Option } from "effect"
import { Commands } from "../commands" import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime" import { Runtime } from "../../framework/runtime"
import { Service } from "@opencode-ai/client/effect" import { Daemon } from "../../services/daemon"
import { ServiceConfig } from "../../services/service-config"
import type { Transport } from "@opencode-ai/client/effect"
const methods = new Set(["delete", "get", "head", "options", "patch", "post", "put"]) const methods = new Set(["delete", "get", "head", "options", "patch", "post", "put"])
@@ -19,9 +17,8 @@ type OpenApi = {
export default Runtime.handler( export default Runtime.handler(
Commands.commands.api, Commands.commands.api,
Effect.fn("cli.api")(function* (input) { Effect.fn("cli.api")(function* (input) {
const options = yield* ServiceConfig.options() const daemon = yield* Daemon.Service
const found = yield* Service.discover(options) const transport = yield* daemon.transport()
const transport = found ?? (yield* Service.start(options))
const params = Option.getOrElse(input.param, () => ({})) const params = Option.getOrElse(input.param, () => ({}))
const request = yield* resolveRequest(transport, input.request, params) const request = yield* resolveRequest(transport, input.request, params)
const headers = new Headers(transport.headers) const headers = new Headers(transport.headers)
@@ -61,7 +58,7 @@ export function rawRequest(input: readonly string[]) {
} }
function resolveRequest( function resolveRequest(
transport: Transport, transport: { url: string; headers: RequestInit["headers"] },
input: readonly string[], input: readonly string[],
params: Record<string, string>, params: Record<string, string>,
) { ) {
@@ -1,18 +1,14 @@
import { EOL } from "os" import { EOL } from "os"
import * as Effect from "effect/Effect" import * as Effect from "effect/Effect"
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
import { Commands } from "../../commands" import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime" import { Runtime } from "../../../framework/runtime"
import { Service } from "@opencode-ai/client/effect" import { Daemon } from "../../../services/daemon"
import { ServiceConfig } from "../../../services/service-config"
export default Runtime.handler( export default Runtime.handler(
Commands.commands.debug.commands.agents, Commands.commands.debug.commands.agents,
Effect.fn("cli.debug.agents")(function* () { Effect.fn("cli.debug.agents")(function* () {
const options = yield* ServiceConfig.options() const daemon = yield* Daemon.Service
const found = yield* Service.discover(options) const client = yield* daemon.client()
const transport = found ?? (yield* Service.start(options))
const client = createOpencodeClient({ baseUrl: transport.url, headers: transport.headers })
const response = yield* Effect.promise(() => client.v2.agent.list({ location: { directory: process.cwd() } })) const response = yield* Effect.promise(() => client.v2.agent.list({ location: { directory: process.cwd() } }))
process.stdout.write( process.stdout.write(
JSON.stringify( JSON.stringify(
+13 -35
View File
@@ -1,10 +1,7 @@
import { NodeFileSystem } from "@effect/platform-node"
import { Commands } from "../commands" import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime" import { Runtime } from "../../framework/runtime"
import { Effect, Option } from "effect" import { Effect, Option } from "effect"
import { Service } from "@opencode-ai/client/effect" import { Daemon } from "../../services/daemon"
import type { Transport } from "@opencode-ai/client/effect"
import { ServiceConfig } from "../../services/service-config"
import { Standalone } from "../../services/standalone" import { Standalone } from "../../services/standalone"
import { Updater } from "../../services/updater" import { Updater } from "../../services/updater"
@@ -14,37 +11,18 @@ export default Runtime.handler(Commands, (input) =>
if (directory !== undefined) process.chdir(directory) if (directory !== undefined) process.chdir(directory)
const updater = yield* Updater.Service const updater = yield* Updater.Service
yield* updater.check().pipe(Effect.forkScoped) yield* updater.check().pipe(Effect.forkScoped)
const server = Option.getOrUndefined(input.server) const daemon = yield* Daemon.Service
if (server !== undefined && input.standalone) const transport = yield* (input.standalone ? Standalone.transport() : daemon.transport())
return yield* Effect.fail(new Error("--server and --standalone cannot be combined"))
const transport = yield* Effect.gen(function* () {
if (server !== undefined) {
const password = process.env["OPENCODE_SERVER_PASSWORD"]
return {
url: server,
headers: password ? { authorization: "Basic " + btoa("opencode:" + password) } : undefined,
} satisfies Transport
}
if (input.standalone) return yield* Standalone.transport()
const options = yield* ServiceConfig.options()
const found = yield* Service.discover(options)
return found ?? (yield* Service.start(options))
})
const { runTui } = yield* Effect.promise(() => import("../../tui")) const { runTui } = yield* Effect.promise(() => import("../../tui"))
// The TUI re-runs discover whenever its event stream drops. For an explicit yield* runTui(
// --server or a standalone child the transport is fixed, so reconnects transport,
// retry the same address; for the managed service discovery re-reads the { continue: input.continue, sessionID: Option.getOrUndefined(input.session) },
// registration and may start a replacement. input.standalone
const serviceOptions = server === undefined && !input.standalone ? yield* ServiceConfig.options() : undefined ? undefined
const discover = serviceOptions : async () => {
? () => await Effect.runPromise(daemon.stop())
Effect.runPromise( return Effect.runPromise(daemon.transport())
Effect.gen(function* () { },
const found = yield* Service.discover(serviceOptions) )
return found ?? (yield* Service.start(serviceOptions))
}).pipe(Effect.provide(NodeFileSystem.layer)),
)
: () => Promise.resolve(transport)
yield* runTui(transport, { continue: input.continue, sessionID: Option.getOrUndefined(input.session) }, discover)
}), }),
) )
+4 -12
View File
@@ -1,15 +1,9 @@
import { EOL } from "node:os" import { EOL } from "node:os"
import { Effect } from "effect" import { Effect } from "effect"
import { import type { IntegrationAttemptStatus, IntegrationOAuthMethod, OpencodeClient } from "@opencode-ai/sdk/v2/client"
createOpencodeClient,
type IntegrationAttemptStatus,
type IntegrationOAuthMethod,
type OpencodeClient,
} from "@opencode-ai/sdk/v2/client"
import { Commands } from "../../commands" import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime" import { Runtime } from "../../../framework/runtime"
import { Service } from "@opencode-ai/client/effect" import { Daemon } from "../../../services/daemon"
import { ServiceConfig } from "../../../services/service-config"
import { resolveIntegration } from "./resolve" import { resolveIntegration } from "./resolve"
const location = { directory: process.cwd() } const location = { directory: process.cwd() }
@@ -17,10 +11,8 @@ const location = { directory: process.cwd() }
export default Runtime.handler( export default Runtime.handler(
Commands.commands.mcp.commands.auth, Commands.commands.mcp.commands.auth,
Effect.fn("cli.mcp.auth")(function* (input) { Effect.fn("cli.mcp.auth")(function* (input) {
const options = yield* ServiceConfig.options() const daemon = yield* Daemon.Service
const found = yield* Service.discover(options) const client = yield* daemon.client()
const transport = found ?? (yield* Service.start(options))
const client = createOpencodeClient({ baseUrl: transport.url, headers: transport.headers })
const integration = yield* resolveIntegration(client, input.name, location) const integration = yield* resolveIntegration(client, input.name, location)
if (!integration) if (!integration)
@@ -1,18 +1,15 @@
import { EOL } from "node:os" import { EOL } from "node:os"
import * as Effect from "effect/Effect" import * as Effect from "effect/Effect"
import { createOpencodeClient, type McpServer } from "@opencode-ai/sdk/v2/client" import type { McpServer } from "@opencode-ai/sdk/v2/client"
import { Commands } from "../../commands" import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime" import { Runtime } from "../../../framework/runtime"
import { Service } from "@opencode-ai/client/effect" import { Daemon } from "../../../services/daemon"
import { ServiceConfig } from "../../../services/service-config"
export default Runtime.handler( export default Runtime.handler(
Commands.commands.mcp.commands.list, Commands.commands.mcp.commands.list,
Effect.fn("cli.mcp.list")(function* () { Effect.fn("cli.mcp.list")(function* () {
const options = yield* ServiceConfig.options() const daemon = yield* Daemon.Service
const found = yield* Service.discover(options) const client = yield* daemon.client()
const transport = found ?? (yield* Service.start(options))
const client = createOpencodeClient({ baseUrl: transport.url, headers: transport.headers })
const response = yield* Effect.promise(() => client.v2.mcp.list({ location: { directory: process.cwd() } })) const response = yield* Effect.promise(() => client.v2.mcp.list({ location: { directory: process.cwd() } }))
const servers = (response.data?.data ?? []).toSorted((a, b) => a.name.localeCompare(b.name)) const servers = (response.data?.data ?? []).toSorted((a, b) => a.name.localeCompare(b.name))
if (servers.length === 0) { if (servers.length === 0) {
@@ -1,10 +1,8 @@
import { EOL } from "node:os" import { EOL } from "node:os"
import { Effect } from "effect" import { Effect } from "effect"
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
import { Commands } from "../../commands" import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime" import { Runtime } from "../../../framework/runtime"
import { Service } from "@opencode-ai/client/effect" import { Daemon } from "../../../services/daemon"
import { ServiceConfig } from "../../../services/service-config"
import { resolveIntegration } from "./resolve" import { resolveIntegration } from "./resolve"
const location = { directory: process.cwd() } const location = { directory: process.cwd() }
@@ -12,10 +10,8 @@ const location = { directory: process.cwd() }
export default Runtime.handler( export default Runtime.handler(
Commands.commands.mcp.commands.logout, Commands.commands.mcp.commands.logout,
Effect.fn("cli.mcp.logout")(function* (input) { Effect.fn("cli.mcp.logout")(function* (input) {
const options = yield* ServiceConfig.options() const daemon = yield* Daemon.Service
const found = yield* Service.discover(options) const client = yield* daemon.client()
const transport = found ?? (yield* Service.start(options))
const client = createOpencodeClient({ baseUrl: transport.url, headers: transport.headers })
const integration = yield* resolveIntegration(client, input.name, location) const integration = yield* resolveIntegration(client, input.name, location)
if (!integration) { if (!integration) {
+7 -58
View File
@@ -4,20 +4,18 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { PermissionSaved } from "@opencode-ai/core/permission/saved" import { PermissionSaved } from "@opencode-ai/core/permission/saved"
import { Global } from "@opencode-ai/core/global" import { Global } from "@opencode-ai/core/global"
import { Context, FileSystem, Layer, Option, Schedule, Schema } from "effect" import { Context, Layer, Option, Schedule } from "effect"
import * as Effect from "effect/Effect" import * as Effect from "effect/Effect"
import { HttpRouter, HttpServer } from "effect/unstable/http" import { HttpRouter, HttpServer } from "effect/unstable/http"
import { createServer } from "node:http" import { createServer } from "node:http"
import { createRoutes } from "@opencode-ai/server/routes" import { createRoutes } from "@opencode-ai/server/routes"
import { ServerAuth } from "@opencode-ai/server/auth" import { ServerAuth } from "@opencode-ai/server/auth"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
import { Commands } from "../commands" import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime" import { Runtime } from "../../framework/runtime"
import { ServiceConfig } from "../../services/service-config" import { Daemon } from "../../services/daemon"
import { Updater } from "../../services/updater" import { Updater } from "../../services/updater"
import { randomBytes, randomUUID } from "crypto" import { randomBytes } from "crypto"
import path from "path"
export default Runtime.handler( export default Runtime.handler(
Commands.commands.serve, Commands.commands.serve,
@@ -25,11 +23,12 @@ export default Runtime.handler(
if (input.service) yield* Effect.sync(() => process.chdir(Global.Path.home)) if (input.service) yield* Effect.sync(() => process.chdir(Global.Path.home))
return yield* Effect.scoped( return yield* Effect.scoped(
Effect.gen(function* () { Effect.gen(function* () {
const daemon = yield* Daemon.Service
const standalonePassword = process.env.OPENCODE_SERVER_PASSWORD const standalonePassword = process.env.OPENCODE_SERVER_PASSWORD
if (input.stdio) delete process.env.OPENCODE_SERVER_PASSWORD if (input.stdio) delete process.env.OPENCODE_SERVER_PASSWORD
const config = input.service ? yield* ServiceConfig.read() : {} const config = input.service ? yield* daemon.config() : {}
const password = input.service const password = input.service
? yield* ServiceConfig.password() ? yield* daemon.password()
: standalonePassword || randomBytes(32).toString("base64url") : standalonePassword || randomBytes(32).toString("base64url")
if (!password) return yield* Effect.fail(new Error("Missing server password")) if (!password) return yield* Effect.fail(new Error("Missing server password"))
const hostname = Option.getOrUndefined(input.hostname) ?? config.hostname ?? "127.0.0.1" const hostname = Option.getOrUndefined(input.hostname) ?? config.hostname ?? "127.0.0.1"
@@ -45,7 +44,7 @@ export default Runtime.handler(
headers: ServerAuth.headers({ password }), headers: ServerAuth.headers({ password }),
}).v2.health.get({}), }).v2.health.get({}),
) )
if (input.service) yield* register(address) if (input.service) yield* daemon.register(address)
const url = HttpServer.formatAddress(address) const url = HttpServer.formatAddress(address)
console.log(input.stdio ? JSON.stringify({ url }) : `server listening on ${url}`) console.log(input.stdio ? JSON.stringify({ url }) : `server listening on ${url}`)
if (!input.service && !input.stdio && !standalonePassword) console.log(`server password ${password}`) if (!input.service && !input.stdio && !standalonePassword) console.log(`server password ${password}`)
@@ -57,56 +56,6 @@ export default Runtime.handler(
}), }),
) )
// Server-side half of the registration protocol. The registration embeds the
// password so the file alone is enough for any client to discover and
// authenticate. The file arbitrates ownership after concurrent starts; it is
// not a startup lock: the atomic rename elects the latest writer, the watcher
// self-evicts losers, and the finalizer id-guard keeps an exiting server from
// deleting its successor's registration.
const RegistrationId = Schema.Struct({ id: Schema.optional(Schema.String) })
const decodeRegistrationId = Schema.decodeUnknownEffect(Schema.fromJsonString(RegistrationId))
const register = Effect.fnUntraced(function* (address: HttpServer.Address) {
const fs = yield* FileSystem.FileSystem
const { file } = yield* ServiceConfig.options()
const id = randomUUID()
const secret = yield* ServiceConfig.password()
const temp = file + "." + id + ".tmp"
yield* fs.makeDirectory(path.dirname(file), { recursive: true })
yield* fs.writeFileString(
temp,
JSON.stringify({
id,
version: InstallationVersion,
url: HttpServer.formatAddress(address),
pid: process.pid,
password: secret,
}),
{ mode: 0o600 },
)
yield* fs.rename(temp, file)
const currentID = fs.readFileString(file).pipe(
Effect.flatMap(decodeRegistrationId),
Effect.map((info) => info.id),
Effect.orElseSucceed(() => undefined),
)
yield* currentID.pipe(
Effect.flatMap((current) =>
current === id
? Effect.void
: Effect.try({ try: () => process.kill(process.pid, "SIGTERM"), catch: (cause) => cause }).pipe(Effect.ignore),
),
Effect.repeat(Schedule.spaced("10 seconds")),
Effect.forkScoped,
)
yield* Effect.addFinalizer(() =>
currentID.pipe(
Effect.flatMap((current) => (current === id ? fs.remove(file) : Effect.void)),
Effect.ignore,
),
)
})
function waitForStdinClose() { function waitForStdinClose() {
return Effect.callback<void>((resume) => { return Effect.callback<void>((resume) => {
const close = () => resume(Effect.void) const close = () => resume(Effect.void)
@@ -3,11 +3,12 @@ import { Option } from "effect"
import * as Effect from "effect/Effect" import * as Effect from "effect/Effect"
import { Commands } from "../../commands" import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime" import { Runtime } from "../../../framework/runtime"
import { ServiceConfig } from "../../../services/service-config" import { Daemon } from "../../../services/daemon"
export default Runtime.handler( export default Runtime.handler(
Commands.commands.service.commands.get, Commands.commands.service.commands.get,
Effect.fn("cli.service.get")(function* (input) { Effect.fn("cli.service.get")(function* (input) {
process.stdout.write((yield* ServiceConfig.get(Option.getOrUndefined(input.key))) + EOL) const daemon = yield* Daemon.Service
process.stdout.write((yield* daemon.get(Option.getOrUndefined(input.key))) + EOL)
}), }),
) )
@@ -1,16 +1,14 @@
import { EOL } from "os" import { EOL } from "os"
import * as Effect from "effect/Effect" import * as Effect from "effect/Effect"
import { Service } from "@opencode-ai/client/effect"
import { Commands } from "../../commands" import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime" import { Runtime } from "../../../framework/runtime"
import { ServiceConfig } from "../../../services/service-config" import { Daemon } from "../../../services/daemon"
export default Runtime.handler( export default Runtime.handler(
Commands.commands.service.commands.restart, Commands.commands.service.commands.restart,
Effect.fn("cli.service.restart")(function* () { Effect.fn("cli.service.restart")(function* () {
const options = yield* ServiceConfig.options() const daemon = yield* Daemon.Service
yield* Service.stop(options) yield* daemon.stop()
const transport = yield* Service.start(options) process.stdout.write((yield* daemon.start()) + EOL)
process.stdout.write(transport.url + EOL)
}), }),
) )
@@ -1,11 +1,11 @@
import * as Effect from "effect/Effect" import * as Effect from "effect/Effect"
import { Commands } from "../../commands" import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime" import { Runtime } from "../../../framework/runtime"
import { ServiceConfig } from "../../../services/service-config" import { Daemon } from "../../../services/daemon"
export default Runtime.handler( export default Runtime.handler(
Commands.commands.service.commands.set, Commands.commands.service.commands.set,
Effect.fn("cli.service.set")(function* (input) { Effect.fn("cli.service.set")(function* (input) {
yield* ServiceConfig.set(input.key, input.value) yield* (yield* Daemon.Service).set(input.key, input.value)
}), }),
) )
@@ -1,14 +1,12 @@
import { EOL } from "os" import { EOL } from "os"
import * as Effect from "effect/Effect" import * as Effect from "effect/Effect"
import { Service } from "@opencode-ai/client/effect"
import { Commands } from "../../commands" import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime" import { Runtime } from "../../../framework/runtime"
import { ServiceConfig } from "../../../services/service-config" import { Daemon } from "../../../services/daemon"
export default Runtime.handler( export default Runtime.handler(
Commands.commands.service.commands.start, Commands.commands.service.commands.start,
Effect.fn("cli.service.start")(function* () { Effect.fn("cli.service.start")(function* () {
const transport = yield* Service.start(yield* ServiceConfig.options()) process.stdout.write((yield* (yield* Daemon.Service).start()) + EOL)
process.stdout.write(transport.url + EOL)
}), }),
) )
@@ -1,14 +1,13 @@
import { EOL } from "os" import { EOL } from "os"
import * as Effect from "effect/Effect" import * as Effect from "effect/Effect"
import { Service } from "@opencode-ai/client/effect"
import { Commands } from "../../commands" import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime" import { Runtime } from "../../../framework/runtime"
import { ServiceConfig } from "../../../services/service-config" import { Daemon } from "../../../services/daemon"
export default Runtime.handler( export default Runtime.handler(
Commands.commands.service.commands.status, Commands.commands.service.commands.status,
Effect.fn("cli.service.status")(function* () { Effect.fn("cli.service.status")(function* () {
const found = yield* Service.discover(yield* ServiceConfig.options()) const url = yield* (yield* Daemon.Service).status()
process.stdout.write((found ? found.url : "stopped") + EOL) process.stdout.write((url ? url : "stopped") + EOL)
}), }),
) )
@@ -1,12 +1,11 @@
import * as Effect from "effect/Effect" import * as Effect from "effect/Effect"
import { Service } from "@opencode-ai/client/effect"
import { Commands } from "../../commands" import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime" import { Runtime } from "../../../framework/runtime"
import { ServiceConfig } from "../../../services/service-config" import { Daemon } from "../../../services/daemon"
export default Runtime.handler( export default Runtime.handler(
Commands.commands.service.commands.stop, Commands.commands.service.commands.stop,
Effect.fn("cli.service.stop")(function* () { Effect.fn("cli.service.stop")(function* () {
yield* Service.stop(yield* ServiceConfig.options()) yield* (yield* Daemon.Service).stop()
}), }),
) )
@@ -1,11 +1,11 @@
import * as Effect from "effect/Effect" import * as Effect from "effect/Effect"
import { Commands } from "../../commands" import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime" import { Runtime } from "../../../framework/runtime"
import { ServiceConfig } from "../../../services/service-config" import { Daemon } from "../../../services/daemon"
export default Runtime.handler( export default Runtime.handler(
Commands.commands.service.commands.unset, Commands.commands.service.commands.unset,
Effect.fn("cli.service.unset")(function* (input) { Effect.fn("cli.service.unset")(function* (input) {
yield* ServiceConfig.unset(input.key) yield* (yield* Daemon.Service).unset(input.key)
}), }),
) )
+5 -5
View File
@@ -1,9 +1,9 @@
import * as Effect from "effect/Effect" import * as Effect from "effect/Effect"
import * as Command from "effect/unstable/cli/Command" import * as Command from "effect/unstable/cli/Command"
import { Spec } from "./spec" import { Spec } from "./spec"
import { Global } from "@opencode-ai/core/global" import { Daemon } from "../services/daemon"
import { Updater } from "../services/updater" import { Updater } from "../services/updater"
import { FileSystem, Scope } from "effect" import { Scope } from "effect"
export type Input<Value> = export type Input<Value> =
Value extends Spec.Node<infer _Name, infer Command, infer _Commands> Value extends Spec.Node<infer _Name, infer Command, infer _Commands>
@@ -12,11 +12,11 @@ export type Input<Value> =
? Input ? Input
: never : never
type RuntimeHandler = (input: unknown) => Effect.Effect<void, unknown, FileSystem.FileSystem | Global.Service | Updater.Service | Scope.Scope> type RuntimeHandler = (input: unknown) => Effect.Effect<void, unknown, Daemon.Service | Updater.Service | Scope.Scope>
type Loader<Node extends Spec.Any> = () => Promise<{ type Loader<Node extends Spec.Any> = () => Promise<{
default: (input: Input<Node>) => Effect.Effect<void, any, FileSystem.FileSystem | Global.Service | Updater.Service | Scope.Scope> default: (input: Input<Node>) => Effect.Effect<void, any, Daemon.Service | Updater.Service | Scope.Scope>
}> }>
type ProvidedCommand = Command.Command<string, unknown, unknown, unknown, FileSystem.FileSystem | Global.Service | Updater.Service | Scope.Scope> type ProvidedCommand = Command.Command<string, unknown, unknown, unknown, Daemon.Service | Updater.Service | Scope.Scope>
export type Handlers<Node extends Spec.Any> = keyof Node["commands"] extends never export type Handlers<Node extends Spec.Any> = keyof Node["commands"] extends never
? Loader<Node> ? Loader<Node>
+2
View File
@@ -7,6 +7,7 @@ import * as Effect from "effect/Effect"
import { Layer, Logger, References } from "effect" import { Layer, Logger, References } from "effect"
import { Commands } from "./commands/commands" import { Commands } from "./commands/commands"
import { Runtime } from "./framework/runtime" import { Runtime } from "./framework/runtime"
import { Daemon } from "./services/daemon"
import { Logging } from "@opencode-ai/core/observability/logging" import { Logging } from "@opencode-ai/core/observability/logging"
import { Updater } from "./services/updater" import { Updater } from "./services/updater"
import { InstallationChannel, InstallationVersion, InstallationLocal } from "@opencode-ai/core/installation/version" import { InstallationChannel, InstallationVersion, InstallationLocal } from "@opencode-ai/core/installation/version"
@@ -49,6 +50,7 @@ const Handlers = Runtime.handlers(Commands, {
Effect.logInfo("cli starting", { version: InstallationVersion, channel: InstallationChannel, local: InstallationLocal }).pipe( Effect.logInfo("cli starting", { version: InstallationVersion, channel: InstallationChannel, local: InstallationLocal }).pipe(
Effect.flatMap(() => Runtime.run(Commands, Handlers, { version: InstallationVersion })), Effect.flatMap(() => Runtime.run(Commands, Handlers, { version: InstallationVersion })),
Effect.annotateLogs({ role: "cli" }), Effect.annotateLogs({ role: "cli" }),
Effect.provide(Daemon.layer),
Effect.provide(Updater.layer), Effect.provide(Updater.layer),
Effect.provide(AppNodeBuilder.build(LayerNode.group([Global.node, AppProcess.node]))), Effect.provide(AppNodeBuilder.build(LayerNode.group([Global.node, AppProcess.node]))),
Effect.provide(LoggingLayer), Effect.provide(LoggingLayer),
+323
View File
@@ -0,0 +1,323 @@
import { Global } from "@opencode-ai/core/global"
import { InstallationChannel, InstallationVersion } from "@opencode-ai/core/installation/version"
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
import { ServerAuth } from "@opencode-ai/server/auth"
import { Context, Effect, FileSystem, Layer, Option, Schedule, Schema, Scope } from "effect"
import { HttpServer } from "effect/unstable/http"
import { randomBytes, randomUUID } from "crypto"
import { spawn } from "node:child_process"
import path from "path"
export interface Interface {
readonly client: () => Effect.Effect<ReturnType<typeof createOpencodeClient>, unknown>
readonly transport: () => Effect.Effect<{ url: string; headers: RequestInit["headers"] }, unknown>
readonly start: () => Effect.Effect<string, Error>
readonly status: () => Effect.Effect<string | undefined>
readonly stop: () => Effect.Effect<void, unknown>
readonly password: (value?: string) => Effect.Effect<string, unknown>
readonly config: () => Effect.Effect<ServiceConfig, unknown>
readonly get: (key?: string) => Effect.Effect<string, unknown>
readonly set: (key: string, value: string) => Effect.Effect<void, unknown>
readonly unset: (key: string) => Effect.Effect<void, unknown>
readonly register: (address: HttpServer.Address) => Effect.Effect<void, unknown, Scope.Scope>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/cli/Daemon") {}
const Registration = Schema.Struct({
id: Schema.optional(Schema.String),
version: Schema.optional(Schema.String),
url: Schema.String,
pid: Schema.Int.check(Schema.isGreaterThan(0)),
})
type Registration = typeof Registration.Type
const ServiceConfig = Schema.Struct({
hostname: Schema.optional(Schema.String),
port: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1), Schema.isLessThanOrEqualTo(65_535))),
password: Schema.optional(Schema.String),
autostart: Schema.optional(Schema.Boolean),
})
export type ServiceConfig = typeof ServiceConfig.Type
const serviceConfigKeys = ["hostname", "port", "password", "autostart"] as const
type ServiceConfigKey = (typeof serviceConfigKeys)[number]
function serviceConfigKey(key: string): ServiceConfigKey {
if (serviceConfigKeys.includes(key as ServiceConfigKey)) return key as ServiceConfigKey
throw new Error(`Unknown service config key: ${key}`)
}
function sameRegistration(left: Registration, right: Registration) {
return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid
}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const global = yield* Global.Service
const directory = global.state
const filename = InstallationChannel === "local" ? "service-local.json" : "service.json"
const file = path.join(directory, filename)
const configFile = path.join(global.config, filename)
const decodeRegistration = Schema.decodeUnknownEffect(Schema.fromJsonString(Registration))
const decodeServiceConfig = Schema.decodeUnknownEffect(Schema.fromJsonString(ServiceConfig))
const config = Effect.fn("cli.daemon.config")(function* () {
return yield* fs.readFileString(configFile).pipe(
Effect.flatMap(decodeServiceConfig),
Effect.catch(() => Effect.succeed({} as ServiceConfig)),
)
})
const writeConfig = Effect.fn("cli.daemon.writeConfig")(function* (value: ServiceConfig) {
const temp = configFile + ".tmp"
yield* fs.makeDirectory(path.dirname(configFile), { recursive: true })
yield* fs.writeFileString(temp, JSON.stringify(value, null, 2) + "\n", { mode: 0o600 })
yield* fs.rename(temp, configFile)
})
const password = Effect.fn("cli.daemon.password")(function* (value?: string) {
const existing = yield* config()
if (value === undefined && existing.password) return existing.password
const next = value ?? randomBytes(32).toString("base64url")
// Keep one private credential across server restarts so discovered clients
// can reconnect without exposing a password flag or environment variable.
yield* writeConfig({ ...existing, password: next })
return next
})
const get = Effect.fn("cli.daemon.get")(function* (key?: string) {
if (key === undefined) {
const { password: _password, ...safe } = yield* config()
return JSON.stringify(safe, null, 2)
}
switch (serviceConfigKey(key)) {
case "hostname": {
return (yield* config()).hostname ?? ""
}
case "port": {
const port = (yield* config()).port
return port === undefined ? "" : String(port)
}
case "password": {
return yield* password()
}
case "autostart": {
const autostart = (yield* config()).autostart
return autostart === undefined ? "" : String(autostart)
}
}
})
const set = Effect.fn("cli.daemon.set")(function* (key: string, value: string) {
switch (serviceConfigKey(key)) {
case "hostname": {
yield* stop()
yield* writeConfig({ ...(yield* config()), hostname: value })
return
}
case "port": {
const port = Number(value)
if (!Number.isInteger(port) || port < 1 || port > 65_535) throw new Error("Port must be between 1 and 65535")
yield* stop()
yield* writeConfig({ ...(yield* config()), port })
return
}
case "password": {
yield* stop()
yield* password(value)
return
}
case "autostart": {
if (value !== "true" && value !== "false") throw new Error("Autostart must be true or false")
yield* writeConfig({ ...(yield* config()), autostart: value === "true" })
return
}
}
})
const unset = Effect.fn("cli.daemon.unset")(function* (key: string) {
switch (serviceConfigKey(key)) {
case "hostname": {
yield* stop()
const { hostname: _hostname, ...next } = yield* config()
yield* writeConfig(next)
return
}
case "port": {
yield* stop()
const { port: _port, ...next } = yield* config()
yield* writeConfig(next)
return
}
case "password": {
yield* stop()
const { password: _password, ...next } = yield* config()
yield* writeConfig(next)
return
}
case "autostart": {
const { autostart: _autostart, ...next } = yield* config()
yield* writeConfig(next)
return
}
}
})
const registration = Effect.fnUntraced(function* () {
return yield* fs.readFileString(file).pipe(Effect.flatMap(decodeRegistration))
})
const createClient = Effect.fnUntraced(function* (url: string) {
return createOpencodeClient({ baseUrl: url, headers: ServerAuth.headers({ password: yield* password() }) })
})
const healthy = Effect.fnUntraced(function* () {
const info = yield* registration()
const client = yield* createClient(info.url)
const response = yield* Effect.tryPromise(() => client.v2.health.get({ signal: AbortSignal.timeout(2_000) }))
if (response.data?.healthy === true) return info
return yield* Effect.fail(new Error("Registered server is not healthy"))
})
const remoteTransport = Effect.fn("cli.daemon.remoteTransport")(function* (input: ServiceConfig) {
const url = serviceURL(input)
const headers = ServerAuth.headers({ password: input.password })
const response = yield* Effect.tryPromise(() =>
createOpencodeClient({ baseUrl: url, headers }).v2.health.get({ signal: AbortSignal.timeout(2_000) }),
)
if (response.data?.healthy === true) return { url, headers }
return yield* Effect.fail(new Error(`Server is not healthy: ${url}`))
})
const compatible = Effect.fnUntraced(function* () {
const info = yield* healthy()
if (info.version === InstallationVersion) return info
return yield* Effect.fail(new Error("Registered server version does not match the client"))
})
const signal = (pid: number, signal: NodeJS.Signals) =>
Effect.try({ try: () => process.kill(pid, signal), catch: (cause) => cause }).pipe(Effect.ignore)
const awaitStopped = Effect.fnUntraced(function* (pid: number) {
const running = yield* Effect.try({ try: () => process.kill(pid, 0), catch: () => false }).pipe(
Effect.orElseSucceed(() => false),
)
if (!running) return true
return yield* Effect.fail(new Error(`Server process ${pid} is still running`))
})
const stopProcess = Effect.fnUntraced(function* (info: Registration) {
const current = yield* healthy().pipe(Effect.option)
if (Option.isNone(current) || !sameRegistration(current.value, info)) return
yield* signal(info.pid, "SIGTERM")
const stopped = yield* awaitStopped(info.pid).pipe(
Effect.retry(Schedule.spaced("50 millis").pipe(Schedule.both(Schedule.recurs(100)))),
Effect.option,
)
if (Option.isSome(stopped)) return
const latest = yield* healthy().pipe(Effect.option)
if (Option.isNone(latest) || !sameRegistration(latest.value, info)) return
yield* signal(info.pid, "SIGKILL")
yield* awaitStopped(info.pid).pipe(
Effect.retry(Schedule.spaced("50 millis").pipe(Schedule.both(Schedule.recurs(100)))),
)
})
const start = Effect.fn("cli.daemon.start")(function* () {
const existing = yield* healthy().pipe(Effect.option)
const found = Option.getOrUndefined(existing)
const compiled = path.basename(process.execPath).replace(/\.exe$/, "") !== "bun"
if (found?.version === InstallationVersion) return found.url
if (found) yield* stopProcess(found).pipe(Effect.ignore)
const entrypoint = compiled ? undefined : process.argv[1]
if (!compiled && entrypoint === undefined)
return yield* Effect.fail(new Error("Failed to resolve CLI entrypoint"))
yield* Effect.try({
try: () => {
spawn(process.execPath, [...(entrypoint ? [entrypoint] : []), "serve", "--service"], {
detached: true,
stdio: "ignore",
}).unref()
},
catch: (cause) => new Error("Failed to start server", { cause }),
})
return yield* compatible().pipe(
Effect.retry(Schedule.spaced("50 millis").pipe(Schedule.both(Schedule.recurs(100)))),
Effect.map((info) => info.url),
Effect.mapError(() => new Error("Failed to start server")),
)
})
const transport = Effect.fn("cli.daemon.transport")(function* () {
const current = yield* config()
if (current.autostart === false) return yield* remoteTransport(current)
return { url: yield* start(), headers: ServerAuth.headers({ password: yield* password() }) }
})
const client = Effect.fn("cli.daemon.client")(function* () {
const connection = yield* transport()
return createOpencodeClient({ baseUrl: connection.url, headers: connection.headers })
})
const status = Effect.fn("cli.daemon.status")(function* () {
const existing = yield* healthy().pipe(Effect.option)
const found = Option.getOrUndefined(existing)
if (found?.version === InstallationVersion) return found.url
if (found) return undefined
yield* fs.remove(file).pipe(Effect.ignore)
return undefined
})
const stop = Effect.fn("cli.daemon.stop")(function* () {
const existing = yield* healthy().pipe(Effect.option)
// A stale registration may point at a PID that has since been reused by
// another process. Only signal the PID after authenticating the server.
if (Option.isNone(existing)) return yield* fs.remove(file).pipe(Effect.ignore)
yield* stopProcess(existing.value)
yield* fs.remove(file).pipe(Effect.ignore)
})
const register = Effect.fn("cli.daemon.register")(function* (address: HttpServer.Address) {
const id = randomUUID()
const temp = file + "." + id + ".tmp"
yield* fs.makeDirectory(directory, { recursive: true })
yield* fs.writeFileString(
temp,
JSON.stringify({ id, version: InstallationVersion, url: HttpServer.formatAddress(address), pid: process.pid }),
{ mode: 0o600 },
)
yield* fs.rename(temp, file)
yield* registration().pipe(
Effect.flatMap((info) => (info.id === id ? Effect.void : signal(process.pid, "SIGTERM"))),
Effect.catch(() => signal(process.pid, "SIGTERM")),
Effect.repeat(Schedule.spaced("10 seconds")),
Effect.forkScoped,
)
yield* Effect.addFinalizer(() =>
registration().pipe(
Effect.flatMap((info) => (info.id === id ? fs.remove(file) : Effect.void)),
Effect.ignore,
),
)
})
return Service.of({ client, transport, start, status, stop, password, config, get, set, unset, register })
}),
)
function serviceURL(config: ServiceConfig) {
const hostname = config.hostname ?? "127.0.0.1"
const result = new URL(`http://${hostname.includes(":") && !hostname.startsWith("[") ? `[${hostname}]` : hostname}`)
result.port = String(config.port ?? 4096)
return result.toString()
}
export * as Daemon from "./daemon"
-143
View File
@@ -1,143 +0,0 @@
import { Global } from "@opencode-ai/core/global"
import { InstallationChannel, InstallationVersion } from "@opencode-ai/core/installation/version"
import { Service } from "@opencode-ai/client/effect"
import { Effect, FileSystem, Schema } from "effect"
import { randomBytes } from "crypto"
import path from "path"
// The CLI's service configuration file, plus the ServiceOptions binding that
// points the client package's service operations at this CLI: which
// registration file (by channel), which version, and how to spawn opencode.
export const Info = Schema.Struct({
hostname: Schema.optional(Schema.String),
port: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1), Schema.isLessThanOrEqualTo(65_535))),
password: Schema.optional(Schema.String),
})
export type Info = typeof Info.Type
const keys = ["hostname", "port", "password"] as const
type Key = (typeof keys)[number]
const decodeInfo = Schema.decodeUnknownEffect(Schema.fromJsonString(Info))
function configKey(key: string): Key {
if (keys.includes(key as Key)) return key as Key
throw new Error(`Unknown service config key: ${key}`)
}
const env = Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const global = yield* Global.Service
const filename = InstallationChannel === "local" ? "service-local.json" : "service.json"
return {
fs,
file: path.join(global.state, filename),
configFile: path.join(global.config, filename),
}
})
export const options = Effect.fnUntraced(function* () {
const { file } = yield* env
const compiled = path.basename(process.execPath).replace(/\.exe$/, "") !== "bun"
const entrypoint = compiled ? undefined : process.argv[1]
if (!compiled && entrypoint === undefined) return yield* Effect.fail(new Error("Failed to resolve CLI entrypoint"))
return {
file,
version: InstallationVersion,
command: [process.execPath, ...(entrypoint ? [entrypoint] : []), "serve", "--service"],
}
})
export const read = Effect.fn("cli.service-config.read")(function* () {
const { fs, configFile } = yield* env
return yield* fs.readFileString(configFile).pipe(
Effect.flatMap(decodeInfo),
Effect.catch(() => Effect.succeed({} as Info)),
)
})
const write = Effect.fn("cli.service-config.write")(function* (value: Info) {
const { fs, configFile } = yield* env
const temp = configFile + ".tmp"
yield* fs.makeDirectory(path.dirname(configFile), { recursive: true })
yield* fs.writeFileString(temp, JSON.stringify(value, null, 2) + "\n", { mode: 0o600 })
yield* fs.rename(temp, configFile)
})
export const password = Effect.fn("cli.service-config.password")(function* (value?: string) {
const existing = yield* read()
if (value === undefined && existing.password) return existing.password
const next = value ?? randomBytes(32).toString("base64url")
// Keep one private credential across server restarts so discovered clients
// can reconnect without exposing a password flag or environment variable.
yield* write({ ...existing, password: next })
return next
})
export const get = Effect.fn("cli.service-config.get")(function* (key?: string) {
if (key === undefined) {
const { password: _password, ...safe } = yield* read()
return JSON.stringify(safe, null, 2)
}
switch (configKey(key)) {
case "hostname": {
return (yield* read()).hostname ?? ""
}
case "port": {
const port = (yield* read()).port
return port === undefined ? "" : String(port)
}
case "password": {
return yield* password()
}
}
})
export const set = Effect.fn("cli.service-config.set")(function* (key: string, value: string) {
switch (configKey(key)) {
case "hostname": {
yield* Service.stop(yield* options())
yield* write({ ...(yield* read()), hostname: value })
return
}
case "port": {
const port = Number(value)
if (!Number.isInteger(port) || port < 1 || port > 65_535) throw new Error("Port must be between 1 and 65535")
yield* Service.stop(yield* options())
yield* write({ ...(yield* read()), port })
return
}
case "password": {
yield* Service.stop(yield* options())
yield* password(value)
return
}
}
})
export const unset = Effect.fn("cli.service-config.unset")(function* (key: string) {
switch (configKey(key)) {
case "hostname": {
yield* Service.stop(yield* options())
const { hostname: _hostname, ...next } = yield* read()
yield* write(next)
return
}
case "port": {
yield* Service.stop(yield* options())
const { port: _port, ...next } = yield* read()
yield* write(next)
return
}
case "password": {
yield* Service.stop(yield* options())
const { password: _password, ...next } = yield* read()
yield* write(next)
return
}
}
})
export * as ServiceConfig from "./service-config"
+6 -5
View File
@@ -4,12 +4,13 @@ import { Effect } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Global } from "@opencode-ai/core/global" import { Global } from "@opencode-ai/core/global"
import { loadBuiltinPlugins } from "@opencode-ai/tui/builtins" import { loadBuiltinPlugins } from "@opencode-ai/tui/builtins"
import { OpenCode } from "@opencode-ai/client/promise" import { OpenCode } from "@opencode-ai/client"
import type { Transport } from "@opencode-ai/client/effect"
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
import type { Args } from "@opencode-ai/tui/context/args" import type { Args } from "@opencode-ai/tui/context/args"
export function runTui(transport: Transport, args: Args, discover?: () => Promise<Transport>) { type Transport = { url: string; headers: RequestInit["headers"] }
export function runTui(transport: Transport, args: Args, reload?: () => Promise<Transport>) {
const config = TuiConfig.resolve({}, { terminalSuspend: false }) const config = TuiConfig.resolve({}, { terminalSuspend: false })
let disposeSlots: (() => void) | undefined let disposeSlots: (() => void) | undefined
return Effect.gen(function* () { return Effect.gen(function* () {
@@ -24,9 +25,9 @@ export function runTui(transport: Transport, args: Args, discover?: () => Promis
return yield* run({ return yield* run({
client: createOpencodeClient({ ...options, directory }), client: createOpencodeClient({ ...options, directory }),
api, api,
discover: discover reload: reload
? async () => { ? async () => {
const next = await discover() const next = await reload()
return { return {
client: createOpencodeClient({ baseUrl: next.url, headers: next.headers, directory }), client: createOpencodeClient({ baseUrl: next.url, headers: next.headers, directory }),
api: OpenCode.make({ baseUrl: next.url, headers: next.headers }), api: OpenCode.make({ baseUrl: next.url, headers: next.headers }),
@@ -5,19 +5,23 @@ import { Effect } from "effect"
import fs from "node:fs/promises" import fs from "node:fs/promises"
import os from "node:os" import os from "node:os"
import path from "node:path" import path from "node:path"
import { ServiceConfig } from "../src/services/service-config" import { Daemon } from "../src/services/daemon"
test("local channel stores service config with the local service filename", async () => { test("local channel stores service config with the local service filename", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-")) const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-daemon-"))
try { try {
await Effect.runPromise( await Effect.runPromise(
ServiceConfig.set("hostname", "127.0.0.2").pipe( Effect.gen(function* () {
const daemon = yield* Daemon.Service
yield* daemon.set("autostart", "false")
}).pipe(
Effect.provide(Daemon.layer),
Effect.provide(Global.layerWith({ config: path.join(root, "config"), state: path.join(root, "state") })), Effect.provide(Global.layerWith({ config: path.join(root, "config"), state: path.join(root, "state") })),
Effect.provide(NodeFileSystem.layer), Effect.provide(NodeFileSystem.layer),
), ),
) )
expect(await Bun.file(path.join(root, "config", "service-local.json")).json()).toEqual({ expect(await Bun.file(path.join(root, "config", "service-local.json")).json()).toEqual({
hostname: "127.0.0.2", autostart: false,
}) })
expect(await Bun.file(path.join(root, "config", "service.json")).exists()).toBe(false) expect(await Bun.file(path.join(root, "config", "service.json")).exists()).toBe(false)
} finally { } finally {
+3 -3
View File
@@ -5,12 +5,12 @@
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"exports": { "exports": {
"./promise": "./src/promise/index.ts", ".": "./src/index.ts",
"./effect": "./src/effect/index.ts" "./effect": "./src/effect.ts"
}, },
"scripts": { "scripts": {
"generate": "bun run script/build.ts", "generate": "bun run script/build.ts",
"check:generated": "bun run generate && git diff --exit-code -- src/promise/generated src/effect/generated", "check:generated": "bun run generate && git diff --exit-code -- src/generated src/generated-effect",
"test": "bun test --timeout 5000", "test": "bun test --timeout 5000",
"typecheck": "tsgo --noEmit" "typecheck": "tsgo --noEmit"
}, },
+3 -3
View File
@@ -25,11 +25,11 @@ await Effect.runPromise(
}, },
}, },
}), }),
fileURLToPath(new URL("../src/promise/generated", import.meta.url)), fileURLToPath(new URL("../src/generated", import.meta.url)),
), ),
write( write(
emitEffectImported(effectContract, { module: "../../contract", api: "ClientApi" }), emitEffectImported(effectContract, { module: "../contract", api: "ClientApi" }),
fileURLToPath(new URL("../src/effect/generated", import.meta.url)), fileURLToPath(new URL("../src/generated-effect", import.meta.url)),
), ),
write( write(
emitEffectShape(effectContract, { module: "@opencode-ai/protocol/client", api: "ClientApi" }), emitEffectShape(effectContract, { module: "@opencode-ai/protocol/client", api: "ClientApi" }),
@@ -1,15 +1,10 @@
// TODO: Keep additional network capabilities inside Schema and Protocol as the client grows; /effect must never import // TODO: Keep additional network capabilities inside Schema and Protocol as the client grows; /effect must never import
// Core or Server. Preserve these datatype exports so internal model reorganizations do not require caller migrations. // Core or Server. Preserve these datatype exports so internal model reorganizations do not require caller migrations.
export * from "./generated/index" export * from "./generated-effect/index"
export { Service } from "./service.js"
export type { Transport, ServiceOptions } from "./service.js"
export { Agent } from "@opencode-ai/schema/agent" export { Agent } from "@opencode-ai/schema/agent"
export { Command } from "@opencode-ai/schema/command" export { Command } from "@opencode-ai/schema/command"
export { Credential } from "@opencode-ai/schema/credential" export { Credential } from "@opencode-ai/schema/credential"
export { Event } from "@opencode-ai/schema/event"
export { EventLog } from "@opencode-ai/schema/event-log"
export { FileSystem } from "@opencode-ai/schema/filesystem" export { FileSystem } from "@opencode-ai/schema/filesystem"
export { Form } from "@opencode-ai/schema/form"
export { Integration } from "@opencode-ai/schema/integration" export { Integration } from "@opencode-ai/schema/integration"
export { Location } from "@opencode-ai/schema/location" export { Location } from "@opencode-ai/schema/location"
export { Model } from "@opencode-ai/schema/model" export { Model } from "@opencode-ai/schema/model"
-165
View File
@@ -1,165 +0,0 @@
import { Effect, FileSystem, Option, Schedule, Schema } from "effect"
import { spawn } from "node:child_process"
import { homedir } from "node:os"
import { join } from "node:path"
// Find, start, and stop the local opencode background service.
//
// The service daemon advertises itself through a registration file in the
// user's state directory: url, pid, version, and the private password, with
// 0600 permissions. That file is the complete discovery contract — reading it
// is all a client needs to connect. The daemon's own configuration (port,
// persisted password) is CLI-owned and never read here.
export type Transport = {
readonly url: string
readonly headers?: RequestInit["headers"]
}
export type ServiceOptions = {
// Absolute path to the service registration file. Defaults to
// opencode/service.json in the XDG state directory.
readonly file?: string
// When set, discovery only returns a server reporting this exact version,
// and start() replaces a healthy server whose version differs.
readonly version?: string
// Argv used to spawn the service. Defaults to ["opencode", "serve",
// "--service"] resolved from PATH.
readonly command?: ReadonlyArray<string>
}
// Read-only lookup: registration file plus health check and version gate.
// Never spawns; escalation to start() is the caller's policy.
export const discover = Effect.fn("service.discover")(function* (options: ServiceOptions = {}) {
const registration = yield* read(options.file)
if (registration === undefined) return undefined
if (options.version !== undefined && registration.version !== options.version) return undefined
const found = yield* probe(registration)
return found?.transport
})
// Idempotent ensure-running: reuses a healthy compatible server, replaces a
// version-mismatched one, and otherwise spawns the service command detached.
export const start = Effect.fn("service.start")(function* (options: ServiceOptions = {}) {
const compatible = yield* discover(options)
if (compatible !== undefined) return compatible
const mismatched = yield* find(options)
if (mismatched !== undefined) yield* kill(mismatched.registration, options).pipe(Effect.ignore)
const [command, ...args] = options.command ?? ["opencode", "serve", "--service"]
if (command === undefined) return yield* Effect.fail(new Error("Missing service command"))
yield* Effect.try({
try: () => {
spawn(command, args, { detached: true, stdio: "ignore" }).unref()
},
catch: (cause) => new Error("Failed to start server", { cause }),
})
return yield* discover(options).pipe(
Effect.flatMap((found) =>
found === undefined ? Effect.fail(new Error("Server is not ready")) : Effect.succeed(found),
),
Effect.retry(poll),
Effect.mapError(() => new Error("Failed to start server")),
)
})
export const stop = Effect.fn("service.stop")(function* (options: ServiceOptions = {}) {
const fs = yield* FileSystem.FileSystem
const existing = yield* find(options)
if (existing !== undefined) yield* kill(existing.registration, options)
yield* fs.remove(options.file ?? fallback()).pipe(Effect.ignore)
})
function fallback() {
const state = process.env["XDG_STATE_HOME"] ?? join(homedir(), ".local", "state")
return join(state, "opencode", "service.json")
}
function auth(password: string): RequestInit["headers"] {
return { authorization: "Basic " + btoa("opencode:" + password) }
}
const Registration = Schema.Struct({
id: Schema.optional(Schema.String),
version: Schema.optional(Schema.String),
url: Schema.String,
pid: Schema.Int.check(Schema.isGreaterThan(0)),
password: Schema.optional(Schema.String),
})
type Registration = typeof Registration.Type
const decode = Schema.decodeUnknownEffect(Schema.fromJsonString(Registration))
// A missing or corrupt file means no valid registration; callers treat both
// the same (the registering server self-evicts, clients rediscover).
const read = Effect.fnUntraced(function* (file?: string) {
const fs = yield* FileSystem.FileSystem
const text = yield* fs.readFileString(file ?? fallback()).pipe(Effect.option)
if (Option.isNone(text)) return undefined
return yield* decode(text.value).pipe(Effect.option, Effect.map(Option.getOrUndefined))
})
type LocalService = {
readonly registration: Registration
readonly transport: Transport
}
const probe = Effect.fnUntraced(function* (registration: Registration) {
const headers = registration.password === undefined ? undefined : auth(registration.password)
const healthy = yield* Effect.tryPromise(() =>
fetch(new URL("/api/health", registration.url), {
headers,
signal: AbortSignal.timeout(2_000),
}),
).pipe(
Effect.map((response) => response.ok),
Effect.orElseSucceed(() => false),
)
if (!healthy) return undefined
return { registration, transport: { url: registration.url, headers } } satisfies LocalService
})
// Health-checked lookup without the version gate: lifecycle operations must be
// able to see (and replace or stop) a server from a different version.
const find = Effect.fnUntraced(function* (options: ServiceOptions) {
const registration = yield* read(options.file)
if (registration === undefined) return undefined
return yield* probe(registration)
})
// 50ms cadence bounded at ~5s, shared by stop escalation and start readiness.
const poll = Schedule.spaced("50 millis").pipe(Schedule.both(Schedule.recurs(100)))
const signal = (pid: number, name: NodeJS.Signals) =>
Effect.try({ try: () => process.kill(pid, name), catch: (cause) => cause }).pipe(Effect.ignore)
const stopped = Effect.fnUntraced(function* (pid: number) {
const running = yield* Effect.try({ try: () => process.kill(pid, 0), catch: () => false }).pipe(
Effect.orElseSucceed(() => false),
)
if (!running) return true
return yield* Effect.fail(new Error(`Server process ${pid} is still running`))
})
function same(left: Registration, right: Registration) {
return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid
}
const kill = Effect.fnUntraced(function* (info: Registration, options: ServiceOptions) {
// A stale registration may point at a PID that has since been reused by
// another process. Only signal the PID after authenticating the server.
const current = yield* find(options)
if (current === undefined || !same(current.registration, info)) return
yield* signal(info.pid, "SIGTERM")
const done = yield* stopped(info.pid).pipe(Effect.retry(poll), Effect.option)
if (Option.isSome(done)) return
const latest = yield* find(options)
if (latest === undefined || !same(latest.registration, info)) return
yield* signal(info.pid, "SIGKILL")
yield* stopped(info.pid).pipe(Effect.retry(poll))
})
export * as Service from "./service.js"
@@ -3,7 +3,7 @@ import { Effect, Stream, Schema } from "effect"
import { Sse } from "effect/unstable/encoding" import { Sse } from "effect/unstable/encoding"
import { HttpClientError } from "effect/unstable/http" import { HttpClientError } from "effect/unstable/http"
import { HttpApiClient } from "effect/unstable/httpapi" import { HttpApiClient } from "effect/unstable/httpapi"
import { ClientApi } from "../../contract" import { ClientApi } from "../contract"
import { ClientError } from "./client-error" import { ClientError } from "./client-error"
type RawClient = HttpApiClient.ForApi<typeof ClientApi> type RawClient = HttpApiClient.ForApi<typeof ClientApi>
@@ -45,7 +45,6 @@ type Endpoint4_0Input = {
readonly limit?: Endpoint4_0Request["query"]["limit"] readonly limit?: Endpoint4_0Request["query"]["limit"]
readonly order?: Endpoint4_0Request["query"]["order"] readonly order?: Endpoint4_0Request["query"]["order"]
readonly search?: Endpoint4_0Request["query"]["search"] readonly search?: Endpoint4_0Request["query"]["search"]
readonly parentID?: Endpoint4_0Request["query"]["parentID"]
readonly directory?: Endpoint4_0Request["query"]["directory"] readonly directory?: Endpoint4_0Request["query"]["directory"]
readonly project?: Endpoint4_0Request["query"]["project"] readonly project?: Endpoint4_0Request["query"]["project"]
readonly subpath?: Endpoint4_0Request["query"]["subpath"] readonly subpath?: Endpoint4_0Request["query"]["subpath"]
@@ -58,7 +57,6 @@ const Endpoint4_0 = (raw: RawClient["server.session"]) => (input?: Endpoint4_0In
limit: input?.["limit"], limit: input?.["limit"],
order: input?.["order"], order: input?.["order"],
search: input?.["search"], search: input?.["search"],
parentID: input?.["parentID"],
directory: input?.["directory"], directory: input?.["directory"],
project: input?.["project"], project: input?.["project"],
subpath: input?.["subpath"], subpath: input?.["subpath"],
@@ -82,7 +80,10 @@ const Endpoint4_1 = (raw: RawClient["server.session"]) => (input?: Endpoint4_1In
) )
const Endpoint4_2 = (raw: RawClient["server.session"]) => () => const Endpoint4_2 = (raw: RawClient["server.session"]) => () =>
raw["session.active"]({}).pipe(Effect.mapError(mapClientError)) raw["session.active"]({}).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
)
type Endpoint4_3Request = Parameters<RawClient["server.session"]["session.get"]>[0] type Endpoint4_3Request = Parameters<RawClient["server.session"]["session.get"]>[0]
type Endpoint4_3Input = { readonly sessionID: Endpoint4_3Request["params"]["sessionID"] } type Endpoint4_3Input = { readonly sessionID: Endpoint4_3Request["params"]["sessionID"] }
@@ -150,81 +151,36 @@ const Endpoint4_8 = (raw: RawClient["server.session"]) => (input: Endpoint4_8Inp
Effect.map((value) => value.data), Effect.map((value) => value.data),
) )
type Endpoint4_9Request = Parameters<RawClient["server.session"]["session.command"]>[0] type Endpoint4_9Request = Parameters<RawClient["server.session"]["session.skill"]>[0]
type Endpoint4_9Input = { type Endpoint4_9Input = {
readonly sessionID: Endpoint4_9Request["params"]["sessionID"] readonly sessionID: Endpoint4_9Request["params"]["sessionID"]
readonly id?: Endpoint4_9Request["payload"]["id"] readonly id?: Endpoint4_9Request["payload"]["id"]
readonly command: Endpoint4_9Request["payload"]["command"] readonly skill: Endpoint4_9Request["payload"]["skill"]
readonly arguments?: Endpoint4_9Request["payload"]["arguments"]
readonly agent?: Endpoint4_9Request["payload"]["agent"]
readonly model?: Endpoint4_9Request["payload"]["model"]
readonly files?: Endpoint4_9Request["payload"]["files"]
readonly agents?: Endpoint4_9Request["payload"]["agents"]
readonly delivery?: Endpoint4_9Request["payload"]["delivery"]
readonly resume?: Endpoint4_9Request["payload"]["resume"] readonly resume?: Endpoint4_9Request["payload"]["resume"]
} }
const Endpoint4_9 = (raw: RawClient["server.session"]) => (input: Endpoint4_9Input) => const Endpoint4_9 = (raw: RawClient["server.session"]) => (input: Endpoint4_9Input) =>
raw["session.command"]({
params: { sessionID: input["sessionID"] },
payload: {
id: input["id"],
command: input["command"],
arguments: input["arguments"],
agent: input["agent"],
model: input["model"],
files: input["files"],
agents: input["agents"],
delivery: input["delivery"],
resume: input["resume"],
},
}).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
)
type Endpoint4_10Request = Parameters<RawClient["server.session"]["session.skill"]>[0]
type Endpoint4_10Input = {
readonly sessionID: Endpoint4_10Request["params"]["sessionID"]
readonly id?: Endpoint4_10Request["payload"]["id"]
readonly skill: Endpoint4_10Request["payload"]["skill"]
readonly resume?: Endpoint4_10Request["payload"]["resume"]
}
const Endpoint4_10 = (raw: RawClient["server.session"]) => (input: Endpoint4_10Input) =>
raw["session.skill"]({ raw["session.skill"]({
params: { sessionID: input["sessionID"] }, params: { sessionID: input["sessionID"] },
payload: { id: input["id"], skill: input["skill"], resume: input["resume"] }, payload: { id: input["id"], skill: input["skill"], resume: input["resume"] },
}).pipe(Effect.mapError(mapClientError)) }).pipe(Effect.mapError(mapClientError))
type Endpoint4_11Request = Parameters<RawClient["server.session"]["session.synthetic"]>[0] type Endpoint4_10Request = Parameters<RawClient["server.session"]["session.compact"]>[0]
type Endpoint4_11Input = { type Endpoint4_10Input = { readonly sessionID: Endpoint4_10Request["params"]["sessionID"] }
readonly sessionID: Endpoint4_11Request["params"]["sessionID"] const Endpoint4_10 = (raw: RawClient["server.session"]) => (input: Endpoint4_10Input) =>
readonly text: Endpoint4_11Request["payload"]["text"]
readonly description?: Endpoint4_11Request["payload"]["description"]
readonly metadata?: Endpoint4_11Request["payload"]["metadata"]
}
const Endpoint4_11 = (raw: RawClient["server.session"]) => (input: Endpoint4_11Input) =>
raw["session.synthetic"]({
params: { sessionID: input["sessionID"] },
payload: { text: input["text"], description: input["description"], metadata: input["metadata"] },
}).pipe(Effect.mapError(mapClientError))
type Endpoint4_12Request = Parameters<RawClient["server.session"]["session.compact"]>[0]
type Endpoint4_12Input = { readonly sessionID: Endpoint4_12Request["params"]["sessionID"] }
const Endpoint4_12 = (raw: RawClient["server.session"]) => (input: Endpoint4_12Input) =>
raw["session.compact"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) raw["session.compact"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint4_13Request = Parameters<RawClient["server.session"]["session.wait"]>[0] type Endpoint4_11Request = Parameters<RawClient["server.session"]["session.wait"]>[0]
type Endpoint4_13Input = { readonly sessionID: Endpoint4_13Request["params"]["sessionID"] } type Endpoint4_11Input = { readonly sessionID: Endpoint4_11Request["params"]["sessionID"] }
const Endpoint4_13 = (raw: RawClient["server.session"]) => (input: Endpoint4_13Input) => const Endpoint4_11 = (raw: RawClient["server.session"]) => (input: Endpoint4_11Input) =>
raw["session.wait"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) raw["session.wait"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint4_14Request = Parameters<RawClient["server.session"]["session.revert.stage"]>[0] type Endpoint4_12Request = Parameters<RawClient["server.session"]["session.revert.stage"]>[0]
type Endpoint4_14Input = { type Endpoint4_12Input = {
readonly sessionID: Endpoint4_14Request["params"]["sessionID"] readonly sessionID: Endpoint4_12Request["params"]["sessionID"]
readonly messageID: Endpoint4_14Request["payload"]["messageID"] readonly messageID: Endpoint4_12Request["payload"]["messageID"]
readonly files?: Endpoint4_14Request["payload"]["files"] readonly files?: Endpoint4_12Request["payload"]["files"]
} }
const Endpoint4_14 = (raw: RawClient["server.session"]) => (input: Endpoint4_14Input) => const Endpoint4_12 = (raw: RawClient["server.session"]) => (input: Endpoint4_12Input) =>
raw["session.revert.stage"]({ raw["session.revert.stage"]({
params: { sessionID: input["sessionID"] }, params: { sessionID: input["sessionID"] },
payload: { messageID: input["messageID"], files: input["files"] }, payload: { messageID: input["messageID"], files: input["files"] },
@@ -233,87 +189,65 @@ const Endpoint4_14 = (raw: RawClient["server.session"]) => (input: Endpoint4_14I
Effect.map((value) => value.data), Effect.map((value) => value.data),
) )
type Endpoint4_15Request = Parameters<RawClient["server.session"]["session.revert.clear"]>[0] type Endpoint4_13Request = Parameters<RawClient["server.session"]["session.revert.clear"]>[0]
type Endpoint4_15Input = { readonly sessionID: Endpoint4_15Request["params"]["sessionID"] } type Endpoint4_13Input = { readonly sessionID: Endpoint4_13Request["params"]["sessionID"] }
const Endpoint4_15 = (raw: RawClient["server.session"]) => (input: Endpoint4_15Input) => const Endpoint4_13 = (raw: RawClient["server.session"]) => (input: Endpoint4_13Input) =>
raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint4_16Request = Parameters<RawClient["server.session"]["session.revert.commit"]>[0] type Endpoint4_14Request = Parameters<RawClient["server.session"]["session.revert.commit"]>[0]
type Endpoint4_16Input = { readonly sessionID: Endpoint4_16Request["params"]["sessionID"] } type Endpoint4_14Input = { readonly sessionID: Endpoint4_14Request["params"]["sessionID"] }
const Endpoint4_16 = (raw: RawClient["server.session"]) => (input: Endpoint4_16Input) => const Endpoint4_14 = (raw: RawClient["server.session"]) => (input: Endpoint4_14Input) =>
raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint4_17Request = Parameters<RawClient["server.session"]["session.context"]>[0] type Endpoint4_15Request = Parameters<RawClient["server.session"]["session.context"]>[0]
type Endpoint4_17Input = { readonly sessionID: Endpoint4_17Request["params"]["sessionID"] } type Endpoint4_15Input = { readonly sessionID: Endpoint4_15Request["params"]["sessionID"] }
const Endpoint4_17 = (raw: RawClient["server.session"]) => (input: Endpoint4_17Input) => const Endpoint4_15 = (raw: RawClient["server.session"]) => (input: Endpoint4_15Input) =>
raw["session.context"]({ params: { sessionID: input["sessionID"] } }).pipe( raw["session.context"]({ params: { sessionID: input["sessionID"] } }).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
Effect.map((value) => value.data), Effect.map((value) => value.data),
) )
type Endpoint4_18Request = Parameters<RawClient["server.session"]["session.context.entry.list"]>[0] type Endpoint4_16Request = Parameters<RawClient["server.session"]["session.history"]>[0]
type Endpoint4_18Input = { readonly sessionID: Endpoint4_18Request["params"]["sessionID"] } type Endpoint4_16Input = {
const Endpoint4_18 = (raw: RawClient["server.session"]) => (input: Endpoint4_18Input) => readonly sessionID: Endpoint4_16Request["params"]["sessionID"]
raw["session.context.entry.list"]({ params: { sessionID: input["sessionID"] } }).pipe( readonly limit?: Endpoint4_16Request["query"]["limit"]
Effect.mapError(mapClientError), readonly after?: Endpoint4_16Request["query"]["after"]
Effect.map((value) => value.data),
)
type Endpoint4_19Request = Parameters<RawClient["server.session"]["session.context.entry.put"]>[0]
type Endpoint4_19Input = {
readonly sessionID: Endpoint4_19Request["params"]["sessionID"]
readonly key: Endpoint4_19Request["params"]["key"]
readonly value: Endpoint4_19Request["payload"]["value"]
} }
const Endpoint4_19 = (raw: RawClient["server.session"]) => (input: Endpoint4_19Input) => const Endpoint4_16 = (raw: RawClient["server.session"]) => (input: Endpoint4_16Input) =>
raw["session.context.entry.put"]({ raw["session.history"]({
params: { sessionID: input["sessionID"], key: input["key"] }, params: { sessionID: input["sessionID"] },
payload: { value: input["value"] }, query: { limit: input["limit"], after: input["after"] },
}).pipe(Effect.mapError(mapClientError)) }).pipe(Effect.mapError(mapClientError))
type Endpoint4_20Request = Parameters<RawClient["server.session"]["session.context.entry.remove"]>[0] type Endpoint4_17Request = Parameters<RawClient["server.session"]["session.events"]>[0]
type Endpoint4_20Input = { type Endpoint4_17Input = {
readonly sessionID: Endpoint4_20Request["params"]["sessionID"] readonly sessionID: Endpoint4_17Request["params"]["sessionID"]
readonly key: Endpoint4_20Request["params"]["key"] readonly after?: Endpoint4_17Request["query"]["after"]
} }
const Endpoint4_20 = (raw: RawClient["server.session"]) => (input: Endpoint4_20Input) => const Endpoint4_17 = (raw: RawClient["server.session"]) => (input: Endpoint4_17Input) =>
raw["session.context.entry.remove"]({ params: { sessionID: input["sessionID"], key: input["key"] } }).pipe(
Effect.mapError(mapClientError),
)
type Endpoint4_21Request = Parameters<RawClient["server.session"]["session.log"]>[0]
type Endpoint4_21Input = {
readonly sessionID: Endpoint4_21Request["params"]["sessionID"]
readonly after?: Endpoint4_21Request["query"]["after"]
readonly follow?: Endpoint4_21Request["query"]["follow"]
}
const Endpoint4_21 = (raw: RawClient["server.session"]) => (input: Endpoint4_21Input) =>
Stream.unwrap( Stream.unwrap(
raw["session.log"]({ raw["session.events"]({ params: { sessionID: input["sessionID"] }, query: { after: input["after"] } }).pipe(
params: { sessionID: input["sessionID"] },
query: { after: input["after"], follow: input["follow"] },
}).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
Effect.map((stream) => stream.pipe(Stream.mapError(mapClientError))), Effect.map((stream) => stream.pipe(Stream.mapError(mapClientError))),
), ),
) )
type Endpoint4_22Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0] type Endpoint4_18Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
type Endpoint4_22Input = { readonly sessionID: Endpoint4_22Request["params"]["sessionID"] } type Endpoint4_18Input = { readonly sessionID: Endpoint4_18Request["params"]["sessionID"] }
const Endpoint4_22 = (raw: RawClient["server.session"]) => (input: Endpoint4_22Input) => const Endpoint4_18 = (raw: RawClient["server.session"]) => (input: Endpoint4_18Input) =>
raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint4_23Request = Parameters<RawClient["server.session"]["session.background"]>[0] type Endpoint4_19Request = Parameters<RawClient["server.session"]["session.background"]>[0]
type Endpoint4_23Input = { readonly sessionID: Endpoint4_23Request["params"]["sessionID"] } type Endpoint4_19Input = { readonly sessionID: Endpoint4_19Request["params"]["sessionID"] }
const Endpoint4_23 = (raw: RawClient["server.session"]) => (input: Endpoint4_23Input) => const Endpoint4_19 = (raw: RawClient["server.session"]) => (input: Endpoint4_19Input) =>
raw["session.background"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) raw["session.background"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint4_24Request = Parameters<RawClient["server.session"]["session.message"]>[0] type Endpoint4_20Request = Parameters<RawClient["server.session"]["session.message"]>[0]
type Endpoint4_24Input = { type Endpoint4_20Input = {
readonly sessionID: Endpoint4_24Request["params"]["sessionID"] readonly sessionID: Endpoint4_20Request["params"]["sessionID"]
readonly messageID: Endpoint4_24Request["params"]["messageID"] readonly messageID: Endpoint4_20Request["params"]["messageID"]
} }
const Endpoint4_24 = (raw: RawClient["server.session"]) => (input: Endpoint4_24Input) => const Endpoint4_20 = (raw: RawClient["server.session"]) => (input: Endpoint4_20Input) =>
raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe( raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
Effect.map((value) => value.data), Effect.map((value) => value.data),
@@ -329,22 +263,18 @@ const adaptGroup4 = (raw: RawClient["server.session"]) => ({
switchModel: Endpoint4_6(raw), switchModel: Endpoint4_6(raw),
rename: Endpoint4_7(raw), rename: Endpoint4_7(raw),
prompt: Endpoint4_8(raw), prompt: Endpoint4_8(raw),
command: Endpoint4_9(raw), skill: Endpoint4_9(raw),
skill: Endpoint4_10(raw), compact: Endpoint4_10(raw),
synthetic: Endpoint4_11(raw), wait: Endpoint4_11(raw),
compact: Endpoint4_12(raw), revertStage: Endpoint4_12(raw),
wait: Endpoint4_13(raw), revertClear: Endpoint4_13(raw),
revertStage: Endpoint4_14(raw), revertCommit: Endpoint4_14(raw),
revertClear: Endpoint4_15(raw), context: Endpoint4_15(raw),
revertCommit: Endpoint4_16(raw), history: Endpoint4_16(raw),
context: Endpoint4_17(raw), events: Endpoint4_17(raw),
listContextEntries: Endpoint4_18(raw), interrupt: Endpoint4_18(raw),
putContextEntry: Endpoint4_19(raw), background: Endpoint4_19(raw),
removeContextEntry: Endpoint4_20(raw), message: Endpoint4_20(raw),
log: Endpoint4_21(raw),
interrupt: Endpoint4_22(raw),
background: Endpoint4_23(raw),
message: Endpoint4_24(raw),
}) })
type Endpoint5_0Request = Parameters<RawClient["server.message"]["session.messages"]>[0] type Endpoint5_0Request = Parameters<RawClient["server.message"]["session.messages"]>[0]
@@ -367,12 +297,7 @@ type Endpoint6_0Input = { readonly location?: Endpoint6_0Request["query"]["locat
const Endpoint6_0 = (raw: RawClient["server.model"]) => (input?: Endpoint6_0Input) => const Endpoint6_0 = (raw: RawClient["server.model"]) => (input?: Endpoint6_0Input) =>
raw["model.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) raw["model.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint6_1Request = Parameters<RawClient["server.model"]["model.default"]>[0] const adaptGroup6 = (raw: RawClient["server.model"]) => ({ list: Endpoint6_0(raw) })
type Endpoint6_1Input = { readonly location?: Endpoint6_1Request["query"]["location"] }
const Endpoint6_1 = (raw: RawClient["server.model"]) => (input?: Endpoint6_1Input) =>
raw["model.default"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
const adaptGroup6 = (raw: RawClient["server.model"]) => ({ list: Endpoint6_0(raw), default: Endpoint6_1(raw) })
type Endpoint7_0Request = Parameters<RawClient["server.generate"]["generate.text"]>[0] type Endpoint7_0Request = Parameters<RawClient["server.generate"]["generate.text"]>[0]
type Endpoint7_0Input = { type Endpoint7_0Input = {
@@ -552,129 +477,36 @@ const adaptGroup12 = (raw: RawClient["server.project"]) => ({
directories: Endpoint12_1(raw), directories: Endpoint12_1(raw),
}) })
type Endpoint13_0Request = Parameters<RawClient["server.form"]["form.request.list"]>[0] type Endpoint13_0Request = Parameters<RawClient["server.permission"]["permission.request.list"]>[0]
type Endpoint13_0Input = { readonly location?: Endpoint13_0Request["query"]["location"] } type Endpoint13_0Input = { readonly location?: Endpoint13_0Request["query"]["location"] }
const Endpoint13_0 = (raw: RawClient["server.form"]) => (input?: Endpoint13_0Input) => const Endpoint13_0 = (raw: RawClient["server.permission"]) => (input?: Endpoint13_0Input) =>
raw["form.request.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint13_1Request = Parameters<RawClient["server.form"]["session.form.list"]>[0]
type Endpoint13_1Input = { readonly sessionID: Endpoint13_1Request["params"]["sessionID"] }
const Endpoint13_1 = (raw: RawClient["server.form"]) => (input: Endpoint13_1Input) =>
raw["session.form.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
)
type Endpoint13_2Request = Parameters<RawClient["server.form"]["session.form.create"]>[0]
type Endpoint13_2Input = {
readonly sessionID: Endpoint13_2Request["params"]["sessionID"]
readonly id?: Endpoint13_2Request["payload"]["id"]
readonly title?: Endpoint13_2Request["payload"]["title"]
readonly metadata?: Endpoint13_2Request["payload"]["metadata"]
readonly mode: Endpoint13_2Request["payload"]["mode"]
readonly fields?: Endpoint13_2Request["payload"]["fields"]
readonly url?: Endpoint13_2Request["payload"]["url"]
}
const Endpoint13_2 = (raw: RawClient["server.form"]) => (input: Endpoint13_2Input) =>
raw["session.form.create"]({
params: { sessionID: input["sessionID"] },
payload: {
id: input["id"],
title: input["title"],
metadata: input["metadata"],
mode: input["mode"],
fields: input["fields"],
url: input["url"],
},
}).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
)
type Endpoint13_3Request = Parameters<RawClient["server.form"]["session.form.get"]>[0]
type Endpoint13_3Input = {
readonly sessionID: Endpoint13_3Request["params"]["sessionID"]
readonly formID: Endpoint13_3Request["params"]["formID"]
}
const Endpoint13_3 = (raw: RawClient["server.form"]) => (input: Endpoint13_3Input) =>
raw["session.form.get"]({ params: { sessionID: input["sessionID"], formID: input["formID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
)
type Endpoint13_4Request = Parameters<RawClient["server.form"]["session.form.state"]>[0]
type Endpoint13_4Input = {
readonly sessionID: Endpoint13_4Request["params"]["sessionID"]
readonly formID: Endpoint13_4Request["params"]["formID"]
}
const Endpoint13_4 = (raw: RawClient["server.form"]) => (input: Endpoint13_4Input) =>
raw["session.form.state"]({ params: { sessionID: input["sessionID"], formID: input["formID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
)
type Endpoint13_5Request = Parameters<RawClient["server.form"]["session.form.reply"]>[0]
type Endpoint13_5Input = {
readonly sessionID: Endpoint13_5Request["params"]["sessionID"]
readonly formID: Endpoint13_5Request["params"]["formID"]
readonly answer: Endpoint13_5Request["payload"]["answer"]
}
const Endpoint13_5 = (raw: RawClient["server.form"]) => (input: Endpoint13_5Input) =>
raw["session.form.reply"]({
params: { sessionID: input["sessionID"], formID: input["formID"] },
payload: { answer: input["answer"] },
}).pipe(Effect.mapError(mapClientError))
type Endpoint13_6Request = Parameters<RawClient["server.form"]["session.form.cancel"]>[0]
type Endpoint13_6Input = {
readonly sessionID: Endpoint13_6Request["params"]["sessionID"]
readonly formID: Endpoint13_6Request["params"]["formID"]
}
const Endpoint13_6 = (raw: RawClient["server.form"]) => (input: Endpoint13_6Input) =>
raw["session.form.cancel"]({ params: { sessionID: input["sessionID"], formID: input["formID"] } }).pipe(
Effect.mapError(mapClientError),
)
const adaptGroup13 = (raw: RawClient["server.form"]) => ({
listRequests: Endpoint13_0(raw),
list: Endpoint13_1(raw),
create: Endpoint13_2(raw),
get: Endpoint13_3(raw),
state: Endpoint13_4(raw),
reply: Endpoint13_5(raw),
cancel: Endpoint13_6(raw),
})
type Endpoint14_0Request = Parameters<RawClient["server.permission"]["permission.request.list"]>[0]
type Endpoint14_0Input = { readonly location?: Endpoint14_0Request["query"]["location"] }
const Endpoint14_0 = (raw: RawClient["server.permission"]) => (input?: Endpoint14_0Input) =>
raw["permission.request.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) raw["permission.request.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint14_1Request = Parameters<RawClient["server.permission"]["permission.saved.list"]>[0] type Endpoint13_1Request = Parameters<RawClient["server.permission"]["permission.saved.list"]>[0]
type Endpoint14_1Input = { readonly projectID?: Endpoint14_1Request["query"]["projectID"] } type Endpoint13_1Input = { readonly projectID?: Endpoint13_1Request["query"]["projectID"] }
const Endpoint14_1 = (raw: RawClient["server.permission"]) => (input?: Endpoint14_1Input) => const Endpoint13_1 = (raw: RawClient["server.permission"]) => (input?: Endpoint13_1Input) =>
raw["permission.saved.list"]({ query: { projectID: input?.["projectID"] } }).pipe( raw["permission.saved.list"]({ query: { projectID: input?.["projectID"] } }).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
Effect.map((value) => value.data), Effect.map((value) => value.data),
) )
type Endpoint14_2Request = Parameters<RawClient["server.permission"]["permission.saved.remove"]>[0] type Endpoint13_2Request = Parameters<RawClient["server.permission"]["permission.saved.remove"]>[0]
type Endpoint14_2Input = { readonly id: Endpoint14_2Request["params"]["id"] } type Endpoint13_2Input = { readonly id: Endpoint13_2Request["params"]["id"] }
const Endpoint14_2 = (raw: RawClient["server.permission"]) => (input: Endpoint14_2Input) => const Endpoint13_2 = (raw: RawClient["server.permission"]) => (input: Endpoint13_2Input) =>
raw["permission.saved.remove"]({ params: { id: input["id"] } }).pipe(Effect.mapError(mapClientError)) raw["permission.saved.remove"]({ params: { id: input["id"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint14_3Request = Parameters<RawClient["server.permission"]["session.permission.create"]>[0] type Endpoint13_3Request = Parameters<RawClient["server.permission"]["session.permission.create"]>[0]
type Endpoint14_3Input = { type Endpoint13_3Input = {
readonly sessionID: Endpoint14_3Request["params"]["sessionID"] readonly sessionID: Endpoint13_3Request["params"]["sessionID"]
readonly id?: Endpoint14_3Request["payload"]["id"] readonly id?: Endpoint13_3Request["payload"]["id"]
readonly action: Endpoint14_3Request["payload"]["action"] readonly action: Endpoint13_3Request["payload"]["action"]
readonly resources: Endpoint14_3Request["payload"]["resources"] readonly resources: Endpoint13_3Request["payload"]["resources"]
readonly save?: Endpoint14_3Request["payload"]["save"] readonly save?: Endpoint13_3Request["payload"]["save"]
readonly metadata?: Endpoint14_3Request["payload"]["metadata"] readonly metadata?: Endpoint13_3Request["payload"]["metadata"]
readonly source?: Endpoint14_3Request["payload"]["source"] readonly source?: Endpoint13_3Request["payload"]["source"]
readonly agent?: Endpoint14_3Request["payload"]["agent"] readonly agent?: Endpoint13_3Request["payload"]["agent"]
} }
const Endpoint14_3 = (raw: RawClient["server.permission"]) => (input: Endpoint14_3Input) => const Endpoint13_3 = (raw: RawClient["server.permission"]) => (input: Endpoint13_3Input) =>
raw["session.permission.create"]({ raw["session.permission.create"]({
params: { sessionID: input["sessionID"] }, params: { sessionID: input["sessionID"] },
payload: { payload: {
@@ -691,87 +523,87 @@ const Endpoint14_3 = (raw: RawClient["server.permission"]) => (input: Endpoint14
Effect.map((value) => value.data), Effect.map((value) => value.data),
) )
type Endpoint14_4Request = Parameters<RawClient["server.permission"]["session.permission.list"]>[0] type Endpoint13_4Request = Parameters<RawClient["server.permission"]["session.permission.list"]>[0]
type Endpoint14_4Input = { readonly sessionID: Endpoint14_4Request["params"]["sessionID"] } type Endpoint13_4Input = { readonly sessionID: Endpoint13_4Request["params"]["sessionID"] }
const Endpoint14_4 = (raw: RawClient["server.permission"]) => (input: Endpoint14_4Input) => const Endpoint13_4 = (raw: RawClient["server.permission"]) => (input: Endpoint13_4Input) =>
raw["session.permission.list"]({ params: { sessionID: input["sessionID"] } }).pipe( raw["session.permission.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
Effect.map((value) => value.data), Effect.map((value) => value.data),
) )
type Endpoint14_5Request = Parameters<RawClient["server.permission"]["session.permission.get"]>[0] type Endpoint13_5Request = Parameters<RawClient["server.permission"]["session.permission.get"]>[0]
type Endpoint14_5Input = { type Endpoint13_5Input = {
readonly sessionID: Endpoint14_5Request["params"]["sessionID"] readonly sessionID: Endpoint13_5Request["params"]["sessionID"]
readonly requestID: Endpoint14_5Request["params"]["requestID"] readonly requestID: Endpoint13_5Request["params"]["requestID"]
} }
const Endpoint14_5 = (raw: RawClient["server.permission"]) => (input: Endpoint14_5Input) => const Endpoint13_5 = (raw: RawClient["server.permission"]) => (input: Endpoint13_5Input) =>
raw["session.permission.get"]({ params: { sessionID: input["sessionID"], requestID: input["requestID"] } }).pipe( raw["session.permission.get"]({ params: { sessionID: input["sessionID"], requestID: input["requestID"] } }).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
Effect.map((value) => value.data), Effect.map((value) => value.data),
) )
type Endpoint14_6Request = Parameters<RawClient["server.permission"]["session.permission.reply"]>[0] type Endpoint13_6Request = Parameters<RawClient["server.permission"]["session.permission.reply"]>[0]
type Endpoint14_6Input = { type Endpoint13_6Input = {
readonly sessionID: Endpoint14_6Request["params"]["sessionID"] readonly sessionID: Endpoint13_6Request["params"]["sessionID"]
readonly requestID: Endpoint14_6Request["params"]["requestID"] readonly requestID: Endpoint13_6Request["params"]["requestID"]
readonly reply: Endpoint14_6Request["payload"]["reply"] readonly reply: Endpoint13_6Request["payload"]["reply"]
readonly message?: Endpoint14_6Request["payload"]["message"] readonly message?: Endpoint13_6Request["payload"]["message"]
} }
const Endpoint14_6 = (raw: RawClient["server.permission"]) => (input: Endpoint14_6Input) => const Endpoint13_6 = (raw: RawClient["server.permission"]) => (input: Endpoint13_6Input) =>
raw["session.permission.reply"]({ raw["session.permission.reply"]({
params: { sessionID: input["sessionID"], requestID: input["requestID"] }, params: { sessionID: input["sessionID"], requestID: input["requestID"] },
payload: { reply: input["reply"], message: input["message"] }, payload: { reply: input["reply"], message: input["message"] },
}).pipe(Effect.mapError(mapClientError)) }).pipe(Effect.mapError(mapClientError))
const adaptGroup14 = (raw: RawClient["server.permission"]) => ({ const adaptGroup13 = (raw: RawClient["server.permission"]) => ({
listRequests: Endpoint14_0(raw), listRequests: Endpoint13_0(raw),
listSaved: Endpoint14_1(raw), listSaved: Endpoint13_1(raw),
removeSaved: Endpoint14_2(raw), removeSaved: Endpoint13_2(raw),
create: Endpoint14_3(raw), create: Endpoint13_3(raw),
list: Endpoint14_4(raw), list: Endpoint13_4(raw),
get: Endpoint14_5(raw), get: Endpoint13_5(raw),
reply: Endpoint14_6(raw), reply: Endpoint13_6(raw),
}) })
type Endpoint15_0Request = Parameters<RawClient["server.fs"]["fs.list"]>[0] type Endpoint14_0Request = Parameters<RawClient["server.fs"]["fs.list"]>[0]
type Endpoint15_0Input = { type Endpoint14_0Input = {
readonly location?: Endpoint15_0Request["query"]["location"] readonly location?: Endpoint14_0Request["query"]["location"]
readonly path?: Endpoint15_0Request["query"]["path"] readonly path?: Endpoint14_0Request["query"]["path"]
} }
const Endpoint15_0 = (raw: RawClient["server.fs"]) => (input?: Endpoint15_0Input) => const Endpoint14_0 = (raw: RawClient["server.fs"]) => (input?: Endpoint14_0Input) =>
raw["fs.list"]({ query: { location: input?.["location"], path: input?.["path"] } }).pipe( raw["fs.list"]({ query: { location: input?.["location"], path: input?.["path"] } }).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
) )
type Endpoint15_1Request = Parameters<RawClient["server.fs"]["fs.find"]>[0] type Endpoint14_1Request = Parameters<RawClient["server.fs"]["fs.find"]>[0]
type Endpoint15_1Input = { type Endpoint14_1Input = {
readonly location?: Endpoint15_1Request["query"]["location"] readonly location?: Endpoint14_1Request["query"]["location"]
readonly query: Endpoint15_1Request["query"]["query"] readonly query: Endpoint14_1Request["query"]["query"]
readonly type?: Endpoint15_1Request["query"]["type"] readonly type?: Endpoint14_1Request["query"]["type"]
readonly limit?: Endpoint15_1Request["query"]["limit"] readonly limit?: Endpoint14_1Request["query"]["limit"]
} }
const Endpoint15_1 = (raw: RawClient["server.fs"]) => (input: Endpoint15_1Input) => const Endpoint14_1 = (raw: RawClient["server.fs"]) => (input: Endpoint14_1Input) =>
raw["fs.find"]({ raw["fs.find"]({
query: { location: input["location"], query: input["query"], type: input["type"], limit: input["limit"] }, query: { location: input["location"], query: input["query"], type: input["type"], limit: input["limit"] },
}).pipe(Effect.mapError(mapClientError)) }).pipe(Effect.mapError(mapClientError))
const adaptGroup15 = (raw: RawClient["server.fs"]) => ({ list: Endpoint15_0(raw), find: Endpoint15_1(raw) }) const adaptGroup14 = (raw: RawClient["server.fs"]) => ({ list: Endpoint14_0(raw), find: Endpoint14_1(raw) })
type Endpoint16_0Request = Parameters<RawClient["server.command"]["command.list"]>[0] type Endpoint15_0Request = Parameters<RawClient["server.command"]["command.list"]>[0]
type Endpoint16_0Input = { readonly location?: Endpoint16_0Request["query"]["location"] } type Endpoint15_0Input = { readonly location?: Endpoint15_0Request["query"]["location"] }
const Endpoint16_0 = (raw: RawClient["server.command"]) => (input?: Endpoint16_0Input) => const Endpoint15_0 = (raw: RawClient["server.command"]) => (input?: Endpoint15_0Input) =>
raw["command.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) raw["command.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
const adaptGroup16 = (raw: RawClient["server.command"]) => ({ list: Endpoint16_0(raw) }) const adaptGroup15 = (raw: RawClient["server.command"]) => ({ list: Endpoint15_0(raw) })
type Endpoint17_0Request = Parameters<RawClient["server.skill"]["skill.list"]>[0] type Endpoint16_0Request = Parameters<RawClient["server.skill"]["skill.list"]>[0]
type Endpoint17_0Input = { readonly location?: Endpoint17_0Request["query"]["location"] } type Endpoint16_0Input = { readonly location?: Endpoint16_0Request["query"]["location"] }
const Endpoint17_0 = (raw: RawClient["server.skill"]) => (input?: Endpoint17_0Input) => const Endpoint16_0 = (raw: RawClient["server.skill"]) => (input?: Endpoint16_0Input) =>
raw["skill.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) raw["skill.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
const adaptGroup17 = (raw: RawClient["server.skill"]) => ({ list: Endpoint17_0(raw) }) const adaptGroup16 = (raw: RawClient["server.skill"]) => ({ list: Endpoint16_0(raw) })
const Endpoint18_0 = (raw: RawClient["server.event"]) => () => const Endpoint17_0 = (raw: RawClient["server.event"]) => () =>
Stream.unwrap( Stream.unwrap(
raw["event.subscribe"]({}).pipe( raw["event.subscribe"]({}).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
@@ -779,31 +611,23 @@ const Endpoint18_0 = (raw: RawClient["server.event"]) => () =>
), ),
) )
const Endpoint18_1 = (raw: RawClient["server.event"]) => () => const adaptGroup17 = (raw: RawClient["server.event"]) => ({ subscribe: Endpoint17_0(raw) })
Stream.unwrap(
raw["event.changes"]({}).pipe(
Effect.mapError(mapClientError),
Effect.map((stream) => stream.pipe(Stream.mapError(mapClientError))),
),
)
const adaptGroup18 = (raw: RawClient["server.event"]) => ({ subscribe: Endpoint18_0(raw), changes: Endpoint18_1(raw) }) type Endpoint18_0Request = Parameters<RawClient["server.pty"]["pty.list"]>[0]
type Endpoint18_0Input = { readonly location?: Endpoint18_0Request["query"]["location"] }
type Endpoint19_0Request = Parameters<RawClient["server.pty"]["pty.list"]>[0] const Endpoint18_0 = (raw: RawClient["server.pty"]) => (input?: Endpoint18_0Input) =>
type Endpoint19_0Input = { readonly location?: Endpoint19_0Request["query"]["location"] }
const Endpoint19_0 = (raw: RawClient["server.pty"]) => (input?: Endpoint19_0Input) =>
raw["pty.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) raw["pty.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint19_1Request = Parameters<RawClient["server.pty"]["pty.create"]>[0] type Endpoint18_1Request = Parameters<RawClient["server.pty"]["pty.create"]>[0]
type Endpoint19_1Input = { type Endpoint18_1Input = {
readonly location?: Endpoint19_1Request["query"]["location"] readonly location?: Endpoint18_1Request["query"]["location"]
readonly command?: Endpoint19_1Request["payload"]["command"] readonly command?: Endpoint18_1Request["payload"]["command"]
readonly args?: Endpoint19_1Request["payload"]["args"] readonly args?: Endpoint18_1Request["payload"]["args"]
readonly cwd?: Endpoint19_1Request["payload"]["cwd"] readonly cwd?: Endpoint18_1Request["payload"]["cwd"]
readonly title?: Endpoint19_1Request["payload"]["title"] readonly title?: Endpoint18_1Request["payload"]["title"]
readonly env?: Endpoint19_1Request["payload"]["env"] readonly env?: Endpoint18_1Request["payload"]["env"]
} }
const Endpoint19_1 = (raw: RawClient["server.pty"]) => (input?: Endpoint19_1Input) => const Endpoint18_1 = (raw: RawClient["server.pty"]) => (input?: Endpoint18_1Input) =>
raw["pty.create"]({ raw["pty.create"]({
query: { location: input?.["location"] }, query: { location: input?.["location"] },
payload: { payload: {
@@ -815,221 +639,203 @@ const Endpoint19_1 = (raw: RawClient["server.pty"]) => (input?: Endpoint19_1Inpu
}, },
}).pipe(Effect.mapError(mapClientError)) }).pipe(Effect.mapError(mapClientError))
type Endpoint19_2Request = Parameters<RawClient["server.pty"]["pty.get"]>[0] type Endpoint18_2Request = Parameters<RawClient["server.pty"]["pty.get"]>[0]
type Endpoint19_2Input = { type Endpoint18_2Input = {
readonly ptyID: Endpoint19_2Request["params"]["ptyID"] readonly ptyID: Endpoint18_2Request["params"]["ptyID"]
readonly location?: Endpoint19_2Request["query"]["location"] readonly location?: Endpoint18_2Request["query"]["location"]
} }
const Endpoint19_2 = (raw: RawClient["server.pty"]) => (input: Endpoint19_2Input) => const Endpoint18_2 = (raw: RawClient["server.pty"]) => (input: Endpoint18_2Input) =>
raw["pty.get"]({ params: { ptyID: input["ptyID"] }, query: { location: input["location"] } }).pipe( raw["pty.get"]({ params: { ptyID: input["ptyID"] }, query: { location: input["location"] } }).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
) )
type Endpoint19_3Request = Parameters<RawClient["server.pty"]["pty.update"]>[0] type Endpoint18_3Request = Parameters<RawClient["server.pty"]["pty.update"]>[0]
type Endpoint19_3Input = { type Endpoint18_3Input = {
readonly ptyID: Endpoint19_3Request["params"]["ptyID"] readonly ptyID: Endpoint18_3Request["params"]["ptyID"]
readonly location?: Endpoint19_3Request["query"]["location"] readonly location?: Endpoint18_3Request["query"]["location"]
readonly title?: Endpoint19_3Request["payload"]["title"] readonly title?: Endpoint18_3Request["payload"]["title"]
readonly size?: Endpoint19_3Request["payload"]["size"] readonly size?: Endpoint18_3Request["payload"]["size"]
} }
const Endpoint19_3 = (raw: RawClient["server.pty"]) => (input: Endpoint19_3Input) => const Endpoint18_3 = (raw: RawClient["server.pty"]) => (input: Endpoint18_3Input) =>
raw["pty.update"]({ raw["pty.update"]({
params: { ptyID: input["ptyID"] }, params: { ptyID: input["ptyID"] },
query: { location: input["location"] }, query: { location: input["location"] },
payload: { title: input["title"], size: input["size"] }, payload: { title: input["title"], size: input["size"] },
}).pipe(Effect.mapError(mapClientError)) }).pipe(Effect.mapError(mapClientError))
type Endpoint19_4Request = Parameters<RawClient["server.pty"]["pty.remove"]>[0] type Endpoint18_4Request = Parameters<RawClient["server.pty"]["pty.remove"]>[0]
type Endpoint19_4Input = { type Endpoint18_4Input = {
readonly ptyID: Endpoint19_4Request["params"]["ptyID"] readonly ptyID: Endpoint18_4Request["params"]["ptyID"]
readonly location?: Endpoint19_4Request["query"]["location"] readonly location?: Endpoint18_4Request["query"]["location"]
} }
const Endpoint19_4 = (raw: RawClient["server.pty"]) => (input: Endpoint19_4Input) => const Endpoint18_4 = (raw: RawClient["server.pty"]) => (input: Endpoint18_4Input) =>
raw["pty.remove"]({ params: { ptyID: input["ptyID"] }, query: { location: input["location"] } }).pipe( raw["pty.remove"]({ params: { ptyID: input["ptyID"] }, query: { location: input["location"] } }).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
) )
const adaptGroup19 = (raw: RawClient["server.pty"]) => ({ const adaptGroup18 = (raw: RawClient["server.pty"]) => ({
list: Endpoint19_0(raw), list: Endpoint18_0(raw),
create: Endpoint19_1(raw), create: Endpoint18_1(raw),
get: Endpoint19_2(raw), get: Endpoint18_2(raw),
update: Endpoint19_3(raw), update: Endpoint18_3(raw),
remove: Endpoint19_4(raw), remove: Endpoint18_4(raw),
}) })
type Endpoint20_0Request = Parameters<RawClient["server.shell"]["shell.list"]>[0] type Endpoint19_0Request = Parameters<RawClient["server.shell"]["shell.list"]>[0]
type Endpoint20_0Input = { readonly location?: Endpoint20_0Request["query"]["location"] } type Endpoint19_0Input = { readonly location?: Endpoint19_0Request["query"]["location"] }
const Endpoint20_0 = (raw: RawClient["server.shell"]) => (input?: Endpoint20_0Input) => const Endpoint19_0 = (raw: RawClient["server.shell"]) => (input?: Endpoint19_0Input) =>
raw["shell.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) raw["shell.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint20_1Request = Parameters<RawClient["server.shell"]["shell.create"]>[0] type Endpoint19_1Request = Parameters<RawClient["server.shell"]["shell.create"]>[0]
type Endpoint20_1Input = { type Endpoint19_1Input = {
readonly location?: Endpoint20_1Request["query"]["location"] readonly location?: Endpoint19_1Request["query"]["location"]
readonly command: Endpoint20_1Request["payload"]["command"] readonly command: Endpoint19_1Request["payload"]["command"]
readonly cwd?: Endpoint20_1Request["payload"]["cwd"] readonly cwd?: Endpoint19_1Request["payload"]["cwd"]
readonly timeout?: Endpoint20_1Request["payload"]["timeout"] readonly timeout?: Endpoint19_1Request["payload"]["timeout"]
readonly metadata?: Endpoint20_1Request["payload"]["metadata"] readonly metadata?: Endpoint19_1Request["payload"]["metadata"]
} }
const Endpoint20_1 = (raw: RawClient["server.shell"]) => (input: Endpoint20_1Input) => const Endpoint19_1 = (raw: RawClient["server.shell"]) => (input: Endpoint19_1Input) =>
raw["shell.create"]({ raw["shell.create"]({
query: { location: input["location"] }, query: { location: input["location"] },
payload: { command: input["command"], cwd: input["cwd"], timeout: input["timeout"], metadata: input["metadata"] }, payload: { command: input["command"], cwd: input["cwd"], timeout: input["timeout"], metadata: input["metadata"] },
}).pipe(Effect.mapError(mapClientError)) }).pipe(Effect.mapError(mapClientError))
type Endpoint20_2Request = Parameters<RawClient["server.shell"]["shell.get"]>[0] type Endpoint19_2Request = Parameters<RawClient["server.shell"]["shell.get"]>[0]
type Endpoint20_2Input = { type Endpoint19_2Input = {
readonly id: Endpoint20_2Request["params"]["id"] readonly id: Endpoint19_2Request["params"]["id"]
readonly location?: Endpoint20_2Request["query"]["location"] readonly location?: Endpoint19_2Request["query"]["location"]
} }
const Endpoint20_2 = (raw: RawClient["server.shell"]) => (input: Endpoint20_2Input) => const Endpoint19_2 = (raw: RawClient["server.shell"]) => (input: Endpoint19_2Input) =>
raw["shell.get"]({ params: { id: input["id"] }, query: { location: input["location"] } }).pipe( raw["shell.get"]({ params: { id: input["id"] }, query: { location: input["location"] } }).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
) )
type Endpoint20_3Request = Parameters<RawClient["server.shell"]["shell.output"]>[0] type Endpoint19_3Request = Parameters<RawClient["server.shell"]["shell.output"]>[0]
type Endpoint20_3Input = { type Endpoint19_3Input = {
readonly id: Endpoint20_3Request["params"]["id"] readonly id: Endpoint19_3Request["params"]["id"]
readonly location?: Endpoint20_3Request["query"]["location"] readonly location?: Endpoint19_3Request["query"]["location"]
readonly cursor?: Endpoint20_3Request["query"]["cursor"] readonly cursor?: Endpoint19_3Request["query"]["cursor"]
readonly limit?: Endpoint20_3Request["query"]["limit"] readonly limit?: Endpoint19_3Request["query"]["limit"]
} }
const Endpoint20_3 = (raw: RawClient["server.shell"]) => (input: Endpoint20_3Input) => const Endpoint19_3 = (raw: RawClient["server.shell"]) => (input: Endpoint19_3Input) =>
raw["shell.output"]({ raw["shell.output"]({
params: { id: input["id"] }, params: { id: input["id"] },
query: { location: input["location"], cursor: input["cursor"], limit: input["limit"] }, query: { location: input["location"], cursor: input["cursor"], limit: input["limit"] },
}).pipe(Effect.mapError(mapClientError)) }).pipe(Effect.mapError(mapClientError))
type Endpoint20_4Request = Parameters<RawClient["server.shell"]["shell.remove"]>[0] type Endpoint19_4Request = Parameters<RawClient["server.shell"]["shell.remove"]>[0]
type Endpoint20_4Input = { type Endpoint19_4Input = {
readonly id: Endpoint20_4Request["params"]["id"] readonly id: Endpoint19_4Request["params"]["id"]
readonly location?: Endpoint20_4Request["query"]["location"] readonly location?: Endpoint19_4Request["query"]["location"]
} }
const Endpoint20_4 = (raw: RawClient["server.shell"]) => (input: Endpoint20_4Input) => const Endpoint19_4 = (raw: RawClient["server.shell"]) => (input: Endpoint19_4Input) =>
raw["shell.remove"]({ params: { id: input["id"] }, query: { location: input["location"] } }).pipe( raw["shell.remove"]({ params: { id: input["id"] }, query: { location: input["location"] } }).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
) )
const adaptGroup20 = (raw: RawClient["server.shell"]) => ({ const adaptGroup19 = (raw: RawClient["server.shell"]) => ({
list: Endpoint20_0(raw), list: Endpoint19_0(raw),
create: Endpoint20_1(raw), create: Endpoint19_1(raw),
get: Endpoint20_2(raw), get: Endpoint19_2(raw),
output: Endpoint20_3(raw), output: Endpoint19_3(raw),
remove: Endpoint20_4(raw), remove: Endpoint19_4(raw),
}) })
type Endpoint21_0Request = Parameters<RawClient["server.question"]["question.request.list"]>[0] type Endpoint20_0Request = Parameters<RawClient["server.question"]["question.request.list"]>[0]
type Endpoint21_0Input = { readonly location?: Endpoint21_0Request["query"]["location"] } type Endpoint20_0Input = { readonly location?: Endpoint20_0Request["query"]["location"] }
const Endpoint21_0 = (raw: RawClient["server.question"]) => (input?: Endpoint21_0Input) => const Endpoint20_0 = (raw: RawClient["server.question"]) => (input?: Endpoint20_0Input) =>
raw["question.request.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) raw["question.request.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint21_1Request = Parameters<RawClient["server.question"]["session.question.list"]>[0] type Endpoint20_1Request = Parameters<RawClient["server.question"]["session.question.list"]>[0]
type Endpoint21_1Input = { readonly sessionID: Endpoint21_1Request["params"]["sessionID"] } type Endpoint20_1Input = { readonly sessionID: Endpoint20_1Request["params"]["sessionID"] }
const Endpoint21_1 = (raw: RawClient["server.question"]) => (input: Endpoint21_1Input) => const Endpoint20_1 = (raw: RawClient["server.question"]) => (input: Endpoint20_1Input) =>
raw["session.question.list"]({ params: { sessionID: input["sessionID"] } }).pipe( raw["session.question.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
Effect.map((value) => value.data), Effect.map((value) => value.data),
) )
type Endpoint21_2Request = Parameters<RawClient["server.question"]["session.question.reply"]>[0] type Endpoint20_2Request = Parameters<RawClient["server.question"]["session.question.reply"]>[0]
type Endpoint21_2Input = { type Endpoint20_2Input = {
readonly sessionID: Endpoint21_2Request["params"]["sessionID"] readonly sessionID: Endpoint20_2Request["params"]["sessionID"]
readonly requestID: Endpoint21_2Request["params"]["requestID"] readonly requestID: Endpoint20_2Request["params"]["requestID"]
readonly answers: Endpoint21_2Request["payload"]["answers"] readonly answers: Endpoint20_2Request["payload"]["answers"]
} }
const Endpoint21_2 = (raw: RawClient["server.question"]) => (input: Endpoint21_2Input) => const Endpoint20_2 = (raw: RawClient["server.question"]) => (input: Endpoint20_2Input) =>
raw["session.question.reply"]({ raw["session.question.reply"]({
params: { sessionID: input["sessionID"], requestID: input["requestID"] }, params: { sessionID: input["sessionID"], requestID: input["requestID"] },
payload: { answers: input["answers"] }, payload: { answers: input["answers"] },
}).pipe(Effect.mapError(mapClientError)) }).pipe(Effect.mapError(mapClientError))
type Endpoint21_3Request = Parameters<RawClient["server.question"]["session.question.reject"]>[0] type Endpoint20_3Request = Parameters<RawClient["server.question"]["session.question.reject"]>[0]
type Endpoint21_3Input = { type Endpoint20_3Input = {
readonly sessionID: Endpoint21_3Request["params"]["sessionID"] readonly sessionID: Endpoint20_3Request["params"]["sessionID"]
readonly requestID: Endpoint21_3Request["params"]["requestID"] readonly requestID: Endpoint20_3Request["params"]["requestID"]
} }
const Endpoint21_3 = (raw: RawClient["server.question"]) => (input: Endpoint21_3Input) => const Endpoint20_3 = (raw: RawClient["server.question"]) => (input: Endpoint20_3Input) =>
raw["session.question.reject"]({ params: { sessionID: input["sessionID"], requestID: input["requestID"] } }).pipe( raw["session.question.reject"]({ params: { sessionID: input["sessionID"], requestID: input["requestID"] } }).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
) )
const adaptGroup21 = (raw: RawClient["server.question"]) => ({ const adaptGroup20 = (raw: RawClient["server.question"]) => ({
listRequests: Endpoint21_0(raw), listRequests: Endpoint20_0(raw),
list: Endpoint21_1(raw), list: Endpoint20_1(raw),
reply: Endpoint21_2(raw), reply: Endpoint20_2(raw),
reject: Endpoint21_3(raw), reject: Endpoint20_3(raw),
}) })
type Endpoint22_0Request = Parameters<RawClient["server.reference"]["reference.list"]>[0] type Endpoint21_0Request = Parameters<RawClient["server.reference"]["reference.list"]>[0]
type Endpoint22_0Input = { readonly location?: Endpoint22_0Request["query"]["location"] } type Endpoint21_0Input = { readonly location?: Endpoint21_0Request["query"]["location"] }
const Endpoint22_0 = (raw: RawClient["server.reference"]) => (input?: Endpoint22_0Input) => const Endpoint21_0 = (raw: RawClient["server.reference"]) => (input?: Endpoint21_0Input) =>
raw["reference.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) raw["reference.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
const adaptGroup22 = (raw: RawClient["server.reference"]) => ({ list: Endpoint22_0(raw) }) const adaptGroup21 = (raw: RawClient["server.reference"]) => ({ list: Endpoint21_0(raw) })
type Endpoint23_0Request = Parameters<RawClient["server.projectCopy"]["projectCopy.create"]>[0] type Endpoint22_0Request = Parameters<RawClient["server.projectCopy"]["projectCopy.create"]>[0]
type Endpoint23_0Input = { type Endpoint22_0Input = {
readonly projectID: Endpoint23_0Request["params"]["projectID"] readonly projectID: Endpoint22_0Request["params"]["projectID"]
readonly location?: Endpoint23_0Request["query"]["location"] readonly location?: Endpoint22_0Request["query"]["location"]
readonly strategy: Endpoint23_0Request["payload"]["strategy"] readonly strategy: Endpoint22_0Request["payload"]["strategy"]
readonly directory: Endpoint23_0Request["payload"]["directory"] readonly directory: Endpoint22_0Request["payload"]["directory"]
readonly name?: Endpoint23_0Request["payload"]["name"] readonly name?: Endpoint22_0Request["payload"]["name"]
} }
const Endpoint23_0 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint23_0Input) => const Endpoint22_0 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint22_0Input) =>
raw["projectCopy.create"]({ raw["projectCopy.create"]({
params: { projectID: input["projectID"] }, params: { projectID: input["projectID"] },
query: { location: input["location"] }, query: { location: input["location"] },
payload: { strategy: input["strategy"], directory: input["directory"], name: input["name"] }, payload: { strategy: input["strategy"], directory: input["directory"], name: input["name"] },
}).pipe(Effect.mapError(mapClientError)) }).pipe(Effect.mapError(mapClientError))
type Endpoint23_1Request = Parameters<RawClient["server.projectCopy"]["projectCopy.remove"]>[0] type Endpoint22_1Request = Parameters<RawClient["server.projectCopy"]["projectCopy.remove"]>[0]
type Endpoint23_1Input = { type Endpoint22_1Input = {
readonly projectID: Endpoint23_1Request["params"]["projectID"] readonly projectID: Endpoint22_1Request["params"]["projectID"]
readonly location?: Endpoint23_1Request["query"]["location"] readonly location?: Endpoint22_1Request["query"]["location"]
readonly directory: Endpoint23_1Request["payload"]["directory"] readonly directory: Endpoint22_1Request["payload"]["directory"]
readonly force: Endpoint23_1Request["payload"]["force"] readonly force: Endpoint22_1Request["payload"]["force"]
} }
const Endpoint23_1 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint23_1Input) => const Endpoint22_1 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint22_1Input) =>
raw["projectCopy.remove"]({ raw["projectCopy.remove"]({
params: { projectID: input["projectID"] }, params: { projectID: input["projectID"] },
query: { location: input["location"] }, query: { location: input["location"] },
payload: { directory: input["directory"], force: input["force"] }, payload: { directory: input["directory"], force: input["force"] },
}).pipe(Effect.mapError(mapClientError)) }).pipe(Effect.mapError(mapClientError))
type Endpoint23_2Request = Parameters<RawClient["server.projectCopy"]["projectCopy.refresh"]>[0] type Endpoint22_2Request = Parameters<RawClient["server.projectCopy"]["projectCopy.refresh"]>[0]
type Endpoint23_2Input = { type Endpoint22_2Input = {
readonly projectID: Endpoint23_2Request["params"]["projectID"] readonly projectID: Endpoint22_2Request["params"]["projectID"]
readonly location?: Endpoint23_2Request["query"]["location"] readonly location?: Endpoint22_2Request["query"]["location"]
} }
const Endpoint23_2 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint23_2Input) => const Endpoint22_2 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint22_2Input) =>
raw["projectCopy.refresh"]({ raw["projectCopy.refresh"]({
params: { projectID: input["projectID"] }, params: { projectID: input["projectID"] },
query: { location: input["location"] }, query: { location: input["location"] },
}).pipe(Effect.mapError(mapClientError)) }).pipe(Effect.mapError(mapClientError))
const adaptGroup23 = (raw: RawClient["server.projectCopy"]) => ({ const adaptGroup22 = (raw: RawClient["server.projectCopy"]) => ({
create: Endpoint23_0(raw), create: Endpoint22_0(raw),
remove: Endpoint23_1(raw), remove: Endpoint22_1(raw),
refresh: Endpoint23_2(raw), refresh: Endpoint22_2(raw),
}) })
type Endpoint24_0Request = Parameters<RawClient["server.vcs"]["vcs.status"]>[0]
type Endpoint24_0Input = { readonly location?: Endpoint24_0Request["query"]["location"] }
const Endpoint24_0 = (raw: RawClient["server.vcs"]) => (input?: Endpoint24_0Input) =>
raw["vcs.status"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint24_1Request = Parameters<RawClient["server.vcs"]["vcs.diff"]>[0]
type Endpoint24_1Input = {
readonly location?: Endpoint24_1Request["query"]["location"]
readonly mode: Endpoint24_1Request["query"]["mode"]
readonly context?: Endpoint24_1Request["query"]["context"]
}
const Endpoint24_1 = (raw: RawClient["server.vcs"]) => (input: Endpoint24_1Input) =>
raw["vcs.diff"]({ query: { location: input["location"], mode: input["mode"], context: input["context"] } }).pipe(
Effect.mapError(mapClientError),
)
const adaptGroup24 = (raw: RawClient["server.vcs"]) => ({ status: Endpoint24_0(raw), diff: Endpoint24_1(raw) })
const adaptClient = (raw: RawClient) => ({ const adaptClient = (raw: RawClient) => ({
health: adaptGroup0(raw["server.health"]), health: adaptGroup0(raw["server.health"]),
location: adaptGroup1(raw["server.location"]), location: adaptGroup1(raw["server.location"]),
@@ -1044,18 +850,16 @@ const adaptClient = (raw: RawClient) => ({
"server.mcp": adaptGroup10(raw["server.mcp"]), "server.mcp": adaptGroup10(raw["server.mcp"]),
credential: adaptGroup11(raw["server.credential"]), credential: adaptGroup11(raw["server.credential"]),
project: adaptGroup12(raw["server.project"]), project: adaptGroup12(raw["server.project"]),
form: adaptGroup13(raw["server.form"]), permission: adaptGroup13(raw["server.permission"]),
permission: adaptGroup14(raw["server.permission"]), file: adaptGroup14(raw["server.fs"]),
file: adaptGroup15(raw["server.fs"]), command: adaptGroup15(raw["server.command"]),
command: adaptGroup16(raw["server.command"]), skill: adaptGroup16(raw["server.skill"]),
skill: adaptGroup17(raw["server.skill"]), event: adaptGroup17(raw["server.event"]),
event: adaptGroup18(raw["server.event"]), pty: adaptGroup18(raw["server.pty"]),
pty: adaptGroup19(raw["server.pty"]), shell: adaptGroup19(raw["server.shell"]),
shell: adaptGroup20(raw["server.shell"]), question: adaptGroup20(raw["server.question"]),
question: adaptGroup21(raw["server.question"]), reference: adaptGroup21(raw["server.reference"]),
reference: adaptGroup22(raw["server.reference"]), projectCopy: adaptGroup22(raw["server.projectCopy"]),
projectCopy: adaptGroup23(raw["server.projectCopy"]),
vcs: adaptGroup24(raw["server.vcs"]),
}) })
export const make = (options?: { readonly baseUrl?: URL | string }) => export const make = (options?: { readonly baseUrl?: URL | string }) =>
@@ -23,12 +23,8 @@ import type {
SessionRenameOutput, SessionRenameOutput,
SessionPromptInput, SessionPromptInput,
SessionPromptOutput, SessionPromptOutput,
SessionCommandInput,
SessionCommandOutput,
SessionSkillInput, SessionSkillInput,
SessionSkillOutput, SessionSkillOutput,
SessionSyntheticInput,
SessionSyntheticOutput,
SessionCompactInput, SessionCompactInput,
SessionCompactOutput, SessionCompactOutput,
SessionWaitInput, SessionWaitInput,
@@ -41,14 +37,10 @@ import type {
SessionRevertCommitOutput, SessionRevertCommitOutput,
SessionContextInput, SessionContextInput,
SessionContextOutput, SessionContextOutput,
SessionListContextEntriesInput, SessionHistoryInput,
SessionListContextEntriesOutput, SessionHistoryOutput,
SessionPutContextEntryInput, SessionEventsInput,
SessionPutContextEntryOutput, SessionEventsOutput,
SessionRemoveContextEntryInput,
SessionRemoveContextEntryOutput,
SessionLogInput,
SessionLogOutput,
SessionInterruptInput, SessionInterruptInput,
SessionInterruptOutput, SessionInterruptOutput,
SessionBackgroundInput, SessionBackgroundInput,
@@ -59,8 +51,6 @@ import type {
MessageListOutput, MessageListOutput,
ModelListInput, ModelListInput,
ModelListOutput, ModelListOutput,
ModelDefaultInput,
ModelDefaultOutput,
GenerateTextInput, GenerateTextInput,
GenerateTextOutput, GenerateTextOutput,
ProviderListInput, ProviderListInput,
@@ -91,20 +81,6 @@ import type {
ProjectCurrentOutput, ProjectCurrentOutput,
ProjectDirectoriesInput, ProjectDirectoriesInput,
ProjectDirectoriesOutput, ProjectDirectoriesOutput,
FormListRequestsInput,
FormListRequestsOutput,
FormListInput,
FormListOutput,
FormCreateInput,
FormCreateOutput,
FormGetInput,
FormGetOutput,
FormStateInput,
FormStateOutput,
FormReplyInput,
FormReplyOutput,
FormCancelInput,
FormCancelOutput,
PermissionListRequestsInput, PermissionListRequestsInput,
PermissionListRequestsOutput, PermissionListRequestsOutput,
PermissionListSavedInput, PermissionListSavedInput,
@@ -130,7 +106,6 @@ import type {
SkillListInput, SkillListInput,
SkillListOutput, SkillListOutput,
EventSubscribeOutput, EventSubscribeOutput,
EventChangesOutput,
PtyListInput, PtyListInput,
PtyListOutput, PtyListOutput,
PtyCreateInput, PtyCreateInput,
@@ -167,10 +142,6 @@ import type {
ProjectCopyRemoveOutput, ProjectCopyRemoveOutput,
ProjectCopyRefreshInput, ProjectCopyRefreshInput,
ProjectCopyRefreshOutput, ProjectCopyRefreshOutput,
VcsStatusInput,
VcsStatusOutput,
VcsDiffInput,
VcsDiffOutput,
} from "./types" } from "./types"
import { ClientError } from "./client-error" import { ClientError } from "./client-error"
@@ -368,7 +339,6 @@ export function make(options: ClientOptions) {
limit: input?.["limit"], limit: input?.["limit"],
order: input?.["order"], order: input?.["order"],
search: input?.["search"], search: input?.["search"],
parentID: input?.["parentID"],
directory: input?.["directory"], directory: input?.["directory"],
project: input?.["project"], project: input?.["project"],
subpath: input?.["subpath"], subpath: input?.["subpath"],
@@ -398,7 +368,7 @@ export function make(options: ClientOptions) {
requestOptions, requestOptions,
).then((value) => value.data), ).then((value) => value.data),
active: (requestOptions?: RequestOptions) => active: (requestOptions?: RequestOptions) =>
request<SessionActiveOutput>( request<{ readonly data: SessionActiveOutput }>(
{ {
method: "GET", method: "GET",
path: `/api/session/active`, path: `/api/session/active`,
@@ -407,7 +377,7 @@ export function make(options: ClientOptions) {
empty: false, empty: false,
}, },
requestOptions, requestOptions,
), ).then((value) => value.data),
get: (input: SessionGetInput, requestOptions?: RequestOptions) => get: (input: SessionGetInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionGetOutput }>( request<{ readonly data: SessionGetOutput }>(
{ {
@@ -479,28 +449,6 @@ export function make(options: ClientOptions) {
}, },
requestOptions, requestOptions,
).then((value) => value.data), ).then((value) => value.data),
command: (input: SessionCommandInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionCommandOutput }>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/command`,
body: {
id: input["id"],
command: input["command"],
arguments: input["arguments"],
agent: input["agent"],
model: input["model"],
files: input["files"],
agents: input["agents"],
delivery: input["delivery"],
resume: input["resume"],
},
successStatus: 200,
declaredStatuses: [409, 404, 500, 400, 401],
empty: false,
},
requestOptions,
).then((value) => value.data),
skill: (input: SessionSkillInput, requestOptions?: RequestOptions) => skill: (input: SessionSkillInput, requestOptions?: RequestOptions) =>
request<SessionSkillOutput>( request<SessionSkillOutput>(
{ {
@@ -513,18 +461,6 @@ export function make(options: ClientOptions) {
}, },
requestOptions, requestOptions,
), ),
synthetic: (input: SessionSyntheticInput, requestOptions?: RequestOptions) =>
request<SessionSyntheticOutput>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/synthetic`,
body: { text: input["text"], description: input["description"], metadata: input["metadata"] },
successStatus: 204,
declaredStatuses: [404, 400, 401],
empty: true,
},
requestOptions,
),
compact: (input: SessionCompactInput, requestOptions?: RequestOptions) => compact: (input: SessionCompactInput, requestOptions?: RequestOptions) =>
request<SessionCompactOutput>( request<SessionCompactOutput>(
{ {
@@ -592,46 +528,24 @@ export function make(options: ClientOptions) {
}, },
requestOptions, requestOptions,
).then((value) => value.data), ).then((value) => value.data),
listContextEntries: (input: SessionListContextEntriesInput, requestOptions?: RequestOptions) => history: (input: SessionHistoryInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionListContextEntriesOutput }>( request<SessionHistoryOutput>(
{ {
method: "GET", method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/context-entry`, path: `/api/session/${encodeURIComponent(input.sessionID)}/history`,
query: { limit: input["limit"], after: input["after"] },
successStatus: 200, successStatus: 200,
declaredStatuses: [404, 400, 401], declaredStatuses: [404, 400, 401],
empty: false, empty: false,
}, },
requestOptions, requestOptions,
).then((value) => value.data),
putContextEntry: (input: SessionPutContextEntryInput, requestOptions?: RequestOptions) =>
request<SessionPutContextEntryOutput>(
{
method: "PUT",
path: `/api/session/${encodeURIComponent(input.sessionID)}/context-entry/${encodeURIComponent(input.key)}`,
body: { value: input["value"] },
successStatus: 204,
declaredStatuses: [404, 400, 401],
empty: true,
},
requestOptions,
), ),
removeContextEntry: (input: SessionRemoveContextEntryInput, requestOptions?: RequestOptions) => events: (input: SessionEventsInput, requestOptions?: RequestOptions): AsyncIterable<SessionEventsOutput> =>
request<SessionRemoveContextEntryOutput>( sse<SessionEventsOutput>(
{
method: "DELETE",
path: `/api/session/${encodeURIComponent(input.sessionID)}/context-entry/${encodeURIComponent(input.key)}`,
successStatus: 204,
declaredStatuses: [404, 400, 401],
empty: true,
},
requestOptions,
),
log: (input: SessionLogInput, requestOptions?: RequestOptions): AsyncIterable<SessionLogOutput> =>
sse<SessionLogOutput>(
{ {
method: "GET", method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/log`, path: `/api/session/${encodeURIComponent(input.sessionID)}/event`,
query: { after: input["after"], follow: input["follow"] }, query: { after: input["after"] },
successStatus: 200, successStatus: 200,
declaredStatuses: [404, 400, 401], declaredStatuses: [404, 400, 401],
empty: false, empty: false,
@@ -699,18 +613,6 @@ export function make(options: ClientOptions) {
}, },
requestOptions, requestOptions,
), ),
default: (input?: ModelDefaultInput, requestOptions?: RequestOptions) =>
request<ModelDefaultOutput>(
{
method: "GET",
path: `/api/model/default`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [503, 401, 400],
empty: false,
},
requestOptions,
),
}, },
generate: { generate: {
text: (input: GenerateTextInput, requestOptions?: RequestOptions) => text: (input: GenerateTextInput, requestOptions?: RequestOptions) =>
@@ -909,95 +811,6 @@ export function make(options: ClientOptions) {
requestOptions, requestOptions,
), ),
}, },
form: {
listRequests: (input?: FormListRequestsInput, requestOptions?: RequestOptions) =>
request<FormListRequestsOutput>(
{
method: "GET",
path: `/api/form/request`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
},
requestOptions,
),
list: (input: FormListInput, requestOptions?: RequestOptions) =>
request<{ readonly data: FormListOutput }>(
{
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/form`,
successStatus: 200,
declaredStatuses: [404, 400, 401],
empty: false,
},
requestOptions,
).then((value) => value.data),
create: (input: FormCreateInput, requestOptions?: RequestOptions) =>
request<{ readonly data: FormCreateOutput }>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/form`,
body: {
id: input["id"],
title: input["title"],
metadata: input["metadata"],
mode: input["mode"],
fields: input["fields"],
url: input["url"],
},
successStatus: 200,
declaredStatuses: [404, 409, 400, 401],
empty: false,
},
requestOptions,
).then((value) => value.data),
get: (input: FormGetInput, requestOptions?: RequestOptions) =>
request<{ readonly data: FormGetOutput }>(
{
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/form/${encodeURIComponent(input.formID)}`,
successStatus: 200,
declaredStatuses: [404, 400, 401],
empty: false,
},
requestOptions,
).then((value) => value.data),
state: (input: FormStateInput, requestOptions?: RequestOptions) =>
request<{ readonly data: FormStateOutput }>(
{
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/form/${encodeURIComponent(input.formID)}/state`,
successStatus: 200,
declaredStatuses: [404, 400, 401],
empty: false,
},
requestOptions,
).then((value) => value.data),
reply: (input: FormReplyInput, requestOptions?: RequestOptions) =>
request<FormReplyOutput>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/form/${encodeURIComponent(input.formID)}/reply`,
body: { answer: input["answer"] },
successStatus: 204,
declaredStatuses: [404, 409, 400, 401],
empty: true,
},
requestOptions,
),
cancel: (input: FormCancelInput, requestOptions?: RequestOptions) =>
request<FormCancelOutput>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/form/${encodeURIComponent(input.formID)}/cancel`,
successStatus: 204,
declaredStatuses: [404, 409, 400, 401],
empty: true,
},
requestOptions,
),
},
permission: { permission: {
listRequests: (input?: PermissionListRequestsInput, requestOptions?: RequestOptions) => listRequests: (input?: PermissionListRequestsInput, requestOptions?: RequestOptions) =>
request<PermissionListRequestsOutput>( request<PermissionListRequestsOutput>(
@@ -1162,11 +975,6 @@ export function make(options: ClientOptions) {
{ method: "GET", path: `/api/event`, successStatus: 200, declaredStatuses: [401, 400], empty: false }, { method: "GET", path: `/api/event`, successStatus: 200, declaredStatuses: [401, 400], empty: false },
requestOptions, requestOptions,
), ),
changes: (requestOptions?: RequestOptions): AsyncIterable<EventChangesOutput> =>
sse<EventChangesOutput>(
{ method: "GET", path: `/api/event/changes`, successStatus: 200, declaredStatuses: [401, 400], empty: false },
requestOptions,
),
}, },
pty: { pty: {
list: (input?: PtyListInput, requestOptions?: RequestOptions) => list: (input?: PtyListInput, requestOptions?: RequestOptions) =>
@@ -1408,32 +1216,6 @@ export function make(options: ClientOptions) {
requestOptions, requestOptions,
), ),
}, },
vcs: {
status: (input?: VcsStatusInput, requestOptions?: RequestOptions) =>
request<VcsStatusOutput>(
{
method: "GET",
path: `/api/vcs/status`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
},
requestOptions,
),
diff: (input: VcsDiffInput, requestOptions?: RequestOptions) =>
request<VcsDiffOutput>(
{
method: "GET",
path: `/api/vcs/diff`,
query: { location: input["location"], mode: input["mode"], context: input["context"] },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
},
requestOptions,
),
},
} }
} }
@@ -1442,11 +1224,7 @@ function encodePath(value: string): string {
} }
function appendQuery(params: URLSearchParams, key: string, value: unknown): void { function appendQuery(params: URLSearchParams, key: string, value: unknown): void {
if (value === undefined) return if (value === undefined || value === null) return
if (value === null) {
params.append(key, "null")
return
}
if (Array.isArray(value)) { if (Array.isArray(value)) {
for (const item of value) appendQuery(params, key, item) for (const item of value) appendQuery(params, key, item)
return return
@@ -1,4 +1,3 @@
export * from "./generated/index" export * from "./generated/index"
export type { Transport } from "../effect/service.js"
export type { EventSubscribeOutput as OpenCodeEvent } from "./generated/types" export type { EventSubscribeOutput as OpenCodeEvent } from "./generated/types"
export type OpenCodeClient = ReturnType<typeof import("./generated/client").make> export type OpenCodeClient = ReturnType<typeof import("./generated/client").make>
+47 -29
View File
@@ -1,9 +1,7 @@
import { expect, test } from "bun:test" import { expect, test } from "bun:test"
import { DateTime, Effect, Stream } from "effect" import { DateTime, Effect, Stream } from "effect"
import { HttpClient, HttpClientResponse } from "effect/unstable/http" import { HttpClient, HttpClientResponse } from "effect/unstable/http"
import { AbsolutePath, Agent, Event, Location, Model, OpenCode, Prompt, Session, SessionMessage } from "../src/effect/index" import { AbsolutePath, Agent, Location, Model, OpenCode, Prompt, Session, SessionMessage } from "../src/effect"
const synced = { type: "log.synced" as const, aggregateID: "ses_test", seq: Event.Seq.make(1) }
test("session.get returns the decoded Effect projection", async () => { test("session.get returns the decoded Effect projection", async () => {
const httpClient = HttpClient.make((request) => const httpClient = HttpClient.make((request) =>
@@ -62,20 +60,32 @@ test("event.subscribe terminates on Effect protocol decode failures", async () =
}) })
test("session methods retain decoded Effect inputs and outputs", async () => { test("session methods retain decoded Effect inputs and outputs", async () => {
const logQueries: Array<Record<string, string>> = [] const historyQueries: Array<Record<string, string>> = []
let historyPage = 0
const httpClient = HttpClient.make((request) => { const httpClient = HttpClient.make((request) => {
const url = request.url const url = request.url
if (url.includes("/log")) { if (url.includes("/event")) {
logQueries.push(Object.fromEntries(request.urlParams.params))
return Effect.succeed( return Effect.succeed(
HttpClientResponse.fromWeb( HttpClientResponse.fromWeb(
request, request,
new Response(`data: ${JSON.stringify(modelSwitchedEvent)}\n\ndata: ${JSON.stringify(synced)}\n\n`, { new Response(`data: ${JSON.stringify(modelSwitchedEvent)}\n\n`, {
headers: { "content-type": "text/event-stream" }, headers: { "content-type": "text/event-stream" },
}), }),
), ),
) )
} }
if (url.includes("/history")) {
historyPage++
historyQueries.push(Object.fromEntries(request.urlParams.params))
return Effect.succeed(
HttpClientResponse.fromWeb(
request,
Response.json(
historyPage === 1 ? { data: [modelSwitchedEvent], hasMore: true } : { data: [], hasMore: false },
),
),
)
}
if (url.includes("/prompt")) { if (url.includes("/prompt")) {
return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(admission))) return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(admission)))
} }
@@ -87,10 +97,7 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
} }
if (url.endsWith("/api/session/active")) { if (url.endsWith("/api/session/active")) {
return Effect.succeed( return Effect.succeed(
HttpClientResponse.fromWeb( HttpClientResponse.fromWeb(request, Response.json({ data: { ses_test: { type: "running" } } })),
request,
Response.json({ data: { ses_test: { type: "running" } }, watermarks: { ses_test: 3 } }),
),
) )
} }
if (request.method === "POST" && url.endsWith("/api/session")) { if (request.method === "POST" && url.endsWith("/api/session")) {
@@ -100,10 +107,7 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
return Effect.succeed(HttpClientResponse.fromWeb(request, new Response(null, { status: 204 }))) return Effect.succeed(HttpClientResponse.fromWeb(request, new Response(null, { status: 204 })))
} }
return Effect.succeed( return Effect.succeed(
HttpClientResponse.fromWeb( HttpClientResponse.fromWeb(request, Response.json({ data: [session.data], cursor: { next: "next" } })),
request,
Response.json({ data: [session.data], watermarks: { ses_test: 3 }, cursor: { next: "next" } }),
),
) )
}) })
const result = await Effect.gen(function* () { const result = await Effect.gen(function* () {
@@ -126,20 +130,31 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
yield* client.session.compact({ sessionID: Session.ID.make("ses_test") }) yield* client.session.compact({ sessionID: Session.ID.make("ses_test") })
yield* client.session.wait({ sessionID: Session.ID.make("ses_test") }) yield* client.session.wait({ sessionID: Session.ID.make("ses_test") })
const context = yield* client.session.context({ sessionID: Session.ID.make("ses_test") }) const context = yield* client.session.context({ sessionID: Session.ID.make("ses_test") })
const log = yield* client.session const history = yield* client.session.history({
.log({ sessionID: Session.ID.make("ses_test"), after: Event.Seq.make(0) }) sessionID: Session.ID.make("ses_test"),
after: 0,
limit: 1,
})
const historyNext = history.hasMore
? yield* client.session.history({
sessionID: Session.ID.make("ses_test"),
after: history.data.at(-1)?.durable?.seq,
limit: 2,
})
: undefined
const events = yield* client.session
.events({ sessionID: Session.ID.make("ses_test"), after: 0 })
.pipe(Stream.runCollect) .pipe(Stream.runCollect)
yield* client.session.interrupt({ sessionID: Session.ID.make("ses_test") }) yield* client.session.interrupt({ sessionID: Session.ID.make("ses_test") })
const message = yield* client.session.message({ const message = yield* client.session.message({
sessionID: Session.ID.make("ses_test"), sessionID: Session.ID.make("ses_test"),
messageID: SessionMessage.ID.make("msg_model"), messageID: SessionMessage.ID.make("msg_model"),
}) })
return { page, active, created, admitted, context, log, message } return { page, active, created, admitted, context, history, historyNext, events, message }
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise) }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
expect(DateTime.toEpochMillis(result.page.data[0].time.created)).toBe(1_717_171_717_000) expect(DateTime.toEpochMillis(result.page.data[0].time.created)).toBe(1_717_171_717_000)
expect(result.active).toEqual({ data: { ses_test: { type: "running" } }, watermarks: { ses_test: 3 } }) expect(result.active).toEqual({ ses_test: { type: "running" } })
expect(result.page.watermarks).toEqual({ ses_test: 3 })
expect(Object.getPrototypeOf(result.page.data[0])).toBe(Object.prototype) expect(Object.getPrototypeOf(result.page.data[0])).toBe(Object.prototype)
expect(Object.getPrototypeOf(result.created)).toBe(Object.prototype) expect(Object.getPrototypeOf(result.created)).toBe(Object.prototype)
expect(result.created.id).toBe("ses_test") expect(result.created.id).toBe("ses_test")
@@ -147,17 +162,16 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
expect(Object.getPrototypeOf(result.admitted.prompt)).toBe(Object.prototype) expect(Object.getPrototypeOf(result.admitted.prompt)).toBe(Object.prototype)
expect(DateTime.toEpochMillis(result.admitted.timeCreated)).toBe(1_717_171_717_000) expect(DateTime.toEpochMillis(result.admitted.timeCreated)).toBe(1_717_171_717_000)
expect(result.context).toEqual([]) expect(result.context).toEqual([])
expect(logQueries[0]).toEqual({ after: "0" }) expect(DateTime.toEpochMillis(result.history.data[0].data.timestamp)).toBe(1_717_171_717_000)
const logged = Array.from(result.log) expect(result.history).toEqual(expect.objectContaining({ hasMore: true }))
expect(logged.map((item) => item.type)).toEqual(["session.next.model.switched", "log.synced"]) expect(result.historyNext).toEqual({ data: [], hasMore: false })
expect(logged[0]?.type === "session.next.model.switched" && DateTime.toEpochMillis(logged[0].data.timestamp)).toBe( expect(historyQueries[0]).toEqual({ limit: "1", after: "0" })
1_717_171_717_000, expect(historyQueries[1]).toEqual({ limit: "2", after: "1" })
) expect(DateTime.toEpochMillis(result.events[0].data.timestamp)).toBe(1_717_171_717_000)
expect(logged.at(-1)).toEqual(synced)
expect(result.message).toEqual(expect.objectContaining({ id: "msg_model", type: "model-switched" })) expect(result.message).toEqual(expect.objectContaining({ id: "msg_model", type: "model-switched" }))
}) })
test("session.log retains the typed SessionNotFoundError", async () => { test("session.history retains the typed SessionNotFoundError", async () => {
const httpClient = HttpClient.make((request) => const httpClient = HttpClient.make((request) =>
Effect.succeed( Effect.succeed(
HttpClientResponse.fromWeb( HttpClientResponse.fromWeb(
@@ -171,7 +185,11 @@ test("session.log retains the typed SessionNotFoundError", async () => {
) )
const error = await Effect.gen(function* () { const error = await Effect.gen(function* () {
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" }) const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
return yield* client.session.log({ sessionID: Session.ID.make("ses_missing") }).pipe(Stream.runCollect, Effect.flip) return yield* client.session
.history({
sessionID: Session.ID.make("ses_missing"),
})
.pipe(Effect.flip)
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise) }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
expect(error._tag).toBe("SessionNotFoundError") expect(error._tag).toBe("SessionNotFoundError")
@@ -12,7 +12,7 @@ const server = resolve(import.meta.dir, "../../server")
describe("public import boundaries", () => { describe("public import boundaries", () => {
test("isolates each public entrypoint", async () => { test("isolates each public entrypoint", async () => {
const root = await bundleInputs("@opencode-ai/client/promise", "browser") const root = await bundleInputs("@opencode-ai/client", "browser")
expect(within(root, effect)).toEqual([]) expect(within(root, effect)).toEqual([])
expect(within(root, schema)).toEqual([]) expect(within(root, schema)).toEqual([])
@@ -20,9 +20,7 @@ describe("public import boundaries", () => {
expect(within(root, core)).toEqual([]) expect(within(root, core)).toEqual([])
expect(within(root, server)).toEqual([]) expect(within(root, server)).toEqual([])
// The effect entry includes local service lifecycle (node spawn/fs), so it const network = await bundleInputs("@opencode-ai/client/effect", "browser")
// bundles for bun; the boundary assertions below are what matter.
const network = await bundleInputs("@opencode-ai/client/effect", "bun")
expect(within(network, effect).length).toBeGreaterThan(0) expect(within(network, effect).length).toBeGreaterThan(0)
expect(within(network, schema).length).toBeGreaterThan(0) expect(within(network, schema).length).toBeGreaterThan(0)
+26 -23
View File
@@ -1,5 +1,5 @@
import { expect, test } from "bun:test" import { expect, test } from "bun:test"
import { isSessionNotFoundError, isUnauthorizedError, OpenCode } from "../src/promise/index" import { isSessionNotFoundError, isUnauthorizedError, OpenCode } from "../src"
test("exposes every standard HTTP API group", () => { test("exposes every standard HTTP API group", () => {
const client = OpenCode.make({ baseUrl: "http://localhost:3000" }) const client = OpenCode.make({ baseUrl: "http://localhost:3000" })
@@ -8,17 +8,14 @@ test("exposes every standard HTTP API group", () => {
"health", "health",
"location", "location",
"agent", "agent",
"plugin",
"session", "session",
"message", "message",
"model", "model",
"generate", "generate",
"provider", "provider",
"integration", "integration",
"server.mcp",
"credential", "credential",
"project", "project",
"form",
"permission", "permission",
"file", "file",
"command", "command",
@@ -29,7 +26,6 @@ test("exposes every standard HTTP API group", () => {
"question", "question",
"reference", "reference",
"projectCopy", "projectCopy",
"vcs",
]) ])
expect(Object.keys(client.message)).toEqual(["list"]) expect(Object.keys(client.message)).toEqual(["list"])
expect(Object.keys(client.integration)).toEqual([ expect(Object.keys(client.integration)).toEqual([
@@ -42,7 +38,6 @@ test("exposes every standard HTTP API group", () => {
"attemptCancel", "attemptCancel",
]) ])
expect(Object.keys(client.file)).toEqual(["read", "list", "find"]) expect(Object.keys(client.file)).toEqual(["read", "list", "find"])
expect(Object.keys(client.vcs)).toEqual(["status", "diff"])
expect(Object.keys(client.pty)).toEqual(["list", "create", "get", "update", "remove"]) expect(Object.keys(client.pty)).toEqual(["list", "create", "get", "update", "remove"])
expect(Object.keys(client.shell)).toEqual(["list", "create", "get", "output", "remove"]) expect(Object.keys(client.shell)).toEqual(["list", "create", "get", "output", "remove"])
expect(Object.keys(client.project)).toEqual(["current", "directories"]) expect(Object.keys(client.project)).toEqual(["current", "directories"])
@@ -177,6 +172,7 @@ test("event.subscribe terminates on malformed Promise SSE data", async () => {
test("session methods use the public HTTP contract", async () => { test("session methods use the public HTTP contract", async () => {
const requests: Array<{ url: string; init?: RequestInit }> = [] const requests: Array<{ url: string; init?: RequestInit }> = []
let historyPage = 0
const client = OpenCode.make({ const client = OpenCode.make({
baseUrl: "http://localhost:3000", baseUrl: "http://localhost:3000",
fetch: async (input, init) => { fetch: async (input, init) => {
@@ -187,23 +183,23 @@ test("session methods use the public HTTP contract", async () => {
headers: { "content-type": "text/event-stream" }, headers: { "content-type": "text/event-stream" },
}) })
} }
if (url.includes("/log")) { if (url.includes("/history")) {
return new Response(`data: ${JSON.stringify(modelSwitchedEvent)}\n\ndata: ${JSON.stringify(synced)}\n\n`, { historyPage++
headers: { "content-type": "text/event-stream" }, return Response.json(
}) historyPage === 1 ? { data: [modelSwitchedEvent], hasMore: true } : { data: [], hasMore: false },
)
} }
if (url.includes("/prompt")) return Response.json(admission) if (url.includes("/prompt")) return Response.json(admission)
if (url.includes("/context")) return Response.json({ data: [] }) if (url.includes("/context")) return Response.json({ data: [] })
if (url.includes("/message/")) return Response.json({ data: modelSwitchedMessage }) if (url.includes("/message/")) return Response.json({ data: modelSwitchedMessage })
if (url.endsWith("/api/session/active")) if (url.endsWith("/api/session/active")) return Response.json({ data: { ses_test: { type: "running" } } })
return Response.json({ data: { ses_test: { type: "running" } }, watermarks: { ses_test: 3 } })
if (init?.method === "POST" && url.endsWith("/api/session")) return Response.json(session) if (init?.method === "POST" && url.endsWith("/api/session")) return Response.json(session)
if (init?.method === "POST") return new Response(null, { status: 204 }) if (init?.method === "POST") return new Response(null, { status: 204 })
return Response.json({ data: [session.data], cursor: { next: "next" } }) return Response.json({ data: [session.data], cursor: { next: "next" } })
}, },
}) })
const page = await client.session.list({ limit: 10, order: "desc", parentID: null }) const page = await client.session.list({ limit: 10, order: "desc" })
const active = await client.session.active() const active = await client.session.active()
const created = await client.session.create({ location: { directory: "/tmp/project" } }) const created = await client.session.create({ location: { directory: "/tmp/project" } })
await client.session.switchAgent({ sessionID: "ses_test", agent: "build" }) await client.session.switchAgent({ sessionID: "ses_test", agent: "build" })
@@ -219,20 +215,27 @@ test("session methods use the public HTTP contract", async () => {
await client.session.compact({ sessionID: "ses_test" }) await client.session.compact({ sessionID: "ses_test" })
await client.session.wait({ sessionID: "ses_test" }) await client.session.wait({ sessionID: "ses_test" })
const context = await client.session.context({ sessionID: "ses_test" }) const context = await client.session.context({ sessionID: "ses_test" })
const log = [] const history = await client.session.history({ sessionID: "ses_test", after: 0, limit: 1 })
for await (const item of client.session.log({ sessionID: "ses_test", after: 0 })) log.push(item) const historyAfter = history.data.at(-1)?.durable?.seq
const historyNext = history.hasMore
? await client.session.history({ sessionID: "ses_test", after: historyAfter, limit: 2 })
: undefined
const events = []
for await (const event of client.session.events({ sessionID: "ses_test", after: 0 })) events.push(event)
await client.session.interrupt({ sessionID: "ses_test" }) await client.session.interrupt({ sessionID: "ses_test" })
const message = await client.session.message({ sessionID: "ses_test", messageID: "msg_model" }) const message = await client.session.message({ sessionID: "ses_test", messageID: "msg_model" })
expect(page.cursor.next).toBe("next") expect(page.cursor.next).toBe("next")
expect(active).toEqual({ data: { ses_test: { type: "running" } }, watermarks: { ses_test: 3 } }) expect(active).toEqual({ ses_test: { type: "running" } })
expect(created.id).toBe("ses_test") expect(created.id).toBe("ses_test")
expect(admitted.id).toBe("msg_test") expect(admitted.id).toBe("msg_test")
expect(context).toEqual([]) expect(context).toEqual([])
expect(log).toEqual([modelSwitchedEvent, synced]) expect(history).toEqual({ data: [modelSwitchedEvent], hasMore: true })
expect(historyNext).toEqual({ data: [], hasMore: false })
expect(events).toEqual([modelSwitchedEvent])
expect(message).toEqual(modelSwitchedMessage) expect(message).toEqual(modelSwitchedMessage)
expect(requests.map((request) => [request.init?.method, request.url])).toEqual([ expect(requests.map((request) => [request.init?.method, request.url])).toEqual([
["GET", "http://localhost:3000/api/session?limit=10&order=desc&parentID=null"], ["GET", "http://localhost:3000/api/session?limit=10&order=desc"],
["GET", "http://localhost:3000/api/session/active"], ["GET", "http://localhost:3000/api/session/active"],
["POST", "http://localhost:3000/api/session"], ["POST", "http://localhost:3000/api/session"],
["POST", "http://localhost:3000/api/session/ses_test/agent"], ["POST", "http://localhost:3000/api/session/ses_test/agent"],
@@ -241,7 +244,9 @@ test("session methods use the public HTTP contract", async () => {
["POST", "http://localhost:3000/api/session/ses_test/compact"], ["POST", "http://localhost:3000/api/session/ses_test/compact"],
["POST", "http://localhost:3000/api/session/ses_test/wait"], ["POST", "http://localhost:3000/api/session/ses_test/wait"],
["GET", "http://localhost:3000/api/session/ses_test/context"], ["GET", "http://localhost:3000/api/session/ses_test/context"],
["GET", "http://localhost:3000/api/session/ses_test/log?after=0"], ["GET", "http://localhost:3000/api/session/ses_test/history?limit=1&after=0"],
["GET", "http://localhost:3000/api/session/ses_test/history?limit=2&after=1"],
["GET", "http://localhost:3000/api/session/ses_test/event?after=0"],
["POST", "http://localhost:3000/api/session/ses_test/interrupt"], ["POST", "http://localhost:3000/api/session/ses_test/interrupt"],
["GET", "http://localhost:3000/api/session/ses_test/message/msg_model"], ["GET", "http://localhost:3000/api/session/ses_test/message/msg_model"],
]) ])
@@ -268,7 +273,7 @@ test("middleware errors remain declared client errors", async () => {
} }
}) })
test("session.log decodes SessionNotFoundError", async () => { test("session.history decodes SessionNotFoundError", async () => {
const client = OpenCode.make({ const client = OpenCode.make({
baseUrl: "http://localhost:3000", baseUrl: "http://localhost:3000",
fetch: async () => fetch: async () =>
@@ -279,7 +284,7 @@ test("session.log decodes SessionNotFoundError", async () => {
}) })
try { try {
await client.session.log({ sessionID: "ses_missing" })[Symbol.asyncIterator]().next() await client.session.history({ sessionID: "ses_missing" })
throw new Error("Expected request to fail") throw new Error("Expected request to fail")
} catch (error) { } catch (error) {
expect(isSessionNotFoundError(error)).toBe(true) expect(isSessionNotFoundError(error)).toBe(true)
@@ -324,8 +329,6 @@ const modelSwitchedMessage = {
model: { id: "claude", providerID: "anthropic" }, model: { id: "claude", providerID: "anthropic" },
} }
const synced = { type: "log.synced", aggregateID: "ses_test", seq: 1 }
const modelSwitchedEvent = { const modelSwitchedEvent = {
id: "evt_model", id: "evt_model",
type: "session.next.model.switched", type: "session.next.model.switched",
-15
View File
@@ -1,15 +0,0 @@
# @opencode-ai/codemode
- This local package owns confined execution over explicit schema-described tools. Applications own authorization, persistence, external authority, and tool-specific delivery semantics.
- Do not add a speculative generic permission or approval policy. A host omits tools it does not expose and enforces domain authorization inside each provided tool.
- Keep Code Mode unaware of host session, channel, and conversation models. The hosting application supplies trusted execution scope around it.
- Tool schemas are the model-facing Interface. Keep arguments minimal and natural to the operation; never add unrelated IDs as ambient capability tokens.
## Future Design Notes
- 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 the sandbox) instead.
- Improve the sandbox failure taxonomy. Distinguish parse/compile mistakes, unsupported syntax, user-thrown errors, invalid returned data, tool refusal, tool internal failure, timeout, and genuine runtime defects so agents can recover accurately instead of treating everything as a generic execution failure.
- Preserve the public/private error split. Tool authors should be able to return a safe model-visible message while retaining a private cause for host diagnostics. Unknown host failures must remain sanitized by default.
- 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.
-338
View File
@@ -1,338 +0,0 @@
# @opencode-ai/codemode
Effect-native confined code execution over explicit, schema-described tools.
CodeMode lets a model write a small JavaScript program that can call only the tools supplied by the host. The program can sequence calls, transform plain data, branch, loop, and run independent calls in parallel without receiving ambient filesystem, process, network, module, or application authority.
The package is currently private to this workspace. Its API is designed around three uses:
```ts
// One execution
yield * CodeMode.execute({ tools, code })
// A reusable runtime
const runtime = CodeMode.make({ tools, limits })
yield * runtime.execute(code)
// One agent-facing code tool
const codeTool = runtime.agentTool()
```
## Install
Within this workspace:
```json
{
"dependencies": {
"@opencode-ai/codemode": "workspace:*"
}
}
```
Hosts interact with CodeMode through `effect` (tool `run` implementations, `Effect`-typed results), so they should depend on `effect` themselves.
## Quick Start
Define tools with Effect Schema, then place them in the object tree exposed to programs as `tools`:
```ts
import { CodeMode, Tool } from "@opencode-ai/codemode"
import { Effect, Schema } from "effect"
const lookupOrder = Tool.make({
description: "Look up an order by ID",
input: Schema.Struct({ id: Schema.String }),
output: Schema.Struct({ id: Schema.String, status: Schema.String }),
run: ({ id }) => Effect.succeed({ id, status: "open" }),
})
const runtime = CodeMode.make({
tools: {
orders: {
lookup: lookupOrder,
},
},
})
const result =
yield *
runtime.execute(`
const order = await tools.orders.lookup({ id: "order_42" })
return { id: order.id, needsAttention: order.status !== "complete" }
`)
```
`result` is always an `ExecuteResult`. Program, validation, limit, and tool failures are returned as diagnostics rather than failing the Effect. Host interruption remains interruption.
Successful result values are JSON-safe data. A program that returns `undefined`, including by reaching the end without `return`, produces `null`; nested `undefined` values are normalized to `null` as well.
## API
### `Tool.make`
```ts
const tool = Tool.make({
description,
input, // Effect Schema (validating) or JSON Schema (render-only)
output, // optional; same choice
run,
})
```
`input` and `output` each accept a validating Effect Schema or a render-only JSON Schema document (the natural shape for adapter-provided tools whose schemas arrive as JSON Schema, e.g. MCP definitions). Effect Schema input is decoded before `run` is invoked, and `run` returns the encoded representation of an Effect Schema `output`, which CodeMode decodes and copies before exposing it to the program. JSON Schemas only shape the model-visible signature; values pass through unvalidated (they still cross the plain-data boundary).
`output` is optional. Without it the tool's signature advertises `Promise<unknown>` and the host result is exposed as-is.
The description and schemas are part of the model-visible tool contract. Keep descriptions concrete and put authorization in `run` or in the service it calls.
### `CodeMode.execute`
Use `CodeMode.execute` for a single execution:
```ts
const result =
yield *
CodeMode.execute({
tools: { orders: { lookup: lookupOrder } },
code: `return await tools.orders.lookup({ id: "order_42" })`,
limits: { maxToolCalls: 10 },
onToolCallStart: (call) => Effect.logDebug("CodeMode tool started", call),
onToolCallEnd: (call) => Effect.logDebug("CodeMode tool settled", call),
})
```
The Effect environment is inferred from the supplied tools. CodeMode does not erase service requirements introduced by tool implementations.
### `CodeMode.make`
Use `CodeMode.make` when the tool set and execution policy are reused:
```ts
const runtime = CodeMode.make({
tools: { orders: { lookup: lookupOrder } },
limits: { timeoutMs: 30_000 },
})
runtime.catalog() // structured tool descriptions
runtime.instructions() // model-facing syntax and tool guide
runtime.execute(source) // ExecuteResult
runtime.agentTool() // { name, description, input, output, execute }
```
`catalog`, `instructions`, and `agentTool` are projections of the same configured tool tree. `agentTool().description` is exactly `instructions()`.
### Results
```ts
type ExecuteResult = ExecuteSuccess | ExecuteFailure
interface ExecuteSuccess {
readonly ok: true
readonly value: Schema.Json
readonly logs?: ReadonlyArray<string>
readonly truncated?: boolean
readonly toolCalls: ReadonlyArray<ToolCall>
}
interface ExecuteFailure {
readonly ok: false
readonly error: Diagnostic
readonly logs?: ReadonlyArray<string>
readonly truncated?: boolean
readonly toolCalls: ReadonlyArray<ToolCall>
}
```
`toolCalls` contains the names of calls admitted by the runtime in call order. It is retained on failure so hosts can audit partial execution without exposing inputs or host failures. `truncated` is present when the value or logs were cut to fit `maxOutputBytes` (see Execution Limits).
### Tool-call hooks
`onToolCallStart` receives `{ index, name, input }` after input decoding and before tool execution. The input is decoded host-side data and may include values produced by schema transformations; applications should avoid logging sensitive tool arguments indiscriminately.
`onToolCallEnd` receives `{ index, name, input, durationMs, outcome, message? }` when an admitted call settles. `outcome` is `"success"` or `"failure"`; `message` is the model-safe failure message and is present only on failure. Interrupted calls (for example when the execution timeout fires) do not produce an end event. Both hooks are Effect-returning and must not fail.
## Discovery
The agent-tool instructions use a budgeted catalog. Every tool namespace is always listed with its tool count regardless of budget, and as many complete tool signatures (each with a one-line description) as fit an estimated-token budget are inlined. Selection is round-robin across namespaces for fairness: in each round (namespaces alphabetical), every namespace still holding un-inlined tools attempts to place its next-cheapest signature line against the shared budget, and a namespace whose next line does not fit drops out while the others keep going - so every namespace gets some representation before any namespace gets everything. The instructions state exactly how comprehensive the list is, both overall (`COMPLETE list` vs `PARTIAL - N of M shown`) and per namespace (`(3 tools)`, `(3 tools, 1 shown)`, `(3 tools, none shown)`).
The default budget is 2,000 estimated tokens (characters / 4, the same heuristic OpenCode uses). Override it when constructing a runtime:
```ts
const runtime = CodeMode.make({
tools,
discovery: { maxInlineCatalogTokens: 6_000 },
})
```
The budget must be a non-negative safe integer.
The runtime search tool is always registered - including when the catalog is fully inlined - so a speculative `tools.$codemode.search` call never fails as an unknown tool. It is only advertised in the instructions when the inlined list is partial:
```ts
const matches = await tools.$codemode.search({
query: "order status",
namespace: "orders", // optional: scope to one top-level namespace
limit: 10,
})
```
`search` performs deterministic, additive field-weighted matching. The query is tokenized (camelCase boundaries split; every non-alphanumeric character is a separator; empties and `*` are dropped), and each term scores every tool: exact path or path-segment match (20), path substring (8), description substring (4), and searchable-text substring (2). Each term also carries naive singular variants (trailing `s`/`es` stripped), and a field check passes when the term or any variant matches - so a plural query term (`issues`) still finds a tool whose text only says `issue`, without changing the weights. The searchable text also includes the input schema's property names and their description strings, so a query naming a parameter finds its tool, and substring matching means partial words match. Scores sum across terms; matches are sorted by score (ties broken alphabetically by path) and capped at `limit` results (default 10).
Each result contains the path, description, and generated TypeScript signature, so no second lookup is needed. The result signature is the pretty, JSDoc-annotated multiline form: each described input/output field carries its schema `description` as a `/** ... */` comment, and constraints TypeScript cannot express ride along as tags (`@deprecated`, `@default`, `@format`, `@minItems`, `@maxItems`). The inline catalog in the instructions keeps the compact single-line form.
```ts
tools.github.list_issues(input: {
/** Repository owner */
owner: string
/** Cursor from the previous response's pageInfo */
after?: string
/**
* Results per page
* @default 30
*/
perPage?: number
}): Promise<unknown>
```
Result paths are rendered as JavaScript expressions rooted at `tools` (`tools.orders.lookup`, or `tools.context7["resolve-library-id"]` for non-identifier segments), so each `path` is directly usable as the call site. An empty query browses the catalog alphabetically by path; combined with `namespace` (`{ query: "", namespace: "orders" }`) it lists everything in that namespace. A query that names one tool path exactly (canonical path, `tools.`-prefixed path, or rendered JavaScript expression) is treated as a lookup and returns that tool alone.
The instructions are structured markdown, ordered so the workflow sits at the top and the catalog at the bottom: a `## Workflow` section with numbered steps (find a tool via search when the catalog is partial, or pick from the inlined list when it is complete; call the exact path as-is; `JSON.parse` string results; return only the needed fields), a `## Rules` section holding only guidance the workflow does not already cover (only listed/search-result tools exist inside `tools`; filter and aggregate collections in code; treat `Promise<unknown>` results as shapeless until verified; run independent calls through `Promise.all`; enumerate `tools` with `Object.keys`/`for...in`; browse a namespace via search when it is advertised), a short `## Syntax` section that assumes standard JavaScript and names only what is unusual (TypeScript annotations stripped; the data-boundary serialization of Date/Map/Set/RegExp) or missing (classes, generators, `for await...of`, `.then`/`.catch`/`.finally`), and the budgeted `## Available tools` catalog. Example call forms use explicit `<namespace>.<tool>`/`<field>` placeholders - never a real or fabricated tool name.
A host cannot define its own `$codemode` top-level namespace.
## Supported Programs
CodeMode executes a deliberately bounded JavaScript subset. It supports:
- Plain data literals, property access, assignment, and destructuring.
- `if`, conditional expressions, `switch`, `for`, `for...of` (arrays, strings, Maps, Sets), `for...in` (own keys of plain objects, index strings of arrays, and namespace/tool names of `tools` references - anything else is an error suggesting `for...of` or `Object.keys`, rather than real JS's surprising behavior of indices for strings and zero iterations for Maps/Sets), `while`, and `do...while`.
- Arrow functions and function declarations with closures, defaults, rest parameters, and destructuring.
- Optional chaining, nullish coalescing, templates, spread (arrays, strings, Maps, Sets), and `try`/`catch`.
- Common array, string, number, `Object`, `Math`, and `JSON` operations. Mutating array methods include `push`/`pop`/`shift`/`unshift`/`splice` (removes in place and returns the removed elements)/`fill`/`copyWithin`; array `keys`/`values`/`entries` return **arrays** (matching the Map/Set convention) and work with `for...of` and spread. String methods include `localeCompare` (locale/options arguments ignored), `normalize`, and the `trimLeft`/`trimRight` aliases. `Object.keys` also accepts arrays (index strings, as in JS) and tool references: `Object.keys(tools)` lists the top-level namespaces and `Object.keys(tools.ns)` the names at that node (a callable tool enumerates as `[]`; an unknown path is an `UnknownTool` diagnostic). `Object.values`/`Object.entries` on a tool reference fail with a pointer at `Object.keys(tools)` and `tools.$codemode.search`.
- `Date` - `Date.now()`/`Date.parse()`/`Date.UTC()`, `new Date(...)`, the getter methods, and date arithmetic/comparison via the time value. Dates stringify as ISO (`toString` included, for determinism across host timezones).
- Regular expressions - `/literals/` and `new RegExp(...)` with `test`/`exec` (stateful `lastIndex` for `g`), plus string `match`/`matchAll`/`replace`/`replaceAll`/`split`/`search` with patterns. Match results are arrays carrying `index` and named `groups` as own properties (`input` is omitted). Invalid patterns, invalid flags, and missing-`g` calls fail with catchable errors that say what was wrong and how to fix it (escaping hints, the exact `/pattern/g` to write). Patterns run on the host engine, so pathological backtracking is bounded only by the execution timeout. Function replacers are not supported.
- `Map` and `Set` - construction from entries/arrays/strings, `get`/`set`/`add`/`has`/`delete`/`clear`/`size`/`forEach`, and `keys`/`values`/`entries` returning **arrays** (not iterators).
- First-class promises - an un-awaited `tools.ns.tool(...)` is a promise value whose call starts immediately on a supervised fiber; `await` resolves it (awaiting a non-promise value is a no-op, and `return tools.ns.tool(...)` resolves like an async-function return). `Promise.all`, `Promise.allSettled`, and `Promise.race` accept any array mixing promises and plain values (built inline, beforehand, or via spread); `Promise.resolve`/`Promise.reject` construct settled promises. `Promise.allSettled` rejection reasons are the same plain `{ name?, message }` data a `catch` binding sees, and `Promise.race` interrupts its losing in-flight calls. At most 8 tool calls run concurrently. When a program completes, still-running un-awaited calls are awaited before the execution ends; a failure from a call that was never awaited surfaces as an unhandled-rejection diagnostic.
- `throw value` and `throw new Error(message)` for explicit program failure. `Error` (and `TypeError`/`RangeError`/`SyntaxError`/`ReferenceError`/`EvalError`/`URIError`) are real constructors, callable with or without `new`; error values are plain `{ name, message }` data that additionally satisfy `instanceof Error` (a specific type matches itself and `Error`, as in JS). Every caught failure - thrown errors, interpreter runtime errors, and tool failures - is `instanceof Error` in a `catch` block; a thrown non-error value (`throw "text"`) is not, matching JS. Caught failures carry the `name` the equivalent real-JS failure would have - `JSON.parse` and invalid regex patterns produce a `SyntaxError` (satisfying `instanceof SyntaxError`), an unknown identifier a `ReferenceError`, assigning to a constant a `TypeError`, a bad `normalize` form a `RangeError`; failures with no specific analogue (including tool failures) are named `"Error"`. `instanceof` also recognizes `Date`, `RegExp`, `Map`, `Set`, `Array`, `Object`, and `Promise`; any other right-hand side is a catchable error.
Inside a program, Date/RegExp/Map/Set values stay live everywhere: the internal data checkpoints (`Object.*` helpers, spread, coercion inputs) preserve the instances, so `Object.values({ d: date })[0].getTime()` and a spread copy of an object holding a Map keep working. Only at the host boundary (final result, tool arguments, `JSON.stringify`) do the four value types serialize exactly as `JSON.stringify` would: a Date becomes its ISO string (`null` when invalid) and RegExp/Map/Set become `{}`. Promise values never cross a data boundary: an un-awaited promise in a result or tool argument produces a diagnostic that says to await it, instead of serializing to `{}`.
It does not expose `eval`, dynamic imports, modules, classes, generators, timers, host globals, prototype mutation, custom promise constructors (`new Promise`), promise chaining (`.then`/`.catch`/`.finally` - `await` with `try`/`catch` is the supported style), or arbitrary method calls. Unsupported syntax returns an `UnsupportedSyntax` diagnostic with a source location when available.
CodeMode is an orchestration language, not a general JavaScript runtime.
## Execution Limits
The limits are exactly three knobs:
| Limit | Default | Bounds |
| ---------------- | -------------------: | -------------------------------------------------------------------- |
| `timeoutMs` | none - no timeout | Wall-clock execution time. |
| `maxToolCalls` | none - unlimited | Tool calls admitted during the execution. |
| `maxOutputBytes` | none - no truncation | Model-facing output: the serialized result value plus captured logs. |
No limit has a default, on purpose: execution budgets are host policy, not library policy - a host that wants a bound sets one; a host that can interrupt the execution fiber (as OpenCode does on user cancel) may set no timeout, and a host with its own tool-output truncation (as OpenCode has) may leave `maxOutputBytes` unset. A host with neither should set `maxOutputBytes`, or oversized results silently flood model context.
Pass only the overrides you need:
```ts
const runtime = CodeMode.make({
tools,
limits: {
maxToolCalls: 20,
timeoutMs: 60_000,
},
})
```
Limits are safe integers. `timeoutMs` must be at least `1`; the others may be `0`. Invalid configuration throws a `RangeError` when `CodeMode.make` or `CodeMode.execute` is called. An explicitly `undefined` value is the same as leaving the limit unset.
Exceeding a configured `maxOutputBytes` never fails the execution. An oversized result value is replaced by its truncated serialized text plus an explanatory marker, logs are kept from the start until the remaining budget is exhausted (with a final marker line noting the cut), and the result carries `truncated: true`.
When configured, the timeout interrupts in-flight tool Effects, including eagerly started calls the program has not awaited (their fibers are supervised by the execution). The interpreter yields cooperatively between steps, so the timeout also interrupts pure busy loops (`while (true) {}`) - no separate work budget exists. Tool implementations remain responsible for making their external operations interruptible or independently bounded.
Two interpreter internals are fixed constants rather than knobs: at most 8 tool calls run concurrently, and values crossing a data boundary may nest at most 32 levels deep (deeper values fail as `InvalidDataValue`, which reads better than a native stack-overflow error). Neither is part of the public contract.
## Diagnostics
Failures are data:
| Kind | Meaning |
| ----------------------- | -------------------------------------------------------------------------------------------------------- |
| `ParseError` | Source is empty or cannot be parsed. |
| `UnsupportedSyntax` | Parsed JavaScript is outside the supported subset. |
| `UnknownTool` | A program referenced a tool the host did not provide. |
| `InvalidToolInput` | Tool input failed schema decoding or safe-data copying. |
| `InvalidToolOutput` | Tool output failed schema decoding or safe-data copying. |
| `InvalidDataValue` | Program data violated the plain-data contract (depth, circularity, blocked properties, non-data values). |
| `ToolCallLimitExceeded` | Calls exceeded `maxToolCalls`. |
| `TimeoutExceeded` | Execution exceeded `timeoutMs`. |
| `ToolFailure` | A tool refused or failed. |
| `ExecutionFailure` | The program threw or another execution error occurred. |
Unknown host failures, defects, invalid outputs, and copying failures are sanitized. To return a safe operational refusal, fail with `toolError`:
```ts
import { toolError } from "@opencode-ai/codemode"
run: ({ id }) => (authorized(id) ? loadOrder(id) : Effect.fail(toolError("Order is unavailable")))
```
Only the supplied message is model-visible. The optional cause is never returned in `ExecuteResult`; hosts should perform any required internal logging before crossing this boundary.
## Authority Boundary
CodeMode confines programs to the supplied tool tree, but it does not decide what those tools may do.
The host owns:
- Authentication and authorization.
- Tool selection and immutable scope.
- Credentials and network clients.
- Persistence, idempotency, approval, and durable side effects.
- Logging and redaction policy.
CodeMode owns:
- Parsing and interpreting the supported subset without `eval`.
- Schema boundaries around tool calls.
- Plain-data copying and blocked prototype members.
- Resource limits, call accounting, and normalized diagnostics.
- Model-facing tool discovery and instructions.
A program cannot gain authority through prose or generated code. It can only exercise authority already present in the supplied tools. Do not expose a broad tool and expect the prompt to restrict it.
## Laws
The public contract is guided by these equivalences:
- `CodeMode.execute({ ...options, code })` is equivalent to `CodeMode.make(options).execute(code)`.
- `CodeMode.make(options).agentTool().execute({ code })` is equivalent to `CodeMode.make(options).execute(code)`.
- `CodeMode.make(options).agentTool().description` equals `CodeMode.make(options).instructions()`.
- A tool implementation is not invoked unless its input has decoded successfully.
- A tool result is not visible to the program unless its output has decoded and crossed the plain-data boundary successfully.
- Unknown host failures do not become model-visible diagnostics; `ToolError` is the explicit safe-message channel.
- Host interruption remains interruption rather than an `ExecuteFailure`.
## Non-Goals
- Generic permission prompts or approval workflows.
- Durable pause/resume, replay, or storage adapters.
- Exactly-once external side effects.
- Application authorization or product policy.
- A filesystem or process sandbox for arbitrary JavaScript.
- Compatibility with the full JavaScript language or npm ecosystem.
Applications that need approval or durable consequences should model those above CodeMode and expose only the currently authorized tools.
## Testing
From the package directory:
```sh
bun test
bun run typecheck
```
The direct suite covers public projections, discovery, schema boundaries, diagnostic sanitization, resource limits, tool-call observation, and interruption.
File diff suppressed because it is too large Load Diff
-26
View File
@@ -1,26 +0,0 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/codemode",
"version": "0.0.1",
"description": "Effect-native confined code execution over schema-described tools",
"private": true,
"type": "module",
"license": "MIT",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"typecheck": "tsgo --noEmit",
"test": "bun test"
},
"dependencies": {
"acorn": "8.15.0",
"effect": "catalog:",
"typescript": "catalog:"
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:"
}
}
File diff suppressed because it is too large Load Diff
-21
View File
@@ -1,21 +0,0 @@
export { ToolError, CodeMode, ExecuteInputSchema, ExecuteResultSchema, toolError } from "./codemode.js"
export { Tool } from "./tool.js"
export type { Definition as ToolDefinition, JsonSchema, ToolSchema } from "./tool.js"
export type { ToolCallEnded, ToolCallHooks } from "./tool-runtime.js"
export type {
AgentToolDefinition,
CodeModeOptions,
CodeModeRuntime,
DataValue,
Diagnostic,
DiagnosticKind,
DiscoveryOptions,
ExecuteFailure,
ExecuteOptions,
ExecuteResult,
ExecuteSuccess,
ExecutionLimits,
ToolCall,
ToolCallStarted,
ToolDescription,
} from "./codemode.js"
-10
View File
@@ -1,10 +0,0 @@
/**
* Token estimation for budgeting model-facing text. Copied from
* `@opencode-ai/core/util/token` (chars / 4) so this package stays
* dependency-free; keep the two in sync if the heuristic ever changes.
*/
export * as Token from "./token.js"
const CHARS_PER_TOKEN = 4
export const estimate = (input: string) => Math.max(0, Math.round(input.length / CHARS_PER_TOKEN))
-11
View File
@@ -1,11 +0,0 @@
import { Schema } from "effect"
/** Safe operational refusal from a standard tool pack, reported as `ToolFailure`. */
export class ToolError extends Schema.TaggedErrorClass<ToolError>()("ToolError", {
message: Schema.String,
cause: Schema.optionalKey(Schema.Defect()),
}) {}
/** 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 }) })
-829
View File
@@ -1,829 +0,0 @@
import { Cause, Effect } from "effect"
import { ToolError, toolError } from "./tool-error.js"
import {
decodeInput as decodeToolInput,
decodeOutput as decodeToolOutput,
identifierSegment,
inputProperties,
inputTypeScript,
isDefinition as isToolDefinition,
outputTypeScript,
type Definition,
} from "./tool.js"
import { estimate } from "./token.js"
import { SandboxDate, SandboxMap, SandboxPromise, SandboxRegExp, SandboxSet } from "./values.js"
export type HostTool<R = never> = (...args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
export type HostTools<R = never> = {
[name: string]: HostTool<R> | Definition<R> | HostTools<R>
}
export type Services<Tools> = Tools extends (...args: Array<unknown>) => Effect.Effect<unknown, unknown, infer R>
? R
: Tools extends {
readonly _tag: "CodeModeTool"
readonly run: (input: unknown) => Effect.Effect<unknown, unknown, infer R>
}
? R
: Tools extends object
? string extends keyof Tools
? never
: Services<Tools[keyof Tools]>
: never
/** Minimal audit record retained for each admitted tool call. */
export type ToolCall = {
readonly name: string
}
/** Decoded tool call observed immediately before tool execution. */
export type ToolCallStarted = {
readonly index: number
readonly name: string
readonly input: unknown
}
/** Completed tool call observed immediately after tool execution settles. */
export type ToolCallEnded = {
readonly index: number
readonly name: string
readonly input: unknown
readonly durationMs: number
readonly outcome: "success" | "failure"
/** Model-safe failure message; present only when `outcome` is `"failure"`. */
readonly message?: string
}
/** Non-throwing observation hooks fired around each admitted tool call. */
export type ToolCallHooks<R = never> = {
readonly onToolCallStart?: ((call: ToolCallStarted) => Effect.Effect<void, never, R>) | undefined
readonly onToolCallEnd?: ((call: ToolCallEnded) => Effect.Effect<void, never, R>) | undefined
}
/** Model-visible description of one schema-backed tool. */
export type ToolDescription = {
readonly path: string
readonly description: string
readonly signature: string
}
export type SafeObject = Record<string, unknown>
const reservedNamespace = "$codemode"
const defaultMaxInlineCatalogTokens = 2_000
const defaultSearchLimit = 10
const searchSignature =
"tools.$codemode.search({ query?: string, namespace?: string, limit?: number }): Promise<{ items: Array<{ path: string; description: string; signature: string }>; total: number }>"
const toolExpression = (path: string) =>
"tools" +
path
.split(".")
.map((segment) => (identifierSegment.test(segment) ? `.${segment}` : `[${JSON.stringify(segment)}]`))
.join("")
export class ToolReference {
constructor(readonly path: ReadonlyArray<string>) {}
}
/**
* Maximum nesting depth for values crossing a data boundary. Fixed (not a configurable
* limit) purely because it produces a clearer diagnostic than a native stack-overflow
* RangeError would.
*/
const MAX_VALUE_DEPTH = 32
export class ToolRuntimeError extends Error {
constructor(
readonly kind:
| "UnknownTool"
| "InvalidToolInput"
| "InvalidToolOutput"
| "InvalidDataValue"
| "ToolCallLimitExceeded",
message: string,
readonly suggestions: ReadonlyArray<string> = [],
) {
super(message)
this.name = "ToolRuntimeError"
}
}
const isDefinition = <R>(value: HostTool<R> | Definition<R> | HostTools<R>): value is Definition<R> =>
isToolDefinition<R>(value)
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)
/**
* Validates and copies a value against the plain-data contract (depth, circularity, plain
* objects only, blocked properties, data-only leaves).
*
* Two modes share the walk:
* - **Boundary** (`preserveSandboxValues` false, the default): the host<->sandbox boundary -
* final results, tool-call arguments, `JSON.stringify`. Sandbox value types serialize
* exactly as JSON.stringify would: Date -> ISO string (invalid -> null), RegExp/Map/Set -> {}.
* - **Intra-sandbox checkpoint** (`preserveSandboxValues` true; see `boundedData` in
* codemode.ts): Date/RegExp/Map/Set instances pass through untouched (treated as leaves,
* contents not walked), so values flowing through `Object.*` helpers, coercion inputs, and
* other in-sandbox checkpoints stay fully usable (`.getTime()`, `.has()`, ...).
*
* Both modes reject un-awaited promises with an await-hinting diagnostic.
*/
export const copyIn = (value: unknown, label: string, preserveSandboxValues = false): unknown =>
copyBounded(value, label, 0, new Set(), preserveSandboxValues)
const copyBounded = (
value: unknown,
label: string,
depth: number,
seen: Set<object>,
preserveSandboxValues: boolean,
): unknown => {
if (depth > MAX_VALUE_DEPTH) {
throw new ToolRuntimeError("InvalidDataValue", `${label} exceeds the maximum value depth of ${MAX_VALUE_DEPTH}.`)
}
if (
value === null ||
value === undefined ||
typeof value === "string" ||
typeof value === "boolean" ||
// NaN/Infinity are allowed to exist as in-sandbox intermediates (matching real JS and a real
// engine) so defensive guards like `Number.isNaN(x)` / `parseInt(x) || 0` can run. They are
// normalized to `null` when the value leaves the sandbox - see copyOut - exactly as
// JSON.stringify already does at any tool boundary.
typeof value === "number"
) {
return value
}
if (typeof value !== "object") {
throw new ToolRuntimeError("InvalidDataValue", `${label} must contain data only.`)
}
// An un-awaited promise never crosses a data checkpoint as `{}`; the diagnostic tells the
// model exactly how to fix the program instead.
if (value instanceof SandboxPromise) {
throw new ToolRuntimeError(
"InvalidDataValue",
`${label} contains an un-awaited Promise; await tool calls (e.g. \`const result = await tools.ns.tool(...)\`) before using their results.`,
)
}
if (preserveSandboxValues) {
// Intra-sandbox checkpoints keep sandbox value instances alive as leaves; their contents
// are never walked here (Map/Set members are validated where mutation happens, and the
// real boundary still serializes them below).
if (
value instanceof SandboxDate ||
value instanceof SandboxRegExp ||
value instanceof SandboxMap ||
value instanceof SandboxSet
) {
return value
}
// Host instances cannot normally reach an intra-sandbox checkpoint (tool results cross
// the boundary first), but wrap them defensively rather than degrading to JSON forms.
if (value instanceof Date) return new SandboxDate(value.getTime())
if (value instanceof RegExp) return new SandboxRegExp(value.source, value.flags)
if (value instanceof Map) {
const wrapped = new SandboxMap()
for (const [key, item] of value.entries()) {
wrapped.map.set(copyBounded(key, label, depth + 1, seen, true), copyBounded(item, label, depth + 1, seen, true))
}
return wrapped
}
if (value instanceof Set) {
const wrapped = new SandboxSet()
for (const item of value.values()) wrapped.set.add(copyBounded(item, label, depth + 1, seen, true))
return wrapped
}
}
// Sandbox value types (and their host counterparts, which a host tool may legitimately
// return) serialize exactly as JSON.stringify would at the data boundary: a Date is its
// toJSON() ISO string (invalid -> null), and RegExp/Map/Set have no JSON form beyond {}.
if (value instanceof SandboxDate) {
return Number.isFinite(value.time) ? new Date(value.time).toISOString() : null
}
if (value instanceof Date) {
return Number.isFinite(value.getTime()) ? value.toISOString() : null
}
if (
value instanceof SandboxRegExp ||
value instanceof SandboxMap ||
value instanceof SandboxSet ||
value instanceof RegExp ||
value instanceof Map ||
value instanceof Set
) {
return Object.create(null) as SafeObject
}
if (seen.has(value)) {
throw new ToolRuntimeError("InvalidDataValue", `${label} contains a circular value.`)
}
seen.add(value)
if (Array.isArray(value)) {
const copied = value.map((item) => copyBounded(item, label, depth + 1, seen, preserveSandboxValues))
seen.delete(value)
return copied
}
const prototype = Object.getPrototypeOf(value)
if (prototype !== Object.prototype && prototype !== null) {
throw new ToolRuntimeError("InvalidDataValue", `${label} must contain plain objects only.`)
}
const copied: SafeObject = Object.create(null) as SafeObject
for (const [key, item] of Object.entries(value)) {
if (isBlockedMember(key)) {
throw new ToolRuntimeError("InvalidDataValue", `${label} contains blocked property '${key}'.`)
}
copied[key] = copyBounded(item, label, depth + 1, seen, preserveSandboxValues)
}
seen.delete(value)
return copied
}
export const copyOut = (value: unknown, undefinedAsNull = false): unknown => {
if (value === undefined && undefinedAsNull) return null
// Normalize non-finite numbers to null as the value crosses out of the sandbox (final return
// and tool-call arguments both funnel through here), matching JSON semantics - NaN/Infinity
// have no JSON representation, so JSON.stringify would produce null anyway.
if (typeof value === "number" && !Number.isFinite(value)) {
return null
}
if (Array.isArray(value)) {
return value.map((item) => copyOut(item, undefinedAsNull))
}
if (value !== null && typeof value === "object" && !(value instanceof ToolReference)) {
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, copyOut(item, undefinedAsNull)]))
}
return value
}
const definitions = <R>(
tools: HostTools<R>,
path: ReadonlyArray<string> = [],
): Array<{ path: string; definition: Definition<R> }> => {
const entries: Array<{ path: string; definition: Definition<R> }> = []
for (const [name, value] of Object.entries(tools)) {
const next = [...path, name]
if (isDefinition(value)) entries.push({ path: next.join("."), definition: value })
else if (typeof value !== "function") entries.push(...definitions(value, next))
}
return entries
}
const describeDefinition = <R>(path: string, definition: Definition<R>): ToolDescription => ({
path,
description: definition.description,
signature: `${toolExpression(path)}(input: ${inputTypeScript(definition)}): Promise<${outputTypeScript(definition)}>`,
})
const visibleDefinitions = <R>(tools: HostTools<R>) =>
definitions(tools).flatMap(({ path, definition }) => {
const description = describeDefinition(path, definition)
return [{ path, definition, description }]
})
export const catalog = <R>(tools: HostTools<R>): ReadonlyArray<ToolDescription> =>
visibleDefinitions(tools).map(({ description }) => description)
export type DiscoveryPlan = {
readonly catalog: ReadonlyArray<ToolDescription>
readonly instructions: string
readonly searchIndex: ReadonlyArray<SearchEntry>
}
export type SearchEntry = {
readonly description: ToolDescription
/**
* JSDoc-annotated multiline signature shown on search-result items; the compact
* single-line form (inline catalog lines) stays in `description.signature`.
*/
readonly signature: string
/** Top-level namespace (first path segment), matched by the search `namespace` option. */
readonly namespace: string
/** Lowercased path + description + input property names/descriptions, for substring matching. */
readonly searchText: string
}
/**
* Split a query into lowercased search terms. camelCase boundaries are split
* (`resolveLibrary` -> `resolve library`) and every non-alphanumeric character is a
* separator, so `resolve-library-id`, `resolveLibraryId`, and `resolve library id` all
* tokenize alike. Empties and the `*` wildcard are dropped.
*/
const tokenize = (query: string): Array<string> =>
query
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
.toLowerCase()
.split(/[^a-z0-9]+/)
.filter((term) => term.length > 0 && term !== "*")
/**
* A term plus its naive singular variants (trailing "s"/"es" stripped), so a plural
* query term ("issues") still matches indexed text that only carries the singular
* ("issue"). Matching is one-directional substring containment, so the variants are
* needed only on the query side; scoring weights are unchanged - each field check
* passes when ANY form matches.
*/
const termForms = (term: string): Array<string> => {
const forms = [term]
if (term.endsWith("es") && term.length > 3) forms.push(term.slice(0, -2))
if (term.endsWith("s") && term.length > 2) forms.push(term.slice(0, -1))
return forms
}
const firstLine = (text: string) => text.split("\n", 1)[0]!.trim()
/** One-line description used on inline catalog lines; the full text stays in search results. */
const brief = (text: string, max = 120) => {
const line = firstLine(text)
return line.length > max ? line.slice(0, max - 1) + "..." : line
}
const catalogLine = (tool: ToolDescription) => {
const description = brief(tool.description)
return description === "" ? ` - ${tool.signature}` : ` - ${tool.signature} // ${description}`
}
const toSearchEntry = <R>(path: string, definition: Definition<R>, description: ToolDescription): SearchEntry => ({
description,
signature: `${toolExpression(path)}(input: ${inputTypeScript(definition, true)}): Promise<${outputTypeScript(definition, true)}>`,
namespace: path.split(".", 1)[0]!,
searchText: [
path,
definition.description,
...inputProperties(definition).flatMap(({ name, description: property }) =>
property === undefined ? [name] : [name, property],
),
]
.join("\n")
.toLowerCase(),
})
/** The runtime search index over every described tool. Search is always registered. */
export const searchIndex = <R>(tools: HostTools<R>): ReadonlyArray<SearchEntry> =>
visibleDefinitions(tools).map(({ path, definition, description }) => toSearchEntry(path, definition, description))
export const assertValidTools = <R>(tools: HostTools<R>): void => {
if (Object.hasOwn(tools, reservedNamespace)) {
throw new Error(`Tool namespace '${reservedNamespace}' is reserved for CodeMode discovery tools.`)
}
}
/**
* Budgeted catalog: every namespace is always listed with its tool count; full call
* signatures are inlined against the `maxInlineCatalogTokens` budget (estimated tokens,
* chars/4) round-robin across namespaces - in each round (namespaces alphabetical), every
* namespace still holding un-inlined tools attempts to place its next-cheapest line, and
* a namespace whose next line does not fit is done while the others keep going - so every
* namespace gets some representation before any namespace gets everything. The section
* states exactly how comprehensive it is - overall (COMPLETE vs PARTIAL) and per
* namespace. Namespace stub lines are never budgeted: every namespace appears with its
* tool count even at budget 0.
*/
export const discoveryPlan = <R>(
tools: HostTools<R>,
maxInlineCatalogTokens = defaultMaxInlineCatalogTokens,
): DiscoveryPlan => {
if (!Number.isSafeInteger(maxInlineCatalogTokens) || maxInlineCatalogTokens < 0) {
throw new RangeError("discovery.maxInlineCatalogTokens must be a non-negative safe integer")
}
const visible = visibleDefinitions(tools)
const described = visible.map(({ description }) => description)
const namespaces = new Map<string, Array<ToolDescription>>()
for (const tool of described) {
const [namespace = tool.path] = tool.path.split(".")
const group = namespaces.get(namespace) ?? []
group.push(tool)
namespaces.set(namespace, group)
}
const ordered = [...namespaces].sort(([left], [right]) => left.localeCompare(right))
// Select which signatures fit the budget before emitting, so the list can state
// exactly how comprehensive it is. Round-robin fairness: in each round (namespaces
// alphabetical), every namespace still holding un-inlined tools tries to place its
// next-cheapest line against the shared budget; a namespace whose next line does not
// fit is done - the others keep going - so every namespace gets some representation
// before any namespace gets everything.
const selections = ordered.map(([namespace, group]) => ({
namespace,
picked: new Set<ToolDescription>(),
queue: [...group].sort(
(left, right) =>
estimate(catalogLine(left)) - estimate(catalogLine(right)) || left.path.localeCompare(right.path),
),
}))
let used = 0
let active = selections.filter((selection) => selection.queue.length > 0)
while (active.length > 0) {
const stillActive: typeof active = []
for (const selection of active) {
const tool = selection.queue[0]!
const cost = estimate(catalogLine(tool))
if (used + cost > maxInlineCatalogTokens) continue
selection.queue.shift()
selection.picked.add(tool)
used += cost
if (selection.queue.length > 0) stillActive.push(selection)
}
active = stillActive
}
const shown = new Map<string, ReadonlySet<ToolDescription>>(
selections.map(({ namespace, picked }) => [namespace, picked]),
)
const totalShown = selections.reduce((total, { picked }) => total + picked.size, 0)
const complete = totalShown === described.length
const empty = described.length === 0
// Section order is deliberate: workflow first (the top is the least likely part of a long
// description to be truncated or skimmed away), then rules, then syntax, with the budgeted
// catalog at the bottom. Example call forms use explicit `<namespace>.<tool>` placeholders -
// never a real or fabricated tool name.
const intro = [
"Write a CodeMode program to answer the request. Return code only.",
empty
? "Execute JavaScript in a confined runtime."
: complete
? "Execute JavaScript in a confined runtime. Inside this program, `tools` contains only the host-provided tools listed below; surrounding agent tools are not available unless listed here."
: "Execute JavaScript in a confined runtime. Inside this program, `tools` contains only the host-provided tools listed or searchable below; surrounding agent tools are not available unless listed here.",
]
// The search step exists only when search is advertised (PARTIAL catalog); a COMPLETE
// catalog already shows every signature, so step 1 picks from the list instead.
const workflow = empty
? []
: [
"",
"## Workflow",
"",
...(complete
? [
"1. Pick a tool from the list under `## Available tools` - each line is the exact call signature; use it as-is rather than guessing segments.",
"2. Call it using the exact signature shown: `const res = await tools.<namespace>.<tool>(input)` - bracket notation may appear for names that are not JavaScript identifiers.",
'3. Parse text results: `const data = typeof res === "string" ? JSON.parse(res) : res` - most tools return JSON as a string.',
"4. Return only the fields you need: `return { <field>: data.<field> }` - raw payloads get truncated and waste context.",
]
: [
'1. Find a tool (skip when it is already listed below): `const { items } = await tools.$codemode.search({ query: "<intent + key nouns>" })` - short phrases like "list issues" work best.',
"2. Read the matches: each item is `{ path, description, signature }` - read the description before using an unfamiliar tool.",
"3. Call it with the result's `path` as-is (never guess segments): `const res = await tools.<namespace>.<tool>(input)` - bracket notation may appear for names that are not JavaScript identifiers.",
'4. Parse text results: `const data = typeof res === "string" ? JSON.parse(res) : res` - most tools return JSON as a string.',
"5. Return only the fields you need: `return { <field>: data.<field> }` - raw payloads get truncated and waste context.",
]),
]
const rules = empty
? []
: [
"",
"## Rules",
"",
complete
? "- Only tools listed here are available inside `tools`; tools from the surrounding agent/runtime are not implicitly exposed."
: "- Only tools listed here or returned by `tools.$codemode.search` are available inside `tools`; tools from the surrounding agent/runtime are not implicitly exposed.",
"- Filter, aggregate, and transform collections in code - never return them raw or call a tool per item across messages.",
"- A result typed `Promise<unknown>` has no guaranteed shape - verify what actually came back before relying on its fields.",
"- Run independent calls in parallel: `await Promise.all(items.map((item) => tools.<namespace>.<tool>(item)))`.",
"- `Object.keys(tools)` lists namespaces; `Object.keys(tools.<namespace>)` lists its tools; `for...in` works on both.",
...(complete
? []
: ['- Browse one namespace: `await tools.$codemode.search({ query: "", namespace: "<name>" })`.']),
]
const syntax = [
"",
"## Syntax",
"",
"Standard modern JavaScript works: functions/closures, destructuring, template literals, loops, try/catch, spread, optional chaining, the usual Array/String/Object/Math/JSON methods, plus Date, RegExp, Map, Set, and Promise.all/allSettled/race/resolve/reject.",
"TypeScript type annotations are allowed and stripped before execution (decorators are not supported).",
"Not supported (each fails with a message naming the alternative): classes, generators, for await...of, .then/.catch/.finally (use await with try/catch).",
"Dates serialize to ISO strings at data boundaries; Map/Set/RegExp serialize to `{}`.",
]
const toolSection: Array<string> = [""]
if (empty) {
toolSection.push("## Available tools", "", "No tools are currently available.")
} else {
toolSection.push(
complete
? "## Available tools (COMPLETE list - every tool is shown below with its full call signature)"
: `## Available tools (PARTIAL - ${totalShown} of ${described.length} shown; find the rest with tools.$codemode.search)`,
"",
)
for (const [namespace, group] of ordered) {
const picked = shown.get(namespace)!
const count = `${group.length} tool${group.length === 1 ? "" : "s"}`
// Annotate only when a namespace is not fully shown, so a comprehensive
// namespace reads cleanly and a truncated one is unambiguous.
const label =
picked.size === group.length
? count
: picked.size === 0
? `${count}, none shown`
: `${count}, ${picked.size} shown`
toolSection.push(`- ${namespace} (${label})`)
for (const tool of group) if (picked.has(tool)) toolSection.push(catalogLine(tool))
}
if (!complete) {
toolSection.push("", "Search returns complete callable signatures:", `- ${searchSignature}`)
}
}
const lines = [...intro, ...workflow, ...rules, ...syntax, ...toolSection]
return {
catalog: described,
instructions: lines.join("\n"),
searchIndex: visible.map(({ path, definition, description }) => toSearchEntry(path, definition, description)),
}
}
/**
* The enumerable names at one node of the host tool tree - namespace names at the root,
* tool/namespace names below - powering `Object.keys(tools)` and `for...in` over tool
* references. A callable tool is a leaf and enumerates as `[]` (like `Object.keys` of a
* function in JS). An unknown path is an `UnknownTool` error pointing at the working
* discovery idioms, mirroring how calling an unknown tool fails.
*/
const namespaceKeys = <R>(
tools: HostTools<R>,
path: ReadonlyArray<string>,
searchEnabled: boolean,
): ReadonlyArray<string> => {
// The reserved discovery namespace is virtual (never present in the host tree); enumerate
// it explicitly so `Object.keys(tools.$codemode)` matches the callable surface.
if (searchEnabled && path.length === 1 && path[0] === reservedNamespace) return ["search"]
let value: HostTool<R> | Definition<R> | HostTools<R> = tools
for (const segment of path) {
if (
isBlockedMember(segment) ||
typeof value === "function" ||
isDefinition(value) ||
!Object.hasOwn(value, segment)
) {
throw new ToolRuntimeError(
"UnknownTool",
`Unknown tool namespace '${path.join(".")}'.`,
searchEnabled
? [
"Object.keys(tools) lists the available namespaces; tools.$codemode.search({ query }) finds described tools.",
]
: ["Object.keys(tools) lists the available namespaces."],
)
}
value = value[segment] as HostTool<R> | Definition<R> | HostTools<R>
}
if (typeof value === "function" || isDefinition(value)) return []
return Object.keys(value)
}
const resolve = <R>(
tools: HostTools<R>,
path: ReadonlyArray<string>,
searchEnabled: boolean,
): HostTool<R> | Definition<R> => {
let value: HostTool<R> | Definition<R> | HostTools<R> = tools
for (const segment of path) {
if (
isBlockedMember(segment) ||
typeof value === "function" ||
isDefinition(value) ||
!Object.hasOwn(value, segment)
) {
throw new ToolRuntimeError(
"UnknownTool",
`Unknown tool '${path.join(".")}'.`,
searchEnabled ? ["Use tools.$codemode.search({ query }) to find available described tools."] : [],
)
}
value = value[segment] as HostTool<R> | Definition<R> | HostTools<R>
}
if (typeof value !== "function" && !isDefinition(value)) {
throw new ToolRuntimeError("UnknownTool", `Tool '${path.join(".")}' is not callable.`)
}
return value
}
export type ToolRuntime<R = never> = {
readonly root: ToolReference
readonly calls: Array<ToolCall>
readonly invoke: (path: ReadonlyArray<string>, args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
/** Enumerable namespace/tool names at one node of the host tool tree; see `namespaceKeys`. */
readonly keys: (path: ReadonlyArray<string>) => ReadonlyArray<string>
}
const failureMessage = (error: unknown): string =>
error instanceof ToolError || error instanceof ToolRuntimeError ? error.message : "Tool execution failed"
export const make = <R>(
tools: HostTools<R>,
/** Undefined means unlimited tool calls. */
maxToolCalls: number | undefined,
hooks?: ToolCallHooks<R>,
searchIndex?: ReadonlyArray<SearchEntry>,
): ToolRuntime<R> => {
const calls: Array<ToolCall> = []
const searchEnabled = searchIndex !== undefined
// Wraps the settling portion of a tool call so onToolCallEnd observes success and failure
// symmetrically. Interruption (e.g. the execution timeout) fires neither outcome.
const observeEnd = <A, E>(effect: Effect.Effect<A, E, R>, call: ToolCallStarted): Effect.Effect<A, E, R> => {
const onEnd = hooks?.onToolCallEnd
if (onEnd === undefined) return effect
const startedAt = Date.now()
return effect.pipe(
Effect.tap(() => onEnd({ ...call, durationMs: Date.now() - startedAt, outcome: "success" })),
Effect.tapError((error) =>
onEnd({ ...call, durationMs: Date.now() - startedAt, outcome: "failure", message: failureMessage(error) }),
),
)
}
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}.`)
}
calls.push(call)
}
return {
root: new ToolReference([]),
calls,
keys: (path) => namespaceKeys(tools, path, searchEnabled),
invoke: (path, args) =>
Effect.gen(function* () {
const name = path.join(".")
const externalArgs = args.map((arg) => copyOut(copyIn(arg, `Arguments for tool '${name}'`)))
const call = { name }
const recordAndObserve = (input: unknown) =>
Effect.sync(() => {
recordCall(call)
return calls.length - 1
}).pipe(Effect.tap((index) => hooks?.onToolCallStart?.({ index, name, input }) ?? Effect.void))
if (name === "$codemode.search") {
if (!searchEnabled) throw new ToolRuntimeError("UnknownTool", `Unknown tool '${name}'.`)
const input = externalArgs[0]
if (externalArgs.length !== 1 || input === null || typeof input !== "object" || Array.isArray(input)) {
throw new ToolRuntimeError(
"InvalidToolInput",
"tools.$codemode.search expects { query?: string; namespace?: string; limit?: number }.",
)
}
const request = input as { query?: unknown; namespace?: unknown; limit?: unknown }
if (request.query !== undefined && typeof request.query !== "string") {
throw new ToolRuntimeError(
"InvalidToolInput",
"tools.$codemode.search query must be a string when provided.",
)
}
if (request.namespace !== undefined && typeof request.namespace !== "string") {
throw new ToolRuntimeError(
"InvalidToolInput",
"tools.$codemode.search namespace must be a string when provided.",
)
}
if (
request.limit !== undefined &&
(typeof request.limit !== "number" || !Number.isSafeInteger(request.limit) || request.limit <= 0)
) {
throw new ToolRuntimeError(
"InvalidToolInput",
"tools.$codemode.search limit must be a positive safe integer when provided.",
)
}
const query = typeof request.query === "string" ? request.query : ""
const namespace = typeof request.namespace === "string" ? request.namespace : undefined
const index = yield* recordAndObserve(request)
return yield* observeEnd(
Effect.try({
try: () => {
const limit = typeof request.limit === "number" ? request.limit : defaultSearchLimit
const scoped =
namespace === undefined ? searchIndex : searchIndex.filter((entry) => entry.namespace === namespace)
// A query that names one tool path exactly (canonical path or rendered
// JavaScript expression) is a lookup, not a search: return that tool alone.
const trimmed = query.trim()
const pathQuery = trimmed.startsWith("tools.") ? trimmed.slice("tools.".length) : trimmed
const exact =
pathQuery === ""
? undefined
: scoped.find(
(entry) =>
entry.description.path === pathQuery || toolExpression(entry.description.path) === trimmed,
)
const terms = tokenize(query).map(termForms)
// Additive field-weighted scoring, summed across terms: exact path or path
// segment (20) > path substring (8) > description substring (4) > any
// searchable text, incl. input parameter names/descriptions (2). Each term
// matches a field when any of its forms (the term or a singular variant)
// does. An empty query browses everything, alphabetical by path.
const ranked =
exact !== undefined
? [exact]
: scoped
.map((entry) => {
const path = entry.description.path.toLowerCase()
const description = entry.description.description.toLowerCase()
const score = terms.reduce(
(total, forms) =>
total +
(forms.some((form) => path === form || path.endsWith(`.${form}`)) ? 20 : 0) +
(forms.some((form) => path.includes(form)) ? 8 : 0) +
(forms.some((form) => description.includes(form)) ? 4 : 0) +
(forms.some((form) => entry.searchText.includes(form)) ? 2 : 0),
0,
)
return { entry, score }
})
.filter(({ score }) => terms.length === 0 || score > 0)
.sort(
(left, right) =>
right.score - left.score ||
left.entry.description.path.localeCompare(right.entry.description.path),
)
.map(({ entry }) => entry)
// Result paths are rendered as JavaScript expressions so each `path` is
// directly usable as the call site (`await tools.github.list({ ... })` or
// `await tools.ns["dashed-name"]({ ... })`). The signature is the pretty,
// JSDoc-annotated form (schema descriptions and constraints ride along as
// field comments).
const items = ranked.slice(0, limit).map(({ description, signature }) => ({
...description,
path: toolExpression(description.path),
signature,
}))
return copyIn({ items, total: ranked.length }, "Result from tool '$codemode.search'")
},
catch: (cause) => cause,
}),
{ index, name, input: request },
)
}
const tool = resolve(tools, path, searchEnabled)
let describedInput: unknown
if (isDefinition(tool)) {
if (externalArgs.length !== 1)
throw new ToolRuntimeError("InvalidToolInput", `Tool '${name}' expects exactly one input object.`)
describedInput = yield* Effect.try({
try: () => decodeToolInput(tool, externalArgs[0]),
catch: (cause) =>
new ToolRuntimeError("InvalidToolInput", `Invalid input for tool '${name}': ${String(cause)}`),
})
}
const input = isDefinition(tool) ? describedInput : externalArgs
const index = yield* recordAndObserve(input)
const currentCall = { index, name, input }
if (isDefinition(tool)) {
return yield* observeEnd(
Effect.gen(function* () {
const raw = yield* runHost(Effect.suspend(() => tool.run(describedInput)))
const result = yield* Effect.try({
try: () => decodeToolOutput(tool, raw),
catch: () => new ToolRuntimeError("InvalidToolOutput", `Invalid output from tool '${name}'.`),
})
return yield* decodeOutput(result, name)
}),
currentCall,
)
}
return yield* observeEnd(
Effect.gen(function* () {
return yield* decodeOutput(yield* runHost(Effect.suspend(() => tool(...externalArgs))), name)
}),
currentCall,
)
}),
}
}
export * as ToolRuntime from "./tool-runtime.js"
-348
View File
@@ -1,348 +0,0 @@
import { Effect, Schema } from "effect"
/**
* JSON Schema subset accepted for render-only tool schemas.
*
* A JSON-Schema-described side of a tool is used to generate the model-visible TypeScript
* signature only - CodeMode performs no validation against it. This is the natural shape for
* adapter-provided tools (e.g. MCP definitions) whose schemas arrive as JSON Schema documents.
*/
export type JsonSchema = {
readonly type?: string | ReadonlyArray<string>
readonly enum?: ReadonlyArray<unknown>
readonly const?: unknown
readonly anyOf?: ReadonlyArray<JsonSchema>
readonly oneOf?: ReadonlyArray<JsonSchema>
readonly properties?: Readonly<Record<string, JsonSchema>>
readonly required?: ReadonlyArray<string>
readonly items?: JsonSchema
readonly additionalProperties?: boolean | JsonSchema
readonly description?: string
readonly default?: unknown
readonly format?: string
readonly deprecated?: boolean
readonly minItems?: number
readonly maxItems?: number
readonly $ref?: string
readonly $defs?: Readonly<Record<string, JsonSchema>>
readonly definitions?: Readonly<Record<string, JsonSchema>>
}
/** Either a validating Effect Schema or a render-only JSON Schema document. */
export type ToolSchema = Schema.Decoder<unknown> | JsonSchema
/** Schema-backed tool definition consumed by a CodeMode tool tree. */
export type Definition<R = never> = {
readonly _tag: "CodeModeTool"
readonly description: string
readonly input: ToolSchema
readonly output: ToolSchema | undefined
readonly run: (input: unknown) => Effect.Effect<unknown, unknown, R>
}
/** The value `run` receives: the decoded type for Effect Schemas, `unknown` for JSON Schemas. */
export type InputType<S> = S extends Schema.Decoder<unknown> ? S["Type"] : unknown
/** The value `run` returns: the encoded type for Effect Schemas, `unknown` otherwise. */
export type ResultType<S> = S extends Schema.Decoder<unknown> ? S["Encoded"] : unknown
/** Options for defining one CodeMode tool. */
export type Options<I extends ToolSchema, O extends ToolSchema | undefined, R = never> = {
readonly description: string
readonly input: I
readonly output?: O
readonly run: (input: InputType<I>) => Effect.Effect<ResultType<O>, unknown, R>
}
export const isDefinition = <R = never>(value: unknown): value is Definition<R> =>
typeof value === "object" && value !== null && "_tag" in value && value._tag === "CodeModeTool"
const isEffectSchema = (schema: ToolSchema): schema is Schema.Decoder<unknown> & Schema.Top => Schema.isSchema(schema)
const renderLiteral = (value: unknown): string => JSON.stringify(value) ?? "unknown"
/**
* Bare TypeScript identifier - usable unquoted as an object key (and, in the tool runtime,
* with dot access as a tool-path segment). Anything else must be quoted/bracketed.
*/
export const identifierSegment = /^[A-Za-z_$][A-Za-z0-9_$]*$/
/** Renders a property name as a valid TS object key: bare when an identifier, quoted otherwise. */
const renderKey = (name: string): string => (identifierSegment.test(name) ? name : JSON.stringify(name))
const effectNumberSentinel = (schema: JsonSchema) =>
schema.type === "string" &&
Array.isArray(schema.enum) &&
schema.enum.length === 1 &&
(schema.enum[0] === "NaN" || schema.enum[0] === "Infinity" || schema.enum[0] === "-Infinity")
/**
* Recursion ceiling for schema rendering. Object, array, and union recursion all increment
* depth, so this bounds every recursion path - pathological or structurally cyclic schemas
* degrade to `unknown` instead of overflowing the stack (rendering must never throw).
*/
const MAX_RENDER_DEPTH = 8
type RenderContext = {
readonly definitions: Readonly<Record<string, JsonSchema>>
/** Indented, JSDoc-annotated multiline rendering (search results); compact single line otherwise. */
readonly pretty: boolean
}
/**
* Schema constraints a TypeScript type cannot express natively but a model benefits from,
* surfaced as JSDoc tags (`@deprecated`, `@default`, `@format`, `@minItems`, `@maxItems`).
*/
const docTags = (schema: JsonSchema): Array<string> => {
const tags: Array<string> = []
if (schema.deprecated === true) tags.push("@deprecated")
if (schema.default !== undefined) {
try {
const rendered = JSON.stringify(schema.default)
if (rendered !== undefined) tags.push(`@default ${rendered}`)
} catch {
// unserializable default: skip rather than emit a broken tag
}
}
if (typeof schema.format === "string") tags.push(`@format ${schema.format}`)
if (typeof schema.minItems === "number") tags.push(`@minItems ${schema.minItems}`)
if (typeof schema.maxItems === "number") tags.push(`@maxItems ${schema.maxItems}`)
return tags
}
/**
* Format a schema `description` plus `tags` as a JSDoc comment at the given indent,
* preserving multi-line text (a single line stays `/** ... *\/`; multiple lines become a
* `*`-prefixed block). `*\/` is neutralized so nothing can close the comment early, and
* blank leading/trailing lines are trimmed. Returns "" (else a trailing newline) so
* callers can prepend it directly to the field line.
*/
const jsdoc = (description: string | undefined, tags: ReadonlyArray<string>, pad: string): string => {
const lines = [...(description === undefined ? [] : description.split("\n")), ...tags].map((line) =>
line.replaceAll("*/", "* /").replace(/\s+$/, ""),
)
while (lines.length > 0 && lines[0]!.trim() === "") lines.shift()
while (lines.length > 0 && lines[lines.length - 1]!.trim() === "") lines.pop()
if (lines.length === 0) return ""
if (lines.length === 1) return `${pad}/** ${lines[0]} */\n`
const body = lines.map((line) => `${pad} *${line === "" ? "" : ` ${line}`}`).join("\n")
return `${pad}/**\n${body}\n${pad} */\n`
}
const renderSchema = (
schema: JsonSchema,
ctx: RenderContext,
depth = 0,
seen: ReadonlySet<string> = new Set(),
): string => {
if (depth > MAX_RENDER_DEPTH) return "unknown"
if (schema.$ref) {
const name = schema.$ref.split("/").pop()
if (!name || !ctx.definitions[name]) return name ?? "unknown"
if (seen.has(name)) return name // recursive type: reference by name rather than loop
return renderSchema(ctx.definitions[name], ctx, depth, new Set([...seen, name]))
}
if (schema.const !== undefined) return renderLiteral(schema.const)
if (schema.enum) return schema.enum.map(renderLiteral).join(" | ")
const alternatives = schema.anyOf ?? schema.oneOf
if (alternatives) {
// Effect's number schema emits `anyOf: [{ type: "number" }, { const: "NaN" },
// { const: "Infinity" }, { const: "-Infinity" }]`. Collapse only that artifact;
// real JSON Schema unions such as `string | number` or `number | null` must keep
// every branch.
if (
alternatives.some((item) => item.type === "number") &&
alternatives.every((item) => item.type === "number" || effectNumberSentinel(item))
)
return "number"
// An empty Schema.Struct({}) emits `anyOf: [{ type: "object" }, { type: "array" }]`
// (no properties/items); render the bare shape as {} instead of `{} | Array<unknown>`.
if (
alternatives.length === 2 &&
alternatives[0]?.type === "object" &&
alternatives[0].properties === undefined &&
alternatives[1]?.type === "array" &&
alternatives[1].items === undefined
) {
return "{}"
}
return alternatives.map((item) => renderSchema(item, ctx, depth + 1, seen)).join(" | ")
}
if (Array.isArray(schema.type)) {
return schema.type.map((item) => renderSchema({ type: item }, ctx, depth + 1, seen)).join(" | ")
}
if (schema.type === "string") return "string"
if (schema.type === "number" || schema.type === "integer") return "number"
if (schema.type === "boolean") return "boolean"
if (schema.type === "null") return "null"
if (schema.type === "array") return `Array<${renderSchema(schema.items ?? {}, ctx, depth + 1, seen)}>`
if (schema.type === "object" || schema.properties) {
const required = new Set(schema.required ?? [])
const properties = Object.entries(schema.properties ?? {})
const additional = schema.additionalProperties
const indexType =
additional && typeof additional === "object" ? renderSchema(additional, ctx, depth + 1, seen) : undefined
const field = ([name, value]: readonly [string, JsonSchema]) =>
`${renderKey(name)}${required.has(name) ? "" : "?"}: ${renderSchema(value, ctx, depth + 1, seen)}`
if (!ctx.pretty) {
const fields = properties.map(field)
if (indexType !== undefined) fields.push(`[key: string]: ${indexType}`)
return fields.length === 0 ? "{}" : `{ ${fields.join("; ")} }`
}
// Pretty: an indented block, each described field preceded by its JSDoc comment.
if (properties.length === 0 && indexType === undefined) return "{}"
const pad = " ".repeat(depth + 1)
const lines = properties.map(
(entry) => `${jsdoc(entry[1].description, docTags(entry[1]), pad)}${pad}${field(entry)}`,
)
if (indexType !== undefined) lines.push(`${pad}[key: string]: ${indexType}`)
return `{\n${lines.join("\n")}\n${" ".repeat(depth)}}`
}
return "unknown"
}
export const toTypeScript = (schema: Schema.Top, decoded = false, pretty = false): string => {
try {
const visible = decoded ? Schema.toType(schema) : schema
const document = Schema.toJsonSchemaDocument(visible) as {
readonly schema: JsonSchema
readonly definitions?: Readonly<Record<string, JsonSchema>>
}
return renderSchema(document.schema, { definitions: document.definitions ?? {}, pretty })
} catch {
return "unknown"
}
}
/** Renders a raw JSON Schema document as a TypeScript type string. */
export const jsonSchemaToTypeScript = (schema: JsonSchema, pretty = false): string => {
try {
return renderSchema(schema, { definitions: { ...(schema.definitions ?? {}), ...(schema.$defs ?? {}) }, pretty })
} catch {
return "unknown"
}
}
/** One input property of a tool, extracted best-effort from its input schema. */
export type InputProperty = {
readonly name: string
readonly description: string | undefined
readonly required: boolean
}
/**
* The property names, descriptions, and required flags of a tool's input schema - the raw
* material for search text. Best-effort: Effect Schemas go through their
* JSON Schema document (the same emission signature rendering uses); JSON Schemas are read
* directly, resolving a trivial top-level `$ref` into `$defs`/`definitions` when present.
* Anything unresolvable yields `[]` (search falls back to path + description).
*/
export const inputProperties = <R>(definition: Definition<R>): Array<InputProperty> => {
try {
const document = isEffectSchema(definition.input)
? (Schema.toJsonSchemaDocument(definition.input) as {
readonly schema: JsonSchema
readonly definitions?: Readonly<Record<string, JsonSchema>>
})
: {
schema: definition.input,
definitions: { ...(definition.input.definitions ?? {}), ...(definition.input.$defs ?? {}) },
}
const definitions = document.definitions ?? {}
let schema = document.schema
if (schema.$ref !== undefined) {
const name = schema.$ref.split("/").pop()
const resolved = name === undefined ? undefined : definitions[name]
if (resolved === undefined) return []
schema = resolved
}
const required = new Set(schema.required ?? [])
return Object.entries(schema.properties ?? {}).map(([name, value]) => ({
name,
description: typeof value.description === "string" ? value.description : undefined,
required: required.has(name),
}))
} catch {
return []
}
}
/**
* The model-visible TypeScript type of a tool's input. `pretty` renders an indented
* multiline block with schema descriptions and constraints as JSDoc comments on the
* fields; the default stays the compact single-line form.
*/
export const inputTypeScript = <R>(definition: Definition<R>, pretty = false): string =>
isEffectSchema(definition.input)
? toTypeScript(definition.input, false, pretty)
: jsonSchemaToTypeScript(definition.input, pretty)
/**
* The model-visible TypeScript type of a tool's result; tools without an output schema
* return `unknown`. `pretty` renders the JSDoc-annotated multiline form, as for inputs.
*/
export const outputTypeScript = <R>(definition: Definition<R>, pretty = false): string =>
definition.output === undefined
? "unknown"
: isEffectSchema(definition.output)
? toTypeScript(definition.output, true, pretty)
: jsonSchemaToTypeScript(definition.output, pretty)
/**
* Decodes tool input before `run` is invoked. Effect Schemas validate (throwing on failure);
* JSON-Schema-described inputs pass through unvalidated (render-only).
*/
export const decodeInput = <R>(definition: Definition<R>, value: unknown): unknown =>
isEffectSchema(definition.input) ? Schema.decodeUnknownSync(definition.input)(value) : value
/**
* Decodes a tool result before it is exposed to the program. Effect Schemas validate and
* transform (throwing on failure); JSON Schema outputs and tools without an output schema pass
* the host value through unchanged.
*/
export const decodeOutput = <R>(definition: Definition<R>, value: unknown): unknown =>
definition.output !== undefined && isEffectSchema(definition.output)
? Schema.decodeUnknownSync(definition.output)(value)
: value
/**
* Defines one schema-described tool available to a CodeMode program through `tools.*`.
*
* `input` and `output` each accept a validating Effect Schema or a render-only JSON Schema
* document. Effect Schema input is decoded before `run` is invoked, and `run` returns the
* encoded representation of an Effect Schema `output`, which CodeMode decodes before returning
* it to the program. JSON Schemas only shape the model-visible signature; values pass through
* unvalidated. `output` is optional - without it the signature advertises `unknown` and the
* host result is exposed as-is. The host tool remains responsible for authorization and
* durable side-effect handling.
*
* @example
* ```ts
* const lookup = Tool.make({
* description: "Look up an order",
* input: Schema.Struct({ id: Schema.String }),
* output: Schema.Struct({ status: Schema.String }),
* run: ({ id }) => Effect.succeed({ status: "open" }),
* })
*
* const fromJsonSchema = Tool.make({
* description: "Call an adapter-described tool",
* input: { type: "object", properties: { id: { type: "string" } }, required: ["id"] },
* run: (input) => callHost(input),
* })
* ```
*/
export const make = <I extends ToolSchema, const O extends ToolSchema | undefined = undefined, R = never>(
options: Options<I, O, R>,
): Definition<R> => ({
_tag: "CodeModeTool",
description: options.description,
input: options.input,
output: options.output,
run: (input) => options.run(input as InputType<I>),
})
/** Constructors for schema-backed tools exposed inside CodeMode programs. */
export const Tool = { make, isDefinition }
-34
View File
@@ -1,34 +0,0 @@
import type { Effect, Fiber } from "effect"
export class SandboxPromise {
interrupted = false
constructor(
readonly fiber: Fiber.Fiber<unknown, unknown> | undefined,
readonly immediate?: Effect.Effect<unknown, unknown>,
) {}
}
export class SandboxDate {
constructor(readonly time: number) {}
}
export class SandboxRegExp {
readonly regex: RegExp
constructor(pattern: string, flags: string) {
this.regex = new RegExp(pattern, flags)
}
}
export class SandboxMap {
readonly map = new Map<unknown, unknown>()
}
export class SandboxSet {
readonly set = new Set<unknown>()
}
export const isSandboxValue = (value: unknown): value is SandboxDate | SandboxRegExp | SandboxMap | SandboxSet =>
value instanceof SandboxDate ||
value instanceof SandboxRegExp ||
value instanceof SandboxMap ||
value instanceof SandboxSet
File diff suppressed because it is too large Load Diff
-159
View File
@@ -1,159 +0,0 @@
import { describe, expect, test } from "bun:test"
import { Effect, Schema } from "effect"
import { CodeMode, Tool } from "../src/index.js"
// Key enumeration: Object.keys and for...in share one surface over plain objects, arrays
// (index strings), and tool references (namespace/tool names from the host tool tree), so a
// model can discover what it may call instead of guessing names from the instructions. The
// motivating transcript: `Object.keys(tools)` failed with the generic plain-objects-only
// message and `for (const key in tools)` was unsupported syntax, forcing blind guesses.
const echo = (description: string) =>
Tool.make({
description,
input: Schema.Struct({ value: Schema.String }),
output: Schema.String,
run: ({ value }) => Effect.succeed(value),
})
const tools = {
github: { list_issues: echo("List issues"), get_issue: echo("Get one issue") },
memory: { search: echo("Search memory") },
playwright: { navigate: echo("Navigate somewhere") },
}
const run = (code: string) => Effect.runPromise(CodeMode.execute({ tools, code }))
const value = async (code: string) => {
const result = await run(code)
if (!result.ok) throw new Error(`expected success, got ${result.error.kind}: ${result.error.message}`)
return result.value
}
const error = async (code: string) => {
const result = await run(code)
if (result.ok) throw new Error(`expected failure, got value ${JSON.stringify(result.value)}`)
return result.error
}
describe("Object.keys over tool references", () => {
test("enumerates top-level namespaces (the transcript program)", async () => {
expect(
await value(`
const namespaces = Object.keys(tools)
return { namespaces, count: namespaces.length }
`),
).toEqual({ namespaces: ["github", "memory", "playwright"], count: 3 })
})
test("enumerates tool names at a nested namespace", async () => {
expect(await value(`return Object.keys(tools.github)`)).toEqual(["list_issues", "get_issue"])
})
test("a callable tool is a leaf and enumerates as []", async () => {
expect(await value(`return Object.keys(tools.github.list_issues)`)).toEqual([])
})
test("the virtual discovery namespace enumerates its callable surface", async () => {
expect(await value(`return Object.keys(tools.$codemode)`)).toEqual(["search"])
})
test("an unknown namespace is an UnknownTool error pointing at the discovery idioms", async () => {
const failure = await error(`return Object.keys(tools.nonexistent)`)
expect(failure.kind).toBe("UnknownTool")
expect(failure.message).toContain("Unknown tool namespace 'nonexistent'")
expect(failure.suggestions?.join(" ")).toContain("Object.keys(tools)")
})
test("Object.values/entries on a tool reference explain the working idioms", async () => {
for (const method of ["values", "entries"] as const) {
const failure = await error(`return Object.${method}(tools)`)
expect(failure.kind).toBe("InvalidDataValue")
expect(failure.message).toContain(
`Object.${method}(...) cannot read tool references: they are not plain data. Use Object.keys(tools) for names, or tools.$codemode.search({ query }) for signatures.`,
)
}
const nested = await error(`return Object.entries(tools.github)`)
expect(nested.message).toContain("Use Object.keys(tools) for names")
})
})
describe("Object.keys over arrays", () => {
test("returns index strings, like JS", async () => {
expect(await value(`return Object.keys(["a", "b", "c"])`)).toEqual(["0", "1", "2"])
expect(await value(`return Object.keys([])`)).toEqual([])
})
test("objects keep their own enumerable keys", async () => {
expect(await value(`return Object.keys({ a: 1, b: 2 })`)).toEqual(["a", "b"])
})
test("non-object inputs still fail clearly", async () => {
const failure = await error(`return Object.keys("nope")`)
expect(failure.message).toContain("Object.keys expects a data object or array")
})
})
describe("for...in", () => {
test("iterates own enumerable keys of a plain object with break/continue", async () => {
expect(
await value(`
const seen = []
for (const key in { a: 1, b: 2, c: 3, d: 4 }) {
if (key === "b") continue
if (key === "d") break
seen.push(key)
}
return seen
`),
).toEqual(["a", "c"])
})
test("iterates index strings over arrays", async () => {
expect(
await value(`
const indexes = []
for (const i in ["x", "y", "z"]) {
if (i === "2") break
indexes.push(i)
}
return indexes
`),
).toEqual(["0", "1"])
})
test("supports let declarations and bare identifiers", async () => {
expect(
await value(`
let last = ""
for (let key in { a: 1, b: 2 }) last = key
return last
`),
).toBe("b")
expect(
await value(`
let key = "before"
for (key in { only: 1 }) {}
return key
`),
).toBe("only")
})
test("enumerates namespaces and tools from the host tool tree", async () => {
expect(
await value(`
const names = []
for (const ns in tools) {
for (const name in tools[ns]) names.push(ns + "." + name)
}
return names
`),
).toEqual(["github.list_issues", "github.get_issue", "memory.search", "playwright.navigate"])
})
test("unsupported values fail with a hint at for...of and Object.keys", async () => {
for (const expression of [`"text"`, "new Map([[1, 2]])", "new Set([1])", "42", "null"]) {
const failure = await error(`for (const key in ${expression}) {}; return "no"`)
expect(failure.message).toContain("for...in requires a plain object, array, or tools reference")
expect(failure.message).toContain("Use for...of for arrays/strings/Maps/Sets, or Object.keys(value)")
}
})
})
-425
View File
@@ -1,425 +0,0 @@
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { CodeMode } from "../src/index.js"
import { ToolRuntime } from "../src/tool-runtime.js"
// Runs a CodeMode program with no host tools and returns the ExecuteResult. These tests pin the
// JS-parity behaviors for the "99% of ordinary defensive JavaScript just works" goal: cases where
// a strict interpreter would throw but idiomatic JS yields undefined / succeeds.
//
// Note on the result boundary: this package normalizes a bare `undefined` result to `null` when
// it crosses out of the sandbox (results are JSON data), so tests asserting an in-sandbox
// `undefined` read check `=== undefined` inside the program and `null` at the boundary.
const run = (code: string) => Effect.runPromise(CodeMode.execute({ code, tools: {} }))
const value = async (code: string) => {
const result = await run(code)
if (!result.ok) throw new Error(`expected success, got ${result.error.kind}: ${result.error.message}`)
return result.value
}
const error = async (code: string) => {
const result = await run(code)
if (result.ok) throw new Error(`expected failure, got value ${JSON.stringify(result.value)}`)
return result.error
}
describe("H2: string property access reads as undefined (not a throw)", () => {
test("unknown property on a string is undefined", async () => {
expect(await value(`const s = "hi"; return s.login === undefined`)).toBe(true)
expect(await value(`const s = "hi"; return s.login`)).toBeNull()
})
test("optional chaining + fallback on a string does not throw", async () => {
expect(await value(`const s = "hi"; return s?.login ?? "fallback"`)).toBe("fallback")
})
test("the real MCP pattern: result is a JSON string, defensive read falls through", async () => {
// me.result is a string; me.result?.login is undefined, so we fall back to the raw string.
expect(await value(`const me = { result: '{"login":"x"}' }; return me.result?.login ?? me.result`)).toBe(
'{"login":"x"}',
)
})
test("unknown property on a number is undefined", async () => {
expect(await value(`return (5).foo ?? "n"`)).toBe("n")
})
test("supported string methods still work", async () => {
expect(await value(`return "AB".toLowerCase()`)).toBe("ab")
expect(await value(`return "hello".length`)).toBe(5)
})
})
describe("H3: array property access reads as undefined (not a throw)", () => {
test("unknown property on an array is undefined", async () => {
expect(await value(`return [1,2,3].foo === undefined`)).toBe(true)
expect(await value(`return [1,2,3].foo`)).toBeNull()
})
test("optional chaining on an array does not throw", async () => {
expect(await value(`return [1,2,3]?.foo ?? "fb"`)).toBe("fb")
})
test("unknown property reads stay undefined for methods CodeMode does not implement", async () => {
expect(await value(`return [1,2,3].toSpliced === undefined`)).toBe(true)
})
test("supported array methods and indexing still work", async () => {
expect(await value(`return [1,2,3].map(x => x + 1)`)).toEqual([2, 3, 4])
expect(await value(`return [1,2,3][9] === undefined`)).toBe(true)
expect(await value(`return [1,2,3][9]`)).toBeNull()
})
})
describe("H6: object spread of null/undefined is a no-op", () => {
test("spreading null is a no-op", async () => {
expect(await value(`const o = null; return { ...o, a: 1 }`)).toEqual({ a: 1 })
})
test("spreading an absent argument merges cleanly", async () => {
expect(await value(`function f(opts){ return { ...opts, a: 1 } } return f(undefined)`)).toEqual({ a: 1 })
})
test("spreading a real object still works", async () => {
expect(await value(`const o = { a: 1 }; return { ...o, b: 2 }`)).toEqual({ a: 1, b: 2 })
})
test("spreading an array into an object still errors", async () => {
const err = await error(`return { ...[1,2], a: 1 }`)
expect(err.kind).toBe("InvalidDataValue")
})
})
describe("H4: typeof on an undeclared identifier is 'undefined'", () => {
test("feature-detection guard does not throw", async () => {
expect(await value(`return typeof foo === "undefined" ? "safe" : "no"`)).toBe("safe")
})
test("typeof of a declared binding is unaffected", async () => {
expect(await value(`const x = 5; return typeof x`)).toBe("number")
expect(await value(`const s = "a"; return typeof s`)).toBe("string")
})
test("referencing an undeclared identifier outside typeof still throws", async () => {
const err = await error(`return foo + 1`)
expect(err.message).toContain("foo")
})
})
describe("H1: NaN/Infinity flow as intermediates and normalize to null at the boundary", () => {
test("guards run instead of the program crashing on a transient NaN", async () => {
expect(await value(`return parseInt("abc") || 0`)).toBe(0)
expect(await value(`const x = Number("abc"); return Number.isNaN(x) ? 0 : x`)).toBe(0)
expect(await value(`const o = {}; o.count = (o.count || 0) + 1; return o.count`)).toBe(1)
// average of an empty list, guarded - the classic divide-by-zero that used to throw pre-guard
expect(await value(`const a = []; return a.length ? a.reduce((s,x)=>s+x,0)/a.length : 0`)).toBe(0)
})
test("a non-finite value becomes null when it leaves the sandbox", async () => {
expect(await value(`return 5/0`)).toBeNull()
expect(await value(`return 0/0`)).toBeNull()
expect(await value(`return Math.max()`)).toBeNull()
// nested, too - normalization walks the returned structure
expect(await value(`return { a: Number("x"), b: 2, c: [1/0] }`)).toEqual({ a: null, b: 2, c: [null] })
})
test("NaN and Infinity are usable identifiers and inspectable in-sandbox", async () => {
expect(await value(`return Number.isNaN(NaN)`)).toBe(true)
expect(await value(`return Infinity > 1e9`)).toBe(true)
expect(await value(`return Number.isFinite(1/0)`)).toBe(false)
expect(await value(`return [3,1,2].reduce((a,b)=>Math.max(a,b), -Infinity)`)).toBe(3)
// JSON.stringify inside the sandbox matches JS: non-finite serializes to null
expect(await value(`return JSON.stringify({ x: Number("z") })`)).toBe('{"x":null}')
})
test("copyOut normalizes non-finite numbers to null (the shared return + tool-arg boundary)", () => {
// Tool-call arguments funnel through copyOut too, so this one function pins both boundaries.
expect(ToolRuntime.copyOut(NaN)).toBeNull()
expect(ToolRuntime.copyOut(Infinity)).toBeNull()
expect(ToolRuntime.copyOut(-Infinity)).toBeNull()
expect(ToolRuntime.copyOut(42)).toBe(42)
expect(ToolRuntime.copyOut({ a: NaN, b: [Infinity, 1] })).toEqual({ a: null, b: [null, 1] })
})
})
describe("Error values and instanceof", () => {
test("new Error carries name/message and is instanceof Error", async () => {
expect(await value(`const e = new Error("boom"); return [e instanceof Error, e.name, e.message]`)).toEqual([
true,
"Error",
"boom",
])
})
test("Error without new behaves like new Error", async () => {
expect(await value(`const e = Error("plain"); return [e instanceof Error, e.name, e.message]`)).toEqual([
true,
"Error",
"plain",
])
expect(await value(`const e = new Error(); return [e.name, e.message, e instanceof Error]`)).toEqual([
"Error",
"",
true,
])
})
test("specific error types are instanceof themselves and Error, not each other", async () => {
expect(
await value(
`const e = new TypeError("t"); return [e instanceof TypeError, e instanceof Error, e instanceof RangeError]`,
),
).toEqual([true, true, false])
expect(await value(`return new Error("e") instanceof TypeError`)).toBe(false)
})
test("thrown errors keep instanceof through try/catch", async () => {
expect(await value(`try { throw new Error("x") } catch (e) { return [e instanceof Error, e.message] }`)).toEqual([
true,
"x",
])
})
test("interpreter runtime failures are caught as Error values", async () => {
expect(await value(`try { JSON.parse("nope") } catch (e) { return e instanceof Error }`)).toBe(true)
expect(await value(`try { undeclared() } catch (e) { return e instanceof Error }`)).toBe(true)
})
test("caught failures carry the constructor name the real-JS failure would have", async () => {
// JSON.parse throws SyntaxError: name and specific-instanceof both carry through, and the
// message keeps the engine's position detail.
expect(
await value(`
try { JSON.parse("{oops") } catch (e) {
return [e.name, e instanceof SyntaxError, e instanceof Error, e instanceof TypeError, e.message.includes("JSON")]
}
`),
).toEqual(["SyntaxError", true, true, false, true])
expect(await value(`try { undeclared() } catch (e) { return [e.name, e instanceof ReferenceError] }`)).toEqual([
"ReferenceError",
true,
])
expect(await value(`try { const c = 1; c = 2 } catch (e) { return [e.name, e instanceof TypeError] }`)).toEqual([
"TypeError",
true,
])
expect(await value(`try { "a".normalize("NOPE") } catch (e) { return [e.name, e instanceof RangeError] }`)).toEqual(
["RangeError", true],
)
expect(await value(`try { "a".match("(") } catch (e) { return [e.name, e instanceof SyntaxError] }`)).toEqual([
"SyntaxError",
true,
])
expect(await value(`try { new RegExp("(") } catch (e) { return [e.name, e instanceof SyntaxError] }`)).toEqual([
"SyntaxError",
true,
])
})
test("diagnostics without a specific real-JS analogue are named plain Error", async () => {
expect(await value(`try { JSON.parse(5) } catch (e) { return [e.name, e instanceof Error] }`)).toEqual([
"Error",
true,
])
})
test("Promise.allSettled rejection reasons are Error values", async () => {
expect(
await value(`
const settled = await Promise.allSettled([Promise.reject(new Error("b"))])
return [settled[0].reason instanceof Error, settled[0].reason.message]
`),
).toEqual([true, "b"])
})
test("non-error thrown values are not instanceof Error", async () => {
expect(await value(`try { throw "raw" } catch (e) { return e instanceof Error }`)).toBe(false)
expect(await value(`try { throw { message: "shaped" } } catch (e) { return e instanceof Error }`)).toBe(false)
})
test("plain data is never instanceof Error", async () => {
expect(await value(`return [({}) instanceof Error, "s" instanceof Error, null instanceof Error]`)).toEqual([
false,
false,
false,
])
})
test("error values still serialize as plain { name, message } data", async () => {
expect(await value(`return new Error("m")`)).toEqual({ name: "Error", message: "m" })
expect(await value(`return JSON.stringify(new Error("m"))`)).toBe('{"name":"Error","message":"m"}')
expect(await value(`try { throw new Error("m") } catch (e) { return Object.keys(e) }`)).toEqual(["name", "message"])
})
test("spreading an error loses the brand, like losing the prototype in JS", async () => {
expect(await value(`const e = new Error("m"); return ({ ...e }) instanceof Error`)).toBe(false)
expect(await value(`const e = new Error("m"); return { ...e }`)).toEqual({ name: "Error", message: "m" })
})
test("typeof Error is function; an unknown instanceof right-hand side is a catchable error", async () => {
expect(await value(`return typeof Error`)).toBe("function")
expect(await value(`try { return 1 instanceof 5 } catch (e) { return "caught" }`)).toBe("caught")
const err = await error(`return 1 instanceof 5`)
expect(err.message).toContain("right-hand side of 'instanceof'")
})
})
describe("array methods: splice, fill, copyWithin, keys/values/entries", () => {
test("splice removes in place and returns the removed elements", async () => {
expect(await value(`const a = [1,2,3,4]; const removed = a.splice(1, 2); return { removed, a }`)).toEqual({
removed: [2, 3],
a: [1, 4],
})
})
test("splice inserts new elements at the cut", async () => {
expect(await value(`const a = ["a","d"]; a.splice(1, 0, "b", "c"); return a`)).toEqual(["a", "b", "c", "d"])
expect(await value(`const a = [1,2,3]; const removed = a.splice(1, 1, "x"); return { removed, a }`)).toEqual({
removed: [2],
a: [1, "x", 3],
})
})
test("splice with one argument removes to the end; negative start counts back", async () => {
expect(await value(`const a = [1,2,3]; const removed = a.splice(1); return { removed, a }`)).toEqual({
removed: [2, 3],
a: [1],
})
expect(await value(`const a = [1,2,3]; const removed = a.splice(-1); return { removed, a }`)).toEqual({
removed: [3],
a: [1, 2],
})
})
test("splice rejects inserting a container into itself", async () => {
const err = await error(`const a = [1]; a.splice(0, 0, [a]); return a`)
expect(err.kind).toBe("InvalidDataValue")
expect(err.message).toContain("circular")
})
test("fill overwrites a range and returns the mutated array", async () => {
expect(await value(`const a = [1,2,3,4]; return a.fill(0, 1, 3)`)).toEqual([1, 0, 0, 4])
expect(await value(`return [1,2,3].fill("z")`)).toEqual(["z", "z", "z"])
})
test("copyWithin copies a range in place", async () => {
expect(await value(`return [1,2,3,4,5].copyWithin(0, 3)`)).toEqual([4, 5, 3, 4, 5])
})
test("keys/values/entries return arrays usable with for...of and spread", async () => {
expect(await value(`return [...["x","y","z"].keys()]`)).toEqual([0, 1, 2])
expect(await value(`return ["x","y"].values()`)).toEqual(["x", "y"])
expect(
await value(`
const out = []
for (const [index, item] of ["a","b"].entries()) out.push(index + ":" + item)
return out
`),
).toEqual(["0:a", "1:b"])
expect(await value(`return [...[7].entries()]`)).toEqual([[0, 7]])
})
})
describe("string methods: localeCompare, normalize, trim aliases", () => {
test("localeCompare orders strings for sorting", async () => {
expect(await value(`return ["b","a","c"].sort((x, y) => x.localeCompare(y))`)).toEqual(["a", "b", "c"])
expect(await value(`return "a".localeCompare("a")`)).toBe(0)
})
test("normalize applies unicode normalization forms", async () => {
expect(await value(`return "\\u0065\\u0301".normalize("NFC").length`)).toBe(1)
expect(await value(`return "\\u00e9".normalize("NFD").length`)).toBe(2)
expect(await value(`return "x".normalize() === "x"`)).toBe(true)
})
test("an invalid normalize form is a clear catchable error", async () => {
expect(await value(`try { "x".normalize("nope"); return "no" } catch (e) { return e.message }`)).toContain('"NFC"')
})
test("trimLeft/trimRight alias trimStart/trimEnd", async () => {
expect(await value(`return " x ".trimLeft()`)).toBe("x ")
expect(await value(`return " x ".trimRight()`)).toBe(" x")
})
})
describe("compound assignment matches its binary operator", () => {
// `x op= y` must behave exactly like `x = x op y`, sharing the binary operator's coercion
// semantics (Dates string-coerce for `+` and use their time value for arithmetic; data
// objects/arrays coerce to their JS string form).
const pair = async (compound: string, expanded: string) => {
const [a, b] = await Promise.all([value(compound), value(expanded)])
expect(a).toEqual(b)
return a
}
test("sandbox Date += concatenates its string form, like d = d + 1", async () => {
const result = await pair(`let d = new Date(1000); d += 1; return d`, `let d = new Date(1000); d = d + 1; return d`)
expect(result).toBe("1970-01-01T00:00:01.000Z1")
})
test("sandbox Date numeric compound ops use its time value", async () => {
expect(
await pair(`let d = new Date(1000); d -= 400; return d`, `let d = new Date(1000); d = d - 400; return d`),
).toBe(600)
expect(await pair(`let d = new Date(1000); d /= 4; return d`, `let d = new Date(1000); d = d / 4; return d`)).toBe(
250,
)
})
test("string += object/array matches x = x + obj", async () => {
expect(await pair(`let x = "a"; x += { b: 1 }; return x`, `let x = "a"; x = x + { b: 1 }; return x`)).toBe(
"a[object Object]",
)
expect(await pair(`let x = "a"; x += [1, 2]; return x`, `let x = "a"; x = x + [1, 2]; return x`)).toBe("a1,2")
})
test("compound assignment through a member target coerces the same way", async () => {
expect(
await pair(
`const o = { s: "t" }; o.s += new Date(0); return o.s`,
`const o = { s: "t" }; o.s = o.s + new Date(0); return o.s`,
),
).toBe("t1970-01-01T00:00:00.000Z")
})
test("numeric and string compound operators sweep identically to their expansions", async () => {
const cases: Array<[string, number | string]> = [
[`let x = 7; x += 3; return x`, 7 + 3],
[`let x = 7; x -= 3; return x`, 7 - 3],
[`let x = 7; x *= 3; return x`, 7 * 3],
[`let x = 7; x /= 2; return x`, 7 / 2],
[`let x = 7; x %= 3; return x`, 7 % 3],
[`let x = 7; x **= 2; return x`, 7 ** 2],
[`let x = 7; x &= 3; return x`, 7 & 3],
[`let x = 7; x |= 8; return x`, 7 | 8],
[`let x = 7; x ^= 2; return x`, 7 ^ 2],
[`let x = 7; x <<= 2; return x`, 7 << 2],
[`let x = -7; x >>= 1; return x`, -7 >> 1],
[`let x = -7; x >>>= 1; return x`, -7 >>> 1],
[`let x = "a"; x += "b"; return x`, "ab"],
]
for (const [compound, expected] of cases) {
expect(await value(compound)).toBe(expected)
expect(await value(compound.replace(/x (\S+)= /, (_, op) => `x = x ${op} `))).toBe(expected)
}
})
})
describe("H5: builtin coercion functions work as array callbacks", () => {
test("filter(Boolean) drops falsy values", async () => {
expect(await value(`return [0, 1, "", 2, null, 3].filter(Boolean)`)).toEqual([1, 2, 3])
})
test("map(String) coerces each element", async () => {
expect(await value(`return [1, 2, 3].map(String)`)).toEqual(["1", "2", "3"])
})
test("arrow callbacks still work (no regression)", async () => {
expect(await value(`return [1, 2, 3, 4].filter(x => x % 2 === 0)`)).toEqual([2, 4])
expect(await value(`return [1, 2, 3].reduce((a, b) => a + b, 0)`)).toBe(6)
})
test("a non-callable callback is still rejected", async () => {
const err = await error(`return [1,2,3].map(42)`)
expect(err.message).toContain("callback")
})
})
-453
View File
@@ -1,453 +0,0 @@
import { describe, expect, test } from "bun:test"
import { Effect, Schema } from "effect"
import { CodeMode, Tool, toolError, type ExecuteResult, type ExecutionLimits } from "../src/index.js"
// Wave 5 acceptance suite: first-class promise values. Un-awaited tool calls start eagerly on
// supervised fibers, `await` settles them, and Promise.all/allSettled/race/resolve/reject are
// ordinary functions over arbitrary arrays mixing promises and plain values.
type Trace = {
starts: Array<number>
active: number
maxActive: number
completed: number
interrupted: number
}
const makeTrace = (): Trace => ({ starts: [], active: 0, maxActive: 0, completed: 0, interrupted: 0 })
/** Echoes `id` after `ms` milliseconds, recording start order, live concurrency, and interruption. */
const sleepyTool = (trace: Trace) =>
Tool.make({
description: "Echo an id after a delay",
input: Schema.Struct({ id: Schema.Number, ms: Schema.optionalKey(Schema.Number) }),
output: Schema.Number,
run: ({ id, ms }) =>
Effect.gen(function* () {
trace.starts.push(id)
trace.active += 1
trace.maxActive = Math.max(trace.maxActive, trace.active)
yield* Effect.sleep(ms ?? 20)
trace.active -= 1
trace.completed += 1
return id
}).pipe(
Effect.onInterrupt(() =>
Effect.sync(() => {
trace.active -= 1
trace.interrupted += 1
}),
),
),
})
const failingTool = Tool.make({
description: "Always refuse",
input: Schema.Struct({}),
output: Schema.String,
run: () => Effect.fail(toolError("Lookup refused")),
})
const run = (code: string, options: { trace?: Trace; limits?: ExecutionLimits } = {}): Promise<ExecuteResult> => {
const trace = options.trace ?? makeTrace()
return Effect.runPromise(
CodeMode.execute({
tools: { host: { sleepy: sleepyTool(trace), fail: failingTool } },
code,
...(options.limits ? { limits: options.limits } : {}),
}),
)
}
const value = async (code: string, options: { trace?: Trace; limits?: ExecutionLimits } = {}) => {
const result = await run(code, options)
if (!result.ok) throw new Error(`expected success, got ${result.error.kind}: ${result.error.message}`)
return result.value
}
const error = async (code: string, options: { trace?: Trace; limits?: ExecutionLimits } = {}) => {
const result = await run(code, options)
if (result.ok) throw new Error(`expected failure, got value ${JSON.stringify(result.value)}`)
return result.error
}
describe("first-class promise values", () => {
test("an un-awaited tool call starts eagerly, in call order, before any await", async () => {
const trace = makeTrace()
const result = await value(
`
const a = tools.host.sleepy({ id: 1, ms: 40 })
const b = tools.host.sleepy({ id: 2, ms: 40 })
const rb = await b
const ra = await a
return [ra, rb]
`,
{ trace },
)
expect(result).toEqual([1, 2])
expect(trace.starts).toEqual([1, 2])
// Both calls overlapped even though they were awaited sequentially.
expect(trace.maxActive).toBeGreaterThan(1)
})
test("awaiting the same promise twice settles once and never re-runs the call", async () => {
const result = await run(`
const p = tools.host.sleepy({ id: 7 })
const x = await p
const y = await p
return [x, y]
`)
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.value).toEqual([7, 7])
expect(result.toolCalls).toStrictEqual([{ name: "host.sleepy" }])
})
test("await of a non-promise value is a passthrough no-op", async () => {
expect(await value(`return await 42`)).toBe(42)
expect(await value(`const x = await "s"; return x`)).toBe("s")
expect(await value(`return await null`)).toBeNull()
expect(await value(`return (await [1, 2]).length`)).toBe(2)
})
test("returning an un-awaited tool call resolves it (async-function return semantics)", async () => {
expect(await value(`return tools.host.sleepy({ id: 9 })`)).toBe(9)
})
test("typeof a promise is 'object', and console.log renders it sensibly", async () => {
const result = await run(`
const p = Promise.resolve(1)
console.log(p)
return typeof p
`)
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.value).toBe("object")
expect(result.logs).toStrictEqual(["[Promise (await it to get its value)]"])
})
test("an awaited failure is catchable exactly like a synchronous throw", async () => {
expect(
await value(`
const p = tools.host.fail({})
try {
await p
return "no"
} catch (e) {
return e.message
}
`),
).toBe("Lookup refused")
})
test("a fire-and-forget call completes before the execution ends", async () => {
const trace = makeTrace()
const result = await value(
`
tools.host.sleepy({ id: 1, ms: 30 })
return "done"
`,
{ trace },
)
expect(result).toBe("done")
expect(trace.completed).toBe(1)
expect(trace.interrupted).toBe(0)
})
test("a never-awaited failing call surfaces as an unhandled-rejection diagnostic", async () => {
const diagnostic = await error(`
tools.host.fail({})
return "done"
`)
expect(diagnostic.kind).toBe("ToolFailure")
expect(diagnostic.message).toContain("Unhandled rejection from an un-awaited tool call")
expect(diagnostic.message).toContain("Lookup refused")
expect(diagnostic.suggestions?.join(" ")).toContain("await tools.ns.tool(...)")
})
})
describe("promises at data boundaries", () => {
test("returning an un-awaited promise inside data is a clear await-hinting diagnostic", async () => {
const diagnostic = await error(`return { result: tools.host.sleepy({ id: 1 }) }`)
expect(diagnostic.kind).toBe("InvalidDataValue")
expect(diagnostic.message).toContain("un-awaited Promise")
expect(diagnostic.message).toContain("await tools.ns.tool(...)")
})
test("passing an un-awaited promise as a tool argument is a clear diagnostic", async () => {
const diagnostic = await error(`return await tools.host.sleepy({ id: tools.host.sleepy({ id: 1 }) })`)
expect(diagnostic.kind).toBe("InvalidDataValue")
expect(diagnostic.message).toContain("un-awaited Promise")
})
test("JSON.stringify of a promise is a diagnostic, not '{}'", async () => {
const diagnostic = await error(`return JSON.stringify(Promise.resolve(1))`)
expect(diagnostic.kind).toBe("InvalidDataValue")
expect(diagnostic.message).toContain("un-awaited Promise")
})
test("operators reject promise operands", async () => {
const diagnostic = await error(`return Promise.resolve(1) + 1`)
expect(diagnostic.kind).toBe("InvalidDataValue")
})
})
describe("Promise.all over arbitrary arrays", () => {
test("mixes promises and plain values, preserving order", async () => {
expect(
await value(`
return await Promise.all([tools.host.sleepy({ id: 1 }), "plain", tools.host.sleepy({ id: 2 }), 42])
`),
).toEqual([1, "plain", 2, 42])
})
test("accepts arrays built beforehand, passed as identifiers, and spread elements", async () => {
expect(
await value(`
const calls = []
calls.push(tools.host.sleepy({ id: 1 }))
calls.push(7)
const more = [tools.host.sleepy({ id: 2 })]
const batch = [...calls, ...more, "x"]
return await Promise.all(batch)
`),
).toEqual([1, 7, 2, "x"])
})
test("runs items.map tool calls in parallel", async () => {
const trace = makeTrace()
const result = await value(
`
const ids = [1, 2, 3, 4]
return await Promise.all(ids.map((id) => tools.host.sleepy({ id, ms: 40 })))
`,
{ trace },
)
expect(result).toEqual([1, 2, 3, 4])
// maxActive counts truly-overlapping live executions, so > 1 proves real
// parallelism deterministically - no wall-clock assertion needed.
expect(trace.maxActive).toBeGreaterThan(1)
})
test("caps live tool-call concurrency at the fixed internal constant (8)", async () => {
const trace = makeTrace()
const result = await value(
`
const ids = []
for (let i = 0; i < 20; i += 1) ids.push(i)
const results = await Promise.all(ids.map((id) => tools.host.sleepy({ id, ms: 10 })))
return results.length
`,
{ trace },
)
expect(result).toBe(20)
expect(trace.maxActive).toBeGreaterThan(1)
expect(trace.maxActive).toBeLessThanOrEqual(8)
})
test("resolves the empty array", async () => {
expect(await value(`return await Promise.all([])`)).toEqual([])
})
test("rejects with the first failure, catchable in-program", async () => {
expect(
await value(`
try {
await Promise.all([tools.host.sleepy({ id: 1 }), tools.host.fail({})])
return "no"
} catch (e) {
return e.message
}
`),
).toBe("Lookup refused")
})
test("a non-collection argument is a clear error", async () => {
const diagnostic = await error(`return await Promise.all(42)`)
expect(diagnostic.message).toContain("Promise.all expects an array")
})
test("exceeding maxToolCalls inside Promise.all is a ToolCallLimitExceeded diagnostic", async () => {
const diagnostic = await error(
`return await Promise.all([tools.host.sleepy({ id: 1 }), tools.host.sleepy({ id: 2 }), tools.host.sleepy({ id: 3 })])`,
{ limits: { maxToolCalls: 2 } },
)
expect(diagnostic.kind).toBe("ToolCallLimitExceeded")
})
})
describe("Promise.allSettled", () => {
test("reports fulfilled and rejected outcomes with catch-normalized reasons", async () => {
expect(
await value(`
return await Promise.allSettled([
tools.host.sleepy({ id: 5 }),
tools.host.fail({}),
"plain",
Promise.reject(new Error("boom")),
])
`),
).toEqual([
{ status: "fulfilled", value: 5 },
{ status: "rejected", reason: { name: "Error", message: "Lookup refused" } },
{ status: "fulfilled", value: "plain" },
{ status: "rejected", reason: { name: "Error", message: "boom" } },
])
})
test("never rejects for program-level failures", async () => {
const result = await run(`
const settled = await Promise.allSettled([tools.host.fail({}), tools.host.fail({})])
return settled.filter((s) => s.status === "rejected").length
`)
expect(result.ok).toBe(true)
if (result.ok) expect(result.value).toBe(2)
})
})
describe("Promise.race", () => {
test("first settlement wins and losers are interrupted", async () => {
const trace = makeTrace()
const result = await value(
`
const fast = tools.host.sleepy({ id: 1, ms: 10 })
const slow = tools.host.sleepy({ id: 2, ms: 5000 })
return await Promise.race([fast, slow])
`,
{ trace },
)
expect(result).toBe(1)
expect(trace.interrupted).toBe(1)
expect(trace.completed).toBe(1)
})
test("awaiting an interrupted loser afterwards is a catchable program failure", async () => {
expect(
await value(`
const fast = tools.host.sleepy({ id: 1, ms: 10 })
const slow = tools.host.sleepy({ id: 2, ms: 5000 })
const winner = await Promise.race([fast, slow])
try {
await slow
return "no"
} catch (e) {
return { winner, caught: e.message }
}
`),
).toEqual({
winner: 1,
caught: "This tool call was interrupted because another value settled a Promise.race first.",
})
})
test("a rejection can win the race", async () => {
expect(
await value(`
try {
await Promise.race([tools.host.fail({}), tools.host.sleepy({ id: 1, ms: 5000 })])
return "no"
} catch (e) {
return e.message
}
`),
).toBe("Lookup refused")
})
test("a plain value wins over pending promises", async () => {
const trace = makeTrace()
expect(
await value(`return await Promise.race([tools.host.sleepy({ id: 1, ms: 5000 }), "immediate"])`, { trace }),
).toBe("immediate")
expect(trace.interrupted).toBe(1)
})
test("an empty race is a clear error instead of hanging", async () => {
const diagnostic = await error(`return await Promise.race([])`)
expect(diagnostic.message).toContain("never settle")
})
})
describe("Promise.resolve / Promise.reject", () => {
test("resolve wraps plain values and passes promises through", async () => {
expect(await value(`return await Promise.resolve(42)`)).toBe(42)
expect(await value(`return await Promise.resolve(Promise.resolve("nested"))`)).toBe("nested")
expect(await value(`return await Promise.resolve(tools.host.sleepy({ id: 3 }))`)).toBe(3)
})
test("reject produces a promise whose await throws the reason", async () => {
expect(
await value(`
try {
await Promise.reject("nope")
return "no"
} catch (e) {
return e
}
`),
).toBe("nope")
})
})
describe("timeout interruption of forked calls", () => {
test("the execution timeout interrupts in-flight forked fibers", async () => {
const trace = makeTrace()
const result = await run(
`
const a = tools.host.sleepy({ id: 1, ms: 60000 })
const b = tools.host.sleepy({ id: 2, ms: 60000 })
return await a
`,
{ trace, limits: { timeoutMs: 100 } },
)
expect(result.ok).toBe(false)
if (result.ok) return
expect(result.error.kind).toBe("TimeoutExceeded")
// Both calls started; neither escaped the timeout - the awaited one AND the abandoned one.
expect(trace.starts).toEqual([1, 2])
expect(trace.interrupted).toBe(2)
expect(trace.completed).toBe(0)
})
test("the timeout also interrupts calls inside Promise.all", async () => {
const trace = makeTrace()
const result = await run(
`return await Promise.all([tools.host.sleepy({ id: 1, ms: 60000 }), tools.host.sleepy({ id: 2, ms: 60000 })])`,
{ trace, limits: { timeoutMs: 100 } },
)
expect(result.ok).toBe(false)
if (result.ok) return
expect(result.error.kind).toBe("TimeoutExceeded")
expect(trace.interrupted).toBe(2)
})
})
describe("unsupported promise surface", () => {
test(".then/.catch/.finally give a clear await-instead error", async () => {
for (const method of ["then", "catch", "finally"]) {
const diagnostic = await error(`return tools.host.sleepy({ id: 1 }).${method}((x) => x)`)
expect(diagnostic.kind).toBe("UnsupportedSyntax")
expect(diagnostic.message).toContain(`Promise.prototype.${method} is not supported`)
expect(diagnostic.message).toContain("await")
}
})
test("other property reads on a promise hint at the missing await", async () => {
const diagnostic = await error(`return tools.host.sleepy({ id: 1 }).value`)
expect(diagnostic.kind).toBe("InvalidDataValue")
expect(diagnostic.message).toContain("un-awaited Promise")
expect(diagnostic.message).toContain("await it first")
})
test("unknown Promise statics list what is available", async () => {
const diagnostic = await error(`return await Promise.any([tools.host.sleepy({ id: 1 })])`)
expect(diagnostic.message).toContain("Promise.any is not available")
expect(diagnostic.message).toContain("Promise.allSettled")
})
test("new Promise(...) points at tool calls instead", async () => {
const diagnostic = await error(`return new Promise((resolve) => resolve(1))`)
expect(diagnostic.kind).toBe("UnsupportedSyntax")
expect(diagnostic.message).toContain("new Promise(...) is not supported")
expect(diagnostic.message).toContain("already return promises")
})
})
-341
View File
@@ -1,341 +0,0 @@
import { describe, expect, test } from "bun:test"
import { Effect, Schema } from "effect"
import { CodeMode } from "../src/index.js"
import { Tool, inputTypeScript, jsonSchemaToTypeScript, outputTypeScript } from "../src/tool.js"
// A raw JSON Schema tool in the shape an MCP adapter produces: render-only input schema
// whose property descriptions and constraints must surface as JSDoc in pretty signatures.
const listIssues = Tool.make({
description: "List issues in a repository",
input: {
type: "object",
properties: {
owner: { type: "string", description: "Repository owner" },
after: { type: "string", description: "Cursor from the previous response's pageInfo" },
perPage: { type: "number", description: "Results per page", default: 30 },
labels: { type: "array", items: { type: "string" }, description: "Filter by labels", minItems: 1, maxItems: 10 },
state: { type: "string", enum: ["open", "closed"] },
},
required: ["owner"],
},
run: () => Effect.succeed("[]"),
})
// An Effect Schema tool whose field annotations must flow through the emitted JSON Schema.
const lookupOrder = Tool.make({
description: "Look up an order",
input: Schema.Struct({
id: Schema.String.annotate({ description: "Order identifier" }),
verbose: Schema.optionalKey(Schema.Boolean),
}),
output: Schema.Struct({
status: Schema.String.annotate({ description: "Current order status" }),
}),
run: () => Effect.succeed({ status: "open" }),
})
describe("pretty signature rendering", () => {
test("described fields get JSDoc comments; undescribed and untagged fields get none", () => {
expect(inputTypeScript(listIssues, true)).toBe(
[
"{",
" /** Repository owner */",
" owner: string",
" /** Cursor from the previous response's pageInfo */",
" after?: string",
" /**",
" * Results per page",
" * @default 30",
" */",
" perPage?: number",
" /**",
" * Filter by labels",
" * @minItems 1",
" * @maxItems 10",
" */",
" labels?: Array<string>",
' state?: "open" | "closed"',
"}",
].join("\n"),
)
})
test("compact mode output is unchanged by the pretty machinery", () => {
expect(inputTypeScript(listIssues)).toBe(
'{ owner: string; after?: string; perPage?: number; labels?: Array<string>; state?: "open" | "closed" }',
)
expect(inputTypeScript(lookupOrder)).toBe("{ id: string; verbose?: boolean }")
expect(outputTypeScript(lookupOrder)).toBe("{ status: string }")
})
test("nested objects recurse with increasing indent and their own JSDoc", () => {
const pretty = jsonSchemaToTypeScript(
{
type: "object",
properties: {
filter: {
type: "object",
description: "Search filter",
properties: { state: { type: "string", description: "Issue state" } },
},
},
},
true,
)
expect(pretty).toBe(
["{", " /** Search filter */", " filter?: {", " /** Issue state */", " state?: string", " }", "}"].join(
"\n",
),
)
})
test("Effect Schema annotations become JSDoc on input and output fields", () => {
expect(inputTypeScript(lookupOrder, true)).toBe(
["{", " /** Order identifier */", " id: string", " verbose?: boolean", "}"].join("\n"),
)
expect(outputTypeScript(lookupOrder, true)).toBe(
["{", " /** Current order status */", " status: string", "}"].join("\n"),
)
})
test("constraints TypeScript cannot express surface as JSDoc tags", () => {
const pretty = jsonSchemaToTypeScript(
{
type: "object",
properties: {
legacy: { type: "string", deprecated: true },
homepage: { type: "string", format: "uri" },
tags: { type: "array", items: { type: "string" }, minItems: 2, maxItems: 5, default: ["a", "b"] },
},
},
true,
)
expect(pretty).toContain(" /** @deprecated */\n legacy?: string")
expect(pretty).toContain(" /** @format uri */\n homepage?: string")
expect(pretty).toContain(
[
" /**",
' * @default ["a","b"]',
" * @minItems 2",
" * @maxItems 5",
" */",
" tags?: Array<string>",
].join("\n"),
)
})
test("skips an unserializable default rather than emitting a broken tag", () => {
const pretty = jsonSchemaToTypeScript(
{ type: "object", properties: { size: { type: "number", default: 1n } } },
true,
)
expect(pretty).toBe(["{", " size?: number", "}"].join("\n"))
})
test("neutralizes */ inside descriptions so nothing closes the comment early", () => {
const pretty = jsonSchemaToTypeScript(
{ type: "object", properties: { note: { type: "string", description: "Ends */ early" } } },
true,
)
expect(pretty).toContain(" /** Ends * / early */")
expect(pretty).not.toContain("Ends */")
})
test("multiline descriptions become *-prefixed blocks with blank edges trimmed", () => {
const pretty = jsonSchemaToTypeScript(
{
type: "object",
properties: { query: { type: "string", description: "\nFirst line\n\nSecond line\n" } },
},
true,
)
expect(pretty).toBe(
["{", " /**", " * First line", " *", " * Second line", " */", " query?: string", "}"].join("\n"),
)
})
test("stays total on cyclic $refs and pathological nesting in both modes", () => {
const cyclic = {
$ref: "#/$defs/Node",
$defs: { Node: { type: "object", properties: { child: { $ref: "#/$defs/Node" }, name: { type: "string" } } } },
} as const
expect(jsonSchemaToTypeScript(cyclic)).toBe("{ child?: Node; name?: string }")
expect(jsonSchemaToTypeScript(cyclic, true)).toContain("child?: Node")
let deep: Record<string, unknown> = { type: "string" }
for (let level = 0; level < 12; level += 1) deep = { type: "object", properties: { next: deep } }
for (const pretty of [false, true]) {
const rendered = jsonSchemaToTypeScript(deep, pretty)
expect(rendered).toContain("unknown")
expect(rendered).toContain("next?:")
}
})
})
describe("non-identifier property names render as quoted keys", () => {
// MCP-style schemas routinely carry property names that are not bare TS identifiers
// (`foo-bar`, `@type`, dotted names); the rendered signature must quote them so the
// model sees a valid TypeScript object type. Bare identifiers stay unquoted.
const rawSchema = {
type: "object",
properties: {
"foo-bar": { type: "string" },
"@type": { type: "string" },
"x.y": { type: "number", description: "Dotted name" },
"123": { type: "number" },
plain: { type: "boolean" },
},
required: ["@type"],
} as const
test("compact rendering quotes non-identifier keys and leaves identifiers bare", () => {
expect(jsonSchemaToTypeScript(rawSchema)).toBe(
'{ "123"?: number; "foo-bar"?: string; "@type": string; "x.y"?: number; plain?: boolean }',
)
})
test("pretty rendering quotes non-identifier keys and keeps their JSDoc", () => {
expect(jsonSchemaToTypeScript(rawSchema, true)).toBe(
[
"{",
' "123"?: number',
' "foo-bar"?: string',
' "@type": string',
" /** Dotted name */",
' "x.y"?: number',
" plain?: boolean",
"}",
].join("\n"),
)
})
test("JSON Schema input and output signatures of a tool both quote", () => {
const tool = Tool.make({
description: "Adapter tool with awkward field names",
input: rawSchema,
output: {
type: "object",
properties: { "content-type": { type: "string" } },
required: ["content-type"],
} as const,
run: () => Effect.succeed({ "content-type": "text/plain" }),
})
expect(inputTypeScript(tool)).toContain('"foo-bar"?: string')
expect(outputTypeScript(tool)).toBe('{ "content-type": string }')
expect(outputTypeScript(tool, true)).toBe(["{", ' "content-type": string', "}"].join("\n"))
})
test("Effect Schema structs with non-identifier field names quote too", () => {
const tool = Tool.make({
description: "Schema tool with awkward field names",
input: Schema.Struct({ "foo-bar": Schema.String, plain: Schema.optionalKey(Schema.Number) }),
run: () => Effect.succeed(null),
})
expect(inputTypeScript(tool)).toBe('{ "foo-bar": string; plain?: number }')
expect(inputTypeScript(tool, true)).toBe(["{", ' "foo-bar": string', " plain?: number", "}"].join("\n"))
})
})
describe("union schemas render every alternative", () => {
test("anyOf with a number branch keeps sibling alternatives", () => {
const schema = {
anyOf: [{ type: "string" }, { type: "number" }],
} as const
expect(jsonSchemaToTypeScript(schema)).toBe("string | number")
expect(jsonSchemaToTypeScript(schema, true)).toBe("string | number")
})
test("nullable numeric unions keep null", () => {
const schema = {
oneOf: [{ type: "number" }, { type: "null" }],
} as const
expect(jsonSchemaToTypeScript(schema)).toBe("number | null")
expect(jsonSchemaToTypeScript(schema, true)).toBe("number | null")
})
test("tool input and output signatures preserve numeric unions", () => {
const tool = Tool.make({
description: "Tool with numeric unions",
input: {
type: "object",
properties: {
value: { anyOf: [{ type: "string" }, { type: "number" }] },
},
} as const,
output: { anyOf: [{ type: "number" }, { type: "boolean" }] } as const,
run: () => Effect.succeed(1),
})
expect(inputTypeScript(tool)).toBe("{ value?: string | number }")
expect(outputTypeScript(tool)).toBe("number | boolean")
})
})
describe("pretty signatures in search results", () => {
const runtime = CodeMode.make({ tools: { github: { list_issues: listIssues }, orders: { lookup: lookupOrder } } })
const search = async (query: string) => {
const result = await Effect.runPromise(
runtime.execute(`return await tools.$codemode.search({ query: ${JSON.stringify(query)} })`),
)
expect(result.ok).toBe(true)
if (!result.ok) throw new Error("search failed")
return result.value as { items: Array<{ path: string; signature: string }>; total: number }
}
test("a raw JSON Schema (MCP-style) tool's result signature carries field JSDoc and tags", async () => {
const { items } = await search("list issues repository")
const item = items.find(({ path }) => path === "tools.github.list_issues")!
expect(item.signature).toBe(
[
"tools.github.list_issues(input: {",
" /** Repository owner */",
" owner: string",
" /** Cursor from the previous response's pageInfo */",
" after?: string",
" /**",
" * Results per page",
" * @default 30",
" */",
" perPage?: number",
" /**",
" * Filter by labels",
" * @minItems 1",
" * @maxItems 10",
" */",
" labels?: Array<string>",
' state?: "open" | "closed"',
"}): Promise<unknown>",
].join("\n"),
)
})
test("an annotated Effect Schema tool's result signature carries field JSDoc (exact-path lookup too)", async () => {
for (const query of ["look up order", "tools.orders.lookup"]) {
const { items } = await search(query)
const item = items.find(({ path }) => path === "tools.orders.lookup")!
expect(item.signature).toBe(
[
"tools.orders.lookup(input: {",
" /** Order identifier */",
" id: string",
" verbose?: boolean",
"}): Promise<{",
" /** Current order status */",
" status: string",
"}>",
].join("\n"),
)
}
})
test("the inline catalog line for the same tool stays single-line compact", () => {
const instructions = runtime.instructions()
expect(instructions).toContain(
' - tools.github.list_issues(input: { owner: string; after?: string; perPage?: number; labels?: Array<string>; state?: "open" | "closed" }): Promise<unknown> // List issues in a repository',
)
expect(instructions).toContain(
" - tools.orders.lookup(input: { id: string; verbose?: boolean }): Promise<{ status: string }> // Look up an order",
)
expect(instructions).not.toContain("/**")
})
})
-495
View File
@@ -1,495 +0,0 @@
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { CodeMode, Tool } from "../src/index.js"
// Standard-library value types: Date, RegExp, Map, Set. Programs use them as ordinary JS;
// intra-sandbox checkpoints (Object.* helpers, spread, coercion inputs) preserve the live
// values, while at the host boundary (final result, tool arguments, JSON.stringify) they
// serialize exactly as JSON.stringify would: Date -> ISO string (invalid -> null),
// RegExp/Map/Set -> {}.
const run = (code: string) => Effect.runPromise(CodeMode.execute({ code, tools: {} }))
const value = async (code: string) => {
const result = await run(code)
if (!result.ok) throw new Error(`expected success, got ${result.error.kind}: ${result.error.message}`)
return result.value
}
const error = async (code: string) => {
const result = await run(code)
if (result.ok) throw new Error(`expected failure, got value ${JSON.stringify(result.value)}`)
return result.error
}
describe("Date", () => {
test("Date.now() returns a number", async () => {
expect(await value(`return typeof Date.now()`)).toBe("number")
})
test("epoch construction and ISO rendering", async () => {
expect(await value(`return new Date(0).toISOString()`)).toBe("1970-01-01T00:00:00.000Z")
})
test("string parsing round-trips", async () => {
expect(await value(`return new Date("2024-01-02T03:04:05.000Z").getTime()`)).toBe(1704164645000)
expect(await value(`return Date.parse("2024-01-02T03:04:05.000Z")`)).toBe(1704164645000)
})
test("date arithmetic and comparison use the time value", async () => {
expect(await value(`const a = new Date(1000); const b = new Date(3000); return b - a`)).toBe(2000)
expect(await value(`const a = new Date(1000); const b = new Date(3000); return a < b`)).toBe(true)
expect(await value(`return +new Date(42)`)).toBe(42)
})
test("UTC getters read calendar components", async () => {
expect(
await value(
`const d = new Date("2024-03-05T06:07:08.009Z"); return [d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), d.getUTCHours(), d.getUTCMinutes(), d.getUTCSeconds(), d.getUTCMilliseconds()]`,
),
).toEqual([2024, 2, 5, 6, 7, 8, 9])
})
test("invalid dates yield NaN times, guardable in-sandbox", async () => {
expect(await value(`return Number.isNaN(new Date("garbage").getTime())`)).toBe(true)
expect(await value(`return new Date("garbage").toJSON()`)).toBeNull()
})
test("toISOString on an invalid date is a catchable error", async () => {
expect(await value(`try { new Date("garbage").toISOString(); return "no" } catch { return "caught" }`)).toBe(
"caught",
)
})
test("template interpolation renders the ISO form", async () => {
expect(await value("return `at ${new Date(0)}`")).toBe("at 1970-01-01T00:00:00.000Z")
})
test("dates serialize to ISO strings at the boundary, direct and nested", async () => {
expect(await value(`return new Date(0)`)).toBe("1970-01-01T00:00:00.000Z")
expect(await value(`return { when: new Date(0), tags: [new Date(1000)] }`)).toEqual({
when: "1970-01-01T00:00:00.000Z",
tags: ["1970-01-01T00:00:01.000Z"],
})
expect(await value(`return JSON.stringify({ d: new Date(0) })`)).toBe('{"d":"1970-01-01T00:00:00.000Z"}')
})
test("coercions: Number is the time, String is ISO, Boolean is true", async () => {
expect(await value(`return Number(new Date(5))`)).toBe(5)
expect(await value(`return String(new Date(0))`)).toBe("1970-01-01T00:00:00.000Z")
expect(await value(`return Boolean(new Date(0))`)).toBe(true)
})
test("sorting dates with a numeric comparator", async () => {
expect(
await value(`
const dates = [new Date(3000), new Date(1000), new Date(2000)]
return dates.sort((a, b) => a - b).map((d) => d.getTime())
`),
).toEqual([1000, 2000, 3000])
})
test("new Date(year, month, day) accepts component form", async () => {
expect(await value(`const d = new Date(2024, 0, 2); return [d.getFullYear(), d.getMonth(), d.getDate()]`)).toEqual([
2024, 0, 2,
])
})
test("typeof and unknown properties are forgiving", async () => {
expect(await value(`return typeof new Date(0)`)).toBe("object")
expect(await value(`return new Date(0).nope === undefined`)).toBe(true)
})
})
describe("RegExp", () => {
test("literal test", async () => {
expect(await value(`return /ab+c/.test("xabbbc")`)).toBe(true)
expect(await value(`return /ab+c/.test("nope")`)).toBe(false)
})
test("exec exposes captures and index", async () => {
expect(await value(`const m = /a(b+)/.exec("xxabbc"); return { full: m[0], group: m[1], index: m.index }`)).toEqual(
{
full: "abb",
group: "bb",
index: 2,
},
)
expect(await value(`return /a/.exec("zzz")`)).toBeNull()
})
test("named groups read through", async () => {
expect(
await value(`const m = /(?<word>[a-z]+)-(?<num>\\d+)/.exec("id ab-42"); return m.groups.word + m.groups.num`),
).toBe("ab42")
})
test("global exec advances lastIndex across calls", async () => {
expect(
await value(`
const r = /\\d+/g
const first = r.exec("a1b22c")
const second = r.exec("a1b22c")
return [first[0], second[0]]
`),
).toEqual(["1", "22"])
})
test("string match: non-global carries index, global lists all matches", async () => {
expect(await value(`const m = "a1b22".match(/\\d+/); return [m[0], m.index]`)).toEqual(["1", 1])
expect(await value(`return "a1b22".match(/\\d+/g)`)).toEqual(["1", "22"])
expect(await value(`return "abc".match(/\\d/)`)).toBeNull()
})
test("matchAll materializes match arrays with captures", async () => {
expect(await value(`return "a1b22".matchAll(/(\\d+)/g).map((m) => m[1])`)).toEqual(["1", "22"])
})
test("replace and replaceAll with patterns and $1 substitution", async () => {
expect(await value(`return "a1b2".replace(/\\d/, "#")`)).toBe("a#b2")
expect(await value(`return "a1b2".replace(/\\d/g, "#")`)).toBe("a#b#")
expect(await value(`return "a1b2".replaceAll(/\\d/g, "#")`)).toBe("a#b#")
expect(await value(`return "hi bob".replace(/b(o)b/, "[$1]")`)).toBe("hi [o]")
})
test("replaceAll without the g flag is a catchable error", async () => {
expect(await value(`try { "a".replaceAll(/a/, "b"); return "no" } catch { return "caught" }`)).toBe("caught")
})
test("split and search accept patterns", async () => {
expect(await value(`return "a1b22c".split(/\\d+/)`)).toEqual(["a", "b", "c"])
expect(await value(`return "ab42".search(/\\d/)`)).toBe(2)
expect(await value(`return "ab".search(/\\d/)`)).toBe(-1)
})
test("new RegExp constructs from strings; invalid patterns are catchable", async () => {
expect(await value(`return new RegExp("a+", "i").test("AAA")`)).toBe(true)
expect(await value(`try { new RegExp("("); return "no" } catch { return "caught" }`)).toBe("caught")
expect(await value(`return [/a/ instanceof RegExp, /a/.source]`)).toEqual([true, "a"])
})
test("invalid patterns fail with actionable messages", async () => {
const fromString = await error(`return "abc".match("(")`)
expect(fromString.message).toContain('String.match received the string "("')
expect(fromString.message).toContain("escape them with a backslash")
const fromConstructor = await error(`return new RegExp("(")`)
expect(fromConstructor.message).toContain('new RegExp(...) received "("')
expect(fromConstructor.message).toContain("escape them with a backslash")
const fromFlags = await error(`return new RegExp("a", "xz")`)
expect(fromFlags.message).toContain('invalid flags "xz"')
expect(fromFlags.message).toContain("Valid flags are")
})
test("missing g-flag errors say how to fix the call", async () => {
expect((await error(`return "aa".replaceAll(/a/, "b")`)).message).toContain("write /a/g, or use String.replace")
expect((await error(`return "aa".matchAll(/a/)`)).message).toContain("write /a/g, or use String.match")
})
test("a non-pattern argument names the expected shapes", async () => {
const err = await error(`return "abc".match(42)`)
expect(err.message).toContain("expects a regular expression")
expect(err.message).toContain("not number")
})
test("source and flags properties read through", async () => {
expect(await value(`const r = /ab/gi; return { source: r.source, flags: r.flags, global: r.global }`)).toEqual({
source: "ab",
flags: "gi",
global: true,
})
})
test("regexes serialize to {} at the boundary, like JSON", async () => {
expect(await value(`return /a/`)).toEqual({})
expect(await value(`return JSON.stringify({ r: /a/g })`)).toBe('{"r":{}}')
})
test("template interpolation renders the literal form", async () => {
expect(await value("return `${/ab/g}`")).toBe("/ab/g")
})
})
describe("Map", () => {
test("get/set/has/size with chaining", async () => {
expect(
await value(`
const m = new Map()
m.set("a", 1).set("b", 2)
return { a: m.get("a"), b: m.get("b"), has: m.has("a"), miss: m.get("zz") === undefined, size: m.size }
`),
).toEqual({ a: 1, b: 2, has: true, miss: true, size: 5 - 3 })
})
test("object keys use identity", async () => {
expect(
await value(`
const key = { id: 1 }
const m = new Map()
m.set(key, "hit")
return [m.get(key), m.get({ id: 1 }) === undefined]
`),
).toEqual(["hit", true])
})
test("construction from entry pairs and another Map", async () => {
expect(await value(`const m = new Map([["a", 1], ["b", 2]]); return m.get("b")`)).toBe(2)
expect(
await value(
`const m = new Map([["a", 1]]); const n = new Map(m); n.set("b", 2); return [n.get("a"), n.get("b"), m.has("b")]`,
),
).toEqual([1, 2, false])
expect((await error(`return new Map("nope")`)).message).toMatch(/\[key, value\] pairs/)
expect((await error(`return new Map(["flat"])`)).message).toMatch(/\[key, value\] pairs/)
})
test("keys/values/entries return arrays", async () => {
expect(
await value(`
const m = new Map([["a", 1], ["b", 2]])
return { keys: m.keys(), values: m.values(), entries: m.entries() }
`),
).toEqual({
keys: ["a", "b"],
values: [1, 2],
entries: [
["a", 1],
["b", 2],
],
})
})
test("Object.fromEntries(map) and Array.from(map)", async () => {
expect(await value(`return Object.fromEntries(new Map([["a", 1], ["b", 2]]))`)).toEqual({ a: 1, b: 2 })
expect(await value(`return Array.from(new Map([["a", 1]]))`)).toEqual([["a", 1]])
})
test("for...of iterates [key, value] pairs with destructuring", async () => {
expect(
await value(`
const m = new Map([["a", 1], ["b", 2]])
let total = 0
let names = ""
for (const [key, count] of m) { names += key; total += count }
return names + total
`),
).toBe("ab3")
})
test("spread produces entry pairs", async () => {
expect(await value(`return [...new Map([["a", 1]])]`)).toEqual([["a", 1]])
})
test("forEach passes (value, key)", async () => {
expect(
await value(`
const m = new Map([["a", 1], ["b", 2]])
const seen = []
m.forEach((count, key) => seen.push(key + count))
return seen
`),
).toEqual(["a1", "b2"])
})
test("delete and clear", async () => {
expect(
await value(`
const m = new Map([["a", 1], ["b", 2]])
const removed = m.delete("a")
const missed = m.delete("zz")
const sizeAfterDelete = m.size
m.clear()
return [removed, missed, sizeAfterDelete, m.size]
`),
).toEqual([true, false, 1, 0])
})
test("counting idiom: grouped tallies", async () => {
expect(
await value(`
const words = ["a", "b", "a", "c", "a"]
const counts = new Map()
for (const word of words) counts.set(word, (counts.get(word) ?? 0) + 1)
return Object.fromEntries(counts)
`),
).toEqual({ a: 3, b: 1, c: 1 })
})
test("maps serialize to {} at the boundary, like JSON", async () => {
expect(await value(`return new Map([["a", 1]])`)).toEqual({})
expect(await value(`return JSON.stringify(new Map([["a", 1]]))`)).toBe("{}")
})
test("console.log renders map contents for debugging", async () => {
const result = await run(`console.log(new Map([["a", 1]])); return null`)
expect(result.ok).toBe(true)
expect(result.logs?.[0]).toBe(`Map(1) [["a",1]]`)
})
})
describe("Set", () => {
test("add/has/delete/size with chaining", async () => {
expect(
await value(`
const s = new Set()
s.add(1).add(2).add(1)
const removed = s.delete(2)
return [s.size, s.has(1), s.has(2), removed]
`),
).toEqual([1, true, false, true])
})
test("dedupe idiom: [...new Set(items)]", async () => {
expect(await value(`return [...new Set([1, 2, 2, 3, 1])]`)).toEqual([1, 2, 3])
})
test("construction from strings and other Sets", async () => {
expect(await value(`return [...new Set("aba")]`)).toEqual(["a", "b"])
expect(await value(`return Array.from(new Set(new Set([1, 2])))`)).toEqual([1, 2])
})
test("SameValueZero: NaN is findable", async () => {
expect(await value(`const s = new Set([NaN]); return s.has(NaN)`)).toBe(true)
})
test("for...of iterates values", async () => {
expect(
await value(`
let total = 0
for (const n of new Set([1, 2, 3])) total += n
return total
`),
).toBe(6)
})
test("sets serialize to {} at the boundary, like JSON", async () => {
expect(await value(`return { s: new Set([1]) }`)).toEqual({ s: {} })
})
})
describe("stdlib integration", () => {
test("typeof reports constructors as functions and never throws", async () => {
expect(await value(`return typeof Map`)).toBe("function")
expect(await value(`return typeof ((x) => x)`)).toBe("function")
expect(await value(`return typeof Math`)).toBe("object")
expect(await value(`return typeof tools`)).toBe("object")
})
test("negation works on any value", async () => {
expect(await value(`return !new Map()`)).toBe(false)
expect(await value(`const fn = () => 1; return !fn`)).toBe(false)
})
test("object spread of sandbox values is a no-op, like JS", async () => {
expect(await value(`return { ...new Map([["a", 1]]), kept: true }`)).toEqual({ kept: true })
})
test("dates inside Map values survive in-sandbox reads", async () => {
expect(
await value(`
const m = new Map([["start", new Date(1000)]])
return m.get("start").getTime()
`),
).toBe(1000)
})
test("instanceof recognizes the stdlib value types", async () => {
expect(
await value(
`return [new Date(0) instanceof Date, /a/ instanceof RegExp, new Map() instanceof Map, new Set() instanceof Set]`,
),
).toEqual([true, true, true, true])
expect(
await value(`return [[1] instanceof Array, [1] instanceof Object, ({}) instanceof Object, 5 instanceof Object]`),
).toEqual([true, true, true, false])
expect(await value(`return [new Map() instanceof Set, "s" instanceof Date]`)).toEqual([false, false])
expect(
await value(`const p = Promise.resolve(1); const isPromise = p instanceof Promise; await p; return isPromise`),
).toBe(true)
})
test("realistic pipeline: parse, extract with regex, dedupe, count by day", async () => {
expect(
await value(`
const raw = '[{"at":"2024-01-01T05:00:00Z","tag":"a b"},{"at":"2024-01-01T09:00:00Z","tag":"b c"},{"at":"2024-01-02T01:00:00Z","tag":"a"}]'
const rows = JSON.parse(raw)
const tags = new Set()
const byDay = new Map()
for (const row of rows) {
for (const m of row.tag.matchAll(/[a-z]+/g)) tags.add(m[0])
const day = new Date(row.at).toISOString().slice(0, 10)
byDay.set(day, (byDay.get(day) ?? 0) + 1)
}
return { tags: [...tags].sort((a, b) => (a < b ? -1 : 1)), byDay: Object.fromEntries(byDay) }
`),
).toEqual({ tags: ["a", "b", "c"], byDay: { "2024-01-01": 2, "2024-01-02": 1 } })
})
})
describe("sandbox values at intra-sandbox checkpoints", () => {
test("Object.values/entries keep Dates usable", async () => {
expect(await value(`return Object.values({ d: new Date(0) })[0].getTime()`)).toBe(0)
expect(await value(`const [key, d] = Object.entries({ d: new Date(0) })[0]; return key + ":" + d.getTime()`)).toBe(
"d:0",
)
})
test("Object.assign keeps Maps usable", async () => {
expect(await value(`const merged = Object.assign({}, { m: new Map([["a", 1]]) }); return merged.m.get("a")`)).toBe(
1,
)
})
test("object and array spread keep sandbox values usable", async () => {
expect(
await value(`
const src = { m: new Map([["a", 1]]) }
const copy = { ...src }
copy.m.set("b", 2)
return [copy.m.get("a"), src.m.get("b")]
`),
).toEqual([1, 2])
expect(await value(`const list = [new Date(1000)]; const copy = [...list]; return copy[0].getTime()`)).toBe(1000)
})
test("Array.from over arrays keeps nested sandbox values usable", async () => {
expect(await value(`return Array.from([new Date(5)])[0].getTime()`)).toBe(5)
})
test("regexes stay callable through Object.values", async () => {
expect(await value(`return Object.values({ r: /ab+/ })[0].test("abb")`)).toBe(true)
})
test("Object.* helpers see sandbox values as empty objects, never internals", async () => {
expect(await value(`return Object.keys(new Map([["a", 1]]))`)).toEqual([])
expect(await value(`return Object.values(new Date(0))`)).toEqual([])
expect(await value(`return Object.entries(new Set([1]))`)).toEqual([])
expect(await value(`return Object.assign({}, new Map([["a", 1]]))`)).toEqual({})
expect(await value(`return Object.hasOwn(new Date(0), "time")`)).toBe(false)
})
test("the host boundary still serializes JSON forms: results, JSON.stringify, and tool arguments", async () => {
expect(await value(`return { d: new Date(0), m: new Map([["a", 1]]) }`)).toEqual({
d: "1970-01-01T00:00:00.000Z",
m: {},
})
expect(await value(`return JSON.stringify({ d: new Date(0) })`)).toBe('{"d":"1970-01-01T00:00:00.000Z"}')
const observed: Array<unknown> = []
const capture = Tool.make({
description: "Capture the exact input the host receives",
input: { type: "object" },
run: (input) =>
Effect.sync(() => {
observed.push(input)
return "ok"
}),
})
const result = await Effect.runPromise(
CodeMode.execute({
tools: { host: { capture } },
code: `return await tools.host.capture({ when: new Date(0), tags: new Map([["a", 1]]) })`,
}),
)
expect(result.ok).toBe(true)
expect(observed).toStrictEqual([{ when: "1970-01-01T00:00:00.000Z", tags: {} }])
})
})
-7
View File
@@ -1,7 +0,0 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@tsconfig/bun/tsconfig.json",
"compilerOptions": {
"noUncheckedIndexedAccess": false
}
}
+48 -222
View File
@@ -1,10 +1,8 @@
{ {
"version": "7", "version": "7",
"dialect": "sqlite", "dialect": "sqlite",
"id": "22e57fed-b9b8-4e94-a3b4-f94bece680a8", "id": "f14a9b18-8207-487e-a3d3-227e629ba9ad",
"prevIds": [ "prevIds": ["169a0f0f-d58f-479f-b024-fa1c7b9a09db"],
"f14a9b18-8207-487e-a3d3-227e629ba9ad"
],
"ddl": [ "ddl": [
{ {
"name": "workspace", "name": "workspace",
@@ -62,10 +60,6 @@
"name": "session_context_epoch", "name": "session_context_epoch",
"entityType": "tables" "entityType": "tables"
}, },
{
"name": "session_context_entry",
"entityType": "tables"
},
{ {
"name": "session_input", "name": "session_input",
"entityType": "tables" "entityType": "tables"
@@ -926,56 +920,6 @@
"entityType": "columns", "entityType": "columns",
"table": "session_context_epoch" "table": "session_context_epoch"
}, },
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "session_id",
"entityType": "columns",
"table": "session_context_entry"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "key",
"entityType": "columns",
"table": "session_context_entry"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "value",
"entityType": "columns",
"table": "session_context_entry"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_created",
"entityType": "columns",
"table": "session_context_entry"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_updated",
"entityType": "columns",
"table": "session_context_entry"
},
{ {
"type": "text", "type": "text",
"notNull": false, "notNull": false,
@@ -1537,13 +1481,9 @@
"table": "session_share" "table": "session_share"
}, },
{ {
"columns": [ "columns": ["project_id"],
"project_id"
],
"tableTo": "project", "tableTo": "project",
"columnsTo": [ "columnsTo": ["id"],
"id"
],
"onUpdate": "NO ACTION", "onUpdate": "NO ACTION",
"onDelete": "CASCADE", "onDelete": "CASCADE",
"nameExplicit": false, "nameExplicit": false,
@@ -1552,13 +1492,9 @@
"table": "workspace" "table": "workspace"
}, },
{ {
"columns": [ "columns": ["active_account_id"],
"active_account_id"
],
"tableTo": "account", "tableTo": "account",
"columnsTo": [ "columnsTo": ["id"],
"id"
],
"onUpdate": "NO ACTION", "onUpdate": "NO ACTION",
"onDelete": "SET NULL", "onDelete": "SET NULL",
"nameExplicit": false, "nameExplicit": false,
@@ -1567,13 +1503,9 @@
"table": "account_state" "table": "account_state"
}, },
{ {
"columns": [ "columns": ["aggregate_id"],
"aggregate_id"
],
"tableTo": "event_sequence", "tableTo": "event_sequence",
"columnsTo": [ "columnsTo": ["aggregate_id"],
"aggregate_id"
],
"onUpdate": "NO ACTION", "onUpdate": "NO ACTION",
"onDelete": "CASCADE", "onDelete": "CASCADE",
"nameExplicit": false, "nameExplicit": false,
@@ -1582,13 +1514,9 @@
"table": "event" "table": "event"
}, },
{ {
"columns": [ "columns": ["project_id"],
"project_id"
],
"tableTo": "project", "tableTo": "project",
"columnsTo": [ "columnsTo": ["id"],
"id"
],
"onUpdate": "NO ACTION", "onUpdate": "NO ACTION",
"onDelete": "CASCADE", "onDelete": "CASCADE",
"nameExplicit": false, "nameExplicit": false,
@@ -1597,13 +1525,9 @@
"table": "permission" "table": "permission"
}, },
{ {
"columns": [ "columns": ["project_id"],
"project_id"
],
"tableTo": "project", "tableTo": "project",
"columnsTo": [ "columnsTo": ["id"],
"id"
],
"onUpdate": "NO ACTION", "onUpdate": "NO ACTION",
"onDelete": "CASCADE", "onDelete": "CASCADE",
"nameExplicit": false, "nameExplicit": false,
@@ -1612,13 +1536,9 @@
"table": "project_directory" "table": "project_directory"
}, },
{ {
"columns": [ "columns": ["session_id"],
"session_id"
],
"tableTo": "session", "tableTo": "session",
"columnsTo": [ "columnsTo": ["id"],
"id"
],
"onUpdate": "NO ACTION", "onUpdate": "NO ACTION",
"onDelete": "CASCADE", "onDelete": "CASCADE",
"nameExplicit": false, "nameExplicit": false,
@@ -1627,13 +1547,9 @@
"table": "message" "table": "message"
}, },
{ {
"columns": [ "columns": ["message_id"],
"message_id"
],
"tableTo": "message", "tableTo": "message",
"columnsTo": [ "columnsTo": ["id"],
"id"
],
"onUpdate": "NO ACTION", "onUpdate": "NO ACTION",
"onDelete": "CASCADE", "onDelete": "CASCADE",
"nameExplicit": false, "nameExplicit": false,
@@ -1642,13 +1558,9 @@
"table": "part" "table": "part"
}, },
{ {
"columns": [ "columns": ["session_id"],
"session_id"
],
"tableTo": "session", "tableTo": "session",
"columnsTo": [ "columnsTo": ["id"],
"id"
],
"onUpdate": "NO ACTION", "onUpdate": "NO ACTION",
"onDelete": "CASCADE", "onDelete": "CASCADE",
"nameExplicit": false, "nameExplicit": false,
@@ -1657,28 +1569,9 @@
"table": "session_context_epoch" "table": "session_context_epoch"
}, },
{ {
"columns": [ "columns": ["session_id"],
"session_id"
],
"tableTo": "session", "tableTo": "session",
"columnsTo": [ "columnsTo": ["id"],
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
"name": "fk_session_context_entry_session_id_session_id_fk",
"entityType": "fks",
"table": "session_context_entry"
},
{
"columns": [
"session_id"
],
"tableTo": "session",
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION", "onUpdate": "NO ACTION",
"onDelete": "CASCADE", "onDelete": "CASCADE",
"nameExplicit": false, "nameExplicit": false,
@@ -1687,13 +1580,9 @@
"table": "session_input" "table": "session_input"
}, },
{ {
"columns": [ "columns": ["session_id"],
"session_id"
],
"tableTo": "session", "tableTo": "session",
"columnsTo": [ "columnsTo": ["id"],
"id"
],
"onUpdate": "NO ACTION", "onUpdate": "NO ACTION",
"onDelete": "CASCADE", "onDelete": "CASCADE",
"nameExplicit": false, "nameExplicit": false,
@@ -1702,13 +1591,9 @@
"table": "session_message" "table": "session_message"
}, },
{ {
"columns": [ "columns": ["project_id"],
"project_id"
],
"tableTo": "project", "tableTo": "project",
"columnsTo": [ "columnsTo": ["id"],
"id"
],
"onUpdate": "NO ACTION", "onUpdate": "NO ACTION",
"onDelete": "CASCADE", "onDelete": "CASCADE",
"nameExplicit": false, "nameExplicit": false,
@@ -1717,13 +1602,9 @@
"table": "session" "table": "session"
}, },
{ {
"columns": [ "columns": ["session_id"],
"session_id"
],
"tableTo": "session", "tableTo": "session",
"columnsTo": [ "columnsTo": ["id"],
"id"
],
"onUpdate": "NO ACTION", "onUpdate": "NO ACTION",
"onDelete": "CASCADE", "onDelete": "CASCADE",
"nameExplicit": false, "nameExplicit": false,
@@ -1732,13 +1613,9 @@
"table": "todo" "table": "todo"
}, },
{ {
"columns": [ "columns": ["session_id"],
"session_id"
],
"tableTo": "session", "tableTo": "session",
"columnsTo": [ "columnsTo": ["id"],
"id"
],
"onUpdate": "NO ACTION", "onUpdate": "NO ACTION",
"onDelete": "CASCADE", "onDelete": "CASCADE",
"nameExplicit": false, "nameExplicit": false,
@@ -1747,184 +1624,133 @@
"table": "session_share" "table": "session_share"
}, },
{ {
"columns": [ "columns": ["email", "url"],
"email",
"url"
],
"nameExplicit": false, "nameExplicit": false,
"name": "control_account_pk", "name": "control_account_pk",
"entityType": "pks", "entityType": "pks",
"table": "control_account" "table": "control_account"
}, },
{ {
"columns": [ "columns": ["project_id", "directory"],
"project_id",
"directory"
],
"nameExplicit": false, "nameExplicit": false,
"name": "project_directory_pk", "name": "project_directory_pk",
"entityType": "pks", "entityType": "pks",
"table": "project_directory" "table": "project_directory"
}, },
{ {
"columns": [ "columns": ["session_id", "position"],
"session_id",
"key"
],
"nameExplicit": false,
"name": "session_context_entry_pk",
"entityType": "pks",
"table": "session_context_entry"
},
{
"columns": [
"session_id",
"position"
],
"nameExplicit": false, "nameExplicit": false,
"name": "todo_pk", "name": "todo_pk",
"entityType": "pks", "entityType": "pks",
"table": "todo" "table": "todo"
}, },
{ {
"columns": [ "columns": ["id"],
"id"
],
"nameExplicit": false, "nameExplicit": false,
"name": "workspace_pk", "name": "workspace_pk",
"table": "workspace", "table": "workspace",
"entityType": "pks" "entityType": "pks"
}, },
{ {
"columns": [ "columns": ["name"],
"name"
],
"nameExplicit": false, "nameExplicit": false,
"name": "data_migration_pk", "name": "data_migration_pk",
"table": "data_migration", "table": "data_migration",
"entityType": "pks" "entityType": "pks"
}, },
{ {
"columns": [ "columns": ["id"],
"id"
],
"nameExplicit": false, "nameExplicit": false,
"name": "account_state_pk", "name": "account_state_pk",
"table": "account_state", "table": "account_state",
"entityType": "pks" "entityType": "pks"
}, },
{ {
"columns": [ "columns": ["id"],
"id"
],
"nameExplicit": false, "nameExplicit": false,
"name": "account_pk", "name": "account_pk",
"table": "account", "table": "account",
"entityType": "pks" "entityType": "pks"
}, },
{ {
"columns": [ "columns": ["id"],
"id"
],
"nameExplicit": false, "nameExplicit": false,
"name": "credential_pk", "name": "credential_pk",
"table": "credential", "table": "credential",
"entityType": "pks" "entityType": "pks"
}, },
{ {
"columns": [ "columns": ["aggregate_id"],
"aggregate_id"
],
"nameExplicit": false, "nameExplicit": false,
"name": "event_sequence_pk", "name": "event_sequence_pk",
"table": "event_sequence", "table": "event_sequence",
"entityType": "pks" "entityType": "pks"
}, },
{ {
"columns": [ "columns": ["id"],
"id"
],
"nameExplicit": false, "nameExplicit": false,
"name": "event_pk", "name": "event_pk",
"table": "event", "table": "event",
"entityType": "pks" "entityType": "pks"
}, },
{ {
"columns": [ "columns": ["id"],
"id"
],
"nameExplicit": false, "nameExplicit": false,
"name": "permission_pk", "name": "permission_pk",
"table": "permission", "table": "permission",
"entityType": "pks" "entityType": "pks"
}, },
{ {
"columns": [ "columns": ["id"],
"id"
],
"nameExplicit": false, "nameExplicit": false,
"name": "project_pk", "name": "project_pk",
"table": "project", "table": "project",
"entityType": "pks" "entityType": "pks"
}, },
{ {
"columns": [ "columns": ["id"],
"id"
],
"nameExplicit": false, "nameExplicit": false,
"name": "message_pk", "name": "message_pk",
"table": "message", "table": "message",
"entityType": "pks" "entityType": "pks"
}, },
{ {
"columns": [ "columns": ["id"],
"id"
],
"nameExplicit": false, "nameExplicit": false,
"name": "part_pk", "name": "part_pk",
"table": "part", "table": "part",
"entityType": "pks" "entityType": "pks"
}, },
{ {
"columns": [ "columns": ["session_id"],
"session_id"
],
"nameExplicit": false, "nameExplicit": false,
"name": "session_context_epoch_pk", "name": "session_context_epoch_pk",
"table": "session_context_epoch", "table": "session_context_epoch",
"entityType": "pks" "entityType": "pks"
}, },
{ {
"columns": [ "columns": ["id"],
"id"
],
"nameExplicit": false, "nameExplicit": false,
"name": "session_input_pk", "name": "session_input_pk",
"table": "session_input", "table": "session_input",
"entityType": "pks" "entityType": "pks"
}, },
{ {
"columns": [ "columns": ["id"],
"id"
],
"nameExplicit": false, "nameExplicit": false,
"name": "session_message_pk", "name": "session_message_pk",
"table": "session_message", "table": "session_message",
"entityType": "pks" "entityType": "pks"
}, },
{ {
"columns": [ "columns": ["id"],
"id"
],
"nameExplicit": false, "nameExplicit": false,
"name": "session_pk", "name": "session_pk",
"table": "session", "table": "session",
"entityType": "pks" "entityType": "pks"
}, },
{ {
"columns": [ "columns": ["session_id"],
"session_id"
],
"nameExplicit": false, "nameExplicit": false,
"name": "session_share_pk", "name": "session_share_pk",
"table": "session_share", "table": "session_share",
@@ -2242,4 +2068,4 @@
} }
], ],
"renames": [] "renames": []
} }
-1
View File
@@ -85,7 +85,6 @@ const layer = Layer.effect(
? { ...model.api, settings: { ...provider.api.settings, ...model.api.settings } } ? { ...model.api, settings: { ...provider.api.settings, ...model.api.settings } }
: model.api : model.api
const request = { const request = {
settings: { ...provider.request.settings, ...model.request.settings },
headers: { ...provider.request.headers, ...model.request.headers }, headers: { ...provider.request.headers, ...model.request.headers },
body: { ...provider.request.body, ...model.request.body }, body: { ...provider.request.body, ...model.request.body },
variant: model.request.variant, variant: model.request.variant,
+5 -189
View File
@@ -1,39 +1,17 @@
export * as CommandV2 from "./command" export * as CommandV2 from "./command"
import { makeLocationNode } from "./effect/app-node" import { makeLocationNode } from "./effect/app-node"
import { Context, Effect, Layer, Schema, Types } from "effect" import { Context, Effect, Layer, Types } from "effect"
import { Command } from "@opencode-ai/schema/command" import { Command } from "@opencode-ai/schema/command"
import { State } from "./state" import { State } from "./state"
import { MCP } from "./mcp/index"
import { EventV2 } from "./event"
import { AppProcess } from "./process"
import { ChildProcess } from "effect/unstable/process"
import { Config } from "./config"
import { Location } from "./location"
import { ShellSelect } from "./shell/select"
export const Info = Command.Info export const Info = Command.Info
export type Info = Command.Info export type Info = Command.Info
export const Event = Command.Event
export type Evaluation = {
readonly text: string
}
export type Data = { export type Data = {
commands: Map<string, Types.DeepMutable<Info>> commands: Map<string, Types.DeepMutable<Info>>
} }
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Command.NotFoundError", {
command: Schema.String,
message: Schema.String,
}) {}
export class EvaluationError extends Schema.TaggedErrorClass<EvaluationError>()("Command.EvaluationError", {
command: Schema.String,
message: Schema.String,
}) {}
export type Draft = { export type Draft = {
list: () => readonly Info[] list: () => readonly Info[]
get: (name: string) => Info | undefined get: (name: string) => Info | undefined
@@ -44,22 +22,13 @@ export type Draft = {
export interface Interface extends State.Transformable<Draft> { export interface Interface extends State.Transformable<Draft> {
readonly get: (name: string) => Effect.Effect<Info | undefined> readonly get: (name: string) => Effect.Effect<Info | undefined>
readonly list: () => Effect.Effect<Info[]> readonly list: () => Effect.Effect<Info[]>
readonly evaluate: (input: {
readonly name: string
readonly arguments?: string
}) => Effect.Effect<Evaluation, NotFoundError | EvaluationError>
} }
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Command") {} export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Command") {}
const layer = Layer.effect( const layer = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.sync(() => {
const mcp = yield* MCP.Service
const events = yield* EventV2.Service
const processes = yield* AppProcess.Service
const config = yield* Config.Service
const location = yield* Location.Service
const state = State.create<Data, Draft>({ const state = State.create<Data, Draft>({
initial: () => ({ commands: new Map() }), initial: () => ({ commands: new Map() }),
draft: (draft) => ({ draft: (draft) => ({
@@ -75,172 +44,19 @@ const layer = Layer.effect(
draft.commands.delete(name) draft.commands.delete(name)
}, },
}), }),
finalize: () => events.publish(Event.Updated, {}).pipe(Effect.asVoid),
})
const staticCommand = (name: string) => state.get().commands.get(name) as Info | undefined
const mcpCommands = Effect.fnUntraced(function* () {
return (yield* mcp.prompts()).map((prompt) =>
Info.make({
name: mcpCommandName(prompt.server, prompt.name),
template: "",
description: prompt.description,
}),
)
}) })
return Service.of({ return Service.of({
reload: state.reload, reload: state.reload,
transform: state.transform, transform: state.transform,
get: Effect.fn("CommandV2.get")(function* (name) { get: Effect.fn("CommandV2.get")(function* (name) {
const command = staticCommand(name) return state.get().commands.get(name)
if (command) return command
return (yield* mcpCommands()).find((command) => command.name === name)
}), }),
list: Effect.fn("CommandV2.list")(function* () { list: Effect.fn("CommandV2.list")(function* () {
const commands = Array.from(state.get().commands.values()) as Info[] return Array.from(state.get().commands.values())
const names = new Set(commands.map((command) => command.name))
return [
...commands,
...(yield* mcpCommands()).filter((command) => !names.has(command.name)),
]
}),
evaluate: Effect.fn("CommandV2.evaluate")(function* (input) {
const command = staticCommand(input.name)
if (command) return yield* evaluateTemplate(input.name, command.template, input.arguments ?? "", {
config,
location,
processes,
})
const prompt = (yield* mcp.prompts()).find((prompt) => mcpCommandName(prompt.server, prompt.name) === input.name)
if (!prompt) return yield* new NotFoundError({ command: input.name, message: `Command not found: ${input.name}` })
const result = yield* mcp
.prompt({
server: prompt.server,
name: prompt.name,
args: Object.fromEntries(
(prompt.arguments ?? []).map((argument, index) => [
argument.name,
parseArguments(input.arguments ?? "")[index] ?? "",
]),
),
})
.pipe(
Effect.catchTag(
"MCP.NotFoundError",
() =>
Effect.fail(
new EvaluationError({
command: input.name,
message: `MCP server could not be found while evaluating prompt: ${prompt.server}`,
}),
),
),
)
if (!result)
return yield* new EvaluationError({
command: input.name,
message: `MCP prompt could not be evaluated: ${prompt.server}:${prompt.name}`,
})
return { text: result.messages.map((message) => promptMessageText(message.content)).join("\n").trim() }
}), }),
}) })
}), }),
) )
function evaluateTemplate( export const node = makeLocationNode({ service: Service, layer, deps: [] })
command: string,
template: string,
input: string,
services: {
readonly config: Config.Interface
readonly location: Location.Info
readonly processes: AppProcess.Interface
},
) {
return Effect.gen(function* () {
const expanded = evaluateArguments(template, input)
return { text: yield* evaluateShell(command, expanded, services) }
})
}
function evaluateArguments(template: string, input: string) {
const args = parseArguments(input)
const placeholders = template.match(placeholderRegex) ?? []
const last = Math.max(0, ...placeholders.map((item) => Number(item.slice(1))))
const expanded = template.replaceAll(placeholderRegex, (_, index) => {
const position = Number(index)
const argIndex = position - 1
if (argIndex >= args.length) return ""
if (position === last) return args.slice(argIndex).join(" ")
return args[argIndex]
})
const withArguments = expanded.replaceAll("$ARGUMENTS", input)
if (placeholders.length === 0 && !template.includes("$ARGUMENTS") && input.trim()) return `${withArguments}\n\n${input}`.trim()
return withArguments.trim()
}
const evaluateShell = Effect.fnUntraced(function* (
command: string,
text: string,
services: {
readonly config: Config.Interface
readonly location: Location.Info
readonly processes: AppProcess.Interface
},
) {
const matches = Array.from(text.matchAll(shellRegex))
if (matches.length === 0) return text
const shell = ShellSelect.preferred(Config.latest(yield* services.config.entries(), "shell"))
const outputs = yield* Effect.forEach(
matches,
(match) => {
const source = match[1] ?? ""
return services.processes
.run(ChildProcess.make(shell, ShellSelect.args(shell, source), { cwd: services.location.directory, stdin: "ignore" }), {
combineOutput: true,
})
.pipe(
Effect.map((result) => (result.output ?? Buffer.concat([result.stdout, result.stderr])).toString("utf8")),
Effect.mapError(
(error) =>
new EvaluationError({ command, message: `Shell interpolation failed for ${JSON.stringify(source)}: ${error.message}` }),
),
)
},
{ concurrency: 2 },
)
const iterator = outputs[Symbol.iterator]()
return text.replace(shellRegex, () => iterator.next().value ?? "")
})
function parseArguments(input: string) {
return (input.match(argsRegex) ?? []).map((arg) => arg.replace(quoteTrimRegex, ""))
}
function promptMessageText(content: unknown) {
if (typeof content === "string") return content
if (!content || typeof content !== "object") return ""
if (!("type" in content) || content.type !== "text") return ""
if (!("text" in content) || typeof content.text !== "string") return ""
return content.text
}
function mcpCommandName(server: string, prompt: string) {
return `${sanitize(server)}:${sanitize(prompt)}`
}
function sanitize(value: string) {
return value.replace(/[^a-zA-Z0-9_-]/g, "_")
}
const argsRegex = /(?:\[Image\s+\d+\]|"[^"]*"|'[^']*'|[^\s"']+)/gi
const placeholderRegex = /\$(\d+)/g
const quoteTrimRegex = /^["']|["']$/g
const shellRegex = /!`([^`]+)`/g
export const node = makeLocationNode({
service: Service,
layer,
deps: [MCP.node, EventV2.node, AppProcess.node, Config.node, Location.node],
})
+1 -1
View File
@@ -8,7 +8,7 @@ export class Timeout extends Schema.Class<Timeout>("ConfigV2.MCP.Timeout")({
description: "Maximum time in milliseconds to establish and initialize the MCP server.", description: "Maximum time in milliseconds to establish and initialize the MCP server.",
}), }),
request: PositiveInt.pipe(Schema.optional).annotate({ request: PositiveInt.pipe(Schema.optional).annotate({
description: "Maximum time in milliseconds to wait for MCP catalog/list requests after initialization.", description: "Maximum time in milliseconds to wait for each MCP request after initialization.",
}), }),
}) {} }) {}
+1 -4
View File
@@ -4,6 +4,7 @@ import { define } from "../../plugin/internal"
import { Effect } from "effect" import { Effect } from "effect"
import { Config } from "../../config" import { Config } from "../../config"
import { ModelV2 } from "../../model" import { ModelV2 } from "../../model"
import { ProviderV2 } from "../../provider"
export const Plugin = define({ export const Plugin = define({
id: "config-provider", id: "config-provider",
@@ -53,7 +54,6 @@ export const Plugin = define({
if (item.name !== undefined) provider.name = item.name if (item.name !== undefined) provider.name = item.name
if (item.api !== undefined) provider.api = { ...item.api } if (item.api !== undefined) provider.api = { ...item.api }
if (item.request !== undefined) { if (item.request !== undefined) {
Object.assign(provider.request.settings, item.request.settings)
Object.assign(provider.request.headers, item.request.headers) Object.assign(provider.request.headers, item.request.headers)
Object.assign(provider.request.body, item.request.body) Object.assign(provider.request.body, item.request.body)
} }
@@ -71,7 +71,6 @@ export const Plugin = define({
} }
} }
if (config.request !== undefined) { if (config.request !== undefined) {
Object.assign(model.request.settings, config.request.settings)
Object.assign(model.request.headers, config.request.headers) Object.assign(model.request.headers, config.request.headers)
Object.assign(model.request.body, config.request.body) Object.assign(model.request.body, config.request.body)
if (config.request.variant !== undefined) model.request.variant = config.request.variant if (config.request.variant !== undefined) model.request.variant = config.request.variant
@@ -82,13 +81,11 @@ export const Plugin = define({
if (!existing) { if (!existing) {
existing = { existing = {
id: variant.id, id: variant.id,
settings: {},
headers: {}, headers: {},
body: {}, body: {},
} }
model.variants.push(existing) model.variants.push(existing)
} }
Object.assign(existing.settings, variant.settings)
Object.assign(existing.headers, variant.headers) Object.assign(existing.headers, variant.headers)
Object.assign(existing.body, variant.body) Object.assign(existing.body, variant.body)
} }
-1
View File
@@ -5,7 +5,6 @@ import { ProviderV2 } from "../provider"
import { ModelV2 } from "../model" import { ModelV2 } from "../model"
export class Request extends Schema.Class<Request>("ConfigV2.Provider.Request")({ export class Request extends Schema.Class<Request>("ConfigV2.Provider.Request")({
settings: ProviderV2.Settings.pipe(Schema.optional),
headers: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional), headers: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
body: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), body: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
}) {} }) {}
-1
View File
@@ -40,6 +40,5 @@ export const migrations = (
import("./migration/20260622142730_simplify_session_context_epoch"), import("./migration/20260622142730_simplify_session_context_epoch"),
import("./migration/20260622170816_reset_v2_session_state"), import("./migration/20260622170816_reset_v2_session_state"),
import("./migration/20260622202450_simplify_session_input"), import("./migration/20260622202450_simplify_session_input"),
import("./migration/20260702134641_add_session_context_entry"),
]) ])
).map((module) => module.default) satisfies DatabaseMigration.Migration[] ).map((module) => module.default) satisfies DatabaseMigration.Migration[]
@@ -1,21 +0,0 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260702134641_add_session_context_entry",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`
CREATE TABLE \`session_context_entry\` (
\`session_id\` text NOT NULL,
\`key\` text NOT NULL,
\`value\` text NOT NULL,
\`time_created\` integer NOT NULL,
\`time_updated\` integer NOT NULL,
CONSTRAINT \`session_context_entry_pk\` PRIMARY KEY(\`session_id\`, \`key\`),
CONSTRAINT \`fk_session_context_entry_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
);
`)
})
},
} satisfies DatabaseMigration.Migration
-11
View File
@@ -154,17 +154,6 @@ export default {
CONSTRAINT \`fk_session_context_epoch_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE CONSTRAINT \`fk_session_context_epoch_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
); );
`) `)
yield* tx.run(`
CREATE TABLE \`session_context_entry\` (
\`session_id\` text NOT NULL,
\`key\` text NOT NULL,
\`value\` text NOT NULL,
\`time_created\` integer NOT NULL,
\`time_updated\` integer NOT NULL,
CONSTRAINT \`session_context_entry_pk\` PRIMARY KEY(\`session_id\`, \`key\`),
CONSTRAINT \`fk_session_context_entry_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
);
`)
yield* tx.run(` yield* tx.run(`
CREATE TABLE \`session_input\` ( CREATE TABLE \`session_input\` (
\`id\` text PRIMARY KEY, \`id\` text PRIMARY KEY,
+1
View File
@@ -0,0 +1 @@
File to save in: ~/.local/share/opencode/worktree/012780/location-layer-tiers/packages/core/src/effect/
+1 -1
View File
@@ -230,7 +230,7 @@ export function hoist<A, E, T extends Tag, const Items extends Replacements = re
if (existing && existing !== node) { if (existing && existing !== node) {
throw new Error(`Tag ${tag} has conflicting implementations for ${node.name}`) throw new Error(`Tag ${tag} has conflicting implementations for ${node.name}`)
} }
hoisted.set(node.name, rewriteReplacementDependencies(node, replacementMap)) hoisted.set(node.name, node)
return group([]) return group([])
} }
if (node.kind === "unbound") { if (node.kind === "unbound") {
+99 -224
View File
@@ -3,8 +3,7 @@ export * as EventV2 from "./event"
import { Cause, Context, Effect, Layer, Option, PubSub, Queue, Schema, Stream } from "effect" import { Cause, Context, Effect, Layer, Option, PubSub, Queue, Schema, Stream } from "effect"
import { Event } from "@opencode-ai/schema/event" import { Event } from "@opencode-ai/schema/event"
import type { Data, Definition, Payload } from "@opencode-ai/schema/event" import type { Data, Definition, Payload } from "@opencode-ai/schema/event"
import type { EventLog } from "@opencode-ai/schema/event-log" import { and, asc, eq, gt, inArray, sql } from "drizzle-orm"
import { and, asc, eq, gt, inArray, lte, sql } from "drizzle-orm"
import { Database } from "./database/database" import { Database } from "./database/database"
import { EventSequenceTable, EventTable } from "./event/sql" import { EventSequenceTable, EventTable } from "./event/sql"
import { Location } from "./location" import { Location } from "./location"
@@ -14,10 +13,6 @@ import { Durable } from "@opencode-ai/schema/durable-event-manifest"
export const ID = Event.ID export const ID = Event.ID
export type ID = import("@opencode-ai/schema/event").ID export type ID = import("@opencode-ai/schema/event").ID
export const Seq = Event.Seq
export type Seq = import("@opencode-ai/schema/event").Seq
export const Version = Event.Version
export type Version = import("@opencode-ai/schema/event").Version
export type { Data, Definition, Payload } from "@opencode-ai/schema/event" export type { Data, Definition, Payload } from "@opencode-ai/schema/event"
export type Subscriber<D extends Definition = Definition> = (event: Payload<D>) => Effect.Effect<void> export type Subscriber<D extends Definition = Definition> = (event: Payload<D>) => Effect.Effect<void>
@@ -68,12 +63,6 @@ export class InvalidDurableEventError extends Schema.TaggedErrorClass<InvalidDur
}, },
) {} ) {}
const envelope = (aggregateID: string, seq: number, version: number) => ({
aggregateID,
seq: Seq.make(seq),
version: Version.make(version),
})
const decodeSerializedEvent = (event: SerializedEvent): Payload => { const decodeSerializedEvent = (event: SerializedEvent): Payload => {
const definition = Durable.get(event.type) const definition = Durable.get(event.type)
if (!definition?.durable) { if (!definition?.durable) {
@@ -82,11 +71,58 @@ const decodeSerializedEvent = (event: SerializedEvent): Payload => {
return { return {
id: event.id, id: event.id,
type: definition.type, type: definition.type,
durable: envelope(event.aggregateID, event.seq, definition.durable.version), durable: { aggregateID: event.aggregateID, seq: event.seq, version: definition.durable.version },
data: Schema.decodeUnknownSync(definition.data)(event.data), data: Schema.decodeUnknownSync(definition.data)(event.data),
} }
} }
export const readAggregate = Effect.fn("EventV2.readAggregate")(function* <A>(
db: Database.Interface["db"],
input: {
readonly aggregateID: string
readonly after?: number
readonly limit: number
readonly manifest: {
readonly definitions: ReadonlyMap<string, Definition>
readonly schema: Schema.Decoder<A, never>
}
},
) {
const after = input.after ?? -1
const rows = yield* db
.select()
.from(EventTable)
.where(
and(
eq(EventTable.aggregate_id, input.aggregateID),
gt(EventTable.seq, after),
inArray(EventTable.type, Array.from(input.manifest.definitions.keys())),
),
)
.orderBy(asc(EventTable.seq))
.limit(input.limit + 1)
.all()
.pipe(Effect.orDie)
const page = rows.slice(0, input.limit)
const decode = Schema.decodeUnknownSync(input.manifest.schema)
const events = page.map((event) =>
decode({
id: event.id,
type: input.manifest.definitions.get(event.type)?.type ?? event.type,
durable: {
aggregateID: event.aggregate_id,
seq: event.seq,
version: input.manifest.definitions.get(event.type)?.durable?.version,
},
data: event.data,
}),
)
return {
events,
hasMore: rows.length > input.limit,
}
})
export class SubscriberOverflowError extends Schema.TaggedErrorClass<SubscriberOverflowError>()( export class SubscriberOverflowError extends Schema.TaggedErrorClass<SubscriberOverflowError>()(
"EventV2.SubscriberOverflow", "EventV2.SubscriberOverflow",
{ capacity: Schema.Int }, { capacity: Schema.Int },
@@ -103,11 +139,6 @@ export interface PublishOptions {
readonly commit?: (seq: number) => Effect.Effect<void> readonly commit?: (seq: number) => Effect.Effect<void>
} }
/** Marker/event union emitted by `log`. */
export type LogItem = Payload | EventLog.Synced
export const isSynced = (item: LogItem): item is EventLog.Synced => item.type === "log.synced"
export interface Interface { export interface Interface {
readonly publish: <D extends Definition>( readonly publish: <D extends Definition>(
definition: D, definition: D,
@@ -115,31 +146,8 @@ export interface Interface {
options?: PublishOptions, options?: PublishOptions,
) => Effect.Effect<Payload<D>> ) => Effect.Effect<Payload<D>>
readonly subscribe: <D extends Definition>(definition: D) => Stream.Stream<Payload<D>> readonly subscribe: <D extends Definition>(definition: D) => Stream.Stream<Payload<D>>
/** readonly all: () => Stream.Stream<Payload>
* Volatile live channel: every event published from now on, nothing before, readonly durable: (input: { readonly aggregateID: string; readonly after?: number }) => Stream.Stream<Payload>
* nothing across a disconnect. The only channel that carries non-durable
* events; consumers that need reliability combine `changes` with `log`.
*/
readonly live: () => Stream.Stream<Payload>
/**
* Durable, ordered, gap-free per-aggregate log read. `follow: false`
* completes at the end of the log; `follow: true` replays then transitions
* to live. Both modes emit one `Synced` marker at the captured replay
* watermark.
*/
readonly log: (input: {
readonly aggregateID: string
readonly after?: number
readonly follow?: boolean
}) => Stream.Stream<LogItem>
/**
* Coalescing hint channel: latest committed seq per aggregate, never a
* delivery guarantee. Emits `SweepRequired` first on every subscribe and
* whenever per-key retention is exceeded. Never fails under backpressure.
*/
readonly changes: () => Stream.Stream<EventLog.Change>
/** Latest committed seq per aggregate. Aggregates without events are absent. */
readonly sequences: (aggregateIDs: ReadonlyArray<string>) => Effect.Effect<ReadonlyMap<string, Seq>>
/** @deprecated Use `all()` and consume the returned stream. */ /** @deprecated Use `all()` and consume the returned stream. */
readonly listen: (listener: Subscriber) => Effect.Effect<Unsubscribe> readonly listen: (listener: Subscriber) => Effect.Effect<Unsubscribe>
readonly project: <D extends Definition>(definition: D, projector: Subscriber<D>) => Effect.Effect<void> readonly project: <D extends Definition>(definition: D, projector: Subscriber<D>) => Effect.Effect<void>
@@ -157,7 +165,7 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/Event") {} export class Service extends Context.Service<Service, Interface>()("@opencode/Event") {}
export const liveBounded = (events: Interface, capacity: number) => export const allBounded = (events: Interface, capacity: number) =>
Effect.gen(function* () { Effect.gen(function* () {
const queue = yield* Queue.dropping<Payload, SubscriberOverflowError>(capacity) const queue = yield* Queue.dropping<Payload, SubscriberOverflowError>(capacity)
const unsubscribe = yield* events.listen((event) => const unsubscribe = yield* events.listen((event) =>
@@ -173,13 +181,6 @@ export const liveBounded = (events: Interface, capacity: number) =>
export interface LayerOptions { export interface LayerOptions {
readonly beforeAggregateRead?: (aggregateID: string) => Effect.Effect<void> readonly beforeAggregateRead?: (aggregateID: string) => Effect.Effect<void>
/**
* Maximum distinct aggregates buffered per changes subscriber before the
* buffer is abandoned and the subscriber is told to sweep.
*/
readonly changesKeyCapacity?: number
/** Maximum durable rows read per page while replaying or tailing an aggregate log. */
readonly logReadPageSize?: number
} }
export const layerWith = (options?: LayerOptions) => export const layerWith = (options?: LayerOptions) =>
@@ -187,21 +188,14 @@ export const layerWith = (options?: LayerOptions) =>
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const pubsub = { const pubsub = {
live: yield* PubSub.unbounded<Payload>(), all: yield* PubSub.unbounded<Payload>(),
durable: new Map<string, Set<PubSub.PubSub<void>>>(), durable: new Map<string, Set<PubSub.PubSub<void>>>(),
typed: new Map<string, PubSub.PubSub<Payload>>(), typed: new Map<string, PubSub.PubSub<Payload>>(),
} }
const projectors = new Map<string, Subscriber[]>() const projectors = new Map<string, Subscriber[]>()
// TODO: Bind durable projectors to exact type+version before supporting incompatible historical payloads. // TODO: Bind durable projectors to exact type+version before supporting incompatible historical payloads.
const listeners = new Array<Subscriber>() const listeners = new Array<Subscriber>()
const changesKeyCapacity = options?.changesKeyCapacity ?? 4096
const changesSubscribers = new Set<{
readonly hints: Map<string, number>
sweepRequired: boolean
readonly wake: PubSub.PubSub<void>
}>()
const { db } = yield* Database.Service const { db } = yield* Database.Service
const logReadPageSize = options?.logReadPageSize ?? 512
const getOrCreate = (definition: Definition) => const getOrCreate = (definition: Definition) =>
Effect.gen(function* () { Effect.gen(function* () {
@@ -214,16 +208,13 @@ export const layerWith = (options?: LayerOptions) =>
yield* Effect.addFinalizer(() => yield* Effect.addFinalizer(() =>
Effect.gen(function* () { Effect.gen(function* () {
yield* PubSub.shutdown(pubsub.live) yield* PubSub.shutdown(pubsub.all)
yield* Effect.forEach( yield* Effect.forEach(
pubsub.durable.values(), pubsub.durable.values(),
(pubsubs) => Effect.forEach(pubsubs, PubSub.shutdown, { discard: true }), (pubsubs) => Effect.forEach(pubsubs, PubSub.shutdown, { discard: true }),
{ discard: true }, { discard: true },
) )
yield* Effect.forEach(pubsub.typed.values(), PubSub.shutdown, { discard: true }) yield* Effect.forEach(pubsub.typed.values(), PubSub.shutdown, { discard: true })
yield* Effect.forEach(changesSubscribers, (subscriber) => PubSub.shutdown(subscriber.wake), {
discard: true,
})
}), }),
) )
@@ -382,27 +373,6 @@ export const layerWith = (options?: LayerOptions) =>
(wake) => PubSub.publish(wake, undefined), (wake) => PubSub.publish(wake, undefined),
{ discard: true }, { discard: true },
) )
yield* Effect.forEach(
changesSubscribers,
(subscriber) =>
Effect.sync(() => {
// Coalesce to the latest seq per aggregate. Overflowing key
// cardinality abandons the buffer instead of dropping hints silently.
if (
subscriber.hints.size >= changesKeyCapacity &&
!subscriber.hints.has(committed.aggregateID)
) {
subscriber.hints.clear()
subscriber.sweepRequired = true
} else if (!subscriber.sweepRequired) {
subscriber.hints.set(
committed.aggregateID,
Math.max(subscriber.hints.get(committed.aggregateID) ?? -1, committed.seq),
)
}
}).pipe(Effect.andThen(PubSub.publish(subscriber.wake, undefined)), Effect.asVoid),
{ discard: true },
)
} }
return committed return committed
}), }),
@@ -426,7 +396,11 @@ export const layerWith = (options?: LayerOptions) =>
if (committed) { if (committed) {
event = { event = {
...event, ...event,
durable: envelope(committed.aggregateID, committed.seq, definition.durable.version), durable: {
aggregateID: committed.aggregateID,
seq: committed.seq,
version: definition.durable.version,
},
} }
yield* notify(event as Payload, true) yield* notify(event as Payload, true)
return event return event
@@ -454,7 +428,7 @@ export const layerWith = (options?: LayerOptions) =>
) )
const typed = pubsub.typed.get(event.type) const typed = pubsub.typed.get(event.type)
if (typed) yield* PubSub.publish(typed, event) if (typed) yield* PubSub.publish(typed, event)
yield* PubSub.publish(pubsub.live, event) yield* PubSub.publish(pubsub.all, event)
}) })
} }
@@ -506,7 +480,11 @@ export const layerWith = (options?: LayerOptions) =>
yield* notify( yield* notify(
{ {
...payload, ...payload,
durable: envelope(committed.aggregateID, committed.seq, definition.durable.version), durable: {
aggregateID: committed.aggregateID,
seq: committed.seq,
version: definition.durable.version,
},
}, },
true, true,
) )
@@ -574,49 +552,30 @@ export const layerWith = (options?: LayerOptions) =>
Stream.map((event) => event as Payload<D>), Stream.map((event) => event as Payload<D>),
) )
const streamLive = (): Stream.Stream<Payload> => Stream.fromPubSub(pubsub.live) const streamAll = (): Stream.Stream<Payload> => Stream.fromPubSub(pubsub.all)
const readAfter = ( const readAfter = (aggregateID: string, after: number) =>
aggregateID: string,
after: number,
input: { readonly through: number; readonly limit: number },
) =>
(options?.beforeAggregateRead?.(aggregateID) ?? Effect.void).pipe( (options?.beforeAggregateRead?.(aggregateID) ?? Effect.void).pipe(
Effect.andThen( Effect.andThen(
Effect.suspend(() => { db
const query = db .select()
.select() .from(EventTable)
.from(EventTable) .where(and(eq(EventTable.aggregate_id, aggregateID), gt(EventTable.seq, after)))
.where( .orderBy(asc(EventTable.seq))
and( .all(),
eq(EventTable.aggregate_id, aggregateID),
gt(EventTable.seq, after),
lte(EventTable.seq, input.through),
),
)
.orderBy(asc(EventTable.seq))
return query.limit(input.limit).all()
}),
), ),
Effect.orDie, Effect.orDie,
// Skip types missing from the durable manifest instead of failing the Effect.map((rows) =>
// read: the aggregate may hold events this process cannot decode. The rows.map((event) =>
// raw tail seq keeps cursors advancing across the resulting gaps. decodeSerializedEvent({
Effect.map((rows) => ({ id: event.id,
seq: rows.at(-1)?.seq, aggregateID: event.aggregate_id,
events: rows.flatMap((event) => { seq: event.seq,
if (!Durable.get(event.type)?.durable) return [] type: event.type,
return [ data: event.data,
decodeSerializedEvent({ }),
id: event.id, ),
aggregateID: event.aggregate_id, ),
seq: event.seq,
type: event.type,
data: event.data,
}),
]
}),
})),
) )
const subscribeDurable = (aggregateID: string) => const subscribeDurable = (aggregateID: string) =>
@@ -639,109 +598,27 @@ export const layerWith = (options?: LayerOptions) =>
return subscription return subscription
}) })
const log = (input: { const durable = (input: { readonly aggregateID: string; readonly after?: number }): Stream.Stream<Payload> =>
readonly aggregateID: string
readonly after?: number
readonly follow?: boolean
}): Stream.Stream<LogItem> =>
Stream.unwrap( Stream.unwrap(
Effect.gen(function* () { Effect.gen(function* () {
const wakes = yield* subscribeDurable(input.aggregateID)
let sequence = input.after ?? -1 let sequence = input.after ?? -1
const readThrough = (through: number): Stream.Stream<Payload> => const read = Effect.suspend(() => readAfter(input.aggregateID, sequence)).pipe(
Stream.paginate(sequence, (cursor) => Effect.tap((events) =>
readAfter(input.aggregateID, cursor, { through, limit: logReadPageSize }).pipe( Effect.sync(() => {
Effect.tap((page) => sequence = events.at(-1)?.durable?.seq ?? sequence
Effect.sync(() => { }),
sequence = page.seq ?? sequence
}),
),
Effect.map(
(page) =>
[
page.events,
page.seq !== undefined && page.seq < through ? Option.some(page.seq) : Option.none<number>(),
] as const,
),
),
)
// Subscribing before the historical read means events committed during
// replay either appear in the read or arrive through a post-marker wake.
const wakes = input.follow ? yield* subscribeDurable(input.aggregateID) : undefined
const target = yield* latestSequence(db, input.aggregateID)
const marker: EventLog.Synced = {
type: "log.synced",
aggregateID: input.aggregateID,
...(target >= 0 ? { seq: Seq.make(target) } : {}),
}
const replay: Stream.Stream<LogItem> = readThrough(target).pipe(
Stream.map((event): LogItem => event),
Stream.concat(Stream.make(marker)),
)
if (!wakes) return replay
const live: Stream.Stream<LogItem> = Stream.fromSubscription(wakes).pipe(
Stream.mapEffect(() => latestSequence(db, input.aggregateID)),
Stream.filter((target) => target > sequence),
Stream.flatMap((target) => readThrough(target)),
Stream.map((event): LogItem => event),
)
return Stream.concat(replay, live)
}),
)
const changes = (): Stream.Stream<EventLog.Change> =>
Stream.unwrap(
Effect.gen(function* () {
const wake = yield* PubSub.sliding<void>(1)
const subscription = yield* PubSub.subscribe(wake)
const subscriber = { hints: new Map<string, number>(), sweepRequired: false, wake }
yield* Effect.acquireRelease(
Effect.sync(() => changesSubscribers.add(subscriber)),
() =>
Effect.sync(() => changesSubscribers.delete(subscriber)).pipe(
Effect.andThen(PubSub.shutdown(wake)),
Effect.asVoid,
),
)
const drain = Effect.sync((): ReadonlyArray<EventLog.Change> => {
if (subscriber.sweepRequired) {
subscriber.sweepRequired = false
subscriber.hints.clear()
return [{ type: "log.sweep_required" }]
}
const hints = Array.from(
subscriber.hints,
([aggregateID, seq]): EventLog.Change => ({ type: "log.hint", aggregateID, seq: Seq.make(seq) }),
)
subscriber.hints.clear()
return hints
})
// Hints missed while unsubscribed were never buffered, so every
// (re)subscribe starts from the sweep contract.
const initial: EventLog.Change = { type: "log.sweep_required" }
return Stream.make(initial).pipe(
Stream.concat(
Stream.fromSubscription(subscription).pipe(
Stream.mapEffect(() => drain),
Stream.flattenIterable,
),
), ),
) )
const historical = yield* read
const live = Stream.fromSubscription(wakes).pipe(
Stream.mapEffect(() => read),
Stream.flattenIterable,
)
return Stream.concat(Stream.fromIterable(historical), live)
}), }),
) )
const sequences = (aggregateIDs: ReadonlyArray<string>): Effect.Effect<ReadonlyMap<string, Seq>> => {
if (aggregateIDs.length === 0) return Effect.succeed(new Map())
return db
.select({ aggregateID: EventSequenceTable.aggregate_id, seq: EventSequenceTable.seq })
.from(EventSequenceTable)
.where(inArray(EventSequenceTable.aggregate_id, Array.from(aggregateIDs)))
.all()
.pipe(
Effect.orDie,
Effect.map((rows) => new Map(rows.map((row) => [row.aggregateID, Seq.make(row.seq)]))),
)
}
const listen = (listener: Subscriber): Effect.Effect<Unsubscribe> => const listen = (listener: Subscriber): Effect.Effect<Unsubscribe> =>
Effect.sync(() => { Effect.sync(() => {
listeners.push(listener) listeners.push(listener)
@@ -761,10 +638,8 @@ export const layerWith = (options?: LayerOptions) =>
return Service.of({ return Service.of({
publish, publish,
subscribe, subscribe,
live: streamLive, all: streamAll,
log, durable,
changes,
sequences,
listen, listen,
project, project,
replay, replay,
-356
View File
@@ -1,356 +0,0 @@
export * as Form from "./form"
import { Form } from "@opencode-ai/schema/form"
import { Cache, Context, Deferred, Duration, Effect, Exit, Layer, Option, Schema } from "effect"
import { makeLocationNode } from "./effect/app-node"
import { EventV2 } from "./event"
const RETENTION = Duration.minutes(10)
export const ID = Form.ID
export type ID = typeof ID.Type
export const Info = Form.Info
export type Info = typeof Info.Type
export const Field = Form.Field
export type Field = Form.Field
export const When = Form.When
export type When = Form.When
export const State = Form.State
export type State = typeof State.Type
export const Answer = Form.Answer
export type Answer = typeof Answer.Type
export const Reply = Form.Reply
export type Reply = typeof Reply.Type
export const Event = Form.Event
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Form.NotFoundError", {
id: ID,
}) {
override get message() {
return `Form not found: ${this.id}`
}
}
export class AlreadySettledError extends Schema.TaggedErrorClass<AlreadySettledError>()("Form.AlreadySettledError", {
id: ID,
}) {
override get message() {
return `Form already settled: ${this.id}`
}
}
export class AlreadyExistsError extends Schema.TaggedErrorClass<AlreadyExistsError>()("Form.AlreadyExistsError", {
id: ID,
}) {
override get message() {
return `Form already exists: ${this.id}`
}
}
export class InvalidAnswerError extends Schema.TaggedErrorClass<InvalidAnswerError>()("Form.InvalidAnswerError", {
id: ID,
message: Schema.String,
}) {}
export class InvalidFormError extends Schema.TaggedErrorClass<InvalidFormError>()("Form.InvalidFormError", {
message: Schema.String,
}) {}
export type CreateInput =
| (Omit<Form.FormInfo, "id"> & { readonly id?: ID })
| (Omit<Form.UrlInfo, "id"> & { readonly id?: ID })
export interface ReplyInput {
readonly id: ID
readonly answer: Answer
}
export interface ListInput {
readonly sessionID?: Form.FormInfo["sessionID"]
}
export interface Interface {
readonly create: (input: CreateInput) => Effect.Effect<Info, AlreadyExistsError | InvalidFormError>
readonly ask: (input: CreateInput) => Effect.Effect<State, AlreadyExistsError | InvalidFormError>
readonly get: (id: ID) => Effect.Effect<Info, NotFoundError>
readonly list: (input?: ListInput) => Effect.Effect<ReadonlyArray<Info>>
readonly state: (id: ID) => Effect.Effect<State, NotFoundError>
readonly reply: (input: ReplyInput) => Effect.Effect<void, AlreadySettledError | InvalidAnswerError | NotFoundError>
readonly cancel: (id: ID) => Effect.Effect<void, AlreadySettledError | NotFoundError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Form") {}
interface Entry {
readonly form: Info
readonly state: State
readonly deferred: Deferred.Deferred<State>
}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const events = yield* EventV2.Service
const forms = yield* Cache.makeWith<ID, Entry>(() => Effect.die("Form cache must be used via set/getSuccess, never get"), {
capacity: Number.MAX_SAFE_INTEGER,
timeToLive: (exit) => (Exit.isSuccess(exit) && exit.value.state.status === "pending" ? Duration.infinity : RETENTION),
})
const find = Effect.fn("Form.find")(function* (id: ID) {
return yield* Cache.getSuccess(forms, id).pipe(
Effect.flatMap((entry) =>
Option.match(entry, {
onNone: () => Effect.fail(new NotFoundError({ id })),
onSome: Effect.succeed,
}),
),
)
})
const create = Effect.fn("Form.create")((input: CreateInput) =>
Effect.uninterruptible(
Effect.gen(function* () {
const id = input.id ?? ID.create()
const existing = yield* Cache.getSuccess(forms, id)
if (Option.isSome(existing)) return yield* new AlreadyExistsError({ id })
if (input.mode === "form") {
const invalid = validateFields(input.fields)
if (invalid) return yield* new InvalidFormError({ message: invalid })
}
const base = {
id,
sessionID: input.sessionID,
title: input.title,
...(input.metadata === undefined ? {} : { metadata: input.metadata }),
}
const form: Info =
input.mode === "form" ? { ...base, mode: "form", fields: input.fields } : { ...base, mode: "url", url: input.url }
const entry: Entry = {
form,
state: { status: "pending" },
deferred: yield* Deferred.make<State>(),
}
yield* Cache.set(forms, id, entry)
yield* events.publish(Event.Created, { form }).pipe(Effect.onError(() => Cache.invalidate(forms, id)))
return form
}),
),
)
const ask = Effect.fn("Form.ask")((input: CreateInput) =>
Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
const form = yield* create(input)
const entry = yield* find(form.id).pipe(Effect.orDie)
return yield* restore(Deferred.await(entry.deferred)).pipe(Effect.onInterrupt(() => Effect.ignore(cancel(form.id))))
}),
),
)
const get = Effect.fn("Form.get")(function* (id: ID) {
return (yield* find(id)).form
})
const list = Effect.fn("Form.list")(function* (input?: ListInput) {
const entries = yield* Cache.values(forms)
return Array.from(entries)
.filter((entry) => entry.state.status === "pending")
.filter((entry) => input?.sessionID === undefined || entry.form.sessionID === input.sessionID)
.map((entry) => entry.form)
})
const state = Effect.fn("Form.state")(function* (id: ID) {
return (yield* find(id)).state
})
const reply = Effect.fn("Form.reply")((input: ReplyInput) =>
Effect.uninterruptible(
Effect.gen(function* () {
const entry = yield* find(input.id)
if (entry.state.status !== "pending") return yield* new AlreadySettledError({ id: input.id })
const invalid = validateAnswer(entry.form, input.answer)
if (invalid) return yield* new InvalidAnswerError({ id: input.id, message: invalid })
const next: State = { status: "answered", answer: input.answer }
yield* events.publish(Event.Replied, { id: input.id, sessionID: entry.form.sessionID, answer: input.answer })
yield* Cache.set(forms, input.id, { ...entry, state: next })
yield* Deferred.succeed(entry.deferred, next)
}),
),
)
const cancel = Effect.fn("Form.cancel")((id: ID) =>
Effect.uninterruptible(
Effect.gen(function* () {
const entry = yield* find(id)
if (entry.state.status !== "pending") return yield* new AlreadySettledError({ id })
const next: State = { status: "cancelled" }
yield* events.publish(Event.Cancelled, { id, sessionID: entry.form.sessionID })
yield* Cache.set(forms, id, { ...entry, state: next })
yield* Deferred.succeed(entry.deferred, next)
}),
),
)
yield* Effect.addFinalizer(() =>
Cache.values(forms).pipe(
Effect.flatMap((entries) =>
Effect.forEach(
Array.from(entries).filter((entry) => entry.state.status === "pending"),
(entry) => cancel(entry.form.id).pipe(Effect.ignore),
{ discard: true },
),
),
),
)
return Service.of({ create, ask, get, list, state, reply, cancel })
}),
)
export const locationLayer = layer
export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.node] })
function validateAnswer(form: Info, answer: Answer) {
if (form.mode === "url") {
if (Object.keys(answer).length === 0) return
return "URL forms must be answered with an empty answer"
}
const fields = new Map(form.fields.map((field) => [field.key, field]))
for (const key of Object.keys(answer)) {
if (!fields.has(key)) return `Unknown form field: ${key}`
}
for (const field of form.fields) {
const value = answer[field.key]
const active = isActive(field, answer)
if (value === undefined) {
if (field.required && active) return `Missing required form field: ${field.key}`
continue
}
if (!active) return `Form field is not active: ${field.key}`
const invalid = validateField(field, value)
if (invalid) return invalid
}
}
function isActive(field: Form.Field, answer: Answer) {
if (!field.when) return true
return field.when.every((when) => matches(when, answer[when.key]))
}
// An unanswered referenced field makes the condition false for both ops. Combined with inactive
// fields being unanswerable, this cascades: hiding a field falsifies every condition referencing it.
function matches(when: Form.When, value: Form.Value | undefined) {
if (value === undefined) return false
const hit = Array.isArray(value) ? value.some((item) => item === when.value) : value === when.value
return when.op === "eq" ? hit : !hit
}
// Create-time validation of `when` references: each condition must point at an earlier field,
// carry a value matching that field's type, and use a declared option when the field's options
// are closed. Rejecting these at creation surfaces authoring mistakes to the caller instead of
// silently never matching.
function validateFields(fields: ReadonlyArray<Form.Field>) {
const earlier = new Map<string, Form.Field>()
for (const field of fields) {
if (earlier.has(field.key)) return `Duplicate form field key: ${field.key}`
for (const when of field.when ?? []) {
const target = earlier.get(when.key)
if (!target) return `Form field condition must reference an earlier field: ${field.key} -> ${when.key}`
const invalid = validateWhen(when, target)
if (invalid) return `${invalid}: ${field.key} -> ${when.key}`
}
earlier.set(field.key, field)
}
}
function validateWhen(when: Form.When, target: Form.Field) {
if (target.type === "boolean") {
if (typeof when.value !== "boolean") return "Form field condition value must be a boolean"
return
}
if (target.type === "number" || target.type === "integer") {
if (typeof when.value !== "number") return "Form field condition value must be a number"
return
}
// string and multiselect targets both compare against string values
if (typeof when.value !== "string") return "Form field condition value must be a string"
const closed = target.type === "multiselect" ? !target.custom : target.options !== undefined && !target.custom
if (closed && !target.options?.some((option) => option.value === when.value)) {
return "Form field condition value must be one of the field's options"
}
}
function validateField(field: Form.Field, value: Form.Value): string | undefined {
if (field.type === "string") {
if (typeof value !== "string") return `Expected string for form field: ${field.key}`
if (field.required && value.length === 0) return `Missing required form field: ${field.key}`
if (field.minLength !== undefined && value.length < field.minLength) return `Form field is too short: ${field.key}`
if (field.maxLength !== undefined && value.length > field.maxLength) return `Form field is too long: ${field.key}`
if (field.pattern !== undefined) {
try {
if (!new RegExp(field.pattern).test(value)) return `Form field does not match pattern: ${field.key}`
} catch {
return `Form field has invalid pattern: ${field.key}`
}
}
if (field.format === "email" && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) return `Expected email for form field: ${field.key}`
if (field.format === "uri" && !isUri(value)) return `Expected URI for form field: ${field.key}`
if (field.format === "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)) {
return `Invalid option for form field: ${field.key}`
}
return
}
if (field.type === "number" || field.type === "integer") {
if (typeof value !== "number" || !Number.isFinite(value)) return `Expected number for form field: ${field.key}`
if (field.type === "integer" && !Number.isInteger(value)) return `Expected integer for form field: ${field.key}`
if (field.minimum !== undefined && value < field.minimum) return `Form field is too small: ${field.key}`
if (field.maximum !== undefined && value > field.maximum) return `Form field is too large: ${field.key}`
return
}
if (field.type === "boolean") {
if (typeof value !== "boolean") return `Expected boolean for form field: ${field.key}`
return
}
if (field.type === "multiselect") {
if (!isStringArray(value)) return `Expected string array for form field: ${field.key}`
if (field.required && value.length === 0) return `Missing required form field: ${field.key}`
if (field.minItems !== undefined && value.length < field.minItems) return `Too few selections for form field: ${field.key}`
if (field.maxItems !== undefined && value.length > field.maxItems) return `Too many selections for form field: ${field.key}`
if (!field.custom && value.some((item) => !field.options.some((option) => option.value === item))) {
return `Invalid option for form field: ${field.key}`
}
}
}
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`)
return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value
}
function isDateTime(value: string) {
return !Number.isNaN(new Date(value).getTime())
}
+21 -22
View File
@@ -1,6 +1,6 @@
export * as InstructionContext from "./instruction-context" export * as InstructionContext from "./instruction-context"
import { Array, Context, Effect, Layer, Schema } from "effect" import { Array, Effect, Layer, Schema } from "effect"
import { isAbsolute, join, relative, sep } from "path" import { isAbsolute, join, relative, sep } from "path"
import { FSUtil } from "./fs-util" import { FSUtil } from "./fs-util"
import { Flag } from "./flag/flag" import { Flag } from "./flag/flag"
@@ -8,6 +8,7 @@ import { Global } from "./global"
import { Location } from "./location" import { Location } from "./location"
import { AbsolutePath } from "./schema" import { AbsolutePath } from "./schema"
import { SystemContext } from "./system-context/index" import { SystemContext } from "./system-context/index"
import { SystemContextRegistry } from "./system-context/registry"
import { makeLocationNode } from "./effect/app-node" import { makeLocationNode } from "./effect/app-node"
class File extends Schema.Class<File>("InstructionContext.File")({ class File extends Schema.Class<File>("InstructionContext.File")({
@@ -18,18 +19,12 @@ class File extends Schema.Class<File>("InstructionContext.File")({
const Files = Schema.Array(File) const Files = Schema.Array(File)
const key = SystemContext.Key.make("core/instructions") const key = SystemContext.Key.make("core/instructions")
export interface Interface { const layer = Layer.effectDiscard(
readonly load: () => Effect.Effect<SystemContext.SystemContext>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/InstructionContext") {}
const layer = Layer.effect(
Service,
Effect.gen(function* () { Effect.gen(function* () {
const fs = yield* FSUtil.Service const fs = yield* FSUtil.Service
const global = yield* Global.Service const global = yield* Global.Service
const location = yield* Location.Service const location = yield* Location.Service
const registry = yield* SystemContextRegistry.Service
const source = (value: ReadonlyArray<File> | SystemContext.Unavailable) => const source = (value: ReadonlyArray<File> | SystemContext.Unavailable) =>
SystemContext.make({ SystemContext.make({
@@ -76,24 +71,28 @@ const layer = Layer.effect(
return files.filter((file): file is File => file !== undefined) return files.filter((file): file is File => file !== undefined)
}) })
return Service.of({ yield* registry.register({
load: () => key,
observe().pipe( load: observe().pipe(
Effect.map((files) => Effect.map((files) =>
files === SystemContext.unavailable files === SystemContext.unavailable
? source(files) ? source(files)
: files.length === 0 : files.length === 0
? SystemContext.empty ? SystemContext.empty
: source(files), : source(files),
),
Effect.catch(() => Effect.succeed(source(SystemContext.unavailable))),
Effect.catchDefect(() => Effect.succeed(source(SystemContext.unavailable))),
), ),
Effect.catch(() => Effect.succeed(source(SystemContext.unavailable))),
Effect.catchDefect(() => Effect.succeed(source(SystemContext.unavailable))),
),
}) })
}), }),
) )
export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node, Global.node, Location.node] }) export const node = makeLocationNode({
name: "instruction-context",
layer,
deps: [FSUtil.node, Global.node, Location.node, SystemContextRegistry.node],
})
function render(files: ReadonlyArray<File>) { function render(files: ReadonlyArray<File>) {
return files.map((file) => `Instructions from: ${file.path}\n${file.content}`).join("\n\n") return files.map((file) => `Instructions from: ${file.path}\n${file.content}`).join("\n\n")
+4 -14
View File
@@ -10,7 +10,6 @@ import { FileMutation } from "./file-mutation"
import { FileSystem } from "./filesystem" import { FileSystem } from "./filesystem"
import { FileSystemSearch } from "./filesystem/search" import { FileSystemSearch } from "./filesystem/search"
import { Generate } from "./generate" import { Generate } from "./generate"
import { Form } from "./form"
import { Watcher } from "./filesystem/watcher" import { Watcher } from "./filesystem/watcher"
import { Image } from "./image" import { Image } from "./image"
import { Integration } from "./integration" import { Integration } from "./integration"
@@ -37,15 +36,13 @@ import { SessionTodo } from "./session/todo"
import { SkillV2 } from "./skill" import { SkillV2 } from "./skill"
import { SkillGuidance } from "./skill/guidance" import { SkillGuidance } from "./skill/guidance"
import { Snapshot } from "./snapshot" import { Snapshot } from "./snapshot"
import { InstructionContext } from "./instruction-context"
import { SystemContextBuiltIns } from "./system-context/builtins" import { SystemContextBuiltIns } from "./system-context/builtins"
import { SessionContextEntry } from "./session/context-entry" import { SystemContextRegistry } from "./system-context/registry"
import { SessionInstructions } from "./session/instructions" import { BuiltInTools } from "./tool/builtins"
import { McpTool } from "./tool/mcp" import { McpTool } from "./tool/mcp"
import { ReadToolFileSystem } from "./tool/read-filesystem" import { ReadToolFileSystem } from "./tool/read-filesystem"
import { ToolRegistry } from "./tool/registry" import { ToolRegistry } from "./tool/registry"
import { ToolOutputStore } from "./tool-output-store" import { ToolOutputStore } from "./tool-output-store"
import { Vcs } from "./vcs"
export { LocationServiceMap } from "./location-service-map" export { LocationServiceMap } from "./location-service-map"
@@ -70,8 +67,8 @@ export const locationServices = LayerNode.group([
Pty.node, Pty.node,
Shell.node, Shell.node,
SkillV2.node, SkillV2.node,
SystemContextRegistry.node,
SystemContextBuiltIns.node, SystemContextBuiltIns.node,
InstructionContext.node,
LocationMutation.node, LocationMutation.node,
FileMutation.node, FileMutation.node,
MCP.node, MCP.node,
@@ -83,19 +80,16 @@ export const locationServices = LayerNode.group([
SkillGuidance.node, SkillGuidance.node,
ReferenceGuidance.node, ReferenceGuidance.node,
SessionTodo.node, SessionTodo.node,
SessionContextEntry.node,
Form.node,
QuestionV2.node, QuestionV2.node,
Generate.node, Generate.node,
ReadToolFileSystem.node, ReadToolFileSystem.node,
BuiltInTools.node,
McpTool.node, McpTool.node,
SessionInstructions.node,
SessionRunnerModel.node, SessionRunnerModel.node,
SessionCompaction.node, SessionCompaction.node,
SessionTitle.node, SessionTitle.node,
Snapshot.node, Snapshot.node,
SessionRunnerLLM.node, SessionRunnerLLM.node,
Vcs.node,
]) ])
export type LocationServices = LayerNode.Output<typeof locationServices> export type LocationServices = LayerNode.Output<typeof locationServices>
@@ -109,10 +103,6 @@ export function buildLocationServiceMap(
LayerMap.make( LayerMap.make(
(ref: Location.Ref) => { (ref: Location.Ref) => {
const allReplacements = replacements.concat([[Location.node, Location.boundNode(ref)]]) 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) const location = LayerNode.hoist(locationServices, Node.tags.values.global, allReplacements)
return LayerNode.compile(location.node).pipe( return LayerNode.compile(location.node).pipe(
+2 -116
View File
@@ -9,18 +9,8 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/
import { UnauthorizedError, type OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js" import { UnauthorizedError, type OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"
import { import {
CallToolResultSchema, CallToolResultSchema,
ElicitationCompleteNotificationSchema,
ElicitRequestSchema,
GetPromptResultSchema,
type ElicitRequestFormParams,
type ElicitRequestParams,
type ElicitRequestURLParams,
type ElicitResult,
ListPromptsResultSchema,
ListRootsRequestSchema, ListRootsRequestSchema,
ListToolsResultSchema, ListToolsResultSchema,
PromptListChangedNotificationSchema,
PromptSchema,
type LoggingMessageNotification, type LoggingMessageNotification,
LoggingMessageNotificationSchema, LoggingMessageNotificationSchema,
ToolListChangedNotificationSchema, ToolListChangedNotificationSchema,
@@ -32,7 +22,6 @@ import { InstallationVersion } from "../installation/version"
const DEFAULT_STARTUP_TIMEOUT = 30_000 const DEFAULT_STARTUP_TIMEOUT = 30_000
const DEFAULT_REQUEST_TIMEOUT = 30_000 const DEFAULT_REQUEST_TIMEOUT = 30_000
const DEFAULT_TOOL_CALL_TIMEOUT = 100_000_000
type Transport = StdioClientTransport | StreamableHTTPClientTransport type Transport = StdioClientTransport | StreamableHTTPClientTransport
@@ -41,9 +30,6 @@ type Transport = StdioClientTransport | StreamableHTTPClientTransport
const TolerantListToolsResult = ListToolsResultSchema.extend({ const TolerantListToolsResult = ListToolsResultSchema.extend({
tools: ToolSchema.omit({ outputSchema: true }).array(), tools: ToolSchema.omit({ outputSchema: true }).array(),
}) })
const TolerantListPromptsResult = ListPromptsResultSchema.extend({
prompts: PromptSchema.array(),
})
export class NeedsAuthError extends Schema.TaggedErrorClass<NeedsAuthError>()("MCP.NeedsAuthError", { export class NeedsAuthError extends Schema.TaggedErrorClass<NeedsAuthError>()("MCP.NeedsAuthError", {
server: Schema.String, server: Schema.String,
@@ -60,25 +46,6 @@ export interface ToolDefinition {
readonly inputSchema: unknown readonly inputSchema: unknown
} }
export interface PromptDefinition {
readonly name: string
readonly description: string | undefined
readonly arguments: ReadonlyArray<{
readonly name: string
readonly description: string | undefined
readonly required: boolean | undefined
}> | undefined
}
export interface PromptMessage {
readonly role: string
readonly content: unknown
}
export interface PromptResult {
readonly messages: ReadonlyArray<PromptMessage>
}
export type CallToolContent = export type CallToolContent =
| { readonly type: "text"; readonly text: string } | { readonly type: "text"; readonly text: string }
| { readonly type: "media"; readonly data: string; readonly mimeType: string } | { readonly type: "media"; readonly data: string; readonly mimeType: string }
@@ -89,22 +56,6 @@ export interface CallToolResult {
readonly content: ReadonlyArray<CallToolContent> readonly content: ReadonlyArray<CallToolContent>
} }
export type ElicitationFormParams = ElicitRequestFormParams
export type ElicitationParams = ElicitRequestParams
export type ElicitationResult = ElicitResult
export interface ElicitationHandler {
readonly create: (input: {
readonly server: string
readonly params: ElicitationParams
readonly signal: AbortSignal
}) => Effect.Effect<ElicitationResult, Error>
readonly complete: (input: {
readonly server: string
readonly elicitationID: ElicitRequestURLParams["elicitationId"]
}) => Effect.Effect<void>
}
export interface LogMessage { export interface LogMessage {
readonly level: LoggingMessageNotification["params"]["level"] readonly level: LoggingMessageNotification["params"]["level"]
readonly logger?: LoggingMessageNotification["params"]["logger"] readonly logger?: LoggingMessageNotification["params"]["logger"]
@@ -117,13 +68,6 @@ export interface Connection {
readonly instructions: string | undefined readonly instructions: string | undefined
/** Lists the server's tools; returns [] when the server doesn't advertise tool support, fails on a transport error. */ /** Lists the server's tools; returns [] when the server doesn't advertise tool support, fails on a transport error. */
readonly tools: () => Effect.Effect<ToolDefinition[], Error> readonly tools: () => Effect.Effect<ToolDefinition[], Error>
/** Lists the server's prompts; returns [] when the server doesn't advertise prompt support, fails on a transport error. */
readonly prompts: () => Effect.Effect<PromptDefinition[], Error>
/** Invokes a prompt on the server. Interruption aborts the in-flight request. */
readonly prompt: (input: {
readonly name: string
readonly args?: Record<string, string>
}) => Effect.Effect<PromptResult, Error>
/** Invokes a tool on the server. Interruption aborts the in-flight request. */ /** Invokes a tool on the server. Interruption aborts the in-flight request. */
readonly callTool: (input: { readonly callTool: (input: {
readonly name: string readonly name: string
@@ -134,8 +78,6 @@ export interface Connection {
readonly onLog: (callback: (message: LogMessage) => void) => void readonly onLog: (callback: (message: LogMessage) => void) => void
/** Registers a callback fired when the server announces its tool list changed; no-op if unsupported. */ /** Registers a callback fired when the server announces its tool list changed; no-op if unsupported. */
readonly onToolsChanged: (callback: () => void) => void readonly onToolsChanged: (callback: () => void) => void
/** Registers a callback fired when the server announces its prompt list changed; no-op if unsupported. */
readonly onPromptsChanged: (callback: () => void) => void
} }
/** Connects an MCP server; closing the calling scope tears down the transport and any spawned process. */ /** Connects an MCP server; closing the calling scope tears down the transport and any spawned process. */
@@ -146,7 +88,6 @@ export const connect = Effect.fnUntraced(function* (
// Only consumed by the remote transport; stdio servers have no auth concept. A provider with no // Only consumed by the remote transport; stdio servers have no auth concept. A provider with no
// stored token (and a no-op redirect) surfaces an UnauthorizedError, which we map to needs_auth. // stored token (and a no-op redirect) surfaces an UnauthorizedError, which we map to needs_auth.
authProvider?: OAuthClientProvider, authProvider?: OAuthClientProvider,
elicitation?: ElicitationHandler,
) { ) {
const transport: Transport = yield* Effect.gen(function* () { const transport: Transport = yield* Effect.gen(function* () {
if (config.type === "local") { if (config.type === "local") {
@@ -173,7 +114,6 @@ export const connect = Effect.fnUntraced(function* (
{ name: "opencode", version: InstallationVersion }, { name: "opencode", version: InstallationVersion },
{ {
capabilities: { capabilities: {
...(elicitation ? { elicitation: { form: { applyDefaults: true }, url: {} } } : {}),
// https://github.com/anomalyco/opencode/issues/2308 // https://github.com/anomalyco/opencode/issues/2308
roots: {}, roots: {},
}, },
@@ -182,14 +122,6 @@ export const connect = Effect.fnUntraced(function* (
client.setRequestHandler(ListRootsRequestSchema, () => client.setRequestHandler(ListRootsRequestSchema, () =>
Promise.resolve({ roots: [{ uri: pathToFileURL(directory).href }] }), Promise.resolve({ roots: [{ uri: pathToFileURL(directory).href }] }),
) )
if (elicitation) {
client.setRequestHandler(ElicitRequestSchema, (request, extra) =>
Effect.runPromise(elicitation.create({ server, params: request.params, signal: extra.signal })),
)
client.setNotificationHandler(ElicitationCompleteNotificationSchema, (notification) =>
Effect.runPromise(elicitation.complete({ server, elicitationID: notification.params.elicitationId })),
)
}
const exit = yield* Effect.tryPromise({ const exit = yield* Effect.tryPromise({
try: (signal) => client.connect(transport, { timeout: config.timeout?.startup ?? DEFAULT_STARTUP_TIMEOUT, signal }), try: (signal) => client.connect(transport, { timeout: config.timeout?.startup ?? DEFAULT_STARTUP_TIMEOUT, signal }),
@@ -234,56 +166,14 @@ export const connect = Effect.fnUntraced(function* (
inputSchema: tool.inputSchema, inputSchema: tool.inputSchema,
})) }))
}), }),
prompts: () =>
Effect.gen(function* () {
if (!client.getServerCapabilities()?.prompts) return []
const prompts = yield* Effect.tryPromise({
try: () =>
paginate(
async (cursor) => {
const params = cursor === undefined ? undefined : { cursor }
return client.request({ method: "prompts/list", params }, TolerantListPromptsResult, {
timeout: requestTimeout,
})
},
(result) => result.prompts,
),
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
}).pipe(
Effect.tapError((error) => Effect.logWarning("failed to list MCP prompts", { server, error: error.message })),
)
return prompts.map((prompt) => ({
name: prompt.name,
description: prompt.description,
arguments: prompt.arguments?.map((argument) => ({
name: argument.name,
description: argument.description,
required: argument.required,
})),
}))
}),
prompt: (input) =>
Effect.tryPromise({
try: (signal) =>
client.request(
{ method: "prompts/get", params: { name: input.name, arguments: input.args ?? {} } },
GetPromptResultSchema,
{ signal },
),
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
}).pipe(
Effect.map((result) => ({
messages: result.messages.map((message) => ({ role: message.role, content: message.content })),
})),
),
callTool: (input) => callTool: (input) =>
Effect.tryPromise({ Effect.tryPromise({
try: (signal) => try: (signal) =>
client.callTool( client.callTool(
{ name: input.name, arguments: input.args ?? {} }, { name: input.name, arguments: input.args ?? {} },
CallToolResultSchema, CallToolResultSchema,
// Human-driven tools can exceed the SDK default; use an effectively infinite timeout. // The SDK only sends a progress token when onprogress is present, which enables timeout resets.
{ signal, timeout: DEFAULT_TOOL_CALL_TIMEOUT, resetTimeoutOnProgress: true, onprogress: () => {} }, { signal, timeout: requestTimeout, resetTimeoutOnProgress: true, onprogress: () => {} },
), ),
catch: (error) => (error instanceof Error ? error : new Error(String(error))), catch: (error) => (error instanceof Error ? error : new Error(String(error))),
}).pipe( }).pipe(
@@ -317,10 +207,6 @@ export const connect = Effect.fnUntraced(function* (
if (!client.getServerCapabilities()?.tools?.listChanged) return if (!client.getServerCapabilities()?.tools?.listChanged) return
client.setNotificationHandler(ToolListChangedNotificationSchema, async () => callback()) client.setNotificationHandler(ToolListChangedNotificationSchema, async () => callback())
}, },
onPromptsChanged: (callback) => {
if (!client.getServerCapabilities()?.prompts?.listChanged) return
client.setNotificationHandler(PromptListChangedNotificationSchema, async () => callback())
},
} satisfies Connection } satisfies Connection
} }
+14 -35
View File
@@ -14,40 +14,16 @@ const Summary = Schema.Struct({
}) })
type Summary = typeof Summary.Type type Summary = typeof Summary.Type
const entries = (servers: ReadonlyArray<Summary>) =>
servers.flatMap((server) => [
` <server name="${server.server}">`,
...server.instructions.split("\n").map((line) => ` ${line}`),
" </server>",
])
const render = (servers: ReadonlyArray<Summary>) => const render = (servers: ReadonlyArray<Summary>) =>
["<mcp_instructions>", ...entries(servers), "</mcp_instructions>"].join("\n") [
"<mcp_instructions>",
const update = (previous: ReadonlyArray<Summary>, current: ReadonlyArray<Summary>) => { ...servers.flatMap((server) => [
const diff = SystemContext.diffByKey( ` <server name="${server.server}">`,
previous, ...server.instructions.split("\n").map((line) => ` ${line}`),
current, " </server>",
(server) => server.server, ]),
(before, after) => before.instructions !== after.instructions, "</mcp_instructions>",
)
// Additions and removals render as small deltas; anything else restates the full list.
if (diff.changed.length > 0 || (diff.added.length === 0 && diff.removed.length === 0))
return [
"The available MCP server instructions have changed. This list supersedes the previous one.",
render(current),
].join("\n")
return [
...(diff.added.length === 0
? []
: ["New MCP server instructions are available in addition to those previously listed:", ...entries(diff.added)]),
...(diff.removed.length === 0
? []
: [
`Instructions for the following MCP servers are no longer available: ${diff.removed.map((server) => server.server).join(", ")}.`,
]),
].join("\n") ].join("\n")
}
export interface Interface { export interface Interface {
readonly load: (agent: AgentV2.Selection) => Effect.Effect<SystemContext.SystemContext> readonly load: (agent: AgentV2.Selection) => Effect.Effect<SystemContext.SystemContext>
@@ -74,8 +50,7 @@ export const layer = Layer.effect(
return ( return (
owned.length === 0 || owned.length === 0 ||
owned.some( owned.some(
(tool) => (tool) => PermissionV2.evaluate(McpTool.name(tool.server, tool.name), "*", agent.permissions).effect !== "deny",
PermissionV2.evaluate(McpTool.name(tool.server, tool.name), "*", agent.permissions).effect !== "deny",
) )
) )
}) })
@@ -86,7 +61,11 @@ export const layer = Layer.effect(
codec: Schema.toCodecJson(Schema.Array(Summary)), codec: Schema.toCodecJson(Schema.Array(Summary)),
load: Effect.succeed(visible), load: Effect.succeed(visible),
baseline: render, baseline: render,
update, update: (_previous, current) =>
[
"The available MCP server instructions have changed. This list supersedes the previous one.",
render(current),
].join("\n"),
removed: () => "MCP server instructions are no longer available.", removed: () => "MCP server instructions are no longer available.",
}) })
}), }),
+8 -200
View File
@@ -2,7 +2,6 @@ export * as MCP from "./index"
import { Mcp } from "@opencode-ai/schema/mcp" import { Mcp } from "@opencode-ai/schema/mcp"
import { McpEvent } from "@opencode-ai/schema/mcp-event" import { McpEvent } from "@opencode-ai/schema/mcp-event"
import { Command } from "@opencode-ai/schema/command"
import { createHash } from "node:crypto" import { createHash } from "node:crypto"
import { Cause, Context, Deferred, Effect, Exit, FiberSet, Layer, Schema, Scope, Stream } from "effect" import { Cause, Context, Deferred, Effect, Exit, FiberSet, Layer, Schema, Scope, Stream } from "effect"
import { makeLocationNode } from "../effect/app-node" import { makeLocationNode } from "../effect/app-node"
@@ -10,11 +9,9 @@ import { Config } from "../config"
import { ConfigMCP } from "../config/mcp" import { ConfigMCP } from "../config/mcp"
import { Credential } from "../credential" import { Credential } from "../credential"
import { EventV2 } from "../event" import { EventV2 } from "../event"
import { Form } from "../form"
import { Integration } from "../integration" import { Integration } from "../integration"
import { IntegrationConnection } from "../integration/connection" import { IntegrationConnection } from "../integration/connection"
import { Location } from "../location" import { Location } from "../location"
import { waitForAbort } from "../process"
import { MCPClient } from "./client" import { MCPClient } from "./client"
import { MCPOAuth } from "./oauth" import { MCPOAuth } from "./oauth"
@@ -142,15 +139,10 @@ type ServerEntry = {
scope?: Scope.Closeable scope?: Scope.Closeable
client?: MCPClient.Connection client?: MCPClient.Connection
tools?: ReadonlyArray<Tool> tools?: ReadonlyArray<Tool>
prompts?: ReadonlyArray<Prompt>
// Set when a remote server is registered as an OAuth integration; the credential lives in the global store. // Set when a remote server is registered as an OAuth integration; the credential lives in the global store.
integrationID?: Integration.ID integrationID?: Integration.ID
} }
// MCP elicitations are Location-scoped, not Session-scoped: the server cannot attribute them to a
// persisted session row, so their forms are owned by this opaque sentinel session identifier.
const GLOBAL_ELICITATION_SESSION_ID = "global"
export interface Interface { export interface Interface {
readonly servers: () => Effect.Effect<ServerInfo[]> readonly servers: () => Effect.Effect<ServerInfo[]>
readonly tools: () => Effect.Effect<Tool[]> readonly tools: () => Effect.Effect<Tool[]>
@@ -181,7 +173,6 @@ export const layer = Layer.effect(
const config = yield* Config.Service const config = yield* Config.Service
const location = yield* Location.Service const location = yield* Location.Service
const events = yield* EventV2.Service const events = yield* EventV2.Service
const forms = yield* Form.Service
const integration = yield* Integration.Service const integration = yield* Integration.Service
const credentials = yield* Credential.Service const credentials = yield* Credential.Service
const root = yield* Scope.make() const root = yield* Scope.make()
@@ -196,7 +187,6 @@ export const layer = Layer.effect(
) )
// Later config files win for duplicate server names; per-server timeout overrides globals. // Later config files win for duplicate server names; per-server timeout overrides globals.
const runtime = new Map<ServerName, ServerEntry>() const runtime = new Map<ServerName, ServerEntry>()
const urlElicitations = new Map<string, Form.ID>()
for (const entry of documents) { for (const entry of documents) {
for (const [name, server] of Object.entries(entry.info.mcp?.servers ?? {})) { for (const [name, server] of Object.entries(entry.info.mcp?.servers ?? {})) {
runtime.set(ServerName.make(name), { runtime.set(ServerName.make(name), {
@@ -316,94 +306,9 @@ export const layer = Layer.effect(
}) })
}) })
const elicitation = {
create: (input: {
readonly server: string
readonly params: MCPClient.ElicitationParams
readonly signal: AbortSignal
}) =>
Effect.gen(function* () {
if (input.params.mode === "url") {
const formID = Form.ID.create()
const key = input.server + "\u0000" + input.params.elicitationId
urlElicitations.set(key, formID)
return yield* forms
.ask({
id: formID,
sessionID: GLOBAL_ELICITATION_SESSION_ID,
title: `${input.server} is requesting input`,
metadata: {
kind: "mcp-elicitation",
server: input.server,
elicitationID: input.params.elicitationId,
message: input.params.message,
},
mode: "url",
url: input.params.url,
})
.pipe(
Effect.raceFirst(waitForAbort(input.signal)),
Effect.ensuring(Effect.sync(() => urlElicitations.delete(key))),
Effect.map(
(state): MCPClient.ElicitationResult => ({
action: state.status === "answered" ? "accept" : "cancel",
}),
),
)
}
const params = input.params
return yield* forms
.ask({
sessionID: GLOBAL_ELICITATION_SESSION_ID,
title: `${input.server} is requesting input`,
metadata: { kind: "mcp-elicitation", server: input.server, message: params.message },
mode: "form",
fields: Object.entries(params.requestedSchema.properties).map(([key, property]) =>
toElicitationField(key, property, params.requestedSchema.required?.includes(key) === true),
),
})
.pipe(
Effect.raceFirst(waitForAbort(input.signal)),
Effect.map((state): MCPClient.ElicitationResult => {
if (state.status !== "answered") return { action: "cancel" }
return {
action: "accept",
content: Object.fromEntries(
Object.entries(state.answer).map(
([key, value]): [string, NonNullable<MCPClient.ElicitationResult["content"]>[string]] =>
typeof value === "object" ? [key, Array.from(value)] : [key, value],
),
),
}
}),
)
}),
complete: (input: { readonly server: string; readonly elicitationID: string }) =>
Effect.gen(function* () {
const formID = urlElicitations.get(input.server + "\u0000" + input.elicitationID)
if (!formID) return
yield* forms.reply({ id: formID, answer: {} }).pipe(Effect.ignore)
}),
} satisfies MCPClient.ElicitationHandler
const toTool = (server: ServerName, def: MCPClient.ToolDefinition) => const toTool = (server: ServerName, def: MCPClient.ToolDefinition) =>
new Tool({ server, name: def.name, description: def.description, inputSchema: def.inputSchema }) new Tool({ server, name: def.name, description: def.description, inputSchema: def.inputSchema })
const toPrompt = (server: ServerName, def: MCPClient.PromptDefinition) =>
new Prompt({
server,
name: def.name,
description: def.description,
arguments: def.arguments?.map(
(argument) =>
new PromptArgument({
name: argument.name,
description: argument.description,
required: argument.required,
}),
),
})
const refreshTools = (name: ServerName, entry: ServerEntry, connection: MCPClient.Connection) => const refreshTools = (name: ServerName, entry: ServerEntry, connection: MCPClient.Connection) =>
connection.tools().pipe( connection.tools().pipe(
Effect.map((defs) => { Effect.map((defs) => {
@@ -411,17 +316,6 @@ export const layer = Layer.effect(
}), }),
) )
const refreshPrompts = (name: ServerName, entry: ServerEntry, connection: MCPClient.Connection) =>
connection.prompts().pipe(
Effect.map((defs) => {
entry.prompts = defs.map((def) => toPrompt(name, def))
}),
Effect.andThen(events.publish(Command.Event.Updated, {})),
Effect.catch(() =>
Effect.sync(() => (entry.prompts = [])).pipe(Effect.andThen(events.publish(Command.Event.Updated, {}))),
),
)
const watch = (name: ServerName, entry: ServerEntry, connection: MCPClient.Connection) => { const watch = (name: ServerName, entry: ServerEntry, connection: MCPClient.Connection) => {
connection.onClose(() => { connection.onClose(() => {
// A reconnect closes the previous scope, but the SDK may fire this onclose after the new // A reconnect closes the previous scope, but the SDK may fire this onclose after the new
@@ -429,10 +323,8 @@ export const layer = Layer.effect(
if (entry.client !== connection) return if (entry.client !== connection) return
entry.client = undefined entry.client = undefined
entry.tools = undefined entry.tools = undefined
entry.prompts = undefined
entry.status = { status: "failed", error: "Connection closed" } entry.status = { status: "failed", error: "Connection closed" }
fork(events.publish(McpEvent.ToolsChanged, { server: name }).pipe(Effect.ignore)) fork(events.publish(McpEvent.ToolsChanged, { server: name }).pipe(Effect.ignore))
fork(events.publish(Command.Event.Updated, {}).pipe(Effect.ignore))
fork(events.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)) fork(events.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore))
}) })
connection.onLog((message) => fork(serverLog(name, message).pipe(Effect.ignore))) connection.onLog((message) => fork(serverLog(name, message).pipe(Effect.ignore)))
@@ -444,9 +336,6 @@ export const layer = Layer.effect(
), ),
) )
}) })
connection.onPromptsChanged(() => {
fork(refreshPrompts(name, entry, connection).pipe(Effect.ignore))
})
} }
const serverLog = (server: ServerName, message: MCPClient.LogMessage) => { const serverLog = (server: ServerName, message: MCPClient.LogMessage) => {
@@ -474,15 +363,14 @@ export const layer = Layer.effect(
const authProvider = yield* connectProvider(entry) const authProvider = yield* connectProvider(entry)
// List tools as part of connect so a failure here marks the server failed rather than // List tools as part of connect so a failure here marks the server failed rather than
// leaving it connected with a silently empty tool list and no path to recover. // leaving it connected with a silently empty tool list and no path to recover.
const result = yield* MCPClient.connect(name, entry.config, location.directory, authProvider, elicitation).pipe( const result = yield* MCPClient.connect(name, entry.config, location.directory, authProvider).pipe(
Effect.flatMap((connection) => connection.tools().pipe(Effect.map((tools) => ({ connection, tools })))), Effect.flatMap((connection) => connection.tools().pipe(Effect.map((defs) => ({ connection, defs })))),
Scope.provide(scope), Scope.provide(scope),
Effect.exit, Effect.exit,
) )
if (Exit.isSuccess(result)) { if (Exit.isSuccess(result)) {
entry.client = result.value.connection entry.client = result.value.connection
entry.tools = result.value.tools.map((def) => toTool(name, def)) entry.tools = result.value.defs.map((def) => toTool(name, def))
entry.prompts = []
entry.status = { status: "connected" } entry.status = { status: "connected" }
watch(name, entry, result.value.connection) watch(name, entry, result.value.connection)
yield* Effect.logInfo("mcp connected", { server: name, tools: entry.tools.length }) yield* Effect.logInfo("mcp connected", { server: name, tools: entry.tools.length })
@@ -491,7 +379,6 @@ export const layer = Layer.effect(
// stay invisible to the model. // stay invisible to the model.
yield* events.publish(McpEvent.ToolsChanged, { server: name }).pipe(Effect.ignore) yield* events.publish(McpEvent.ToolsChanged, { server: name }).pipe(Effect.ignore)
yield* events.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore) yield* events.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
fork(refreshPrompts(name, entry, result.value.connection).pipe(Effect.ignore))
return return
} }
yield* Scope.close(scope, Exit.void) yield* Scope.close(scope, Exit.void)
@@ -529,8 +416,6 @@ export const layer = Layer.effect(
entry.scope = undefined entry.scope = undefined
entry.client = undefined entry.client = undefined
entry.tools = undefined entry.tools = undefined
entry.prompts = undefined
yield* events.publish(Command.Event.Updated, {}).pipe(Effect.ignore)
} }
yield* startServer(name, entry) yield* startServer(name, entry)
}) })
@@ -604,25 +489,12 @@ export const layer = Layer.effect(
.toSorted((a, b) => a.server.localeCompare(b.server)) .toSorted((a, b) => a.server.localeCompare(b.server))
}), }),
prompts: Effect.fn("MCP.prompts")(function* () { prompts: Effect.fn("MCP.prompts")(function* () {
return Array.from(runtime.values()) yield* whenAllReady
.flatMap((entry) => entry.prompts ?? []) return []
.toSorted((a, b) => a.server.localeCompare(b.server) || a.name.localeCompare(b.name))
}), }),
prompt: Effect.fn("MCP.prompt")(function* (input) { prompt: Effect.fn("MCP.prompt")(function* (input) {
const target = yield* requireServer(input.server) yield* gate(input.server)
yield* Deferred.await(target.entry.startup) return undefined
if (!target.entry.client) return undefined
const result = yield* target.entry.client
.prompt({ name: input.name, args: input.args })
.pipe(Effect.catch(() => Effect.succeed(undefined)))
if (!result) return undefined
return new PromptResult({
server: target.name,
name: input.name,
messages: result.messages.map(
(message) => new PromptMessage({ role: message.role, content: message.content }),
),
})
}), }),
resourceCatalog: Effect.fn("MCP.resourceCatalog")(function* () { resourceCatalog: Effect.fn("MCP.resourceCatalog")(function* () {
yield* whenAllReady yield* whenAllReady
@@ -639,69 +511,5 @@ export const layer = Layer.effect(
export const node = makeLocationNode({ export const node = makeLocationNode({
service: Service, service: Service,
layer, layer,
deps: [Config.node, Location.node, EventV2.node, Form.node, Integration.node, Credential.node], deps: [Config.node, Location.node, EventV2.node, Integration.node, Credential.node],
}) })
// Schema `optional` strips undefined-valued properties on encode, so fields can assign
// optional properties directly instead of conditionally spreading them.
function toElicitationField(key: string, property: ElicitationProperty, required: boolean): Form.Field {
// Some servers emit machine titles like "string with format email"; prefer description/key over those.
const machineTitle = /^(boolean|string|number|integer|array|object)(\s+with\b.*|\s+in\b.*)?$/i
const title =
property.title && !machineTitle.test(property.title.trim()) ? property.title : (property.description ?? key)
const base = {
key,
title,
description: property.description === title ? undefined : property.description,
required: required || undefined,
}
switch (property.type) {
case "boolean":
return { ...base, type: "boolean", default: property.default }
case "number":
case "integer":
return {
...base,
type: property.type,
minimum: property.minimum,
maximum: property.maximum,
default: property.default,
}
case "array":
return {
...base,
type: "multiselect",
options:
"anyOf" in property.items
? property.items.anyOf.map((option) => ({ value: option.const, label: option.title }))
: property.items.enum.map((value) => ({ value, label: value })),
custom: false,
minItems: property.minItems,
maxItems: property.maxItems,
default: property.default,
}
case "string": {
const options =
"oneOf" in property
? property.oneOf.map((option) => ({ value: option.const, label: option.title }))
: "enum" in property
? property.enum.map((value, index) => ({
value,
label: ("enumNames" in property ? property.enumNames?.[index] : undefined) ?? value,
}))
: undefined
return {
...base,
type: "string",
format: "format" in property ? property.format : undefined,
minLength: "minLength" in property ? property.minLength : undefined,
maxLength: "maxLength" in property ? property.maxLength : undefined,
default: property.default,
options,
custom: options ? false : undefined,
}
}
}
}
type ElicitationProperty = MCPClient.ElicitationFormParams["requestedSchema"]["properties"][string]
+1 -6
View File
@@ -26,13 +26,8 @@ export type Api = Model.Api
export const Info = Model.Info export const Info = Model.Info
export type Info = Model.Info export type Info = Model.Info
export type MutableRequest = ProviderV2.MutableRequest & { variant?: string } export type MutableInfo = Omit<Types.DeepMutable<Info>, "api"> & {
export type MutableVariant = ProviderV2.MutableRequest & { id: VariantID }
export type MutableInfo = Omit<Types.DeepMutable<Info>, "api" | "request" | "variants"> & {
api: ProviderV2.MutableApi<Api> api: ProviderV2.MutableApi<Api>
request: MutableRequest
variants: MutableVariant[]
} }
export function parse(input: string): { providerID: ProviderV2.ID; modelID: ID } { export function parse(input: string): { providerID: ProviderV2.ID; modelID: ID } {
-2
View File
@@ -18,7 +18,6 @@ import { Reference } from "./reference"
import { SkillV2 } from "./skill" import { SkillV2 } from "./skill"
import { State } from "./state" import { State } from "./state"
import { ToolRegistry } from "./tool/registry" import { ToolRegistry } from "./tool/registry"
import { ToolHooks } from "./tool/hooks"
export const ID = Plugin.ID export const ID = Plugin.ID
export type ID = typeof ID.Type export type ID = typeof ID.Type
@@ -166,7 +165,6 @@ export const node = makeLocationNode({
Reference.node, Reference.node,
SkillV2.node, SkillV2.node,
ToolRegistry.toolsNode, ToolRegistry.toolsNode,
ToolHooks.node,
PluginRuntime.node, PluginRuntime.node,
], ],
}) })
+1
View File
@@ -18,6 +18,7 @@ export const Plugin = define({
draft.update("review", (command) => { draft.update("review", (command) => {
command.template = PROMPT_REVIEW.replace("${path}", location.project.directory) command.template = PROMPT_REVIEW.replace("${path}", location.project.directory)
command.description = "review changes [commit|branch|pr], defaults to uncommitted" command.description = "review changes [commit|branch|pr], defaults to uncommitted"
command.subtask = true
}) })
}) })
}), }),
-44
View File
@@ -17,7 +17,6 @@ import { Reference } from "../reference"
import { AbsolutePath, type DeepMutable } from "../schema" import { AbsolutePath, type DeepMutable } from "../schema"
import { SkillV2 } from "../skill" import { SkillV2 } from "../skill"
import { Tools } from "../tool/tools" import { Tools } from "../tool/tools"
import { ToolHooks } from "../tool/hooks"
import { WorkspaceV2 } from "../workspace" import { WorkspaceV2 } from "../workspace"
const mutable = <T>(value: T) => value as DeepMutable<T> const mutable = <T>(value: T) => value as DeepMutable<T>
@@ -32,7 +31,6 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
const reference = yield* Reference.Service const reference = yield* Reference.Service
const skill = yield* SkillV2.Service const skill = yield* SkillV2.Service
const tools = yield* Tools.Service const tools = yield* Tools.Service
const toolHooks = yield* ToolHooks.Service
const runtime = yield* PluginRuntime.Service const runtime = yield* PluginRuntime.Service
const locationInfo = () => const locationInfo = () =>
new Location.Info({ new Location.Info({
@@ -249,47 +247,6 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
}, },
tool: { tool: {
register: (input) => tools.register(input), register: (input) => tools.register(input),
execute: {
before: (callback) =>
toolHooks.hook.before((event) => {
const output = {
tool: event.tool,
sessionID: event.sessionID,
agent: event.agent,
assistantMessageID: event.assistantMessageID,
toolCallID: event.toolCallID,
input: event.input,
}
const result = callback(output)
return Effect.suspend(() => (Effect.isEffect(result) ? result : Effect.void)).pipe(
Effect.tap(() => Effect.sync(() => (event.input = output.input))),
)
}),
after: (callback) =>
toolHooks.hook.after((event) => {
const output = {
tool: event.tool,
sessionID: event.sessionID,
agent: event.agent,
assistantMessageID: event.assistantMessageID,
toolCallID: event.toolCallID,
input: event.input,
result: event.result,
output: event.output,
outputPaths: event.outputPaths,
}
const result = callback(output)
return Effect.suspend(() => (Effect.isEffect(result) ? result : Effect.void)).pipe(
Effect.tap(() =>
Effect.sync(() => {
event.result = output.result
event.output = output.output
event.outputPaths = output.outputPaths
}),
),
)
}),
},
}, },
session: { session: {
create: (input) => create: (input) =>
@@ -302,7 +259,6 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
}), }),
get: (input) => runtime.session.get(input.sessionID), get: (input) => runtime.session.get(input.sessionID),
prompt: runtime.session.prompt, prompt: runtime.session.prompt,
command: runtime.session.command,
interrupt: (input) => runtime.session.interrupt(input.sessionID), interrupt: (input) => runtime.session.interrupt(input.sessionID),
}, },
} satisfies Interface } satisfies Interface
+52 -80
View File
@@ -3,7 +3,7 @@ export * as PluginInternal from "./internal"
import { makeLocationNode } from "../effect/app-node" import { makeLocationNode } from "../effect/app-node"
import { httpClient } from "../effect/app-node-platform" import { httpClient } from "../effect/app-node-platform"
import type { PluginContext } from "@opencode-ai/plugin/v2/effect" import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
import { Context, Effect, Layer, Scope } from "effect" import { Effect, Layer, Scope } from "effect"
import { AgentV2 } from "../agent" import { AgentV2 } from "../agent"
import { Catalog } from "../catalog" import { Catalog } from "../catalog"
import { CommandV2 } from "../command" import { CommandV2 } from "../command"
@@ -15,11 +15,9 @@ import { ConfigProviderPlugin } from "../config/plugin/provider"
import { ConfigReferencePlugin } from "../config/plugin/reference" import { ConfigReferencePlugin } from "../config/plugin/reference"
import { ConfigSkillPlugin } from "../config/plugin/skill" import { ConfigSkillPlugin } from "../config/plugin/skill"
import { EventV2 } from "../event" import { EventV2 } from "../event"
import { FileMutation } from "../file-mutation"
import { FileSystem } from "../filesystem" import { FileSystem } from "../filesystem"
import { FSUtil } from "../fs-util" import { FSUtil } from "../fs-util"
import { Global } from "../global" import { Global } from "../global"
import { Image } from "../image"
import { Integration } from "../integration" import { Integration } from "../integration"
import { Location } from "../location" import { Location } from "../location"
import { LocationMutation } from "../location-mutation" import { LocationMutation } from "../location-mutation"
@@ -28,11 +26,7 @@ import { Npm } from "../npm"
import { PluginV2 } from "../plugin" import { PluginV2 } from "../plugin"
import { PluginRuntime } from "../plugin/runtime" import { PluginRuntime } from "../plugin/runtime"
import { PermissionV2 } from "../permission" import { PermissionV2 } from "../permission"
import { QuestionV2 } from "../question"
import { Reference } from "../reference" import { Reference } from "../reference"
import { Ripgrep } from "../ripgrep"
import { SessionInstructions } from "../session/instructions"
import { SessionTodo } from "../session/todo"
import { Shell } from "../shell" import { Shell } from "../shell"
import { SkillV2 } from "../skill" import { SkillV2 } from "../skill"
import { State } from "../state" import { State } from "../state"
@@ -46,20 +40,8 @@ import { ProviderPlugins } from "./provider"
import { SdkPlugins } from "./sdk" import { SdkPlugins } from "./sdk"
import { SkillPlugin } from "./skill" import { SkillPlugin } from "./skill"
import { VariantPlugin } from "./variant" import { VariantPlugin } from "./variant"
import { ApplyPatchTool } from "../tool/apply-patch"
import { EditTool } from "../tool/edit"
import { GlobTool } from "../tool/glob"
import { GrepTool } from "../tool/grep"
import { QuestionTool } from "../tool/question"
import { ReadTool } from "../tool/read"
import { ReadToolFileSystem } from "../tool/read-filesystem"
import { ShellTool } from "../tool/shell" import { ShellTool } from "../tool/shell"
import { SkillTool } from "../tool/skill"
import { SubagentTool } from "../tool/subagent" import { SubagentTool } from "../tool/subagent"
import { TodoWriteTool } from "../tool/todowrite"
import { WebFetchTool } from "../tool/webfetch"
import { WebSearchTool } from "../tool/websearch"
import { WriteTool } from "../tool/write"
export type Requirements = export type Requirements =
| AgentV2.Service | AgentV2.Service
@@ -67,12 +49,10 @@ export type Requirements =
| CommandV2.Service | CommandV2.Service
| Config.Service | Config.Service
| EventV2.Service | EventV2.Service
| FileMutation.Service
| FileSystem.Service | FileSystem.Service
| FSUtil.Service | FSUtil.Service
| Global.Service | Global.Service
| HttpClient.HttpClient | HttpClient.HttpClient
| Image.Service
| Integration.Service | Integration.Service
| Location.Service | Location.Service
| LocationMutation.Service | LocationMutation.Service
@@ -80,16 +60,10 @@ export type Requirements =
| Npm.Service | Npm.Service
| PermissionV2.Service | PermissionV2.Service
| PluginRuntime.Service | PluginRuntime.Service
| QuestionV2.Service
| ReadToolFileSystem.Service
| Reference.Service | Reference.Service
| Ripgrep.Service
| SessionInstructions.Service
| SessionTodo.Service
| Shell.Service | Shell.Service
| SkillV2.Service | SkillV2.Service
| Tools.Service | Tools.Service
| WebSearchTool.ConfigService
export interface Plugin<R = never> { export interface Plugin<R = never> {
readonly id: string readonly id: string
@@ -102,42 +76,59 @@ export function define<R>(plugin: Plugin<R>) {
const layer = Layer.effectDiscard( const layer = Layer.effectDiscard(
Effect.gen(function* () { Effect.gen(function* () {
const catalog = yield* Catalog.Service
const commands = yield* CommandV2.Service
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const sdkPlugins = yield* SdkPlugins.Service const sdkPlugins = yield* SdkPlugins.Service
const services = Context.mergeAll( const integration = yield* Integration.Service
Context.make(Catalog.Service, yield* Catalog.Service), const agents = yield* AgentV2.Service
Context.make(CommandV2.Service, yield* CommandV2.Service), const config = yield* Config.Service
Context.make(Integration.Service, yield* Integration.Service), const location = yield* Location.Service
Context.make(AgentV2.Service, yield* AgentV2.Service), const modelsDev = yield* ModelsDev.Service
Context.make(Config.Service, yield* Config.Service), const npm = yield* Npm.Service
Context.make(Location.Service, yield* Location.Service), const events = yield* EventV2.Service
Context.make(ModelsDev.Service, yield* ModelsDev.Service), const fs = yield* FSUtil.Service
Context.make(Npm.Service, yield* Npm.Service), const filesystem = yield* FileSystem.Service
Context.make(EventV2.Service, yield* EventV2.Service), const global = yield* Global.Service
Context.make(FSUtil.Service, yield* FSUtil.Service), const http = yield* HttpClient.HttpClient
Context.make(FileSystem.Service, yield* FileSystem.Service), const mutation = yield* LocationMutation.Service
Context.make(Global.Service, yield* Global.Service), const permission = yield* PermissionV2.Service
Context.make(HttpClient.HttpClient, yield* HttpClient.HttpClient), const skill = yield* SkillV2.Service
Context.make(LocationMutation.Service, yield* LocationMutation.Service), const reference = yield* Reference.Service
Context.make(FileMutation.Service, yield* FileMutation.Service), const shell = yield* Shell.Service
Context.make(Image.Service, yield* Image.Service), const tools = yield* Tools.Service
Context.make(PermissionV2.Service, yield* PermissionV2.Service), const runtime = yield* PluginRuntime.Service
Context.make(QuestionV2.Service, yield* QuestionV2.Service), const add = <R>(input: Plugin<R>) => {
Context.make(ReadToolFileSystem.Service, yield* ReadToolFileSystem.Service), const loaded = {
Context.make(SessionInstructions.Service, yield* SessionInstructions.Service), id: input.id,
Context.make(SessionTodo.Service, yield* SessionTodo.Service), effect: (context: PluginContext) =>
Context.make(SkillV2.Service, yield* SkillV2.Service), input
Context.make(Reference.Service, yield* Reference.Service), .effect(context)
Context.make(Ripgrep.Service, yield* Ripgrep.Service), .pipe(
Context.make(Shell.Service, yield* Shell.Service), Effect.provideService(Catalog.Service, catalog),
Context.make(Tools.Service, yield* Tools.Service), Effect.provideService(CommandV2.Service, commands),
Context.make(PluginRuntime.Service, yield* PluginRuntime.Service), Effect.provideService(Integration.Service, integration),
Context.make(WebSearchTool.ConfigService, yield* WebSearchTool.ConfigService), Effect.provideService(AgentV2.Service, agents),
) Effect.provideService(Config.Service, config),
const add = (input: Plugin<Requirements | Scope.Scope>) => Effect.provideService(Location.Service, location),
plugin.add(PluginV2.ID.make(input.id), (context: PluginContext) => Effect.provideService(ModelsDev.Service, modelsDev),
input.effect(context).pipe(Effect.provide(services)), Effect.provideService(Npm.Service, npm),
) Effect.provideService(EventV2.Service, events),
Effect.provideService(FSUtil.Service, fs),
Effect.provideService(FileSystem.Service, filesystem),
Effect.provideService(Global.Service, global),
Effect.provideService(HttpClient.HttpClient, http),
Effect.provideService(LocationMutation.Service, mutation),
Effect.provideService(PermissionV2.Service, permission),
Effect.provideService(SkillV2.Service, skill),
Effect.provideService(Reference.Service, reference),
Effect.provideService(Shell.Service, shell),
Effect.provideService(Tools.Service, tools),
Effect.provideService(PluginRuntime.Service, runtime),
),
}
return plugin.add(PluginV2.ID.make(loaded.id), loaded.effect)
}
yield* State.batch( yield* State.batch(
Effect.gen(function* () { Effect.gen(function* () {
@@ -147,19 +138,8 @@ const layer = Layer.effectDiscard(
yield* add(SkillPlugin.Plugin) yield* add(SkillPlugin.Plugin)
yield* add(ModelsDevPlugin) yield* add(ModelsDevPlugin)
yield* add(ConfigExternalPlugin.Plugin) yield* add(ConfigExternalPlugin.Plugin)
yield* add(ApplyPatchTool.Plugin)
yield* add(EditTool.Plugin)
yield* add(GlobTool.Plugin)
yield* add(GrepTool.Plugin)
yield* add(QuestionTool.Plugin)
yield* add(ReadTool.Plugin)
yield* add(ShellTool.Plugin) yield* add(ShellTool.Plugin)
yield* add(SkillTool.Plugin)
yield* add(SubagentTool.Plugin) yield* add(SubagentTool.Plugin)
yield* add(TodoWriteTool.Plugin)
yield* add(WebFetchTool.Plugin)
yield* add(WebSearchTool.Plugin)
yield* add(WriteTool.Plugin)
yield* add(ConfigAgentPlugin.Plugin) yield* add(ConfigAgentPlugin.Plugin)
yield* add(ConfigCommandPlugin.Plugin) yield* add(ConfigCommandPlugin.Plugin)
yield* add(ConfigSkillPlugin.Plugin) yield* add(ConfigSkillPlugin.Plugin)
@@ -185,8 +165,6 @@ export const node = makeLocationNode({
Config.node, Config.node,
Location.node, Location.node,
LocationMutation.node, LocationMutation.node,
FileMutation.node,
Image.node,
ModelsDev.node, ModelsDev.node,
Npm.node, Npm.node,
EventV2.node, EventV2.node,
@@ -195,17 +173,11 @@ export const node = makeLocationNode({
Global.node, Global.node,
httpClient, httpClient,
PermissionV2.node, PermissionV2.node,
QuestionV2.node,
ReadToolFileSystem.node,
SessionInstructions.node,
SessionTodo.node,
SkillV2.node, SkillV2.node,
Reference.node, Reference.node,
Ripgrep.node,
Shell.node, Shell.node,
ToolRegistry.toolsNode, ToolRegistry.toolsNode,
PluginRuntime.node, PluginRuntime.node,
SdkPlugins.node, SdkPlugins.node,
WebSearchTool.configNode,
], ],
}) })
+18 -66
View File
@@ -70,73 +70,25 @@ function mergeCost(base: ModelV2Info["cost"], override: ModelsDev.Model["cost"]
return [merge(baseDefault ?? { input: 0, output: 0, cache: { read: 0, write: 0 } }, nextDefault), ...tiers.values()] return [merge(baseDefault ?? { input: 0, output: 0, cache: { read: 0, write: 0 } }, nextDefault), ...tiers.values()]
} }
const OPENAI_INCLUDE_ENCRYPTED_REASONING = ["reasoning.encrypted_content"] function reasoningVariants(model: ModelsDev.Model, packageName: string | undefined): ModelV2Info["variants"] {
const result = new Map<ModelV2.VariantID, ModelV2Info["variants"][number]>()
function reasoningVariants(provider: ModelsDev.Provider, model: ModelsDev.Model): ModelV2Info["variants"] { if (packageName === "@ai-sdk/openai" || packageName === "@ai-sdk/openai-compatible") {
const npm = model.provider?.npm ?? provider.npm const option = model.reasoning_options?.find((option) => option.type === "effort")
const options = model.reasoning_options ?? [] for (const value of option?.values ?? []) {
const effort = options.find((option) => option.type === "effort") const id = value === null ? "none" : value
if (effort?.type === "effort") { if (typeof id !== "string") continue
return effort.values.flatMap((value) => { const variantID = ModelV2.VariantID.make(id)
const raw: unknown = value result.set(variantID, {
const id = raw === null ? "none" : typeof raw === "string" ? raw : undefined id: variantID,
if (id === undefined) return [] headers: {},
const settings = settingsForEffort(npm, id) body:
return settings ? [{ id, settings, headers: {}, body: {} }] : [] packageName === "@ai-sdk/openai"
}) ? { include: ["reasoning.encrypted_content"], reasoning: { effort: id, summary: "auto" } }
} : { reasoning_effort: id },
})
const budget = options.find((option) => option.type === "budget_tokens")
if (budget?.type === "budget_tokens") return budgetVariants(npm, budget)
// Toggle-only reasoning is intentionally left for a follow-up because V1 has
// provider/model-specific behavior like MiniMax M3 adaptive thinking and
// Qwen/GLM enable_thinking request shapes in packages/opencode.
return []
}
function settingsForEffort(npm: string | undefined, effort: string): ProviderV2.Settings | undefined {
if (npm === "@openrouter/ai-sdk-provider") return { reasoning: { effort } }
if (npm === "@ai-sdk/anthropic" || npm === "@ai-sdk/google-vertex/anthropic") {
return { thinking: { type: "adaptive", display: "summarized" }, effort }
}
if (npm === "@ai-sdk/google" || npm === "@ai-sdk/google-vertex") {
return { thinkingConfig: { includeThoughts: true, thinkingLevel: effort } }
}
if (npm === "@ai-sdk/azure") return { reasoningEffort: effort }
if (npm === "@ai-sdk/openai") {
return {
reasoningEffort: effort,
reasoningSummary: "auto",
include: OPENAI_INCLUDE_ENCRYPTED_REASONING,
} }
} }
if (npm === "@ai-sdk/openai-compatible") return { reasoningEffort: effort } return [...result.values()]
}
function budgetVariants(
npm: string | undefined,
option: Extract<NonNullable<ModelsDev.Model["reasoning_options"]>[number], { type: "budget_tokens" }>,
): ModelV2Info["variants"] {
const max = option.max
const high = option.max === undefined ? Math.max(option.min ?? 0, 16_000) : Math.min(Math.max(option.min ?? 0, 16_000), option.max)
return [
{ id: "high", budget: high },
...(max === undefined || max === high ? [] : [{ id: "max", budget: max }]),
].flatMap((item) => {
const settings = settingsForBudget(npm, item.budget)
return settings ? [{ id: item.id, settings, headers: {}, body: {} }] : []
})
}
function settingsForBudget(npm: string | undefined, budget: number): ProviderV2.Settings | undefined {
if (npm === "@openrouter/ai-sdk-provider") return { reasoning: { max_tokens: budget } }
if (npm === "@ai-sdk/anthropic" || npm === "@ai-sdk/google-vertex/anthropic") {
return { thinking: { type: "enabled", budgetTokens: budget } }
}
if (npm === "@ai-sdk/google" || npm === "@ai-sdk/google-vertex") {
return { thinkingConfig: { includeThoughts: true, thinkingBudget: budget } }
}
} }
function modeName(model: ModelsDev.Model, mode: string) { function modeName(model: ModelsDev.Model, mode: string) {
@@ -241,7 +193,7 @@ export const ModelsDevPlugin = define({
for (const model of Object.values(item.models)) { for (const model of Object.values(item.models)) {
const baseCost = cost(model.cost) const baseCost = cost(model.cost)
const variants = reasoningVariants(item, model) const variants = reasoningVariants(model, model.provider?.npm ?? item.npm)
catalog.model.update(providerID, model.id, (draft) => applyModel(draft, model, { cost: baseCost, variants })) catalog.model.update(providerID, model.id, (draft) => applyModel(draft, model, { cost: baseCost, variants }))
for (const [mode, options] of Object.entries(model.experimental?.modes ?? {})) { for (const [mode, options] of Object.entries(model.experimental?.modes ?? {})) {
catalog.model.update(providerID, `${model.id}-${mode}`, (draft) => catalog.model.update(providerID, `${model.id}-${mode}`, (draft) =>
@@ -1,42 +0,0 @@
export * as OpenAICodex from "./openai-codex"
// TEMPORARY SEAM (#34765): plugins have no hook into LLM route construction, so
// codex routing lives in SessionRunnerModel.fromCatalogModel and catalog filtering
// in OpenAIPlugin, sharing this module. Once the native provider packages land
// (#33689/#33925/#34462) this should collapse into the native OpenAI provider.
// The eligibility rules mirror V1's CodexAuthPlugin allowlist; models.dev has no
// plan-eligibility data for OpenAI today, but models other vendors' subscriptions
// as dedicated providers (e.g. zai-coding-plan) - a future openai-chatgpt-plan
// provider entry could replace the hardcoded rules with catalog data.
/** ChatGPT-plan requests must target the codex backend instead of the public API. */
export const baseURL = "https://chatgpt.com/backend-api/codex"
const methodIDs: readonly string[] = ["chatgpt-browser", "chatgpt-headless"]
/** Structural credential shape so both core and plugin-facing credential types fit. */
type CredentialLike = {
readonly type: string
readonly methodID?: string
readonly metadata?: Record<string, unknown> | undefined
}
export const isChatGPT = (credential: CredentialLike | undefined) =>
credential?.type === "oauth" && credential.methodID !== undefined && methodIDs.includes(credential.methodID)
export const accountID = (credential: CredentialLike | undefined) => {
if (!isChatGPT(credential)) return undefined
const value = credential?.metadata?.accountID
return typeof value === "string" ? value : undefined
}
const allowed = new Set(["gpt-5.5", "gpt-5.3-codex-spark", "gpt-5.4", "gpt-5.4-mini"])
const disallowed = new Set(["gpt-5.5-pro"])
/** Which API model ids a ChatGPT subscription may call through the codex backend. */
export const eligible = (apiID: string) => {
if (allowed.has(apiID)) return true
if (disallowed.has(apiID)) return false
const match = apiID.match(/^gpt-(\d+\.\d+)/)
return match ? Number.parseFloat(match[1]) > 5.4 : false
}
+1 -37
View File
@@ -1,17 +1,15 @@
import { createServer } from "node:http" import { createServer } from "node:http"
import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration" import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration"
import { define } from "@opencode-ai/plugin/v2/effect/plugin" import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import { Deferred, Effect, Semaphore, Stream } from "effect" import { Deferred, Effect } from "effect"
import type { Scope } from "effect" import type { Scope } from "effect"
import { Credential } from "../../credential" import { Credential } from "../../credential"
import { EventV2 } from "../../event"
import { InstallationVersion } from "../../installation/version" import { InstallationVersion } from "../../installation/version"
import { Integration } from "../../integration" import { Integration } from "../../integration"
import { ModelV2 } from "../../model" import { ModelV2 } from "../../model"
import { OauthCallbackPage } from "../../oauth/page" import { OauthCallbackPage } from "../../oauth/page"
import { ProviderV2 } from "../../provider" import { ProviderV2 } from "../../provider"
import type { PluginInternal } from "../internal" import type { PluginInternal } from "../internal"
import { OpenAICodex } from "./openai-codex"
const clientID = "app_EMoamEEZ73f0CkXaXp7hrann" const clientID = "app_EMoamEEZ73f0CkXaXp7hrann"
const issuer = "https://auth.openai.com" const issuer = "https://auth.openai.com"
@@ -156,18 +154,6 @@ const headless = {
export const OpenAIPlugin = define({ export const OpenAIPlugin = define({
id: "openai", id: "openai",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
const events = yield* EventV2.Service
const loading = Semaphore.makeUnsafe(1)
let chatgpt = false
const load = Effect.fn("OpenAIPlugin.load")(function* () {
const connection = yield* ctx.integration.connection.active("openai")
const credential = connection
? yield* ctx.integration.connection.resolve(connection).pipe(Effect.catch(() => Effect.succeed(undefined)))
: undefined
chatgpt = OpenAICodex.isChatGPT(credential)
})
yield* ctx.integration.transform((draft) => { yield* ctx.integration.transform((draft) => {
draft.method.update(browser) draft.method.update(browser)
draft.method.update(headless) draft.method.update(headless)
@@ -184,30 +170,8 @@ export const OpenAIPlugin = define({
model.enabled = false model.enabled = false
}) })
} }
if (!chatgpt) return
const item = evt.provider.get(ProviderV2.ID.openai)
if (!item) return
for (const model of item.models.values()) {
// ChatGPT-plan tokens only authorize codex-eligible models, and the
// subscription covers usage, so hide the rest and zero the cost.
evt.model.update(item.provider.id, model.id, (draft) => {
if (!OpenAICodex.eligible(draft.api.id)) {
draft.enabled = false
return
}
draft.cost = []
})
}
}), }),
) )
const refresh = () => loading.withPermit(load().pipe(Effect.andThen(ctx.catalog.reload())))
yield* events.subscribe(Integration.Event.ConnectionUpdated).pipe(
Stream.filter((event) => event.data.integrationID === Integration.ID.make("openai")),
Stream.runForEach(refresh),
Effect.forkScoped({ startImmediately: true }),
)
yield* refresh().pipe(Effect.forkScoped)
yield* ctx.aisdk.sdk( yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) { Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/openai") return if (evt.package !== "@ai-sdk/openai") return
@@ -146,7 +146,7 @@ export const OpencodePlugin = define<HttpClient.HttpClient | EventV2.Service | S
const variantID = ModelV2.VariantID.make(id) const variantID = ModelV2.VariantID.make(id)
let existing = model.variants.find((item) => item.id === variantID) let existing = model.variants.find((item) => item.id === variantID)
if (!existing) { if (!existing) {
existing = { id: variantID, settings: {}, headers: {}, body: {} } existing = { id: variantID, headers: {}, body: {} }
model.variants.push(existing) model.variants.push(existing)
} }
Object.assign(existing.headers, options.headers) Object.assign(existing.headers, options.headers)
+6 -12
View File
@@ -11,7 +11,7 @@ import { SessionV2 } from "../session"
export interface Interface { export interface Interface {
readonly session: Pick< readonly session: Pick<
SessionV2.Interface, SessionV2.Interface,
"get" | "create" | "messages" | "prompt" | "command" | "resume" | "interrupt" | "synthetic" "get" | "create" | "messages" | "prompt" | "resume" | "interrupt" | "synthetic"
> >
readonly job: Pick<Job.Interface, "start" | "wait" | "block" | "background" | "cancel"> readonly job: Pick<Job.Interface, "start" | "wait" | "block" | "background" | "cancel">
readonly location: { readonly location: {
@@ -50,7 +50,6 @@ export const layerWithCell = (cell: Cell) =>
create: (input) => require(cell, (runtime) => runtime.session.create(input)), create: (input) => require(cell, (runtime) => runtime.session.create(input)),
messages: (input) => require(cell, (runtime) => runtime.session.messages(input)), messages: (input) => require(cell, (runtime) => runtime.session.messages(input)),
prompt: (input) => require(cell, (runtime) => runtime.session.prompt(input)), prompt: (input) => require(cell, (runtime) => runtime.session.prompt(input)),
command: (input) => require(cell, (runtime) => runtime.session.command(input)),
resume: (sessionID) => require(cell, (runtime) => runtime.session.resume(sessionID)), resume: (sessionID) => require(cell, (runtime) => runtime.session.resume(sessionID)),
interrupt: (sessionID) => require(cell, (runtime) => runtime.session.interrupt(sessionID)), interrupt: (sessionID) => require(cell, (runtime) => runtime.session.interrupt(sessionID)),
synthetic: (input) => require(cell, (runtime) => runtime.session.synthetic(input)), synthetic: (input) => require(cell, (runtime) => runtime.session.synthetic(input)),
@@ -111,13 +110,8 @@ export const providerLayer = providerLayerWithCell(defaultCell)
export const node = makeGlobalNode({ service: Service, layer, deps: [] }) export const node = makeGlobalNode({ service: Service, layer, deps: [] })
// Raw layer replacements are compiled without dependencies, so cell-scoped export const providerNode = makeGlobalNode({
// provider replacements must go through this node to keep their deps wired. name: "plugin-runtime-provider",
export const providerNodeWithCell = (cell: Cell) => layer: providerLayer,
makeGlobalNode({ deps: [node, SessionV2.node, Job.node, LocationServiceMap.node],
name: "plugin-runtime-provider", })
layer: providerLayerWithCell(cell),
deps: [node, SessionV2.node, Job.node, LocationServiceMap.node],
})
export const providerNode = providerNodeWithCell(defaultCell)
+1 -2
View File
@@ -33,8 +33,7 @@ export function generate(model: ModelV2Info): ModelV2Info["variants"] {
if (!["glm-5.2", "glm-5-2", "glm-5p2"].some((name) => ids.includes(name))) return [] if (!["glm-5.2", "glm-5-2", "glm-5p2"].some((name) => ids.includes(name))) return []
return ["high", "max"].map((id) => ({ return ["high", "max"].map((id) => ({
id, id,
settings: { reasoningEffort: id },
headers: {}, headers: {},
body: {}, body: { reasoning_effort: id },
})) }))
} }
+9 -57
View File
@@ -2,12 +2,10 @@ export * as ProjectV2 from "./project"
export * as Project from "./project" export * as Project from "./project"
import { Context, Effect, Layer, Schema } from "effect" import { Context, Effect, Layer, Schema } from "effect"
import { ChildProcess } from "effect/unstable/process"
import path from "path" import path from "path"
import { AbsolutePath } from "./schema" import { AbsolutePath } from "./schema"
import { FSUtil } from "./fs-util" import { FSUtil } from "./fs-util"
import { Git } from "./git" import { Git } from "./git"
import { AppProcess } from "./process"
import { makeGlobalNode } from "./effect/app-node" import { makeGlobalNode } from "./effect/app-node"
import { Hash } from "./util/hash" import { Hash } from "./util/hash"
import { ProjectDirectories } from "./project/directories" import { ProjectDirectories } from "./project/directories"
@@ -47,7 +45,7 @@ export const root = Effect.fn("Project.root")(function* (
fs: FSUtil.Interface, fs: FSUtil.Interface,
input: AbsolutePath, input: AbsolutePath,
) { ) {
return yield* fs.up({ targets: [".git", ".hg"], start: input }).pipe( return yield* fs.up({ targets: [".git"], start: input }).pipe(
Effect.map((matches) => matches[0] ? AbsolutePath.make(path.dirname(matches[0])) : undefined), Effect.map((matches) => matches[0] ? AbsolutePath.make(path.dirname(matches[0])) : undefined),
Effect.catch(() => Effect.succeed(undefined)), Effect.catch(() => Effect.succeed(undefined)),
) )
@@ -75,7 +73,6 @@ const layer = Layer.effect(
Effect.gen(function* () { Effect.gen(function* () {
const fs = yield* FSUtil.Service const fs = yield* FSUtil.Service
const git = yield* Git.Service const git = yield* Git.Service
const proc = yield* AppProcess.Service
const projectDirectories = yield* ProjectDirectories.Service const projectDirectories = yield* ProjectDirectories.Service
const directories = Effect.fn("Project.directories")(function* (input: DirectoriesInput) { const directories = Effect.fn("Project.directories")(function* (input: DirectoriesInput) {
@@ -127,65 +124,20 @@ const layer = Layer.effect(
return root ? ID.make(root) : undefined return root ? ID.make(root) : undefined
}) })
// Mercurial identity uses the cached ID or the first root changeset; remote-derived const resolve = Effect.fn("Project.resolve")(function* (input: AbsolutePath) {
// identity (the git `remote()` path) is a follow-up. const repo = yield* git.repo.discover(input)
const hgRoot = Effect.fnUntraced(function* (worktree: AbsolutePath) { if (!repo) return { id: ID.global, directory: AbsolutePath.make(path.parse(input).root), vcs: undefined }
const result = yield* proc
.run(
ChildProcess.make("hg", ["log", "-r", "roots(all())", "-T", "{node}\n"], {
cwd: worktree,
env: { HGPLAIN: "1" },
extendEnv: true,
stdin: "ignore",
}),
)
.pipe(Effect.catch(() => Effect.succeed(undefined)))
if (!result || result.exitCode !== 0) return undefined
const node = result.stdout
.toString("utf8")
.split("\n")
.map((item) => item.trim())
.filter(Boolean)
.toSorted()[0]
return node ? ID.make(node) : undefined
})
const hgDiscover = Effect.fnUntraced(function* (input: AbsolutePath) { const previous = yield* cached(repo.commonDirectory)
const dotHg = yield* fs.up({ targets: [".hg"], start: input }).pipe( const id = (yield* remote(repo)) ?? previous ?? (yield* root(repo))
Effect.map((matches) => matches[0]),
Effect.catch(() => Effect.succeed(undefined)),
)
if (!dotHg) return undefined
const worktree = AbsolutePath.make(path.dirname(dotHg))
const store = AbsolutePath.make(dotHg)
const previous = yield* cached(store)
const id = previous ?? (yield* hgRoot(worktree))
return { return {
previous, previous,
id: id ?? ID.global, id: id ?? ID.global,
directory: worktree, directory: repo.worktree,
vcs: { type: "hg" as const, store }, vcs: { type: "git" as const, store: repo.commonDirectory },
} }
}) })
const resolve = Effect.fn("Project.resolve")(function* (input: AbsolutePath) {
const repo = yield* git.repo.discover(input)
if (repo) {
const previous = yield* cached(repo.commonDirectory)
const id = (yield* remote(repo)) ?? previous ?? (yield* root(repo))
return {
previous,
id: id ?? ID.global,
directory: repo.worktree,
vcs: { type: "git" as const, store: repo.commonDirectory },
}
}
const hg = yield* hgDiscover(input)
if (hg) return hg
return { id: ID.global, directory: AbsolutePath.make(path.parse(input).root), vcs: undefined }
})
const commit = Effect.fn("Project.commit")(function* (input: { store: AbsolutePath; id: ID }) { const commit = Effect.fn("Project.commit")(function* (input: { store: AbsolutePath; id: ID }) {
yield* fs.writeFileString(path.join(input.store, "opencode"), input.id).pipe(Effect.ignore) yield* fs.writeFileString(path.join(input.store, "opencode"), input.id).pipe(Effect.ignore)
}) })
@@ -197,5 +149,5 @@ const layer = Layer.effect(
export const node = makeGlobalNode({ export const node = makeGlobalNode({
service: Service, service: Service,
layer: layer, layer: layer,
deps: [FSUtil.node, Git.node, AppProcess.node, ProjectDirectories.node], deps: [FSUtil.node, Git.node, ProjectDirectories.node],
}) })
-4
View File
@@ -24,9 +24,5 @@ export const Vcs = Schema.Union([
type: Schema.Literal("git"), type: Schema.Literal("git"),
store: AbsolutePath, store: AbsolutePath,
}), }),
Schema.Struct({
type: Schema.Literal("hg"),
store: AbsolutePath,
}),
]) ])
export type Vcs = typeof Vcs.Type export type Vcs = typeof Vcs.Type
+1 -9
View File
@@ -19,15 +19,7 @@ export type MutableApi<T extends Api = Api> = T extends Api
export const Request = Provider.Request export const Request = Provider.Request
export type Request = Provider.Request export type Request = Provider.Request
export const Settings = Provider.Settings
export type Settings = Provider.Settings
export const Info = Provider.Info export const Info = Provider.Info
export type Info = Provider.Info export type Info = Provider.Info
export type MutableRequest = Types.DeepMutable<Request> export type MutableInfo = Omit<Types.DeepMutable<Info>, "api"> & { api: MutableApi }
export type MutableInfo = Omit<Types.DeepMutable<Info>, "api" | "request"> & {
api: MutableApi
request: MutableRequest
}
+12 -36
View File
@@ -11,48 +11,20 @@ const Summary = Schema.Struct({
description: Schema.String.pipe(Schema.optional), description: Schema.String.pipe(Schema.optional),
}) })
const entries = (references: ReadonlyArray<typeof Summary.Type>) =>
references.flatMap((reference) => [
" <reference>",
` <name>${reference.name}</name>`,
` <path>${reference.path}</path>`,
...(reference.description === undefined ? [] : [` <description>${reference.description}</description>`]),
" </reference>",
])
const render = (references: ReadonlyArray<typeof Summary.Type>) => const render = (references: ReadonlyArray<typeof Summary.Type>) =>
[ [
"Project references provide additional directories that can be accessed when relevant.", "Project references provide additional directories that can be accessed when relevant.",
"<available_references>", "<available_references>",
...entries(references), ...references.flatMap((reference) => [
" <reference>",
` <name>${reference.name}</name>`,
` <path>${reference.path}</path>`,
...(reference.description === undefined ? [] : [` <description>${reference.description}</description>`]),
" </reference>",
]),
"</available_references>", "</available_references>",
].join("\n") ].join("\n")
const update = (previous: ReadonlyArray<typeof Summary.Type>, current: ReadonlyArray<typeof Summary.Type>) => {
const diff = SystemContext.diffByKey(
previous,
current,
(reference) => reference.name,
(before, after) => before.path !== after.path || before.description !== after.description,
)
// Additions and removals render as small deltas; anything else restates the full list.
if (diff.changed.length > 0 || (diff.added.length === 0 && diff.removed.length === 0))
return [
"The available project references have changed. This list supersedes the previous reference list.",
render(current),
].join("\n")
return [
...(diff.added.length === 0
? []
: ["New project references are available in addition to those previously listed:", ...entries(diff.added)]),
...(diff.removed.length === 0
? []
: [
`The following project references are no longer available and must not be used: ${diff.removed.map((reference) => reference.name).join(", ")}.`,
]),
].join("\n")
}
export interface Interface { export interface Interface {
readonly load: () => Effect.Effect<SystemContext.SystemContext> readonly load: () => Effect.Effect<SystemContext.SystemContext>
} }
@@ -80,7 +52,11 @@ const layer = Layer.effect(
codec: Schema.toCodecJson(Schema.Array(Summary)), codec: Schema.toCodecJson(Schema.Array(Summary)),
load: Effect.succeed(available), load: Effect.succeed(available),
baseline: render, baseline: render,
update, update: (_previous, current) =>
[
"The available project references have changed. This list supersedes the previous reference list.",
render(current),
].join("\n"),
removed: () => "Project reference guidance is no longer available. Do not use previously listed references.", removed: () => "Project reference guidance is no longer available. Do not use previously listed references.",
}) })
}), }),
+28 -109
View File
@@ -3,7 +3,7 @@ export * from "./session/schema"
import { DateTime, Effect, Layer, Schema, Context, Stream, Scope } from "effect" import { DateTime, Effect, Layer, Schema, Context, Stream, Scope } from "effect"
import { ListAnchor } from "@opencode-ai/schema/session" import { ListAnchor } from "@opencode-ai/schema/session"
import { and, asc, desc, eq, gt, isNull, like, lt, or, type SQL } from "drizzle-orm" import { and, asc, desc, eq, gt, like, lt, or, type SQL } from "drizzle-orm"
import { ProjectV2 } from "./project" import { ProjectV2 } from "./project"
import { WorkspaceV2 } from "./workspace" import { WorkspaceV2 } from "./workspace"
import { ModelV2 } from "./model" import { ModelV2 } from "./model"
@@ -37,10 +37,9 @@ import { SessionCompaction } from "./session/compaction"
import { SessionRevert } from "./session/revert" import { SessionRevert } from "./session/revert"
import { Revert } from "@opencode-ai/schema/revert" import { Revert } from "@opencode-ai/schema/revert"
import { FSUtil } from "./fs-util" import { FSUtil } from "./fs-util"
import type { EventLog } from "@opencode-ai/schema/event-log" import { SessionDurable } from "@opencode-ai/schema/durable-event-manifest"
import { SkillV2 } from "./skill" import { SkillV2 } from "./skill"
import { Job } from "./job" import { Job } from "./job"
import { CommandV2 } from "./command"
export const RevertState = Revert.State export const RevertState = Revert.State
export type RevertState = Revert.State export type RevertState = Revert.State
@@ -61,7 +60,6 @@ const ListInputBase = {
search: Schema.String.pipe(Schema.optional), search: Schema.String.pipe(Schema.optional),
limit: PositiveInt.pipe(Schema.optional), limit: PositiveInt.pipe(Schema.optional),
order: Schema.Literals(["asc", "desc"]).pipe(Schema.optional), order: Schema.Literals(["asc", "desc"]).pipe(Schema.optional),
parentID: Schema.NullOr(SessionSchema.ID).pipe(Schema.optional),
anchor: ListAnchor.pipe(Schema.optional), anchor: ListAnchor.pipe(Schema.optional),
} }
@@ -110,7 +108,7 @@ export class OperationUnavailableError extends Schema.TaggedErrorClass<Operation
}, },
) {} ) {}
export { MessageDecodeError } from "./session/error" export { ContextSnapshotDecodeError, MessageDecodeError } from "./session/error"
export class PromptConflictError extends Schema.TaggedErrorClass<PromptConflictError>()("Session.PromptConflictError", { export class PromptConflictError extends Schema.TaggedErrorClass<PromptConflictError>()("Session.PromptConflictError", {
sessionID: SessionSchema.ID, sessionID: SessionSchema.ID,
@@ -132,16 +130,10 @@ export type Error =
| PromptConflictError | PromptConflictError
| BusyError | BusyError
| SkillNotFoundError | SkillNotFoundError
| CommandV2.NotFoundError
| CommandV2.EvaluationError
| MessageNotFoundError | MessageNotFoundError
export interface Interface { export interface Interface {
readonly list: (input?: ListInput) => Effect.Effect<{ readonly list: (input?: ListInput) => Effect.Effect<SessionSchema.Info[]>
readonly data: SessionSchema.Info[]
/** Per-session durable log watermark, read in the same transaction as the snapshot. Sessions without events are absent. */
readonly watermarks: ReadonlyMap<string, EventV2.Seq>
}>
readonly create: (input: CreateInput) => Effect.Effect<SessionSchema.Info, NotFoundError> readonly create: (input: CreateInput) => Effect.Effect<SessionSchema.Info, NotFoundError>
readonly fork: (input: ForkInput) => Effect.Effect<SessionSchema.Info, NotFoundError | MessageNotFoundError> readonly fork: (input: ForkInput) => Effect.Effect<SessionSchema.Info, NotFoundError | MessageNotFoundError>
readonly get: (sessionID: SessionSchema.ID) => Effect.Effect<SessionSchema.Info, NotFoundError> readonly get: (sessionID: SessionSchema.ID) => Effect.Effect<SessionSchema.Info, NotFoundError>
@@ -161,21 +153,15 @@ export interface Interface {
readonly context: ( readonly context: (
sessionID: SessionSchema.ID, sessionID: SessionSchema.ID,
) => Effect.Effect<SessionMessage.Message[], NotFoundError | MessageDecodeError> ) => Effect.Effect<SessionMessage.Message[], NotFoundError | MessageDecodeError>
/** readonly events: (input: {
* Durable, ordered, gap-free session log read. Replays public durable
* session events after the exclusive `after` cursor, emits a `Synced`
* marker at the captured replay watermark, then continues live when `follow`
* is set.
* The marker's seq may exceed the last emitted event because non-public
* durable events share the aggregate's sequence space.
*/
readonly log: (input: {
sessionID: SessionSchema.ID sessionID: SessionSchema.ID
after?: number after?: number
follow?: boolean }) => Stream.Stream<SessionEvent.DurableEvent, NotFoundError>
}) => Stream.Stream<SessionEvent.DurableEvent | EventLog.Synced, NotFoundError> readonly history: (input: {
/** Latest durable log seq per session. Sessions without events are absent. */ sessionID: SessionSchema.ID
readonly watermarks: (sessionIDs: ReadonlyArray<SessionSchema.ID>) => Effect.Effect<ReadonlyMap<string, EventV2.Seq>> after?: number
limit: number
}) => Effect.Effect<{ events: ReadonlyArray<SessionEvent.DurableEvent>; hasMore: boolean }, NotFoundError>
readonly switchAgent: (input: { sessionID: SessionSchema.ID; agent: string }) => Effect.Effect<void, NotFoundError> readonly switchAgent: (input: { sessionID: SessionSchema.ID; agent: string }) => Effect.Effect<void, NotFoundError>
readonly switchModel: (input: { readonly switchModel: (input: {
sessionID: SessionSchema.ID sessionID: SessionSchema.ID
@@ -189,21 +175,6 @@ export interface Interface {
delivery?: SessionInput.Delivery delivery?: SessionInput.Delivery
resume?: boolean resume?: boolean
}) => Effect.Effect<SessionInput.Admitted, NotFoundError | PromptConflictError> }) => Effect.Effect<SessionInput.Admitted, NotFoundError | PromptConflictError>
readonly command: (input: {
id?: SessionMessage.ID
sessionID: SessionSchema.ID
command: string
arguments?: string
agent?: string
model?: ModelV2.Ref
files?: PromptInput.Prompt["files"]
agents?: PromptInput.Prompt["agents"]
delivery?: SessionInput.Delivery
resume?: boolean
}) => Effect.Effect<
SessionInput.Admitted,
NotFoundError | PromptConflictError | CommandV2.NotFoundError | CommandV2.EvaluationError
>
readonly shell: (input: { readonly shell: (input: {
id?: EventV2.ID id?: EventV2.ID
sessionID: SessionSchema.ID sessionID: SessionSchema.ID
@@ -228,7 +199,6 @@ export interface Interface {
sessionID: SessionSchema.ID sessionID: SessionSchema.ID
text: string text: string
description?: string description?: string
metadata?: Record<string, unknown>
}) => Effect.Effect<void, NotFoundError> }) => Effect.Effect<void, NotFoundError>
readonly revert: { readonly revert: {
readonly stage: (input: { readonly stage: (input: {
@@ -360,16 +330,12 @@ const layer = Layer.effect(
const direction = input.anchor?.direction ?? "next" const direction = input.anchor?.direction ?? "next"
const requestedOrder = input.order ?? "desc" const requestedOrder = input.order ?? "desc"
const order = direction === "previous" ? (requestedOrder === "asc" ? "desc" : "asc") : requestedOrder const order = direction === "previous" ? (requestedOrder === "asc" ? "desc" : "asc") : requestedOrder
const sortColumn = SessionTable.time_updated const sortColumn = SessionTable.time_created
const conditions: SQL[] = [] const conditions: SQL[] = []
if ("directory" in input) conditions.push(eq(SessionTable.directory, input.directory)) if ("directory" in input) conditions.push(eq(SessionTable.directory, input.directory))
if (input.workspaceID) conditions.push(eq(SessionTable.workspace_id, input.workspaceID)) if (input.workspaceID) conditions.push(eq(SessionTable.workspace_id, input.workspaceID))
if ("project" in input) conditions.push(eq(SessionTable.project_id, input.project)) if ("project" in input) conditions.push(eq(SessionTable.project_id, input.project))
if (input.search) conditions.push(like(SessionTable.title, `%${input.search}%`)) if (input.search) conditions.push(like(SessionTable.title, `%${input.search}%`))
if (input.parentID !== undefined)
conditions.push(
input.parentID === null ? isNull(SessionTable.parent_id) : eq(SessionTable.parent_id, input.parentID),
)
if (input.anchor) { if (input.anchor) {
conditions.push( conditions.push(
order === "asc" order === "asc"
@@ -391,21 +357,10 @@ const layer = Layer.effect(
order === "asc" ? asc(sortColumn) : desc(sortColumn), order === "asc" ? asc(sortColumn) : desc(sortColumn),
order === "asc" ? asc(SessionTable.id) : desc(SessionTable.id), order === "asc" ? asc(SessionTable.id) : desc(SessionTable.id),
) )
// Watermarks must pair with the snapshot exactly, so both reads share a transaction: const rows = yield* (input.limit === undefined ? query.all() : query.limit(input.limit).all()).pipe(
// a higher watermark would let an attached tail skip events missing from the snapshot. Effect.orDie,
const snapshot = yield* db )
.transaction(() => return (direction === "previous" ? rows.toReversed() : rows).map((row) => fromRow(row))
Effect.gen(function* () {
const rows = yield* (input.limit === undefined ? query.all() : query.limit(input.limit).all()).pipe(
Effect.orDie,
)
const watermarks = yield* events.sequences(rows.map((row) => row.id))
return { rows, watermarks }
}),
)
.pipe(Effect.orDie)
const rows = direction === "previous" ? snapshot.rows.toReversed() : snapshot.rows
return { data: rows.map((row) => fromRow(row)), watermarks: snapshot.watermarks }
}), }),
messages: Effect.fn("V2Session.messages")(function* (input) { messages: Effect.fn("V2Session.messages")(function* (input) {
yield* result.get(input.sessionID) yield* result.get(input.sessionID)
@@ -449,19 +404,19 @@ const layer = Layer.effect(
yield* result.get(sessionID) yield* result.get(sessionID)
return yield* store.context(sessionID) return yield* store.context(sessionID)
}), }),
log: (input) => events: (input) =>
Stream.unwrap( Stream.unwrap(
result result
.get(input.sessionID) .get(input.sessionID)
.pipe(Effect.as(events.log({ aggregateID: input.sessionID, after: input.after, follow: input.follow }))), .pipe(Effect.as(events.durable({ aggregateID: input.sessionID, after: input.after }))),
).pipe( ).pipe(Stream.filter((event): event is SessionEvent.DurableEvent => isDurableSessionEvent(event))),
Stream.filter( history: Effect.fn("V2Session.history")(function* (input) {
(item): item is SessionEvent.DurableEvent | EventLog.Synced => yield* result.get(input.sessionID)
EventV2.isSynced(item) || isDurableSessionEvent(item), return yield* EventV2.readAggregate(db, {
), ...input,
), aggregateID: input.sessionID,
watermarks: Effect.fn("V2Session.watermarks")(function* (sessionIDs) { manifest: SessionDurable,
return yield* events.sequences(sessionIDs) })
}), }),
prompt: Effect.fn("V2Session.prompt")((input) => prompt: Effect.fn("V2Session.prompt")((input) =>
Effect.uninterruptible( Effect.uninterruptible(
@@ -494,37 +449,6 @@ const layer = Layer.effect(
}), }),
), ),
), ),
command: Effect.fn("V2Session.command")(function* (input) {
const session = yield* result.get(input.sessionID)
const commands = yield* CommandV2.Service.pipe(Effect.provide(locations.get(session.location)))
const command = yield* commands.get(input.command)
if (!command)
return yield* new CommandV2.NotFoundError({
command: input.command,
message: `Command not found: ${input.command}`,
})
const evaluated = yield* commands.evaluate({ name: input.command, arguments: input.arguments })
// TODO(v2 commands): decide whether command-level subtask/background execution belongs in v2 commands.
const agent = command.agent ?? input.agent
const commandAgent = yield* Effect.gen(function* () {
if (!command.agent) return undefined
const agents = yield* AgentV2.Service.pipe(Effect.provide(locations.get(session.location)))
return yield* agents.get(AgentV2.ID.make(command.agent))
})
const model = command.model ?? commandAgent?.model ?? input.model
if (agent !== undefined && session.agent !== AgentV2.ID.make(agent))
yield* result.switchAgent({ sessionID: input.sessionID, agent })
if (model !== undefined) yield* result.switchModel({ sessionID: input.sessionID, model })
return yield* result.prompt({
id: input.id,
sessionID: input.sessionID,
prompt: { text: evaluated.text, files: input.files, agents: input.agents },
delivery: input.delivery,
resume: input.resume,
})
}),
shell: Effect.fn("V2Session.shell")(function* () { shell: Effect.fn("V2Session.shell")(function* () {
return yield* new OperationUnavailableError({ operation: "shell" }) return yield* new OperationUnavailableError({ operation: "shell" })
}), }),
@@ -541,9 +465,7 @@ const layer = Layer.effect(
text: skill.content, text: skill.content,
}) })
if (input.resume !== false) if (input.resume !== false)
yield* execution yield* execution.resume(input.sessionID).pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid)
.resume(input.sessionID)
.pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid)
}), }),
switchAgent: Effect.fn("V2Session.switchAgent")(function* (input) { switchAgent: Effect.fn("V2Session.switchAgent")(function* (input) {
yield* result.get(input.sessionID) yield* result.get(input.sessionID)
@@ -625,11 +547,8 @@ const layer = Layer.effect(
timestamp: yield* DateTime.now, timestamp: yield* DateTime.now,
text: input.text, text: input.text,
description: input.description, description: input.description,
metadata: input.metadata,
}) })
yield* execution yield* execution.resume(input.sessionID).pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid)
.resume(input.sessionID)
.pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid)
}), }),
interrupt: Effect.fn("V2Session.interrupt")((sessionID) => interrupt: Effect.fn("V2Session.interrupt")((sessionID) =>
Effect.uninterruptible(execution.interrupt(sessionID)), Effect.uninterruptible(execution.interrupt(sessionID)),
+3 -3
View File
@@ -321,12 +321,12 @@ export const layer = Layer.effect(
compactIfNeeded: compaction.compactIfNeeded, compactIfNeeded: compaction.compactIfNeeded,
compactAfterOverflow: compaction.compactAfterOverflow, compactAfterOverflow: compaction.compactAfterOverflow,
compactManual: Effect.fn("SessionCompaction.compactManual")(function* (input) { compactManual: Effect.fn("SessionCompaction.compactManual")(function* (input) {
const resolved = yield* models.resolve(input.session).pipe(Effect.catch(() => Effect.succeed(undefined))) const model = yield* models.resolve(input.session).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (!resolved) return false if (!model) return false
return yield* compaction.compactManual({ return yield* compaction.compactManual({
sessionID: input.session.id, sessionID: input.session.id,
messages: input.messages, messages: input.messages,
model: resolved.model, model,
}) })
}), }),
}) })
@@ -1,131 +0,0 @@
export * as SessionContextCheckpoint from "./context-checkpoint"
import { eq } from "drizzle-orm"
import { DateTime, Effect, Option, Schema } from "effect"
import type { Database } from "../database/database"
import { EventV2 } from "../event"
import { SystemContext } from "../system-context/index"
import { SessionEvent } from "./event"
import { SessionHistory } from "./history"
import { SessionMessage } from "./message"
import { SessionSchema } from "./schema"
import { SessionContextCheckpointTable } from "./sql"
type DatabaseService = Database.Interface["db"]
const decodeApplied = Schema.decodeUnknownOption(SystemContext.Applied)
/**
* Loads or creates the session's durable context checkpoint, narrating any
* drift since the model was last told as a chronological update. Completed
* compaction rebaselines; nothing else rewrites the baseline. Runs before
* input promotion so a blocked first turn leaves pending inputs untouched.
*/
export const prepare = Effect.fn("SessionContextCheckpoint.prepare")(function* (
db: DatabaseService,
events: EventV2.Interface,
context: Effect.Effect<SystemContext.SystemContext>,
sessionID: SessionSchema.ID,
) {
const [value, stored, compaction] = yield* Effect.all(
[context, find(db, sessionID), SessionHistory.latestCompaction(db, sessionID)],
{ concurrency: "unbounded" },
)
if (!stored) {
const baseline = yield* SystemContext.initialize(value)
const baselineSeq = yield* insert(db, sessionID, baseline)
return { baseline: baseline.text, baselineSeq }
}
// The applied record is comparison state only; an undecodable one heals by
// treating every source as new, re-announcing baselines as updates.
const applied = Option.getOrElse(decodeApplied(stored.snapshot), () => ({}))
if (compaction !== undefined && compaction.seq > stored.baseline_seq) {
const baseline = yield* SystemContext.rebaseline(value, applied)
yield* rewrite(db, sessionID, compaction.seq, baseline)
return { baseline: baseline.text, baselineSeq: compaction.seq }
}
const result = yield* SystemContext.reconcile(value, applied)
if (result._tag === "Unchanged") return { baseline: stored.baseline, baselineSeq: stored.baseline_seq }
yield* events.publish(
SessionEvent.ContextUpdated,
{ sessionID, messageID: SessionMessage.ID.create(), timestamp: yield* DateTime.now, text: result.text },
{ commit: () => advance(db, sessionID, result.applied).pipe(Effect.orDie) },
)
return { baseline: stored.baseline, baselineSeq: stored.baseline_seq }
})
export const reset = Effect.fn("SessionContextCheckpoint.reset")(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
) {
yield* db
.delete(SessionContextCheckpointTable)
.where(eq(SessionContextCheckpointTable.session_id, sessionID))
.run()
.pipe(Effect.orDie)
})
const find = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
return yield* db
.select()
.from(SessionContextCheckpointTable)
.where(eq(SessionContextCheckpointTable.session_id, sessionID))
.get()
.pipe(Effect.orDie)
})
const insert = Effect.fnUntraced(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
baseline: SystemContext.Baseline,
) {
const baselineSeq = yield* EventV2.latestSequence(db, sessionID)
yield* db
.insert(SessionContextCheckpointTable)
.values({
session_id: sessionID,
baseline: baseline.text,
snapshot: baseline.applied,
baseline_seq: baselineSeq,
})
.run()
.pipe(Effect.orDie)
return baselineSeq
})
const rewrite = Effect.fnUntraced(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
baselineSeq: number,
baseline: SystemContext.Baseline,
) {
const updated = yield* db
.update(SessionContextCheckpointTable)
.set({
baseline: baseline.text,
snapshot: baseline.applied,
baseline_seq: baselineSeq,
})
.where(eq(SessionContextCheckpointTable.session_id, sessionID))
.returning({ sessionID: SessionContextCheckpointTable.session_id })
.get()
.pipe(Effect.orDie)
if (!updated) return yield* Effect.die("Context checkpoint not found")
})
const advance = Effect.fnUntraced(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
applied: SystemContext.Applied,
) {
const updated = yield* db
.update(SessionContextCheckpointTable)
.set({ snapshot: applied })
.where(eq(SessionContextCheckpointTable.session_id, sessionID))
.returning({ sessionID: SessionContextCheckpointTable.session_id })
.get()
.pipe(Effect.orDie)
if (!updated) return yield* Effect.die("Context checkpoint not found")
})
-106
View File
@@ -1,106 +0,0 @@
export * as SessionContextEntry from "./context-entry"
import { and, asc, eq } from "drizzle-orm"
import { Context, Effect, Layer, Schema } from "effect"
import { SessionContextEntry } from "@opencode-ai/schema/session-context-entry"
import { Database } from "../database/database"
import { makeLocationNode } from "../effect/app-node"
import { SystemContext } from "../system-context/index"
import { SessionSchema } from "./schema"
import { SessionContextEntryTable } from "./sql"
export const Key = SessionContextEntry.Key
export type Key = typeof Key.Type
export const Info = SessionContextEntry.Info
export type Info = typeof Info.Type
export interface Interface {
readonly list: (sessionID: SessionSchema.ID) => Effect.Effect<ReadonlyArray<Info>>
readonly put: (input: {
readonly sessionID: SessionSchema.ID
readonly key: Key
readonly value: Schema.Json
}) => Effect.Effect<void>
readonly remove: (input: { readonly sessionID: SessionSchema.ID; readonly key: Key }) => Effect.Effect<void>
/** Produces one SystemContext source per stored entry, keyed `api/<key>`. */
readonly load: (sessionID: SessionSchema.ID) => Effect.Effect<SystemContext.SystemContext>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SessionContextEntry") {}
const renderValue = (value: Schema.Json) => (typeof value === "string" ? value : JSON.stringify(value, null, 2))
const renderBlock = (key: Key, value: Schema.Json) =>
[`<context key="${key}">`, renderValue(value), "</context>"].join("\n")
// Rendering stays mechanism-neutral: the model sees session context, not how
// it was attached. Only chronological updates and removals carry narration.
const source = (entry: Info) =>
SystemContext.make({
key: SystemContext.Key.make(`api/${entry.key}`),
codec: Schema.toCodecJson(Schema.Json),
load: Effect.succeed(entry.value),
baseline: (value) => renderBlock(entry.key, value),
update: (_previous, value) =>
[
`The context under "${entry.key}" changed and supersedes the previous value:`,
renderBlock(entry.key, value),
].join("\n"),
removed: () => `The context under "${entry.key}" no longer applies. Disregard it.`,
})
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const { db } = yield* Database.Service
const list = Effect.fn("SessionContextEntry.list")(function* (sessionID: SessionSchema.ID) {
const rows = yield* db
.select()
.from(SessionContextEntryTable)
.where(eq(SessionContextEntryTable.session_id, sessionID))
.orderBy(asc(SessionContextEntryTable.key))
.all()
.pipe(Effect.orDie)
return rows.map((row) => ({ key: row.key, value: row.value }))
})
const put = Effect.fn("SessionContextEntry.put")(function* (input: {
readonly sessionID: SessionSchema.ID
readonly key: Key
readonly value: Schema.Json
}) {
yield* db
.insert(SessionContextEntryTable)
.values({ session_id: input.sessionID, key: input.key, value: input.value })
.onConflictDoUpdate({
target: [SessionContextEntryTable.session_id, SessionContextEntryTable.key],
set: { value: input.value, time_updated: Date.now() },
})
.run()
.pipe(Effect.orDie)
})
const remove = Effect.fn("SessionContextEntry.remove")(function* (input: {
readonly sessionID: SessionSchema.ID
readonly key: Key
}) {
yield* db
.delete(SessionContextEntryTable)
.where(
and(eq(SessionContextEntryTable.session_id, input.sessionID), eq(SessionContextEntryTable.key, input.key)),
)
.run()
.pipe(Effect.orDie)
})
const load = Effect.fn("SessionContextEntry.load")(function* (sessionID: SessionSchema.ID) {
const entries = yield* list(sessionID)
return SystemContext.combine(entries.map(source))
})
return Service.of({ list, put, remove, load })
}),
)
export const node = makeLocationNode({ service: Service, layer, deps: [Database.node] })
+174
View File
@@ -0,0 +1,174 @@
export * as SessionContextEpoch from "./context-epoch"
import { eq } from "drizzle-orm"
import { DateTime, Effect, Schema } from "effect"
import type { Database } from "../database/database"
import { EventV2 } from "../event"
import { SystemContext } from "../system-context/index"
import { ContextSnapshotDecodeError } from "./error"
import { SessionEvent } from "./event"
import { SessionHistory } from "./history"
import { SessionInput } from "./input"
import { SessionMessage } from "./message"
import { SessionSchema } from "./schema"
import { SessionContextEpochTable } from "./sql"
type DatabaseService = Database.Interface["db"]
interface Prepared {
readonly baseline: string
readonly baselineSeq: number
}
export function initialize(
db: DatabaseService,
context: Effect.Effect<SystemContext.SystemContext>,
sessionID: SessionSchema.ID,
): Effect.Effect<Prepared | undefined, SystemContext.InitializationBlocked> {
return initializeOnce(db, context, sessionID).pipe(Effect.withSpan("SessionContextEpoch.initialize"))
}
export function prepare(
db: DatabaseService,
events: EventV2.Interface,
context: Effect.Effect<SystemContext.SystemContext>,
sessionID: SessionSchema.ID,
): Effect.Effect<Prepared, SystemContext.InitializationBlocked | ContextSnapshotDecodeError> {
return prepareOnce(db, events, context, sessionID).pipe(Effect.withSpan("SessionContextEpoch.prepare"))
}
const prepareOnce = Effect.fnUntraced(function* (
db: DatabaseService,
events: EventV2.Interface,
context: Effect.Effect<SystemContext.SystemContext>,
sessionID: SessionSchema.ID,
) {
const [value, stored, compaction] = yield* Effect.all(
[context, find(db, sessionID), SessionHistory.latestCompaction(db, sessionID)],
{ concurrency: "unbounded" },
)
if (!stored) {
const generation = yield* SystemContext.initialize(value)
const baselineSeq = yield* insert(db, sessionID, generation)
return { baseline: generation.baseline, baselineSeq }
}
const snapshot = yield* Schema.decodeUnknownEffect(SystemContext.Snapshot)(stored.snapshot).pipe(
Effect.mapError((error) => new ContextSnapshotDecodeError({ sessionID, details: String(error) })),
)
const replacementSeq = compaction !== undefined && compaction.seq > stored.baseline_seq ? compaction.seq : undefined
const result = replacementSeq
? yield* SystemContext.replace(value, snapshot)
: yield* SystemContext.reconcile(value, snapshot)
if (result._tag === "Unchanged" || result._tag === "ReplacementBlocked") {
return { baseline: stored.baseline, baselineSeq: stored.baseline_seq }
}
if (result._tag === "ReplacementReady") {
const baselineSeq = replacementSeq ?? (yield* EventV2.latestSequence(db, sessionID))
yield* replace(db, sessionID, baselineSeq, result.generation)
return { baseline: result.generation.baseline, baselineSeq }
}
yield* events.publish(
SessionEvent.ContextUpdated,
{ sessionID, messageID: SessionMessage.ID.create(), timestamp: yield* DateTime.now, text: result.text },
{ commit: () => advance(db, sessionID, result.snapshot).pipe(Effect.orDie) },
)
return { baseline: stored.baseline, baselineSeq: stored.baseline_seq }
})
const initializeOnce = Effect.fnUntraced(function* (
db: DatabaseService,
context: Effect.Effect<SystemContext.SystemContext>,
sessionID: SessionSchema.ID,
) {
if (yield* exists(db, sessionID)) return
const generation = yield* context.pipe(Effect.flatMap(SystemContext.initialize))
const baselineSeq = yield* insert(db, sessionID, generation)
return { baseline: generation.baseline, baselineSeq }
})
const exists = Effect.fn("SessionContextEpoch.exists")(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
return (
(yield* db
.select({ sessionID: SessionContextEpochTable.session_id })
.from(SessionContextEpochTable)
.where(eq(SessionContextEpochTable.session_id, sessionID))
.get()
.pipe(Effect.orDie)) !== undefined
)
})
const find = Effect.fn("SessionContextEpoch.find")(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
return yield* db
.select()
.from(SessionContextEpochTable)
.where(eq(SessionContextEpochTable.session_id, sessionID))
.get()
.pipe(Effect.orDie)
})
export const reset = Effect.fn("SessionContextEpoch.reset")(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
) {
yield* db
.delete(SessionContextEpochTable)
.where(eq(SessionContextEpochTable.session_id, sessionID))
.run()
.pipe(Effect.orDie)
})
const insert = Effect.fnUntraced(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
generation: SystemContext.Generation,
) {
const baselineSeq = yield* EventV2.latestSequence(db, sessionID)
yield* db
.insert(SessionContextEpochTable)
.values({
session_id: sessionID,
baseline: generation.baseline,
snapshot: generation.snapshot,
baseline_seq: baselineSeq,
})
.run()
.pipe(Effect.orDie)
return baselineSeq
})
const replace = Effect.fnUntraced(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
baselineSeq: number,
generation: SystemContext.Generation,
) {
const updated = yield* db
.update(SessionContextEpochTable)
.set({
baseline: generation.baseline,
snapshot: generation.snapshot,
baseline_seq: baselineSeq,
})
.where(eq(SessionContextEpochTable.session_id, sessionID))
.returning({ sessionID: SessionContextEpochTable.session_id })
.get()
.pipe(Effect.orDie)
if (!updated) return yield* Effect.die("Context Epoch not found")
})
const advance = Effect.fnUntraced(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
snapshot: SystemContext.Snapshot,
) {
const updated = yield* db
.update(SessionContextEpochTable)
.set({ snapshot })
.where(eq(SessionContextEpochTable.session_id, sessionID))
.returning({ sessionID: SessionContextEpochTable.session_id })
.get()
.pipe(Effect.orDie)
if (!updated) return yield* Effect.die("Context Epoch not found")
})

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