Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c590e27639 | |||
| 8f4b62eb49 | |||
| 945d1c8cb2 | |||
| 610e618bc5 | |||
| 9daa4d85a4 | |||
| 62af66a74f | |||
| 5b44e5bf41 | |||
| a15afbe8f2 | |||
| 8e0856c43b | |||
| f66c829231 | |||
| 3baaabede8 | |||
| afe3ebbc35 | |||
| e2faeb84e5 | |||
| 35ed09ff37 | |||
| 9751615651 | |||
| c9b24ef027 | |||
| b04d8d53e6 | |||
| 7bd3f8ac83 | |||
| 6ae2fa5196 | |||
| 650d774372 | |||
| 64e4f6f91b | |||
| d097cc8065 | |||
| 438654768c | |||
| 37b26e495b | |||
| 5d14c7a185 |
@@ -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" })
|
||||
})
|
||||
})
|
||||
},
|
||||
}
|
||||
@@ -104,6 +104,25 @@ bun dev api <operationId> --param key=value
|
||||
- If no compatible background server is registered, `bun dev api` starts one through the daemon service. Use `bun dev service status`, `bun dev service restart`, and `bun dev service stop` when you need explicit lifecycle control.
|
||||
- Prefer raw method/path calls for quick server debugging and operation IDs when exercising documented OpenAPI routes with path or query parameters.
|
||||
|
||||
## Auditing installed `opencode2` sessions
|
||||
|
||||
Installed next-channel sessions normally use `~/.local/share/opencode/opencode-next.db` and `~/.local/share/opencode/log/opencode.log`; `OPENCODE_DB` can override the database. Before calling `opencode2 api`, inspect `~/.local/state/opencode/service.json` because the command may start a daemon when none is healthy.
|
||||
|
||||
For a supplied `ses_...` ID, compare three sources:
|
||||
|
||||
- `opencode2 api get /api/session/active` and the Session/message endpoints for live server state.
|
||||
- The database's ordered `event` rows for durable history.
|
||||
- `packages/tui/src/context/data.tsx` and the relevant route for client projection and rendering.
|
||||
|
||||
Locate an uncertain database without modifying it:
|
||||
|
||||
```bash
|
||||
SESSION=ses_...
|
||||
for db in ~/.local/share/opencode/*.db; do
|
||||
sqlite3 "file:$db?mode=ro" "select 1 from session where id='$SESSION' limit 1" 2>/dev/null | grep -q 1 && printf '%s\n' "$db"
|
||||
done
|
||||
```
|
||||
|
||||
## Logs
|
||||
|
||||
- Log files live under `~/.local/share/opencode/log/`. In a local/dev checkout the active file is `opencode-local.log`; `opencode.log` is used for non-local (released) channel installs. Both are append-only, shared across every CLI and server process on the machine.
|
||||
|
||||
@@ -151,6 +151,7 @@ const table = sqliteTable("session", {
|
||||
|
||||
## V2 Session Core
|
||||
|
||||
- Keep durable events minimal: record irreducible new facts and do not repeat state derivable by folding the ordered aggregate history. Enrich projections and read models with previous or derived state when consumers need self-contained views.
|
||||
- Keep durable prompt admission separate from model execution. `SessionV2.prompt(...)` admits one durable `session_input` row before scheduling advisory `SessionExecution.wake(sessionID)` unless `resume: false` requests admit-only behavior. The serialized runner promotes admitted inputs into visible user messages at safe boundaries.
|
||||
- Reusing a Session ID adopts the existing Session. Reusing a prompt message ID reconciles an exact retry only when Session, prompt, and delivery mode match; conflicting reuse fails. Historical projected prompts lazily synthesize promoted inbox records during exact retry.
|
||||
- Keep `SessionExecution` process-global and Session-ID based. Its local implementation owns the process-local Session coordinator and discovers placement through `SessionStore` plus `LocationServiceMap.get(session.location)` only when a drain starts; no layer should take a Session ID. V2 interruption targets the active process-local ownership chain for that Session; idle or missing interruption is a no-op.
|
||||
|
||||
@@ -122,15 +122,14 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@opencode-ai/client",
|
||||
"version": "1.17.13",
|
||||
"dependencies": {
|
||||
"@opencode-ai/protocol": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/httpapi-codegen": "workspace:*",
|
||||
"@opencode-ai/server": "workspace:*",
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
@@ -695,6 +694,7 @@
|
||||
"version": "1.17.13",
|
||||
"dependencies": {
|
||||
"@ai-sdk/provider": "3.0.8",
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/llm": "workspace:*",
|
||||
"@opencode-ai/protocol": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
|
||||
@@ -177,7 +177,7 @@ export function DialogCustomProvider(props: Props) {
|
||||
>
|
||||
<div class="flex flex-col gap-6 px-2.5 pb-3 overflow-y-auto max-h-[60vh]">
|
||||
<div class="px-2.5 flex gap-4 items-center">
|
||||
<ProviderIcon id="synthetic" class="size-5 shrink-0 icon-strong-base" />
|
||||
<ProviderIcon id="session.synthetic" class="size-5 shrink-0 icon-strong-base" />
|
||||
<div class="text-16-medium text-text-strong">{language.t("provider.custom.title")}</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -226,7 +226,7 @@ const SettingsProvidersContent: Component = () => {
|
||||
>
|
||||
<div class="flex flex-col min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<ProviderIcon id="synthetic" class="size-5 shrink-0 icon-strong-base" />
|
||||
<ProviderIcon id="session.synthetic" class="size-5 shrink-0 icon-strong-base" />
|
||||
<span class="text-14-medium text-text-strong">{language.t("provider.custom.title")}</span>
|
||||
<Tag>{language.t("settings.providers.tag.custom")}</Tag>
|
||||
</div>
|
||||
|
||||
@@ -223,7 +223,7 @@ export const SettingsProvidersV2: Component = () => {
|
||||
<div class="settings-v2-provider-row" data-component="custom-provider-section">
|
||||
<div class="settings-v2-provider-lead">
|
||||
<ProviderIcon
|
||||
id="synthetic"
|
||||
id="session.synthetic"
|
||||
width={PROVIDER_ICON_SIZE}
|
||||
height={PROVIDER_ICON_SIZE}
|
||||
class="settings-v2-provider-icon shrink-0"
|
||||
|
||||
@@ -7,7 +7,7 @@ describe("file watcher invalidation", () => {
|
||||
const refresh: string[] = []
|
||||
invalidateFromWatcher(
|
||||
{
|
||||
type: "file.watcher.updated",
|
||||
type: "filesystem.changed",
|
||||
properties: {
|
||||
file: "src/new.ts",
|
||||
event: "add",
|
||||
@@ -32,7 +32,7 @@ describe("file watcher invalidation", () => {
|
||||
|
||||
invalidateFromWatcher(
|
||||
{
|
||||
type: "file.watcher.updated",
|
||||
type: "filesystem.changed",
|
||||
properties: {
|
||||
file: "src/open.ts",
|
||||
event: "change",
|
||||
@@ -63,7 +63,7 @@ describe("file watcher invalidation", () => {
|
||||
|
||||
invalidateFromWatcher(
|
||||
{
|
||||
type: "file.watcher.updated",
|
||||
type: "filesystem.changed",
|
||||
properties: {
|
||||
file: "src",
|
||||
event: "change",
|
||||
@@ -81,7 +81,7 @@ describe("file watcher invalidation", () => {
|
||||
|
||||
invalidateFromWatcher(
|
||||
{
|
||||
type: "file.watcher.updated",
|
||||
type: "filesystem.changed",
|
||||
properties: {
|
||||
file: "src/file.ts",
|
||||
event: "change",
|
||||
@@ -111,7 +111,7 @@ describe("file watcher invalidation", () => {
|
||||
|
||||
invalidateFromWatcher(
|
||||
{
|
||||
type: "file.watcher.updated",
|
||||
type: "filesystem.changed",
|
||||
properties: {
|
||||
file: ".git/index.lock",
|
||||
event: "change",
|
||||
|
||||
@@ -16,7 +16,7 @@ type WatcherOps = {
|
||||
}
|
||||
|
||||
export function invalidateFromWatcher(event: WatcherEvent, ops: WatcherOps) {
|
||||
if (event.type !== "file.watcher.updated") return
|
||||
if (event.type !== "filesystem.changed") return
|
||||
const props =
|
||||
typeof event.properties === "object" && event.properties ? (event.properties as Record<string, unknown>) : undefined
|
||||
const rawPath = typeof props?.file === "string" ? props.file : undefined
|
||||
|
||||
@@ -820,7 +820,7 @@ export default function Page() {
|
||||
)
|
||||
|
||||
const stopVcs = sdk().event.listen((evt) => {
|
||||
if (evt.details.type !== "file.watcher.updated") return
|
||||
if (evt.details.type !== "filesystem.changed") return
|
||||
const props =
|
||||
typeof evt.details.properties === "object" && evt.details.properties
|
||||
? (evt.details.properties as Record<string, unknown>)
|
||||
|
||||
@@ -1,16 +1,30 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/client",
|
||||
"private": true,
|
||||
"version": "1.17.13",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/anomalyco/opencode.git",
|
||||
"directory": "packages/client"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"exports": {
|
||||
"./promise": "./src/promise/index.ts",
|
||||
"./effect": "./src/effect/index.ts"
|
||||
"./promise/api": "./src/promise/api.ts",
|
||||
"./effect": "./src/effect/index.ts",
|
||||
"./effect/api": "./src/effect/api.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "bun run script/build-package.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/promise/generated src/effect/generated src/effect/api",
|
||||
"test": "bun test --timeout 5000",
|
||||
"typecheck": "tsgo --noEmit"
|
||||
},
|
||||
@@ -28,9 +42,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/httpapi-codegen": "workspace:*",
|
||||
"@opencode-ai/server": "workspace:*",
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { $ } from "bun"
|
||||
import { fileURLToPath } from "node:url"
|
||||
|
||||
process.chdir(fileURLToPath(new URL("..", import.meta.url)))
|
||||
|
||||
await $`rm -rf dist`
|
||||
await $`bun tsc -p tsconfig.build.json`
|
||||
@@ -32,8 +32,8 @@ await Effect.runPromise(
|
||||
fileURLToPath(new URL("../src/effect/generated", import.meta.url)),
|
||||
),
|
||||
write(
|
||||
emitEffectShape(effectContract, { module: "@opencode-ai/protocol/client", api: "ClientApi" }),
|
||||
fileURLToPath(new URL("../../plugin/src/v2/effect/generated", import.meta.url)),
|
||||
emitEffectShape(effectContract, { module: "../../contract", api: "ClientApi" }),
|
||||
fileURLToPath(new URL("../src/effect/api", import.meta.url)),
|
||||
),
|
||||
],
|
||||
{ concurrency: 3, discard: true },
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { Script } from "@opencode-ai/script"
|
||||
import { $ } from "bun"
|
||||
import { rm } from "node:fs/promises"
|
||||
import { fileURLToPath } from "node:url"
|
||||
|
||||
process.chdir(fileURLToPath(new URL("..", import.meta.url)))
|
||||
|
||||
const originalText = await Bun.file("package.json").text()
|
||||
const pkg = JSON.parse(originalText) as {
|
||||
name: string
|
||||
version: string
|
||||
exports: Record<string, string | { import: string; types: string }>
|
||||
}
|
||||
const tarball = `${pkg.name.replace("@", "").replace("/", "-")}-${pkg.version}.tgz`
|
||||
|
||||
if ((await $`npm view ${pkg.name}@${pkg.version} version`.nothrow()).exitCode === 0) {
|
||||
console.log(`already published ${pkg.name}@${pkg.version}`)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
try {
|
||||
await $`bun run typecheck`
|
||||
await $`bun run build`
|
||||
pkg.exports = Object.fromEntries(
|
||||
Object.entries(pkg.exports).map(([key, value]) => {
|
||||
if (typeof value !== "string") return [key, value]
|
||||
return [
|
||||
key,
|
||||
{
|
||||
import: value.replace("./src/", "./dist/").replace(/\.ts$/, ".js"),
|
||||
types: value.replace("./src/", "./dist/").replace(/\.ts$/, ".d.ts"),
|
||||
},
|
||||
]
|
||||
}),
|
||||
)
|
||||
await Bun.write("package.json", JSON.stringify(pkg, null, 2) + "\n")
|
||||
await rm(tarball, { force: true })
|
||||
await $`bun pm pack`
|
||||
await $`npm publish ${tarball} --tag ${Script.channel} --access public`
|
||||
} finally {
|
||||
await Bun.write("package.json", originalText)
|
||||
await rm(tarball, { force: true })
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { ModelApi, ProviderApi } from "./api/api.js"
|
||||
|
||||
export type * from "./api/api.js"
|
||||
|
||||
export interface CatalogApi<E = never> {
|
||||
readonly provider: ProviderApi<E>
|
||||
readonly model: ModelApi<E>
|
||||
}
|
||||
+91
-101
@@ -1,7 +1,7 @@
|
||||
// Generated by @opencode-ai/httpapi-codegen. Do not edit.
|
||||
import type { Effect, Stream } from "effect"
|
||||
import type { HttpApiClient } from "effect/unstable/httpapi"
|
||||
import type { ClientApi } from "@opencode-ai/protocol/client"
|
||||
import type { ClientApi } from "../../contract"
|
||||
|
||||
type RawClient = HttpApiClient.ForApi<typeof ClientApi>
|
||||
type EffectValue<A> = A extends Effect.Effect<infer Success, any, any> ? Success : never
|
||||
@@ -106,161 +106,152 @@ export type Endpoint4_7Input = {
|
||||
export type Endpoint4_7Output = EffectValue<ReturnType<RawClient["server.session"]["session.rename"]>>
|
||||
export type SessionRenameOperation<E = never> = (input: Endpoint4_7Input) => Effect.Effect<Endpoint4_7Output, E>
|
||||
|
||||
type Endpoint4_8Request = Parameters<RawClient["server.session"]["session.move"]>[0]
|
||||
type Endpoint4_8Request = Parameters<RawClient["server.session"]["session.prompt"]>[0]
|
||||
export type Endpoint4_8Input = {
|
||||
readonly sessionID: Endpoint4_8Request["params"]["sessionID"]
|
||||
readonly destination: Endpoint4_8Request["payload"]["destination"]
|
||||
readonly moveChanges?: Endpoint4_8Request["payload"]["moveChanges"]
|
||||
readonly id?: Endpoint4_8Request["payload"]["id"]
|
||||
readonly prompt: Endpoint4_8Request["payload"]["prompt"]
|
||||
readonly delivery?: Endpoint4_8Request["payload"]["delivery"]
|
||||
readonly resume?: Endpoint4_8Request["payload"]["resume"]
|
||||
}
|
||||
export type Endpoint4_8Output = EffectValue<ReturnType<RawClient["server.session"]["session.move"]>>
|
||||
export type SessionMoveOperation<E = never> = (input: Endpoint4_8Input) => Effect.Effect<Endpoint4_8Output, E>
|
||||
export type Endpoint4_8Output = EffectValue<ReturnType<RawClient["server.session"]["session.prompt"]>>["data"]
|
||||
export type SessionPromptOperation<E = never> = (input: Endpoint4_8Input) => Effect.Effect<Endpoint4_8Output, E>
|
||||
|
||||
type Endpoint4_9Request = Parameters<RawClient["server.session"]["session.prompt"]>[0]
|
||||
type Endpoint4_9Request = Parameters<RawClient["server.session"]["session.command"]>[0]
|
||||
export type Endpoint4_9Input = {
|
||||
readonly sessionID: Endpoint4_9Request["params"]["sessionID"]
|
||||
readonly id?: Endpoint4_9Request["payload"]["id"]
|
||||
readonly prompt: Endpoint4_9Request["payload"]["prompt"]
|
||||
readonly command: Endpoint4_9Request["payload"]["command"]
|
||||
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"]
|
||||
}
|
||||
export type Endpoint4_9Output = EffectValue<ReturnType<RawClient["server.session"]["session.prompt"]>>["data"]
|
||||
export type SessionPromptOperation<E = never> = (input: Endpoint4_9Input) => Effect.Effect<Endpoint4_9Output, E>
|
||||
export type Endpoint4_9Output = EffectValue<ReturnType<RawClient["server.session"]["session.command"]>>["data"]
|
||||
export type SessionCommandOperation<E = never> = (input: Endpoint4_9Input) => Effect.Effect<Endpoint4_9Output, E>
|
||||
|
||||
type Endpoint4_10Request = Parameters<RawClient["server.session"]["session.command"]>[0]
|
||||
type Endpoint4_10Request = Parameters<RawClient["server.session"]["session.skill"]>[0]
|
||||
export type Endpoint4_10Input = {
|
||||
readonly sessionID: Endpoint4_10Request["params"]["sessionID"]
|
||||
readonly id?: Endpoint4_10Request["payload"]["id"]
|
||||
readonly command: Endpoint4_10Request["payload"]["command"]
|
||||
readonly arguments?: Endpoint4_10Request["payload"]["arguments"]
|
||||
readonly agent?: Endpoint4_10Request["payload"]["agent"]
|
||||
readonly model?: Endpoint4_10Request["payload"]["model"]
|
||||
readonly files?: Endpoint4_10Request["payload"]["files"]
|
||||
readonly agents?: Endpoint4_10Request["payload"]["agents"]
|
||||
readonly delivery?: Endpoint4_10Request["payload"]["delivery"]
|
||||
readonly skill: Endpoint4_10Request["payload"]["skill"]
|
||||
readonly resume?: Endpoint4_10Request["payload"]["resume"]
|
||||
}
|
||||
export type Endpoint4_10Output = EffectValue<ReturnType<RawClient["server.session"]["session.command"]>>["data"]
|
||||
export type SessionCommandOperation<E = never> = (input: Endpoint4_10Input) => Effect.Effect<Endpoint4_10Output, E>
|
||||
export type Endpoint4_10Output = EffectValue<ReturnType<RawClient["server.session"]["session.skill"]>>
|
||||
export type SessionSkillOperation<E = never> = (input: Endpoint4_10Input) => Effect.Effect<Endpoint4_10Output, E>
|
||||
|
||||
type Endpoint4_11Request = Parameters<RawClient["server.session"]["session.skill"]>[0]
|
||||
type Endpoint4_11Request = Parameters<RawClient["server.session"]["session.synthetic"]>[0]
|
||||
export type Endpoint4_11Input = {
|
||||
readonly sessionID: Endpoint4_11Request["params"]["sessionID"]
|
||||
readonly id?: Endpoint4_11Request["payload"]["id"]
|
||||
readonly skill: Endpoint4_11Request["payload"]["skill"]
|
||||
readonly resume?: Endpoint4_11Request["payload"]["resume"]
|
||||
readonly text: Endpoint4_11Request["payload"]["text"]
|
||||
readonly description?: Endpoint4_11Request["payload"]["description"]
|
||||
readonly metadata?: Endpoint4_11Request["payload"]["metadata"]
|
||||
}
|
||||
export type Endpoint4_11Output = EffectValue<ReturnType<RawClient["server.session"]["session.skill"]>>
|
||||
export type SessionSkillOperation<E = never> = (input: Endpoint4_11Input) => Effect.Effect<Endpoint4_11Output, E>
|
||||
export type Endpoint4_11Output = EffectValue<ReturnType<RawClient["server.session"]["session.synthetic"]>>
|
||||
export type SessionSyntheticOperation<E = never> = (input: Endpoint4_11Input) => Effect.Effect<Endpoint4_11Output, E>
|
||||
|
||||
type Endpoint4_12Request = Parameters<RawClient["server.session"]["session.synthetic"]>[0]
|
||||
type Endpoint4_12Request = Parameters<RawClient["server.session"]["session.shell"]>[0]
|
||||
export type Endpoint4_12Input = {
|
||||
readonly sessionID: Endpoint4_12Request["params"]["sessionID"]
|
||||
readonly text: Endpoint4_12Request["payload"]["text"]
|
||||
readonly description?: Endpoint4_12Request["payload"]["description"]
|
||||
readonly metadata?: Endpoint4_12Request["payload"]["metadata"]
|
||||
readonly id?: Endpoint4_12Request["payload"]["id"]
|
||||
readonly command: Endpoint4_12Request["payload"]["command"]
|
||||
}
|
||||
export type Endpoint4_12Output = EffectValue<ReturnType<RawClient["server.session"]["session.synthetic"]>>
|
||||
export type SessionSyntheticOperation<E = never> = (input: Endpoint4_12Input) => Effect.Effect<Endpoint4_12Output, E>
|
||||
export type Endpoint4_12Output = EffectValue<ReturnType<RawClient["server.session"]["session.shell"]>>
|
||||
export type SessionShellOperation<E = never> = (input: Endpoint4_12Input) => Effect.Effect<Endpoint4_12Output, E>
|
||||
|
||||
type Endpoint4_13Request = Parameters<RawClient["server.session"]["session.shell"]>[0]
|
||||
export type Endpoint4_13Input = {
|
||||
readonly sessionID: Endpoint4_13Request["params"]["sessionID"]
|
||||
readonly id?: Endpoint4_13Request["payload"]["id"]
|
||||
readonly command: Endpoint4_13Request["payload"]["command"]
|
||||
}
|
||||
export type Endpoint4_13Output = EffectValue<ReturnType<RawClient["server.session"]["session.shell"]>>
|
||||
export type SessionShellOperation<E = never> = (input: Endpoint4_13Input) => Effect.Effect<Endpoint4_13Output, E>
|
||||
type Endpoint4_13Request = Parameters<RawClient["server.session"]["session.compact"]>[0]
|
||||
export type Endpoint4_13Input = { readonly sessionID: Endpoint4_13Request["params"]["sessionID"] }
|
||||
export type Endpoint4_13Output = EffectValue<ReturnType<RawClient["server.session"]["session.compact"]>>
|
||||
export type SessionCompactOperation<E = never> = (input: Endpoint4_13Input) => Effect.Effect<Endpoint4_13Output, E>
|
||||
|
||||
type Endpoint4_14Request = Parameters<RawClient["server.session"]["session.compact"]>[0]
|
||||
type Endpoint4_14Request = Parameters<RawClient["server.session"]["session.wait"]>[0]
|
||||
export type Endpoint4_14Input = { readonly sessionID: Endpoint4_14Request["params"]["sessionID"] }
|
||||
export type Endpoint4_14Output = EffectValue<ReturnType<RawClient["server.session"]["session.compact"]>>
|
||||
export type SessionCompactOperation<E = never> = (input: Endpoint4_14Input) => Effect.Effect<Endpoint4_14Output, E>
|
||||
export type Endpoint4_14Output = EffectValue<ReturnType<RawClient["server.session"]["session.wait"]>>
|
||||
export type SessionWaitOperation<E = never> = (input: Endpoint4_14Input) => Effect.Effect<Endpoint4_14Output, E>
|
||||
|
||||
type Endpoint4_15Request = Parameters<RawClient["server.session"]["session.wait"]>[0]
|
||||
export type Endpoint4_15Input = { readonly sessionID: Endpoint4_15Request["params"]["sessionID"] }
|
||||
export type Endpoint4_15Output = EffectValue<ReturnType<RawClient["server.session"]["session.wait"]>>
|
||||
export type SessionWaitOperation<E = never> = (input: Endpoint4_15Input) => Effect.Effect<Endpoint4_15Output, E>
|
||||
|
||||
type Endpoint4_16Request = Parameters<RawClient["server.session"]["session.revert.stage"]>[0]
|
||||
export type Endpoint4_16Input = {
|
||||
readonly sessionID: Endpoint4_16Request["params"]["sessionID"]
|
||||
readonly messageID: Endpoint4_16Request["payload"]["messageID"]
|
||||
readonly files?: Endpoint4_16Request["payload"]["files"]
|
||||
type Endpoint4_15Request = Parameters<RawClient["server.session"]["session.revert.stage"]>[0]
|
||||
export type Endpoint4_15Input = {
|
||||
readonly sessionID: Endpoint4_15Request["params"]["sessionID"]
|
||||
readonly messageID: Endpoint4_15Request["payload"]["messageID"]
|
||||
readonly files?: Endpoint4_15Request["payload"]["files"]
|
||||
}
|
||||
export type Endpoint4_16Output = EffectValue<ReturnType<RawClient["server.session"]["session.revert.stage"]>>["data"]
|
||||
export type SessionRevertStageOperation<E = never> = (input: Endpoint4_16Input) => Effect.Effect<Endpoint4_16Output, E>
|
||||
export type Endpoint4_15Output = EffectValue<ReturnType<RawClient["server.session"]["session.revert.stage"]>>["data"]
|
||||
export type SessionRevertStageOperation<E = never> = (input: Endpoint4_15Input) => Effect.Effect<Endpoint4_15Output, E>
|
||||
|
||||
type Endpoint4_17Request = Parameters<RawClient["server.session"]["session.revert.clear"]>[0]
|
||||
type Endpoint4_16Request = Parameters<RawClient["server.session"]["session.revert.clear"]>[0]
|
||||
export type Endpoint4_16Input = { readonly sessionID: Endpoint4_16Request["params"]["sessionID"] }
|
||||
export type Endpoint4_16Output = EffectValue<ReturnType<RawClient["server.session"]["session.revert.clear"]>>
|
||||
export type SessionRevertClearOperation<E = never> = (input: Endpoint4_16Input) => Effect.Effect<Endpoint4_16Output, E>
|
||||
|
||||
type Endpoint4_17Request = Parameters<RawClient["server.session"]["session.revert.commit"]>[0]
|
||||
export type Endpoint4_17Input = { readonly sessionID: Endpoint4_17Request["params"]["sessionID"] }
|
||||
export type Endpoint4_17Output = EffectValue<ReturnType<RawClient["server.session"]["session.revert.clear"]>>
|
||||
export type SessionRevertClearOperation<E = never> = (input: Endpoint4_17Input) => Effect.Effect<Endpoint4_17Output, E>
|
||||
export type Endpoint4_17Output = EffectValue<ReturnType<RawClient["server.session"]["session.revert.commit"]>>
|
||||
export type SessionRevertCommitOperation<E = never> = (input: Endpoint4_17Input) => Effect.Effect<Endpoint4_17Output, E>
|
||||
|
||||
type Endpoint4_18Request = Parameters<RawClient["server.session"]["session.revert.commit"]>[0]
|
||||
type Endpoint4_18Request = Parameters<RawClient["server.session"]["session.context"]>[0]
|
||||
export type Endpoint4_18Input = { readonly sessionID: Endpoint4_18Request["params"]["sessionID"] }
|
||||
export type Endpoint4_18Output = EffectValue<ReturnType<RawClient["server.session"]["session.revert.commit"]>>
|
||||
export type SessionRevertCommitOperation<E = never> = (input: Endpoint4_18Input) => Effect.Effect<Endpoint4_18Output, E>
|
||||
export type Endpoint4_18Output = EffectValue<ReturnType<RawClient["server.session"]["session.context"]>>["data"]
|
||||
export type SessionContextOperation<E = never> = (input: Endpoint4_18Input) => Effect.Effect<Endpoint4_18Output, E>
|
||||
|
||||
type Endpoint4_19Request = Parameters<RawClient["server.session"]["session.context"]>[0]
|
||||
type Endpoint4_19Request = Parameters<RawClient["server.session"]["session.context.entry.list"]>[0]
|
||||
export type Endpoint4_19Input = { readonly sessionID: Endpoint4_19Request["params"]["sessionID"] }
|
||||
export type Endpoint4_19Output = EffectValue<ReturnType<RawClient["server.session"]["session.context"]>>["data"]
|
||||
export type SessionContextOperation<E = never> = (input: Endpoint4_19Input) => Effect.Effect<Endpoint4_19Output, E>
|
||||
|
||||
type Endpoint4_20Request = Parameters<RawClient["server.session"]["session.context.entry.list"]>[0]
|
||||
export type Endpoint4_20Input = { readonly sessionID: Endpoint4_20Request["params"]["sessionID"] }
|
||||
export type Endpoint4_20Output = EffectValue<
|
||||
export type Endpoint4_19Output = EffectValue<
|
||||
ReturnType<RawClient["server.session"]["session.context.entry.list"]>
|
||||
>["data"]
|
||||
export type SessionListContextEntriesOperation<E = never> = (
|
||||
input: Endpoint4_19Input,
|
||||
) => Effect.Effect<Endpoint4_19Output, E>
|
||||
|
||||
type Endpoint4_20Request = Parameters<RawClient["server.session"]["session.context.entry.put"]>[0]
|
||||
export type Endpoint4_20Input = {
|
||||
readonly sessionID: Endpoint4_20Request["params"]["sessionID"]
|
||||
readonly key: Endpoint4_20Request["params"]["key"]
|
||||
readonly value: Endpoint4_20Request["payload"]["value"]
|
||||
}
|
||||
export type Endpoint4_20Output = EffectValue<ReturnType<RawClient["server.session"]["session.context.entry.put"]>>
|
||||
export type SessionPutContextEntryOperation<E = never> = (
|
||||
input: Endpoint4_20Input,
|
||||
) => Effect.Effect<Endpoint4_20Output, E>
|
||||
|
||||
type Endpoint4_21Request = Parameters<RawClient["server.session"]["session.context.entry.put"]>[0]
|
||||
type Endpoint4_21Request = Parameters<RawClient["server.session"]["session.context.entry.remove"]>[0]
|
||||
export type Endpoint4_21Input = {
|
||||
readonly sessionID: Endpoint4_21Request["params"]["sessionID"]
|
||||
readonly key: Endpoint4_21Request["params"]["key"]
|
||||
readonly value: Endpoint4_21Request["payload"]["value"]
|
||||
}
|
||||
export type Endpoint4_21Output = EffectValue<ReturnType<RawClient["server.session"]["session.context.entry.put"]>>
|
||||
export type SessionPutContextEntryOperation<E = never> = (
|
||||
export type Endpoint4_21Output = EffectValue<ReturnType<RawClient["server.session"]["session.context.entry.remove"]>>
|
||||
export type SessionRemoveContextEntryOperation<E = never> = (
|
||||
input: Endpoint4_21Input,
|
||||
) => Effect.Effect<Endpoint4_21Output, E>
|
||||
|
||||
type Endpoint4_22Request = Parameters<RawClient["server.session"]["session.context.entry.remove"]>[0]
|
||||
type Endpoint4_22Request = Parameters<RawClient["server.session"]["session.log"]>[0]
|
||||
export type Endpoint4_22Input = {
|
||||
readonly sessionID: Endpoint4_22Request["params"]["sessionID"]
|
||||
readonly key: Endpoint4_22Request["params"]["key"]
|
||||
readonly after?: Endpoint4_22Request["query"]["after"]
|
||||
readonly follow?: Endpoint4_22Request["query"]["follow"]
|
||||
}
|
||||
export type Endpoint4_22Output = EffectValue<ReturnType<RawClient["server.session"]["session.context.entry.remove"]>>
|
||||
export type SessionRemoveContextEntryOperation<E = never> = (
|
||||
input: Endpoint4_22Input,
|
||||
) => Effect.Effect<Endpoint4_22Output, E>
|
||||
export type Endpoint4_22Output = StreamValue<EffectValue<ReturnType<RawClient["server.session"]["session.log"]>>>
|
||||
export type SessionLogOperation<E = never> = (input: Endpoint4_22Input) => Stream.Stream<Endpoint4_22Output, E>
|
||||
|
||||
type Endpoint4_23Request = Parameters<RawClient["server.session"]["session.log"]>[0]
|
||||
export type Endpoint4_23Input = {
|
||||
readonly sessionID: Endpoint4_23Request["params"]["sessionID"]
|
||||
readonly after?: Endpoint4_23Request["query"]["after"]
|
||||
readonly follow?: Endpoint4_23Request["query"]["follow"]
|
||||
}
|
||||
export type Endpoint4_23Output = StreamValue<EffectValue<ReturnType<RawClient["server.session"]["session.log"]>>>
|
||||
export type SessionLogOperation<E = never> = (input: Endpoint4_23Input) => Stream.Stream<Endpoint4_23Output, E>
|
||||
type Endpoint4_23Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
|
||||
export type Endpoint4_23Input = { readonly sessionID: Endpoint4_23Request["params"]["sessionID"] }
|
||||
export type Endpoint4_23Output = EffectValue<ReturnType<RawClient["server.session"]["session.interrupt"]>>
|
||||
export type SessionInterruptOperation<E = never> = (input: Endpoint4_23Input) => Effect.Effect<Endpoint4_23Output, E>
|
||||
|
||||
type Endpoint4_24Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
|
||||
type Endpoint4_24Request = Parameters<RawClient["server.session"]["session.background"]>[0]
|
||||
export type Endpoint4_24Input = { readonly sessionID: Endpoint4_24Request["params"]["sessionID"] }
|
||||
export type Endpoint4_24Output = EffectValue<ReturnType<RawClient["server.session"]["session.interrupt"]>>
|
||||
export type SessionInterruptOperation<E = never> = (input: Endpoint4_24Input) => Effect.Effect<Endpoint4_24Output, E>
|
||||
export type Endpoint4_24Output = EffectValue<ReturnType<RawClient["server.session"]["session.background"]>>
|
||||
export type SessionBackgroundOperation<E = never> = (input: Endpoint4_24Input) => Effect.Effect<Endpoint4_24Output, E>
|
||||
|
||||
type Endpoint4_25Request = Parameters<RawClient["server.session"]["session.background"]>[0]
|
||||
export type Endpoint4_25Input = { readonly sessionID: Endpoint4_25Request["params"]["sessionID"] }
|
||||
export type Endpoint4_25Output = EffectValue<ReturnType<RawClient["server.session"]["session.background"]>>
|
||||
export type SessionBackgroundOperation<E = never> = (input: Endpoint4_25Input) => Effect.Effect<Endpoint4_25Output, E>
|
||||
|
||||
type Endpoint4_26Request = Parameters<RawClient["server.session"]["session.message"]>[0]
|
||||
export type Endpoint4_26Input = {
|
||||
readonly sessionID: Endpoint4_26Request["params"]["sessionID"]
|
||||
readonly messageID: Endpoint4_26Request["params"]["messageID"]
|
||||
type Endpoint4_25Request = Parameters<RawClient["server.session"]["session.message"]>[0]
|
||||
export type Endpoint4_25Input = {
|
||||
readonly sessionID: Endpoint4_25Request["params"]["sessionID"]
|
||||
readonly messageID: Endpoint4_25Request["params"]["messageID"]
|
||||
}
|
||||
export type Endpoint4_26Output = EffectValue<ReturnType<RawClient["server.session"]["session.message"]>>["data"]
|
||||
export type SessionMessageOperation<E = never> = (input: Endpoint4_26Input) => Effect.Effect<Endpoint4_26Output, E>
|
||||
export type Endpoint4_25Output = EffectValue<ReturnType<RawClient["server.session"]["session.message"]>>["data"]
|
||||
export type SessionMessageOperation<E = never> = (input: Endpoint4_25Input) => Effect.Effect<Endpoint4_25Output, E>
|
||||
|
||||
export interface SessionApi<E = never> {
|
||||
readonly list: SessionListOperation<E>
|
||||
@@ -271,7 +262,6 @@ export interface SessionApi<E = never> {
|
||||
readonly switchAgent: SessionSwitchAgentOperation<E>
|
||||
readonly switchModel: SessionSwitchModelOperation<E>
|
||||
readonly rename: SessionRenameOperation<E>
|
||||
readonly move: SessionMoveOperation<E>
|
||||
readonly prompt: SessionPromptOperation<E>
|
||||
readonly command: SessionCommandOperation<E>
|
||||
readonly skill: SessionSkillOperation<E>
|
||||
@@ -133,27 +133,15 @@ const Endpoint4_7 = (raw: RawClient["server.session"]) => (input: Endpoint4_7Inp
|
||||
Effect.mapError(mapClientError),
|
||||
)
|
||||
|
||||
type Endpoint4_8Request = Parameters<RawClient["server.session"]["session.move"]>[0]
|
||||
type Endpoint4_8Request = Parameters<RawClient["server.session"]["session.prompt"]>[0]
|
||||
type Endpoint4_8Input = {
|
||||
readonly sessionID: Endpoint4_8Request["params"]["sessionID"]
|
||||
readonly destination: Endpoint4_8Request["payload"]["destination"]
|
||||
readonly moveChanges?: Endpoint4_8Request["payload"]["moveChanges"]
|
||||
readonly id?: Endpoint4_8Request["payload"]["id"]
|
||||
readonly prompt: Endpoint4_8Request["payload"]["prompt"]
|
||||
readonly delivery?: Endpoint4_8Request["payload"]["delivery"]
|
||||
readonly resume?: Endpoint4_8Request["payload"]["resume"]
|
||||
}
|
||||
const Endpoint4_8 = (raw: RawClient["server.session"]) => (input: Endpoint4_8Input) =>
|
||||
raw["session.move"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
payload: { destination: input["destination"], moveChanges: input["moveChanges"] },
|
||||
}).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint4_9Request = Parameters<RawClient["server.session"]["session.prompt"]>[0]
|
||||
type Endpoint4_9Input = {
|
||||
readonly sessionID: Endpoint4_9Request["params"]["sessionID"]
|
||||
readonly id?: Endpoint4_9Request["payload"]["id"]
|
||||
readonly prompt: Endpoint4_9Request["payload"]["prompt"]
|
||||
readonly delivery?: Endpoint4_9Request["payload"]["delivery"]
|
||||
readonly resume?: Endpoint4_9Request["payload"]["resume"]
|
||||
}
|
||||
const Endpoint4_9 = (raw: RawClient["server.session"]) => (input: Endpoint4_9Input) =>
|
||||
raw["session.prompt"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
payload: { id: input["id"], prompt: input["prompt"], delivery: input["delivery"], resume: input["resume"] },
|
||||
@@ -162,20 +150,20 @@ const Endpoint4_9 = (raw: RawClient["server.session"]) => (input: Endpoint4_9Inp
|
||||
Effect.map((value) => value.data),
|
||||
)
|
||||
|
||||
type Endpoint4_10Request = Parameters<RawClient["server.session"]["session.command"]>[0]
|
||||
type Endpoint4_10Input = {
|
||||
readonly sessionID: Endpoint4_10Request["params"]["sessionID"]
|
||||
readonly id?: Endpoint4_10Request["payload"]["id"]
|
||||
readonly command: Endpoint4_10Request["payload"]["command"]
|
||||
readonly arguments?: Endpoint4_10Request["payload"]["arguments"]
|
||||
readonly agent?: Endpoint4_10Request["payload"]["agent"]
|
||||
readonly model?: Endpoint4_10Request["payload"]["model"]
|
||||
readonly files?: Endpoint4_10Request["payload"]["files"]
|
||||
readonly agents?: Endpoint4_10Request["payload"]["agents"]
|
||||
readonly delivery?: Endpoint4_10Request["payload"]["delivery"]
|
||||
readonly resume?: Endpoint4_10Request["payload"]["resume"]
|
||||
type Endpoint4_9Request = Parameters<RawClient["server.session"]["session.command"]>[0]
|
||||
type Endpoint4_9Input = {
|
||||
readonly sessionID: Endpoint4_9Request["params"]["sessionID"]
|
||||
readonly id?: Endpoint4_9Request["payload"]["id"]
|
||||
readonly command: Endpoint4_9Request["payload"]["command"]
|
||||
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"]
|
||||
}
|
||||
const Endpoint4_10 = (raw: RawClient["server.session"]) => (input: Endpoint4_10Input) =>
|
||||
const Endpoint4_9 = (raw: RawClient["server.session"]) => (input: Endpoint4_9Input) =>
|
||||
raw["session.command"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
payload: {
|
||||
@@ -194,61 +182,61 @@ const Endpoint4_10 = (raw: RawClient["server.session"]) => (input: Endpoint4_10I
|
||||
Effect.map((value) => value.data),
|
||||
)
|
||||
|
||||
type Endpoint4_11Request = Parameters<RawClient["server.session"]["session.skill"]>[0]
|
||||
type Endpoint4_11Input = {
|
||||
readonly sessionID: Endpoint4_11Request["params"]["sessionID"]
|
||||
readonly id?: Endpoint4_11Request["payload"]["id"]
|
||||
readonly skill: Endpoint4_11Request["payload"]["skill"]
|
||||
readonly resume?: Endpoint4_11Request["payload"]["resume"]
|
||||
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_11 = (raw: RawClient["server.session"]) => (input: Endpoint4_11Input) =>
|
||||
const Endpoint4_10 = (raw: RawClient["server.session"]) => (input: Endpoint4_10Input) =>
|
||||
raw["session.skill"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
payload: { id: input["id"], skill: input["skill"], resume: input["resume"] },
|
||||
}).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint4_12Request = Parameters<RawClient["server.session"]["session.synthetic"]>[0]
|
||||
type Endpoint4_12Input = {
|
||||
readonly sessionID: Endpoint4_12Request["params"]["sessionID"]
|
||||
readonly text: Endpoint4_12Request["payload"]["text"]
|
||||
readonly description?: Endpoint4_12Request["payload"]["description"]
|
||||
readonly metadata?: Endpoint4_12Request["payload"]["metadata"]
|
||||
type Endpoint4_11Request = Parameters<RawClient["server.session"]["session.synthetic"]>[0]
|
||||
type Endpoint4_11Input = {
|
||||
readonly sessionID: Endpoint4_11Request["params"]["sessionID"]
|
||||
readonly text: Endpoint4_11Request["payload"]["text"]
|
||||
readonly description?: Endpoint4_11Request["payload"]["description"]
|
||||
readonly metadata?: Endpoint4_11Request["payload"]["metadata"]
|
||||
}
|
||||
const Endpoint4_12 = (raw: RawClient["server.session"]) => (input: Endpoint4_12Input) =>
|
||||
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_13Request = Parameters<RawClient["server.session"]["session.shell"]>[0]
|
||||
type Endpoint4_13Input = {
|
||||
readonly sessionID: Endpoint4_13Request["params"]["sessionID"]
|
||||
readonly id?: Endpoint4_13Request["payload"]["id"]
|
||||
readonly command: Endpoint4_13Request["payload"]["command"]
|
||||
type Endpoint4_12Request = Parameters<RawClient["server.session"]["session.shell"]>[0]
|
||||
type Endpoint4_12Input = {
|
||||
readonly sessionID: Endpoint4_12Request["params"]["sessionID"]
|
||||
readonly id?: Endpoint4_12Request["payload"]["id"]
|
||||
readonly command: Endpoint4_12Request["payload"]["command"]
|
||||
}
|
||||
const Endpoint4_13 = (raw: RawClient["server.session"]) => (input: Endpoint4_13Input) =>
|
||||
const Endpoint4_12 = (raw: RawClient["server.session"]) => (input: Endpoint4_12Input) =>
|
||||
raw["session.shell"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
payload: { id: input["id"], command: input["command"] },
|
||||
}).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint4_14Request = Parameters<RawClient["server.session"]["session.compact"]>[0]
|
||||
type Endpoint4_14Input = { readonly sessionID: Endpoint4_14Request["params"]["sessionID"] }
|
||||
const Endpoint4_14 = (raw: RawClient["server.session"]) => (input: Endpoint4_14Input) =>
|
||||
type Endpoint4_13Request = Parameters<RawClient["server.session"]["session.compact"]>[0]
|
||||
type Endpoint4_13Input = { readonly sessionID: Endpoint4_13Request["params"]["sessionID"] }
|
||||
const Endpoint4_13 = (raw: RawClient["server.session"]) => (input: Endpoint4_13Input) =>
|
||||
raw["session.compact"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint4_15Request = Parameters<RawClient["server.session"]["session.wait"]>[0]
|
||||
type Endpoint4_15Input = { readonly sessionID: Endpoint4_15Request["params"]["sessionID"] }
|
||||
const Endpoint4_15 = (raw: RawClient["server.session"]) => (input: Endpoint4_15Input) =>
|
||||
type Endpoint4_14Request = Parameters<RawClient["server.session"]["session.wait"]>[0]
|
||||
type Endpoint4_14Input = { readonly sessionID: Endpoint4_14Request["params"]["sessionID"] }
|
||||
const Endpoint4_14 = (raw: RawClient["server.session"]) => (input: Endpoint4_14Input) =>
|
||||
raw["session.wait"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint4_16Request = Parameters<RawClient["server.session"]["session.revert.stage"]>[0]
|
||||
type Endpoint4_16Input = {
|
||||
readonly sessionID: Endpoint4_16Request["params"]["sessionID"]
|
||||
readonly messageID: Endpoint4_16Request["payload"]["messageID"]
|
||||
readonly files?: Endpoint4_16Request["payload"]["files"]
|
||||
type Endpoint4_15Request = Parameters<RawClient["server.session"]["session.revert.stage"]>[0]
|
||||
type Endpoint4_15Input = {
|
||||
readonly sessionID: Endpoint4_15Request["params"]["sessionID"]
|
||||
readonly messageID: Endpoint4_15Request["payload"]["messageID"]
|
||||
readonly files?: Endpoint4_15Request["payload"]["files"]
|
||||
}
|
||||
const Endpoint4_16 = (raw: RawClient["server.session"]) => (input: Endpoint4_16Input) =>
|
||||
const Endpoint4_15 = (raw: RawClient["server.session"]) => (input: Endpoint4_15Input) =>
|
||||
raw["session.revert.stage"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
payload: { messageID: input["messageID"], files: input["files"] },
|
||||
@@ -257,61 +245,61 @@ const Endpoint4_16 = (raw: RawClient["server.session"]) => (input: Endpoint4_16I
|
||||
Effect.map((value) => value.data),
|
||||
)
|
||||
|
||||
type Endpoint4_17Request = Parameters<RawClient["server.session"]["session.revert.clear"]>[0]
|
||||
type Endpoint4_17Input = { readonly sessionID: Endpoint4_17Request["params"]["sessionID"] }
|
||||
const Endpoint4_17 = (raw: RawClient["server.session"]) => (input: Endpoint4_17Input) =>
|
||||
type Endpoint4_16Request = Parameters<RawClient["server.session"]["session.revert.clear"]>[0]
|
||||
type Endpoint4_16Input = { readonly sessionID: Endpoint4_16Request["params"]["sessionID"] }
|
||||
const Endpoint4_16 = (raw: RawClient["server.session"]) => (input: Endpoint4_16Input) =>
|
||||
raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint4_18Request = Parameters<RawClient["server.session"]["session.revert.commit"]>[0]
|
||||
type Endpoint4_18Input = { readonly sessionID: Endpoint4_18Request["params"]["sessionID"] }
|
||||
const Endpoint4_18 = (raw: RawClient["server.session"]) => (input: Endpoint4_18Input) =>
|
||||
type Endpoint4_17Request = Parameters<RawClient["server.session"]["session.revert.commit"]>[0]
|
||||
type Endpoint4_17Input = { readonly sessionID: Endpoint4_17Request["params"]["sessionID"] }
|
||||
const Endpoint4_17 = (raw: RawClient["server.session"]) => (input: Endpoint4_17Input) =>
|
||||
raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint4_19Request = Parameters<RawClient["server.session"]["session.context"]>[0]
|
||||
type Endpoint4_19Input = { readonly sessionID: Endpoint4_19Request["params"]["sessionID"] }
|
||||
const Endpoint4_19 = (raw: RawClient["server.session"]) => (input: Endpoint4_19Input) =>
|
||||
type Endpoint4_18Request = Parameters<RawClient["server.session"]["session.context"]>[0]
|
||||
type Endpoint4_18Input = { readonly sessionID: Endpoint4_18Request["params"]["sessionID"] }
|
||||
const Endpoint4_18 = (raw: RawClient["server.session"]) => (input: Endpoint4_18Input) =>
|
||||
raw["session.context"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
)
|
||||
|
||||
type Endpoint4_20Request = Parameters<RawClient["server.session"]["session.context.entry.list"]>[0]
|
||||
type Endpoint4_20Input = { readonly sessionID: Endpoint4_20Request["params"]["sessionID"] }
|
||||
const Endpoint4_20 = (raw: RawClient["server.session"]) => (input: Endpoint4_20Input) =>
|
||||
type Endpoint4_19Request = Parameters<RawClient["server.session"]["session.context.entry.list"]>[0]
|
||||
type Endpoint4_19Input = { readonly sessionID: Endpoint4_19Request["params"]["sessionID"] }
|
||||
const Endpoint4_19 = (raw: RawClient["server.session"]) => (input: Endpoint4_19Input) =>
|
||||
raw["session.context.entry.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
)
|
||||
|
||||
type Endpoint4_21Request = Parameters<RawClient["server.session"]["session.context.entry.put"]>[0]
|
||||
type Endpoint4_21Input = {
|
||||
readonly sessionID: Endpoint4_21Request["params"]["sessionID"]
|
||||
readonly key: Endpoint4_21Request["params"]["key"]
|
||||
readonly value: Endpoint4_21Request["payload"]["value"]
|
||||
type Endpoint4_20Request = Parameters<RawClient["server.session"]["session.context.entry.put"]>[0]
|
||||
type Endpoint4_20Input = {
|
||||
readonly sessionID: Endpoint4_20Request["params"]["sessionID"]
|
||||
readonly key: Endpoint4_20Request["params"]["key"]
|
||||
readonly value: Endpoint4_20Request["payload"]["value"]
|
||||
}
|
||||
const Endpoint4_21 = (raw: RawClient["server.session"]) => (input: Endpoint4_21Input) =>
|
||||
const Endpoint4_20 = (raw: RawClient["server.session"]) => (input: Endpoint4_20Input) =>
|
||||
raw["session.context.entry.put"]({
|
||||
params: { sessionID: input["sessionID"], key: input["key"] },
|
||||
payload: { value: input["value"] },
|
||||
}).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint4_22Request = Parameters<RawClient["server.session"]["session.context.entry.remove"]>[0]
|
||||
type Endpoint4_22Input = {
|
||||
readonly sessionID: Endpoint4_22Request["params"]["sessionID"]
|
||||
readonly key: Endpoint4_22Request["params"]["key"]
|
||||
type Endpoint4_21Request = Parameters<RawClient["server.session"]["session.context.entry.remove"]>[0]
|
||||
type Endpoint4_21Input = {
|
||||
readonly sessionID: Endpoint4_21Request["params"]["sessionID"]
|
||||
readonly key: Endpoint4_21Request["params"]["key"]
|
||||
}
|
||||
const Endpoint4_22 = (raw: RawClient["server.session"]) => (input: Endpoint4_22Input) =>
|
||||
const Endpoint4_21 = (raw: RawClient["server.session"]) => (input: Endpoint4_21Input) =>
|
||||
raw["session.context.entry.remove"]({ params: { sessionID: input["sessionID"], key: input["key"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
)
|
||||
|
||||
type Endpoint4_23Request = Parameters<RawClient["server.session"]["session.log"]>[0]
|
||||
type Endpoint4_23Input = {
|
||||
readonly sessionID: Endpoint4_23Request["params"]["sessionID"]
|
||||
readonly after?: Endpoint4_23Request["query"]["after"]
|
||||
readonly follow?: Endpoint4_23Request["query"]["follow"]
|
||||
type Endpoint4_22Request = Parameters<RawClient["server.session"]["session.log"]>[0]
|
||||
type Endpoint4_22Input = {
|
||||
readonly sessionID: Endpoint4_22Request["params"]["sessionID"]
|
||||
readonly after?: Endpoint4_22Request["query"]["after"]
|
||||
readonly follow?: Endpoint4_22Request["query"]["follow"]
|
||||
}
|
||||
const Endpoint4_23 = (raw: RawClient["server.session"]) => (input: Endpoint4_23Input) =>
|
||||
const Endpoint4_22 = (raw: RawClient["server.session"]) => (input: Endpoint4_22Input) =>
|
||||
Stream.unwrap(
|
||||
raw["session.log"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
@@ -322,22 +310,22 @@ const Endpoint4_23 = (raw: RawClient["server.session"]) => (input: Endpoint4_23I
|
||||
),
|
||||
)
|
||||
|
||||
type Endpoint4_24Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
|
||||
type Endpoint4_24Input = { readonly sessionID: Endpoint4_24Request["params"]["sessionID"] }
|
||||
const Endpoint4_24 = (raw: RawClient["server.session"]) => (input: Endpoint4_24Input) =>
|
||||
type Endpoint4_23Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
|
||||
type Endpoint4_23Input = { readonly sessionID: Endpoint4_23Request["params"]["sessionID"] }
|
||||
const Endpoint4_23 = (raw: RawClient["server.session"]) => (input: Endpoint4_23Input) =>
|
||||
raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint4_25Request = Parameters<RawClient["server.session"]["session.background"]>[0]
|
||||
type Endpoint4_25Input = { readonly sessionID: Endpoint4_25Request["params"]["sessionID"] }
|
||||
const Endpoint4_25 = (raw: RawClient["server.session"]) => (input: Endpoint4_25Input) =>
|
||||
type Endpoint4_24Request = Parameters<RawClient["server.session"]["session.background"]>[0]
|
||||
type Endpoint4_24Input = { readonly sessionID: Endpoint4_24Request["params"]["sessionID"] }
|
||||
const Endpoint4_24 = (raw: RawClient["server.session"]) => (input: Endpoint4_24Input) =>
|
||||
raw["session.background"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint4_26Request = Parameters<RawClient["server.session"]["session.message"]>[0]
|
||||
type Endpoint4_26Input = {
|
||||
readonly sessionID: Endpoint4_26Request["params"]["sessionID"]
|
||||
readonly messageID: Endpoint4_26Request["params"]["messageID"]
|
||||
type Endpoint4_25Request = Parameters<RawClient["server.session"]["session.message"]>[0]
|
||||
type Endpoint4_25Input = {
|
||||
readonly sessionID: Endpoint4_25Request["params"]["sessionID"]
|
||||
readonly messageID: Endpoint4_25Request["params"]["messageID"]
|
||||
}
|
||||
const Endpoint4_26 = (raw: RawClient["server.session"]) => (input: Endpoint4_26Input) =>
|
||||
const Endpoint4_25 = (raw: RawClient["server.session"]) => (input: Endpoint4_25Input) =>
|
||||
raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
@@ -352,25 +340,24 @@ const adaptGroup4 = (raw: RawClient["server.session"]) => ({
|
||||
switchAgent: Endpoint4_5(raw),
|
||||
switchModel: Endpoint4_6(raw),
|
||||
rename: Endpoint4_7(raw),
|
||||
move: Endpoint4_8(raw),
|
||||
prompt: Endpoint4_9(raw),
|
||||
command: Endpoint4_10(raw),
|
||||
skill: Endpoint4_11(raw),
|
||||
synthetic: Endpoint4_12(raw),
|
||||
shell: Endpoint4_13(raw),
|
||||
compact: Endpoint4_14(raw),
|
||||
wait: Endpoint4_15(raw),
|
||||
revertStage: Endpoint4_16(raw),
|
||||
revertClear: Endpoint4_17(raw),
|
||||
revertCommit: Endpoint4_18(raw),
|
||||
context: Endpoint4_19(raw),
|
||||
listContextEntries: Endpoint4_20(raw),
|
||||
putContextEntry: Endpoint4_21(raw),
|
||||
removeContextEntry: Endpoint4_22(raw),
|
||||
log: Endpoint4_23(raw),
|
||||
interrupt: Endpoint4_24(raw),
|
||||
background: Endpoint4_25(raw),
|
||||
message: Endpoint4_26(raw),
|
||||
prompt: Endpoint4_8(raw),
|
||||
command: Endpoint4_9(raw),
|
||||
skill: Endpoint4_10(raw),
|
||||
synthetic: Endpoint4_11(raw),
|
||||
shell: Endpoint4_12(raw),
|
||||
compact: Endpoint4_13(raw),
|
||||
wait: Endpoint4_14(raw),
|
||||
revertStage: Endpoint4_15(raw),
|
||||
revertClear: Endpoint4_16(raw),
|
||||
revertCommit: Endpoint4_17(raw),
|
||||
context: Endpoint4_18(raw),
|
||||
listContextEntries: Endpoint4_19(raw),
|
||||
putContextEntry: Endpoint4_20(raw),
|
||||
removeContextEntry: Endpoint4_21(raw),
|
||||
log: Endpoint4_22(raw),
|
||||
interrupt: Endpoint4_23(raw),
|
||||
background: Endpoint4_24(raw),
|
||||
message: Endpoint4_25(raw),
|
||||
})
|
||||
|
||||
type Endpoint5_0Request = Parameters<RawClient["server.message"]["session.messages"]>[0]
|
||||
|
||||
@@ -1,6 +1,22 @@
|
||||
// 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.
|
||||
import type { Effect } from "effect"
|
||||
|
||||
export * from "./generated/index"
|
||||
export type {
|
||||
AgentApi,
|
||||
AppApi,
|
||||
CatalogApi,
|
||||
CommandApi,
|
||||
EventApi,
|
||||
IntegrationApi,
|
||||
ModelApi,
|
||||
PluginApi,
|
||||
ProviderApi,
|
||||
ReferenceApi,
|
||||
SessionApi,
|
||||
SkillApi,
|
||||
} from "./api.js"
|
||||
export { Service } from "./service.js"
|
||||
export { Agent } from "@opencode-ai/schema/agent"
|
||||
export { Command } from "@opencode-ai/schema/command"
|
||||
@@ -27,3 +43,4 @@ export { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
export { Skill } from "@opencode-ai/schema/skill"
|
||||
export { Prompt } from "@opencode-ai/schema/prompt"
|
||||
export type { OpenCodeEvent } from "@opencode-ai/protocol/groups/event"
|
||||
export type OpenCodeClient = Effect.Success<ReturnType<typeof import("./generated/client").make>>
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import type {
|
||||
AgentApi as EffectAgentApi,
|
||||
CommandApi as EffectCommandApi,
|
||||
EventApi as EffectEventApi,
|
||||
IntegrationApi as EffectIntegrationApi,
|
||||
ModelApi as EffectModelApi,
|
||||
PluginApi as EffectPluginApi,
|
||||
ProviderApi as EffectProviderApi,
|
||||
ReferenceApi as EffectReferenceApi,
|
||||
SessionApi as EffectSessionApi,
|
||||
SkillApi as EffectSkillApi,
|
||||
} from "../effect/api/api.js"
|
||||
import type { Effect, Stream } from "effect"
|
||||
|
||||
type PromisifyOperation<Operation> = Operation extends (
|
||||
...args: infer Args
|
||||
) => Effect.Effect<infer Success, unknown, unknown>
|
||||
? (...args: Args) => Promise<Success>
|
||||
: Operation extends (...args: infer Args) => Stream.Stream<infer Success, unknown, unknown>
|
||||
? (...args: Args) => AsyncIterable<Success>
|
||||
: Operation
|
||||
|
||||
type PromisifyApi<Api> = {
|
||||
readonly [Name in keyof Api]: PromisifyOperation<Api[Name]>
|
||||
}
|
||||
|
||||
export type AgentApi = PromisifyApi<EffectAgentApi<unknown>>
|
||||
export type CommandApi = PromisifyApi<EffectCommandApi<unknown>>
|
||||
export type EventApi = PromisifyApi<EffectEventApi<unknown>>
|
||||
export type IntegrationApi = PromisifyApi<EffectIntegrationApi<unknown>>
|
||||
export type ModelApi = PromisifyApi<EffectModelApi<unknown>>
|
||||
export type PluginApi = PromisifyApi<EffectPluginApi<unknown>>
|
||||
export type ProviderApi = PromisifyApi<EffectProviderApi<unknown>>
|
||||
export type ReferenceApi = PromisifyApi<EffectReferenceApi<unknown>>
|
||||
export type SessionApi = PromisifyApi<EffectSessionApi<unknown>>
|
||||
export type SkillApi = PromisifyApi<EffectSkillApi<unknown>>
|
||||
|
||||
export interface CatalogApi {
|
||||
readonly provider: ProviderApi
|
||||
readonly model: ModelApi
|
||||
}
|
||||
@@ -21,8 +21,6 @@ import type {
|
||||
SessionSwitchModelOutput,
|
||||
SessionRenameInput,
|
||||
SessionRenameOutput,
|
||||
SessionMoveInput,
|
||||
SessionMoveOutput,
|
||||
SessionPromptInput,
|
||||
SessionPromptOutput,
|
||||
SessionCommandInput,
|
||||
@@ -471,18 +469,6 @@ export function make(options: ClientOptions) {
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
move: (input: SessionMoveInput, requestOptions?: RequestOptions) =>
|
||||
request<SessionMoveOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/move`,
|
||||
body: { destination: input["destination"], moveChanges: input["moveChanges"] },
|
||||
successStatus: 204,
|
||||
declaredStatuses: [404, 400, 401],
|
||||
empty: true,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
prompt: (input: SessionPromptInput, requestOptions?: RequestOptions) =>
|
||||
request<{ readonly data: SessionPromptOutput }>(
|
||||
{
|
||||
|
||||
@@ -522,20 +522,6 @@ export type SessionRenameInput = {
|
||||
|
||||
export type SessionRenameOutput = void
|
||||
|
||||
export type SessionMoveInput = {
|
||||
readonly sessionID: { readonly sessionID: string }["sessionID"]
|
||||
readonly destination: {
|
||||
readonly destination: { readonly directory: string }
|
||||
readonly moveChanges?: boolean | undefined
|
||||
}["destination"]
|
||||
readonly moveChanges?: {
|
||||
readonly destination: { readonly directory: string }
|
||||
readonly moveChanges?: boolean | undefined
|
||||
}["moveChanges"]
|
||||
}
|
||||
|
||||
export type SessionMoveOutput = void
|
||||
|
||||
export type SessionPromptInput = {
|
||||
readonly sessionID: { readonly sessionID: string }["sessionID"]
|
||||
readonly id?: {
|
||||
@@ -942,6 +928,7 @@ export type SessionContextOutput = {
|
||||
readonly time: { readonly created: number }
|
||||
readonly type: "model-switched"
|
||||
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
|
||||
readonly previous?: { readonly id: string; readonly providerID: string; readonly variant?: string }
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
@@ -990,9 +977,27 @@ export type SessionContextOutput = {
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly time: { readonly created: number; readonly completed?: number }
|
||||
readonly type: "shell"
|
||||
readonly callID: string
|
||||
readonly command: string
|
||||
readonly output: string
|
||||
readonly shell: {
|
||||
readonly id: string
|
||||
readonly status: "running" | "exited" | "timeout" | "killed"
|
||||
readonly command: string
|
||||
readonly cwd: string
|
||||
readonly shell: string
|
||||
readonly file: string
|
||||
readonly pid?: number
|
||||
readonly exit?: number | "Infinity" | "-Infinity" | "NaN"
|
||||
readonly metadata: { readonly [x: string]: JsonValue }
|
||||
readonly time: {
|
||||
readonly started: number | "Infinity" | "-Infinity" | "NaN"
|
||||
readonly completed?: number | "Infinity" | "-Infinity" | "NaN"
|
||||
}
|
||||
}
|
||||
readonly output?: {
|
||||
readonly output: string
|
||||
readonly cursor: number
|
||||
readonly size: number
|
||||
readonly truncated: boolean
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
@@ -1123,7 +1128,7 @@ export type SessionLogOutput =
|
||||
readonly id: string
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "agent.selected"
|
||||
readonly type: "session.agent.selected"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: { readonly sessionID: string; readonly agent: string }
|
||||
@@ -1132,7 +1137,7 @@ export type SessionLogOutput =
|
||||
readonly id: string
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "model.selected"
|
||||
readonly type: "session.model.selected"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
@@ -1157,7 +1162,7 @@ export type SessionLogOutput =
|
||||
readonly id: string
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "renamed"
|
||||
readonly type: "session.renamed"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: { readonly sessionID: string; readonly title: string }
|
||||
@@ -1166,7 +1171,7 @@ export type SessionLogOutput =
|
||||
readonly id: string
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "forked"
|
||||
readonly type: "session.forked"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: { readonly sessionID: string; readonly parentID: string; readonly from?: string }
|
||||
@@ -1175,7 +1180,7 @@ export type SessionLogOutput =
|
||||
readonly id: string
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "prompt.promoted"
|
||||
readonly type: "session.prompt.promoted"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: { readonly sessionID: string; readonly inputID: string }
|
||||
@@ -1184,7 +1189,7 @@ export type SessionLogOutput =
|
||||
readonly id: string
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "prompt.admitted"
|
||||
readonly type: "session.prompt.admitted"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
@@ -1220,7 +1225,7 @@ export type SessionLogOutput =
|
||||
readonly id: string
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "synthetic"
|
||||
readonly type: "session.synthetic"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
@@ -1234,7 +1239,7 @@ export type SessionLogOutput =
|
||||
readonly id: string
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "skill.activated"
|
||||
readonly type: "session.skill.activated"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: { readonly sessionID: string; readonly name: string; readonly text: string }
|
||||
@@ -1243,25 +1248,59 @@ export type SessionLogOutput =
|
||||
readonly id: string
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "shell.started"
|
||||
readonly type: "session.shell.started"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: { readonly sessionID: string; readonly callID: string; readonly command: string }
|
||||
readonly data: {
|
||||
readonly sessionID: string
|
||||
readonly shell: {
|
||||
readonly id: string
|
||||
readonly status: "running" | "exited" | "timeout" | "killed"
|
||||
readonly command: string
|
||||
readonly cwd: string
|
||||
readonly shell: string
|
||||
readonly file: string
|
||||
readonly pid?: number
|
||||
readonly exit?: number
|
||||
readonly metadata: { readonly [x: string]: unknown }
|
||||
readonly time: { readonly started: number; readonly completed?: number }
|
||||
}
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "shell.ended"
|
||||
readonly type: "session.shell.ended"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: { readonly sessionID: string; readonly callID: string; readonly output: string }
|
||||
readonly data: {
|
||||
readonly sessionID: string
|
||||
readonly shell: {
|
||||
readonly id: string
|
||||
readonly status: "running" | "exited" | "timeout" | "killed"
|
||||
readonly command: string
|
||||
readonly cwd: string
|
||||
readonly shell: string
|
||||
readonly file: string
|
||||
readonly pid?: number
|
||||
readonly exit?: number
|
||||
readonly metadata: { readonly [x: string]: unknown }
|
||||
readonly time: { readonly started: number; readonly completed?: number }
|
||||
}
|
||||
readonly output: {
|
||||
readonly output: string
|
||||
readonly cursor: number
|
||||
readonly size: number
|
||||
readonly truncated: boolean
|
||||
}
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "step.started"
|
||||
readonly type: "session.step.started"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
@@ -1276,7 +1315,7 @@ export type SessionLogOutput =
|
||||
readonly id: string
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "step.ended"
|
||||
readonly type: "session.step.ended"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
@@ -1298,7 +1337,7 @@ export type SessionLogOutput =
|
||||
readonly id: string
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "step.failed"
|
||||
readonly type: "session.step.failed"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
@@ -1311,7 +1350,7 @@ export type SessionLogOutput =
|
||||
readonly id: string
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "text.started"
|
||||
readonly type: "session.text.started"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: { readonly sessionID: string; readonly assistantMessageID: string; readonly textID: string }
|
||||
@@ -1320,7 +1359,7 @@ export type SessionLogOutput =
|
||||
readonly id: string
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "text.ended"
|
||||
readonly type: "session.text.ended"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
@@ -1334,7 +1373,7 @@ export type SessionLogOutput =
|
||||
readonly id: string
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "reasoning.started"
|
||||
readonly type: "session.reasoning.started"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
@@ -1348,7 +1387,7 @@ export type SessionLogOutput =
|
||||
readonly id: string
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "reasoning.ended"
|
||||
readonly type: "session.reasoning.ended"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
@@ -1363,7 +1402,7 @@ export type SessionLogOutput =
|
||||
readonly id: string
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "tool.input.started"
|
||||
readonly type: "session.tool.input.started"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
@@ -1377,7 +1416,7 @@ export type SessionLogOutput =
|
||||
readonly id: string
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "tool.input.ended"
|
||||
readonly type: "session.tool.input.ended"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
@@ -1391,7 +1430,7 @@ export type SessionLogOutput =
|
||||
readonly id: string
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "tool.called"
|
||||
readonly type: "session.tool.called"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
@@ -1410,7 +1449,7 @@ export type SessionLogOutput =
|
||||
readonly id: string
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "tool.progress"
|
||||
readonly type: "session.tool.progress"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
@@ -1428,7 +1467,7 @@ export type SessionLogOutput =
|
||||
readonly id: string
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "tool.success"
|
||||
readonly type: "session.tool.success"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
@@ -1452,7 +1491,7 @@ export type SessionLogOutput =
|
||||
readonly id: string
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "tool.failed"
|
||||
readonly type: "session.tool.failed"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
@@ -1471,7 +1510,7 @@ export type SessionLogOutput =
|
||||
readonly id: string
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "retried"
|
||||
readonly type: "session.retried"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
@@ -1491,7 +1530,7 @@ export type SessionLogOutput =
|
||||
readonly id: string
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "compaction.started"
|
||||
readonly type: "session.compaction.started"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: { readonly sessionID: string; readonly reason: "auto" | "manual" }
|
||||
@@ -1500,7 +1539,7 @@ export type SessionLogOutput =
|
||||
readonly id: string
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "compaction.ended"
|
||||
readonly type: "session.compaction.ended"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
@@ -1514,7 +1553,7 @@ export type SessionLogOutput =
|
||||
readonly id: string
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "revert.staged"
|
||||
readonly type: "session.revert.staged"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
@@ -1538,7 +1577,7 @@ export type SessionLogOutput =
|
||||
readonly id: string
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "revert.cleared"
|
||||
readonly type: "session.revert.cleared"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: { readonly sessionID: string }
|
||||
@@ -1547,7 +1586,7 @@ export type SessionLogOutput =
|
||||
readonly id: string
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "revert.committed"
|
||||
readonly type: "session.revert.committed"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: { readonly sessionID: string; readonly messageID: string }
|
||||
@@ -1583,6 +1622,7 @@ export type SessionMessageOutput = {
|
||||
readonly time: { readonly created: number }
|
||||
readonly type: "model-switched"
|
||||
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
|
||||
readonly previous?: { readonly id: string; readonly providerID: string; readonly variant?: string }
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
@@ -1631,9 +1671,27 @@ export type SessionMessageOutput = {
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly time: { readonly created: number; readonly completed?: number }
|
||||
readonly type: "shell"
|
||||
readonly callID: string
|
||||
readonly command: string
|
||||
readonly output: string
|
||||
readonly shell: {
|
||||
readonly id: string
|
||||
readonly status: "running" | "exited" | "timeout" | "killed"
|
||||
readonly command: string
|
||||
readonly cwd: string
|
||||
readonly shell: string
|
||||
readonly file: string
|
||||
readonly pid?: number
|
||||
readonly exit?: number | "Infinity" | "-Infinity" | "NaN"
|
||||
readonly metadata: { readonly [x: string]: JsonValue }
|
||||
readonly time: {
|
||||
readonly started: number | "Infinity" | "-Infinity" | "NaN"
|
||||
readonly completed?: number | "Infinity" | "-Infinity" | "NaN"
|
||||
}
|
||||
}
|
||||
readonly output?: {
|
||||
readonly output: string
|
||||
readonly cursor: number
|
||||
readonly size: number
|
||||
readonly truncated: boolean
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
@@ -1764,6 +1822,7 @@ export type MessageListOutput = {
|
||||
readonly time: { readonly created: number }
|
||||
readonly type: "model-switched"
|
||||
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
|
||||
readonly previous?: { readonly id: string; readonly providerID: string; readonly variant?: string }
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
@@ -1812,9 +1871,27 @@ export type MessageListOutput = {
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly time: { readonly created: number; readonly completed?: number }
|
||||
readonly type: "shell"
|
||||
readonly callID: string
|
||||
readonly command: string
|
||||
readonly output: string
|
||||
readonly shell: {
|
||||
readonly id: string
|
||||
readonly status: "running" | "exited" | "timeout" | "killed"
|
||||
readonly command: string
|
||||
readonly cwd: string
|
||||
readonly shell: string
|
||||
readonly file: string
|
||||
readonly pid?: number
|
||||
readonly exit?: number | "Infinity" | "-Infinity" | "NaN"
|
||||
readonly metadata: { readonly [x: string]: JsonValue }
|
||||
readonly time: {
|
||||
readonly started: number | "Infinity" | "-Infinity" | "NaN"
|
||||
readonly completed?: number | "Infinity" | "-Infinity" | "NaN"
|
||||
}
|
||||
}
|
||||
readonly output?: {
|
||||
readonly output: string
|
||||
readonly cursor: number
|
||||
readonly size: number
|
||||
readonly truncated: boolean
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
@@ -2353,7 +2430,7 @@ export type ServerMcpListOutput = {
|
||||
readonly name: string
|
||||
readonly status:
|
||||
| { readonly status: "connected" }
|
||||
| { readonly status: "disconnected" }
|
||||
| { readonly status: "pending" }
|
||||
| { readonly status: "disabled" }
|
||||
| { readonly status: "failed"; readonly error: string }
|
||||
| { readonly status: "needs_auth" }
|
||||
@@ -4323,7 +4400,7 @@ export type EventSubscribeOutput =
|
||||
readonly id: string
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "agent.selected"
|
||||
readonly type: "session.agent.selected"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: { readonly sessionID: string; readonly agent: string }
|
||||
@@ -4332,7 +4409,7 @@ export type EventSubscribeOutput =
|
||||
readonly id: string
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "model.selected"
|
||||
readonly type: "session.model.selected"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
@@ -4357,7 +4434,7 @@ export type EventSubscribeOutput =
|
||||
readonly id: string
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "renamed"
|
||||
readonly type: "session.renamed"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: { readonly sessionID: string; readonly title: string }
|
||||
@@ -4366,7 +4443,7 @@ export type EventSubscribeOutput =
|
||||
readonly id: string
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "forked"
|
||||
readonly type: "session.forked"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: { readonly sessionID: string; readonly parentID: string; readonly from?: string }
|
||||
@@ -4375,7 +4452,7 @@ export type EventSubscribeOutput =
|
||||
readonly id: string
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "prompt.promoted"
|
||||
readonly type: "session.prompt.promoted"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: { readonly sessionID: string; readonly inputID: string }
|
||||
@@ -4384,7 +4461,7 @@ export type EventSubscribeOutput =
|
||||
readonly id: string
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "prompt.admitted"
|
||||
readonly type: "session.prompt.admitted"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
@@ -4411,7 +4488,7 @@ export type EventSubscribeOutput =
|
||||
readonly id: string
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "execution.settled"
|
||||
readonly type: "session.execution.settled"
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
readonly sessionID: string
|
||||
@@ -4432,7 +4509,7 @@ export type EventSubscribeOutput =
|
||||
readonly id: string
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "synthetic"
|
||||
readonly type: "session.synthetic"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
@@ -4446,7 +4523,7 @@ export type EventSubscribeOutput =
|
||||
readonly id: string
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "skill.activated"
|
||||
readonly type: "session.skill.activated"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: { readonly sessionID: string; readonly name: string; readonly text: string }
|
||||
@@ -4455,25 +4532,59 @@ export type EventSubscribeOutput =
|
||||
readonly id: string
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "shell.started"
|
||||
readonly type: "session.shell.started"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: { readonly sessionID: string; readonly callID: string; readonly command: string }
|
||||
readonly data: {
|
||||
readonly sessionID: string
|
||||
readonly shell: {
|
||||
readonly id: string
|
||||
readonly status: "running" | "exited" | "timeout" | "killed"
|
||||
readonly command: string
|
||||
readonly cwd: string
|
||||
readonly shell: string
|
||||
readonly file: string
|
||||
readonly pid?: number
|
||||
readonly exit?: number
|
||||
readonly metadata: { readonly [x: string]: unknown }
|
||||
readonly time: { readonly started: number; readonly completed?: number }
|
||||
}
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "shell.ended"
|
||||
readonly type: "session.shell.ended"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: { readonly sessionID: string; readonly callID: string; readonly output: string }
|
||||
readonly data: {
|
||||
readonly sessionID: string
|
||||
readonly shell: {
|
||||
readonly id: string
|
||||
readonly status: "running" | "exited" | "timeout" | "killed"
|
||||
readonly command: string
|
||||
readonly cwd: string
|
||||
readonly shell: string
|
||||
readonly file: string
|
||||
readonly pid?: number
|
||||
readonly exit?: number
|
||||
readonly metadata: { readonly [x: string]: unknown }
|
||||
readonly time: { readonly started: number; readonly completed?: number }
|
||||
}
|
||||
readonly output: {
|
||||
readonly output: string
|
||||
readonly cursor: number
|
||||
readonly size: number
|
||||
readonly truncated: boolean
|
||||
}
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "step.started"
|
||||
readonly type: "session.step.started"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
@@ -4488,7 +4599,7 @@ export type EventSubscribeOutput =
|
||||
readonly id: string
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "step.ended"
|
||||
readonly type: "session.step.ended"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
@@ -4510,7 +4621,7 @@ export type EventSubscribeOutput =
|
||||
readonly id: string
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "step.failed"
|
||||
readonly type: "session.step.failed"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
@@ -4523,7 +4634,7 @@ export type EventSubscribeOutput =
|
||||
readonly id: string
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "text.started"
|
||||
readonly type: "session.text.started"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: { readonly sessionID: string; readonly assistantMessageID: string; readonly textID: string }
|
||||
@@ -4532,7 +4643,7 @@ export type EventSubscribeOutput =
|
||||
readonly id: string
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "text.delta"
|
||||
readonly type: "session.text.delta"
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
readonly sessionID: string
|
||||
@@ -4545,7 +4656,7 @@ export type EventSubscribeOutput =
|
||||
readonly id: string
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "text.ended"
|
||||
readonly type: "session.text.ended"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
@@ -4559,7 +4670,7 @@ export type EventSubscribeOutput =
|
||||
readonly id: string
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "reasoning.started"
|
||||
readonly type: "session.reasoning.started"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
@@ -4573,7 +4684,7 @@ export type EventSubscribeOutput =
|
||||
readonly id: string
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "reasoning.delta"
|
||||
readonly type: "session.reasoning.delta"
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
readonly sessionID: string
|
||||
@@ -4586,7 +4697,7 @@ export type EventSubscribeOutput =
|
||||
readonly id: string
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "reasoning.ended"
|
||||
readonly type: "session.reasoning.ended"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
@@ -4601,7 +4712,7 @@ export type EventSubscribeOutput =
|
||||
readonly id: string
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "tool.input.started"
|
||||
readonly type: "session.tool.input.started"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
@@ -4615,7 +4726,7 @@ export type EventSubscribeOutput =
|
||||
readonly id: string
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "tool.input.delta"
|
||||
readonly type: "session.tool.input.delta"
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
readonly sessionID: string
|
||||
@@ -4628,7 +4739,7 @@ export type EventSubscribeOutput =
|
||||
readonly id: string
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "tool.input.ended"
|
||||
readonly type: "session.tool.input.ended"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
@@ -4642,7 +4753,7 @@ export type EventSubscribeOutput =
|
||||
readonly id: string
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "tool.called"
|
||||
readonly type: "session.tool.called"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
@@ -4661,7 +4772,7 @@ export type EventSubscribeOutput =
|
||||
readonly id: string
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "tool.progress"
|
||||
readonly type: "session.tool.progress"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
@@ -4679,7 +4790,7 @@ export type EventSubscribeOutput =
|
||||
readonly id: string
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "tool.success"
|
||||
readonly type: "session.tool.success"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
@@ -4703,7 +4814,7 @@ export type EventSubscribeOutput =
|
||||
readonly id: string
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "tool.failed"
|
||||
readonly type: "session.tool.failed"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
@@ -4722,7 +4833,7 @@ export type EventSubscribeOutput =
|
||||
readonly id: string
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "retried"
|
||||
readonly type: "session.retried"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
@@ -4742,7 +4853,7 @@ export type EventSubscribeOutput =
|
||||
readonly id: string
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "compaction.started"
|
||||
readonly type: "session.compaction.started"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: { readonly sessionID: string; readonly reason: "auto" | "manual" }
|
||||
@@ -4751,7 +4862,7 @@ export type EventSubscribeOutput =
|
||||
readonly id: string
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "compaction.delta"
|
||||
readonly type: "session.compaction.delta"
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: { readonly sessionID: string; readonly text: string }
|
||||
}
|
||||
@@ -4759,7 +4870,7 @@ export type EventSubscribeOutput =
|
||||
readonly id: string
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "compaction.ended"
|
||||
readonly type: "session.compaction.ended"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
@@ -4773,7 +4884,7 @@ export type EventSubscribeOutput =
|
||||
readonly id: string
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "revert.staged"
|
||||
readonly type: "session.revert.staged"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
@@ -4797,7 +4908,7 @@ export type EventSubscribeOutput =
|
||||
readonly id: string
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "revert.cleared"
|
||||
readonly type: "session.revert.cleared"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: { readonly sessionID: string }
|
||||
@@ -4806,7 +4917,7 @@ export type EventSubscribeOutput =
|
||||
readonly id: string
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "revert.committed"
|
||||
readonly type: "session.revert.committed"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: { readonly sessionID: string; readonly messageID: string }
|
||||
@@ -4815,9 +4926,9 @@ export type EventSubscribeOutput =
|
||||
readonly id: string
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "file.edited"
|
||||
readonly type: "filesystem.changed"
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: { readonly file: string }
|
||||
readonly data: { readonly file: string; readonly event: "add" | "change" | "unlink" }
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
@@ -4883,7 +4994,7 @@ export type EventSubscribeOutput =
|
||||
readonly id: string
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "skill.updated"
|
||||
readonly type: "config.updated"
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {}
|
||||
}
|
||||
@@ -4891,9 +5002,9 @@ export type EventSubscribeOutput =
|
||||
readonly id: string
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "file.watcher.updated"
|
||||
readonly type: "skill.updated"
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: { readonly file: string; readonly event: "add" | "change" | "unlink" }
|
||||
readonly data: {}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
|
||||
@@ -1,3 +1,16 @@
|
||||
export * from "./generated/index"
|
||||
export type {
|
||||
AgentApi,
|
||||
CatalogApi,
|
||||
CommandApi,
|
||||
EventApi,
|
||||
IntegrationApi,
|
||||
ModelApi,
|
||||
PluginApi,
|
||||
ProviderApi,
|
||||
ReferenceApi,
|
||||
SessionApi,
|
||||
SkillApi,
|
||||
} from "./api.js"
|
||||
export type { EventSubscribeOutput as OpenCodeEvent } from "./generated/types"
|
||||
export type OpenCodeClient = ReturnType<typeof import("./generated/client").make>
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Effect } from "effect"
|
||||
import { OpenCode as EffectOpenCode, type AppApi as EffectApi } from "../src/effect"
|
||||
|
||||
type EffectClient = Effect.Success<ReturnType<typeof EffectOpenCode.make>>
|
||||
|
||||
declare const effectClient: EffectClient
|
||||
|
||||
const effectApi: EffectApi<unknown> = effectClient
|
||||
|
||||
void effectApi
|
||||
@@ -45,9 +45,9 @@ test("event.subscribe exposes and decodes the native Effect event stream", async
|
||||
return yield* client.event.subscribe().pipe(Stream.runCollect)
|
||||
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
|
||||
|
||||
expect(Array.from(events).map((event) => event.type)).toEqual(["server.connected", "model.selected"])
|
||||
expect(Array.from(events).map((event) => event.type)).toEqual(["server.connected", "session.model.selected"])
|
||||
const durable = events[1]
|
||||
if (durable?.type !== "model.selected") throw new Error("Expected model event")
|
||||
if (durable?.type !== "session.model.selected") throw new Error("Expected model event")
|
||||
expect(DateTime.toEpochMillis(durable.created)).toBe(1_717_171_717_000)
|
||||
expect(durable.durable).toEqual({ aggregateID: "ses_test", seq: 1, version: 1 })
|
||||
})
|
||||
@@ -159,8 +159,8 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
|
||||
expect(result.context).toEqual([])
|
||||
expect(logQueries[0]).toEqual({ after: "0" })
|
||||
const logged = Array.from(result.log)
|
||||
expect(logged.map((item) => item.type)).toEqual(["model.selected", "log.synced"])
|
||||
expect(logged[0]?.type === "model.selected" && DateTime.toEpochMillis(logged[0].created)).toBe(
|
||||
expect(logged.map((item) => item.type)).toEqual(["session.model.selected", "log.synced"])
|
||||
expect(logged[0]?.type === "session.model.selected" && DateTime.toEpochMillis(logged[0].created)).toBe(
|
||||
1_717_171_717_000,
|
||||
)
|
||||
expect(logged.at(-1)).toEqual(synced)
|
||||
@@ -228,7 +228,7 @@ const modelSwitchedMessage = {
|
||||
const modelSwitchedEvent = {
|
||||
id: "evt_model",
|
||||
created: 1_717_171_717_000,
|
||||
type: "model.selected",
|
||||
type: "session.model.selected",
|
||||
durable: { aggregateID: "ses_test", seq: 1, version: 1 },
|
||||
data: {
|
||||
sessionID: "ses_test",
|
||||
|
||||
@@ -160,7 +160,7 @@ test("event.subscribe exposes the Promise event stream wire projection", async (
|
||||
for await (const event of client.event.subscribe()) events.push(event)
|
||||
|
||||
expect(events).toEqual([{ id: "evt_connected", created: 0, type: "server.connected", data: {} }, modelSwitchedEvent])
|
||||
expect(events[1]?.type === "model.selected" && events[1].created).toBe(1_717_171_717_000)
|
||||
expect(events[1]?.type === "session.model.selected" && events[1].created).toBe(1_717_171_717_000)
|
||||
})
|
||||
|
||||
test("event.subscribe terminates on malformed Promise SSE data", async () => {
|
||||
@@ -329,7 +329,7 @@ const synced = { type: "log.synced", aggregateID: "ses_test", seq: 1 }
|
||||
const modelSwitchedEvent = {
|
||||
id: "evt_model",
|
||||
created: 1_717_171_717_000,
|
||||
type: "model.selected",
|
||||
type: "session.model.selected",
|
||||
durable: { aggregateID: "ses_test", seq: 1, version: 1 },
|
||||
data: {
|
||||
sessionID: "ses_test",
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"noEmit": false,
|
||||
"declaration": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -3,6 +3,8 @@
|
||||
"extends": "@tsconfig/bun/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||
"allowImportingTsExtensions": false,
|
||||
"allowJs": false,
|
||||
"noUncheckedIndexedAccess": false
|
||||
},
|
||||
"include": ["src"]
|
||||
|
||||
@@ -458,8 +458,8 @@ export const discoveryPlan = <R>(
|
||||
|
||||
// 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.
|
||||
// catalog at the bottom. Example call forms use placeholders - never a real or fabricated
|
||||
// tool name - and show both dot and bracket notation so non-identifier names are not normalized.
|
||||
const intro = [
|
||||
"Write a CodeMode program to answer the request. Return code only.",
|
||||
empty
|
||||
@@ -467,6 +467,7 @@ export const discoveryPlan = <R>(
|
||||
: 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.",
|
||||
...(empty ? [] : ["Do not infer or normalize tool names; use only exact signatures shown below or returned by search."]),
|
||||
]
|
||||
|
||||
// The search step exists only when search is advertised (PARTIAL catalog); a COMPLETE
|
||||
@@ -480,14 +481,14 @@ export const discoveryPlan = <R>(
|
||||
...(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.",
|
||||
'2. Call it using the exact signature shown; bracket notation and quotes are part of the path.',
|
||||
'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.',
|
||||
'1. If the exact signature is not listed below, first search: `const { items } = await tools.$codemode.search({ query: "<intent + key nouns>" })`.',
|
||||
"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.",
|
||||
"3. Call the result's `path` as-is; bracket notation and quotes are part of the path.",
|
||||
'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.",
|
||||
]),
|
||||
@@ -504,7 +505,7 @@ export const discoveryPlan = <R>(
|
||||
: "- 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)))`.",
|
||||
'- Run independent calls in parallel: `await Promise.all(items.map((item) => tools.<namespace>.<tool>(item)))`, or use `tools.<namespace>["tool-name"](item)` when the listed signature uses bracket notation.',
|
||||
"- `Object.keys(tools)` lists namespaces; `Object.keys(tools.<namespace>)` lists its tools; `for...in` works on both.",
|
||||
...(complete
|
||||
? []
|
||||
|
||||
@@ -622,12 +622,12 @@ describe("CodeMode public contract", () => {
|
||||
)
|
||||
expect(instructions).toContain("Return only the fields you need")
|
||||
expect(instructions).toContain("raw payloads get truncated and waste context")
|
||||
expect(instructions).toContain("`const res = await tools.<namespace>.<tool>(input)`")
|
||||
expect(instructions).toContain("Do not infer or normalize tool names")
|
||||
expect(instructions).toContain("bracket notation and quotes are part of the path")
|
||||
expect(instructions).toContain("surrounding agent tools are not available unless listed here")
|
||||
expect(instructions).toContain("Only tools listed here are available inside `tools`")
|
||||
expect(instructions).toContain("bracket notation may appear for names that are not JavaScript identifiers")
|
||||
// Placeholders use the <namespace>.<tool>/<field> style ONLY - no fabricated tool
|
||||
// names, and no real catalog tools cherry-picked into example lines.
|
||||
// Placeholders use generic namespace/tool/field names only - no fabricated real tools
|
||||
// and no real catalog tools cherry-picked into example lines.
|
||||
expect(instructions).toContain("`return { <field>: data.<field> }`")
|
||||
expect(instructions).not.toContain("total_count")
|
||||
expect(instructions).not.toContain("list_issues")
|
||||
@@ -640,7 +640,7 @@ describe("CodeMode public contract", () => {
|
||||
// PARTIAL: the workflow starts with search (with query-style guidance that is clearly
|
||||
// a query string, never a tool name) and the browse-namespace rule appears.
|
||||
expect(partial).toContain(
|
||||
'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.',
|
||||
'1. If the exact signature is not listed below, first search: `const { items } = await tools.$codemode.search({ query: "<intent + key nouns>" })`.',
|
||||
)
|
||||
expect(partial).toContain(
|
||||
"Only tools listed here or returned by `tools.$codemode.search` are available inside `tools`",
|
||||
|
||||
@@ -339,3 +339,43 @@ describe("pretty signatures in search results", () => {
|
||||
expect(instructions).not.toContain("/**")
|
||||
})
|
||||
})
|
||||
|
||||
describe("non-identifier tool paths", () => {
|
||||
const resolveLibrary = Tool.make({
|
||||
description: "Resolve a Context7 library ID",
|
||||
input: {
|
||||
type: "object",
|
||||
properties: {
|
||||
query: { type: "string" },
|
||||
libraryName: { type: "string" },
|
||||
},
|
||||
required: ["query", "libraryName"],
|
||||
} as const,
|
||||
run: () => Effect.succeed("/reactjs/react.dev"),
|
||||
})
|
||||
const runtime = CodeMode.make({ tools: { context7: { "resolve-library-id": resolveLibrary } } })
|
||||
|
||||
test("inline catalog uses bracket notation for dashed tool names", () => {
|
||||
const instructions = runtime.instructions()
|
||||
|
||||
expect(instructions).toContain(
|
||||
'tools.context7["resolve-library-id"](input: { query: string; libraryName: string }): Promise<unknown>',
|
||||
)
|
||||
expect(instructions).toContain("Do not infer or normalize tool names")
|
||||
expect(instructions).toContain("bracket notation and quotes are part of the path")
|
||||
expect(instructions).not.toContain("tools.context7.resolve-library-id")
|
||||
expect(instructions).not.toContain("tools.context7.resolve_library_id")
|
||||
})
|
||||
|
||||
test("search results return callable bracket-notation paths and signatures", async () => {
|
||||
const result = await Effect.runPromise(
|
||||
runtime.execute(`return await tools.$codemode.search({ query: "resolve library" })`),
|
||||
)
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) throw new Error("search failed")
|
||||
|
||||
const value = result.value as { items: Array<{ path: string; signature: string }> }
|
||||
expect(value.items[0]?.path).toBe('tools.context7["resolve-library-id"]')
|
||||
expect(value.items[0]?.signature).toContain('tools.context7["resolve-library-id"](input: {')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
export * as Catalog from "./catalog"
|
||||
|
||||
import { makeLocationNode } from "./effect/app-node"
|
||||
import { Array, Context, Effect, Layer, Option, Order, pipe, Schema } from "effect"
|
||||
import { Array, Context, Effect, Layer, Option, Order, pipe } from "effect"
|
||||
import { Catalog } from "@opencode-ai/schema/catalog"
|
||||
import { ModelV2 } from "./model"
|
||||
import { ProviderV2 } from "./provider"
|
||||
import { EventV2 } from "./event"
|
||||
import { Policy } from "./policy"
|
||||
import { State } from "./state"
|
||||
import { Integration } from "./integration"
|
||||
|
||||
@@ -17,8 +16,6 @@ export type ProviderRecord = {
|
||||
|
||||
export type DefaultModel = { providerID: ProviderV2.ID; modelID: ModelV2.ID }
|
||||
|
||||
export const PolicyActions = Schema.Literals(["provider.use"])
|
||||
|
||||
export const Event = Catalog.Event
|
||||
|
||||
type Data = {
|
||||
@@ -65,7 +62,6 @@ const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const policy = yield* Policy.Service
|
||||
const integrations = yield* Integration.Service
|
||||
|
||||
const available = (provider: ProviderV2.Info, integration: Integration.Info | undefined) => {
|
||||
@@ -159,13 +155,6 @@ const layer = Layer.effect(
|
||||
return result
|
||||
},
|
||||
finalize: Effect.fn("CatalogV2.finalize")(function* (catalog) {
|
||||
if (policy.hasStatements()) {
|
||||
for (const record of [...catalog.provider.list()]) {
|
||||
if ((yield* policy.evaluate("provider.use", record.provider.id, "allow")) === "deny") {
|
||||
catalog.provider.remove(record.provider.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
yield* events.publish(Event.Updated, {})
|
||||
}),
|
||||
})
|
||||
@@ -294,4 +283,4 @@ const layer = Layer.effect(
|
||||
|
||||
const SMALL_MODEL_RE = /\b(nano|flash|lite|mini|haiku|small|fast)\b/
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.node, Policy.node, Integration.node] })
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.node, Integration.node] })
|
||||
|
||||
+80
-44
@@ -3,18 +3,19 @@ export * as Config from "./config"
|
||||
import { makeLocationNode } from "./effect/app-node"
|
||||
import path from "path"
|
||||
import { type ParseError, parse } from "jsonc-parser"
|
||||
import { Context, Effect, Layer, Option, Schema } from "effect"
|
||||
import { Context, Effect, Fiber, Layer, Option, PubSub, Schema, Stream } from "effect"
|
||||
import { Permission } from "@opencode-ai/schema/permission"
|
||||
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
|
||||
import { EventV2 } from "./event"
|
||||
import { Watcher } from "./filesystem/watcher"
|
||||
import { FSUtil } from "./fs-util"
|
||||
import { Global } from "./global"
|
||||
import { Location } from "./location"
|
||||
import { Policy } from "./policy"
|
||||
import { AbsolutePath } from "./schema"
|
||||
import { ConfigAgent } from "./config/agent"
|
||||
import { ConfigAttachments } from "./config/attachments"
|
||||
import { ConfigCompaction } from "./config/compaction"
|
||||
import { ConfigCommand } from "./config/command"
|
||||
import { ConfigExperimental } from "./config/experimental"
|
||||
import { ConfigFormatter } from "./config/formatter"
|
||||
import { ConfigLSP } from "./config/lsp"
|
||||
import { ConfigMCP } from "./config/mcp"
|
||||
@@ -22,6 +23,7 @@ import { ConfigPlugin } from "./config/plugin"
|
||||
import { ConfigProvider } from "./config/provider"
|
||||
import { ConfigReference } from "./config/reference"
|
||||
import { ConfigToolOutput } from "./config/tool-output"
|
||||
import { ConfigVariable } from "./config/variable"
|
||||
import { ConfigWatcher } from "./config/watcher"
|
||||
import { ConfigV1 } from "./v1/config/config"
|
||||
import { ConfigMigrateV1 } from "./v1/config/migrate"
|
||||
@@ -102,7 +104,6 @@ export class Info extends Schema.Class<Info>("Config.Info")({
|
||||
plugins: ConfigPlugin.Plugins.pipe(Schema.optional).annotate({
|
||||
description: "Ordered external plugin packages to load",
|
||||
}),
|
||||
experimental: ConfigExperimental.Experimental.pipe(Schema.optional),
|
||||
providers: Schema.Record(Schema.String, ConfigProvider.Info).pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
@@ -138,7 +139,8 @@ const layer = Layer.effect(
|
||||
const fs = yield* FSUtil.Service
|
||||
const global = yield* Global.Service
|
||||
const location = yield* Location.Service
|
||||
const policy = yield* Policy.Service
|
||||
const watcher = yield* Watcher.Service
|
||||
const events = yield* EventV2.Service
|
||||
const names = ["opencode.json", "opencode.jsonc"]
|
||||
const decodeOptions = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const
|
||||
const decodeInfo = Schema.decodeUnknownOption(Info, decodeOptions)
|
||||
@@ -147,9 +149,10 @@ const layer = Layer.effect(
|
||||
const loadFile = Effect.fnUntraced(function* (filepath: string) {
|
||||
const text = yield* fs.readFileStringSafe(filepath)
|
||||
if (!text) return
|
||||
const substituted = yield* ConfigVariable.substitute({ type: "path", path: filepath, text })
|
||||
|
||||
const errors: ParseError[] = []
|
||||
const input: unknown = parse(text, errors, { allowTrailingComma: true })
|
||||
const input: unknown = parse(substituted, errors, { allowTrailingComma: true })
|
||||
if (errors.length) return
|
||||
|
||||
const info = Option.getOrUndefined(
|
||||
@@ -170,45 +173,78 @@ const layer = Layer.effect(
|
||||
]
|
||||
})
|
||||
|
||||
const globalDirectory = AbsolutePath.make(global.config)
|
||||
const locationIsGlobal = path.resolve(location.directory) === path.resolve(global.config)
|
||||
// Read configuration once when this location opens. Later calls reuse these
|
||||
// values until the location is reopened.
|
||||
const discovered = locationIsGlobal
|
||||
? []
|
||||
: yield* fs
|
||||
.up({
|
||||
targets: [".opencode", ...names.toReversed()],
|
||||
start: location.directory,
|
||||
stop: location.project.directory,
|
||||
})
|
||||
.pipe(Effect.orDie)
|
||||
const directories = [
|
||||
globalDirectory,
|
||||
...discovered
|
||||
.filter((item) => path.basename(item) === ".opencode")
|
||||
.toReversed()
|
||||
.map((directory) => AbsolutePath.make(directory)),
|
||||
const discover = Effect.fn("Config.discover")(function* () {
|
||||
const globalDirectory = AbsolutePath.make(global.config)
|
||||
const locationIsGlobal = path.resolve(location.directory) === path.resolve(global.config)
|
||||
const discovered = locationIsGlobal
|
||||
? []
|
||||
: yield* fs
|
||||
.up({
|
||||
targets: [".opencode", ...names.toReversed()],
|
||||
start: location.directory,
|
||||
stop: location.project.directory,
|
||||
})
|
||||
.pipe(Effect.orDie)
|
||||
const directories = [
|
||||
globalDirectory,
|
||||
...discovered
|
||||
.filter((item) => path.basename(item) === ".opencode")
|
||||
.toReversed()
|
||||
.map((directory) => AbsolutePath.make(directory)),
|
||||
]
|
||||
const directPaths = discovered.filter((item) => path.basename(item) !== ".opencode").toReversed()
|
||||
const direct = yield* Effect.forEach(directPaths, loadFile).pipe(
|
||||
Effect.orDie,
|
||||
Effect.map((configs) => configs.filter((config): config is Document => config !== undefined)),
|
||||
)
|
||||
const supplementary = yield* Effect.forEach(directories, loadDirectory).pipe(Effect.orDie)
|
||||
return {
|
||||
entries: [...(supplementary[0] ?? []), ...direct, ...supplementary.slice(1).flat()],
|
||||
directories,
|
||||
files: directPaths,
|
||||
}
|
||||
})
|
||||
|
||||
const initial = yield* discover()
|
||||
let configs = initial.entries
|
||||
const updates = yield* PubSub.unbounded<Watcher.Update>()
|
||||
const subscriptions = new Map<string, Effect.Effect<unknown>>()
|
||||
const targets = (snapshot: typeof initial) => [
|
||||
...snapshot.directories.map((path) => ({ path, type: "directory" as const })),
|
||||
...snapshot.files
|
||||
.filter((file) => !snapshot.directories.some((directory) => FSUtil.contains(directory, file)))
|
||||
.map((path) => ({ path, type: "file" as const })),
|
||||
]
|
||||
// A config closer to the opened directory should win over one higher up.
|
||||
// Search starts nearby, so reverse the results before applying them.
|
||||
const directPaths = discovered.filter((item) => path.basename(item) !== ".opencode").toReversed()
|
||||
const direct = yield* Effect.forEach(directPaths, loadFile).pipe(
|
||||
Effect.orDie,
|
||||
Effect.map((configs) => configs.filter((config): config is Document => config !== undefined)),
|
||||
)
|
||||
const supplementary = yield* Effect.forEach(directories, loadDirectory).pipe(Effect.orDie)
|
||||
// Apply general settings first and more specific settings last:
|
||||
// global config, project files, then `.opencode` files.
|
||||
const configs = [...(supplementary[0] ?? []), ...direct, ...supplementary.slice(1).flat()]
|
||||
// Rules use the opposite order so a user-global rule can override a
|
||||
// repository rule. Statement order inside each file stays unchanged.
|
||||
yield* policy.load(
|
||||
configs
|
||||
.filter((config): config is Document => config.type === "document")
|
||||
.toReversed()
|
||||
.flatMap((config) => config.info.experimental?.policies ?? []),
|
||||
const reconcile = Effect.fn("Config.reconcileWatches")(function* (snapshot: typeof initial) {
|
||||
const next = new Map(targets(snapshot).map((target) => [JSON.stringify(target), target]))
|
||||
for (const [key, stop] of subscriptions) {
|
||||
if (next.has(key)) continue
|
||||
yield* stop
|
||||
subscriptions.delete(key)
|
||||
}
|
||||
for (const [key, target] of next) {
|
||||
if (subscriptions.has(key)) continue
|
||||
const fiber = yield* watcher.subscribe(target).pipe(
|
||||
Stream.runForEach((update) => PubSub.publish(updates, update)),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
subscriptions.set(key, Fiber.interrupt(fiber))
|
||||
}
|
||||
})
|
||||
|
||||
yield* Stream.fromPubSub(updates).pipe(
|
||||
Stream.debounce("100 millis"),
|
||||
Stream.runForEach((update) =>
|
||||
Effect.gen(function* () {
|
||||
const next = yield* discover()
|
||||
configs = next.entries
|
||||
yield* reconcile(next)
|
||||
yield* events.publish(ConfigSchema.Event.Updated, {})
|
||||
}).pipe(Effect.catchCause((cause) => Effect.logError("failed to reload config", { path: update.path, cause }))),
|
||||
),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
yield* reconcile(initial)
|
||||
|
||||
return Service.of({
|
||||
entries: Effect.fn("Config.entries")(function* () {
|
||||
@@ -221,5 +257,5 @@ const layer = Layer.effect(
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [FSUtil.node, Global.node, Location.node, Policy.node],
|
||||
deps: [Watcher.node, EventV2.node, FSUtil.node, Global.node, Location.node],
|
||||
})
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
export * as ConfigExperimental from "./experimental"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { Catalog } from "../catalog"
|
||||
import { Policy } from "../policy"
|
||||
|
||||
// Each core domain exports the policy actions it supports. Adding an action to
|
||||
// this union makes it valid in authored config while keeping Policy generic.
|
||||
export const PolicyAction = Schema.Union([Catalog.PolicyActions])
|
||||
|
||||
class PolicyConfig extends Schema.Class<PolicyConfig>("ConfigV2.Experimental.Policy")({
|
||||
...Policy.Info.fields,
|
||||
action: PolicyAction,
|
||||
}) {}
|
||||
|
||||
export { PolicyConfig as Policy }
|
||||
|
||||
export class Experimental extends Schema.Class<Experimental>("ConfigV2.Experimental")({
|
||||
policies: PolicyConfig.pipe(Schema.Array, Schema.optional),
|
||||
}) {}
|
||||
@@ -2,7 +2,7 @@ export * as ConfigAgentPlugin from "./agent"
|
||||
|
||||
import { define } from "../../plugin/internal"
|
||||
import path from "path"
|
||||
import { Effect, Option, Schema } from "effect"
|
||||
import { Effect, Option, Schema, Stream } from "effect"
|
||||
import { AgentV2 } from "../../agent"
|
||||
import { Config } from "../../config"
|
||||
import { ConfigAgent } from "../agent"
|
||||
@@ -38,64 +38,75 @@ export const Plugin = define({
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const config = yield* Config.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
yield* ctx.agent.transform(
|
||||
Effect.fn(function* (draft) {
|
||||
const documents = yield* Effect.forEach(yield* config.entries(), (entry) => {
|
||||
if (entry.type === "document") return Effect.succeed([entry])
|
||||
return Effect.gen(function* () {
|
||||
const files = yield* discover(fs, entry.path)
|
||||
return yield* Effect.forEach(files, (file) =>
|
||||
fs.readFileStringSafe(file.filepath).pipe(
|
||||
Effect.map((content) => content && decode(file, content)),
|
||||
Effect.catch(() => Effect.succeed(undefined)),
|
||||
),
|
||||
).pipe(
|
||||
Effect.map((documents) =>
|
||||
documents.filter((document): document is Config.Document => document !== undefined),
|
||||
),
|
||||
)
|
||||
})
|
||||
}).pipe(Effect.map((documents) => documents.flat()))
|
||||
const global = documents.flatMap((document) => document.info.permissions ?? [])
|
||||
const configuredDefault = Config.latest(documents, "default_agent")
|
||||
if (configuredDefault !== undefined) draft.default(AgentV2.ID.make(configuredDefault))
|
||||
for (const current of draft.list()) {
|
||||
draft.update(current.id, (agent) => agent.permissions.push(...global))
|
||||
}
|
||||
const load = Effect.fn("ConfigAgentPlugin.load")(function* () {
|
||||
return yield* Effect.forEach(yield* config.entries(), (entry) => {
|
||||
if (entry.type === "document") return Effect.succeed([entry])
|
||||
return Effect.gen(function* () {
|
||||
const files = yield* discover(fs, entry.path)
|
||||
return yield* Effect.forEach(files, (file) =>
|
||||
fs.readFileStringSafe(file.filepath).pipe(
|
||||
Effect.map((content) => content && decode(file, content)),
|
||||
Effect.catch(() => Effect.succeed(undefined)),
|
||||
),
|
||||
).pipe(
|
||||
Effect.map((documents) =>
|
||||
documents.filter((document): document is Config.Document => document !== undefined),
|
||||
),
|
||||
)
|
||||
})
|
||||
}).pipe(Effect.map((documents) => documents.flat()))
|
||||
})
|
||||
const loaded = { documents: yield* load() }
|
||||
yield* ctx.agent.transform((draft) => {
|
||||
const global = loaded.documents.flatMap((document) => document.info.permissions ?? [])
|
||||
const configuredDefault = Config.latest(loaded.documents, "default_agent")
|
||||
if (configuredDefault !== undefined) draft.default(AgentV2.ID.make(configuredDefault))
|
||||
for (const current of draft.list()) {
|
||||
draft.update(current.id, (agent) => agent.permissions.push(...global))
|
||||
}
|
||||
|
||||
for (const document of documents) {
|
||||
for (const [id, item] of Object.entries(document.info.agents ?? {})) {
|
||||
const agentID = AgentV2.ID.make(id)
|
||||
if (item.disabled) {
|
||||
draft.remove(agentID)
|
||||
continue
|
||||
}
|
||||
|
||||
const exists = draft.get(agentID) !== undefined
|
||||
draft.update(agentID, (agent) => {
|
||||
if (!exists) agent.permissions.push(...global)
|
||||
if (item.model !== undefined) {
|
||||
const model = ModelV2.parse(item.model)
|
||||
agent.model = { id: model.modelID, providerID: model.providerID, variant: agent.model?.variant }
|
||||
}
|
||||
if (item.variant !== undefined && agent.model !== undefined) {
|
||||
agent.model.variant = ModelV2.VariantID.make(item.variant)
|
||||
}
|
||||
if (item.request !== undefined) {
|
||||
Object.assign(agent.request.headers, item.request.headers ?? {})
|
||||
Object.assign(agent.request.body, item.request.body ?? {})
|
||||
}
|
||||
if (item.system !== undefined) agent.system = item.system
|
||||
if (item.description !== undefined) agent.description = item.description
|
||||
if (item.mode !== undefined) agent.mode = item.mode
|
||||
if (item.hidden !== undefined) agent.hidden = item.hidden
|
||||
if (item.color !== undefined) agent.color = item.color
|
||||
if (item.steps !== undefined) agent.steps = item.steps
|
||||
if (item.permissions !== undefined) agent.permissions.push(...item.permissions)
|
||||
})
|
||||
for (const document of loaded.documents) {
|
||||
for (const [id, item] of Object.entries(document.info.agents ?? {})) {
|
||||
const agentID = AgentV2.ID.make(id)
|
||||
if (item.disabled) {
|
||||
draft.remove(agentID)
|
||||
continue
|
||||
}
|
||||
|
||||
const exists = draft.get(agentID) !== undefined
|
||||
draft.update(agentID, (agent) => {
|
||||
if (!exists) agent.permissions.push(...global)
|
||||
if (item.model !== undefined) {
|
||||
const model = ModelV2.parse(item.model)
|
||||
agent.model = { id: model.modelID, providerID: model.providerID, variant: agent.model?.variant }
|
||||
}
|
||||
if (item.variant !== undefined && agent.model !== undefined) {
|
||||
agent.model.variant = ModelV2.VariantID.make(item.variant)
|
||||
}
|
||||
if (item.request !== undefined) {
|
||||
Object.assign(agent.request.headers, item.request.headers ?? {})
|
||||
Object.assign(agent.request.body, item.request.body ?? {})
|
||||
}
|
||||
if (item.system !== undefined) agent.system = item.system
|
||||
if (item.description !== undefined) agent.description = item.description
|
||||
if (item.mode !== undefined) agent.mode = item.mode
|
||||
if (item.hidden !== undefined) agent.hidden = item.hidden
|
||||
if (item.color !== undefined) agent.color = item.color
|
||||
if (item.steps !== undefined) agent.steps = item.steps
|
||||
if (item.permissions !== undefined) agent.permissions.push(...item.permissions)
|
||||
})
|
||||
}
|
||||
}),
|
||||
}
|
||||
})
|
||||
yield* ctx.event.subscribe().pipe(
|
||||
Stream.filter((event) => event.type === "config.updated"),
|
||||
Stream.runForEach(() =>
|
||||
load().pipe(
|
||||
Effect.tap((documents) => Effect.sync(() => (loaded.documents = documents))),
|
||||
Effect.andThen(ctx.agent.reload()),
|
||||
),
|
||||
),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@ export * as ConfigCommandPlugin from "./command"
|
||||
|
||||
import { define } from "../../plugin/internal"
|
||||
import path from "path"
|
||||
import { Effect, Option, Schema } from "effect"
|
||||
import { Effect, Option, Schema, Stream } from "effect"
|
||||
import { CommandV2 } from "../../command"
|
||||
import { Config } from "../../config"
|
||||
import { FSUtil } from "../../fs-util"
|
||||
@@ -17,34 +17,45 @@ export const Plugin = define({
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const config = yield* Config.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
yield* ctx.command.transform(
|
||||
Effect.fn(function* (draft) {
|
||||
const documents = yield* Effect.forEach(yield* config.entries(), (entry) => {
|
||||
if (entry.type === "document") return Effect.succeed([{ commands: entry.info.commands }])
|
||||
return loadDirectory(fs, entry.path).pipe(
|
||||
Effect.map((commands) => [
|
||||
{ commands: Object.fromEntries(commands.map((command) => [command.name, command.info])) },
|
||||
]),
|
||||
)
|
||||
}).pipe(Effect.map((documents) => documents.flat()))
|
||||
for (const document of documents) {
|
||||
for (const [name, command] of Object.entries(document.commands ?? {})) {
|
||||
draft.update(name, (item) => {
|
||||
item.template = command.template
|
||||
if (command.description !== undefined) item.description = command.description
|
||||
if (command.agent !== undefined) item.agent = command.agent
|
||||
if (command.model !== undefined) {
|
||||
const model = ModelV2.parse(command.model)
|
||||
item.model = { id: model.modelID, providerID: model.providerID, variant: item.model?.variant }
|
||||
}
|
||||
if (command.variant !== undefined && item.model !== undefined) {
|
||||
item.model.variant = ModelV2.VariantID.make(command.variant)
|
||||
}
|
||||
if (command.subtask !== undefined) item.subtask = command.subtask
|
||||
})
|
||||
}
|
||||
const load = Effect.fn("ConfigCommandPlugin.load")(function* () {
|
||||
return yield* Effect.forEach(yield* config.entries(), (entry) => {
|
||||
if (entry.type === "document") return Effect.succeed([{ commands: entry.info.commands }])
|
||||
return loadDirectory(fs, entry.path).pipe(
|
||||
Effect.map((commands) => [
|
||||
{ commands: Object.fromEntries(commands.map((command) => [command.name, command.info])) },
|
||||
]),
|
||||
)
|
||||
}).pipe(Effect.map((documents) => documents.flat()))
|
||||
})
|
||||
const loaded = { documents: yield* load() }
|
||||
yield* ctx.command.transform((draft) => {
|
||||
for (const document of loaded.documents) {
|
||||
for (const [name, command] of Object.entries(document.commands ?? {})) {
|
||||
draft.update(name, (item) => {
|
||||
item.template = command.template
|
||||
if (command.description !== undefined) item.description = command.description
|
||||
if (command.agent !== undefined) item.agent = command.agent
|
||||
if (command.model !== undefined) {
|
||||
const model = ModelV2.parse(command.model)
|
||||
item.model = { id: model.modelID, providerID: model.providerID, variant: item.model?.variant }
|
||||
}
|
||||
if (command.variant !== undefined && item.model !== undefined) {
|
||||
item.model.variant = ModelV2.VariantID.make(command.variant)
|
||||
}
|
||||
if (command.subtask !== undefined) item.subtask = command.subtask
|
||||
})
|
||||
}
|
||||
}),
|
||||
}
|
||||
})
|
||||
yield* ctx.event.subscribe().pipe(
|
||||
Stream.filter((event) => event.type === "config.updated"),
|
||||
Stream.runForEach(() =>
|
||||
load().pipe(
|
||||
Effect.tap((documents) => Effect.sync(() => (loaded.documents = documents))),
|
||||
Effect.andThen(ctx.command.reload()),
|
||||
),
|
||||
),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -42,8 +42,8 @@ export const Plugin = define({
|
||||
const fs = yield* FSUtil.Service
|
||||
const location = yield* Location.Service
|
||||
const npm = yield* Npm.Service
|
||||
yield* Effect.gen(function* () {
|
||||
const configured: { package: string; options?: Record<string, any> }[] = []
|
||||
const load = Effect.fn("ConfigExternalPlugin.load")(function* () {
|
||||
const configured: { package: string; options?: Record<string, unknown> }[] = []
|
||||
|
||||
for (const entry of yield* config.entries()) {
|
||||
if (entry.type === "document") {
|
||||
@@ -98,8 +98,8 @@ export const Plugin = define({
|
||||
}
|
||||
}
|
||||
|
||||
for (const ref of configured) {
|
||||
yield* Effect.gen(function* () {
|
||||
return yield* Effect.forEach(configured, (ref) =>
|
||||
Effect.gen(function* () {
|
||||
const entrypoint = path.isAbsolute(ref.package)
|
||||
? pathToFileURL(ref.package).href
|
||||
: (yield* npm.add(ref.package)).entrypoint
|
||||
@@ -108,13 +108,15 @@ export const Plugin = define({
|
||||
const mod = yield* Effect.promise(() => import(entrypoint))
|
||||
const value = (yield* Schema.decodeUnknownEffect(PluginModule)(mod)).default
|
||||
const plugin = "effect" in value ? value : PluginPromise.fromPromise(value)
|
||||
yield* ctx.plugin.add({
|
||||
return {
|
||||
id: plugin.id,
|
||||
effect: (host) => plugin.effect({ ...host, options: ref.options ?? {} }),
|
||||
})
|
||||
}).pipe(Effect.ignoreCause)
|
||||
}
|
||||
effect: (host: Parameters<typeof plugin.effect>[0]) =>
|
||||
plugin.effect({ ...host, options: ref.options ?? {} }),
|
||||
}
|
||||
}).pipe(Effect.catchCause(() => Effect.succeed(undefined))),
|
||||
).pipe(Effect.map((plugins) => plugins.filter((plugin) => plugin !== undefined)))
|
||||
})
|
||||
for (const plugin of yield* load()) yield* ctx.plugin.add(plugin)
|
||||
}),
|
||||
})
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export * as ConfigProviderPlugin from "./provider"
|
||||
|
||||
import { define } from "../../plugin/internal"
|
||||
import { Effect } from "effect"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { Config } from "../../config"
|
||||
import { ModelV2 } from "../../model"
|
||||
|
||||
@@ -9,108 +9,115 @@ export const Plugin = define({
|
||||
id: "config-provider",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const config = yield* Config.Service
|
||||
yield* ctx.integration.transform(
|
||||
Effect.fn(function* (integrations) {
|
||||
const files = (yield* config.entries()).filter((entry): entry is Config.Document => entry.type === "document")
|
||||
const configuredIntegrations = new Set(
|
||||
files.flatMap((file) =>
|
||||
Object.entries(file.info.providers ?? {}).flatMap(([id, provider]) =>
|
||||
provider.env === undefined ? [] : [id],
|
||||
),
|
||||
const loaded = { entries: yield* config.entries() }
|
||||
yield* ctx.integration.transform((integrations) => {
|
||||
const files = loaded.entries.filter((entry): entry is Config.Document => entry.type === "document")
|
||||
const configuredIntegrations = new Set(
|
||||
files.flatMap((file) =>
|
||||
Object.entries(file.info.providers ?? {}).flatMap(([id, provider]) =>
|
||||
provider.env === undefined ? [] : [id],
|
||||
),
|
||||
)
|
||||
for (const file of files) {
|
||||
for (const [id, item] of Object.entries(file.info.providers ?? {})) {
|
||||
const integrationID = id
|
||||
if (!configuredIntegrations.has(id) && !integrations.get(integrationID)) continue
|
||||
integrations.update(integrationID, (integration) => {
|
||||
integration.name = item.name ?? integration.name
|
||||
),
|
||||
)
|
||||
for (const file of files) {
|
||||
for (const [id, item] of Object.entries(file.info.providers ?? {})) {
|
||||
const integrationID = id
|
||||
if (!configuredIntegrations.has(id) && !integrations.get(integrationID)) continue
|
||||
integrations.update(integrationID, (integration) => {
|
||||
integration.name = item.name ?? integration.name
|
||||
})
|
||||
if (item.env !== undefined) {
|
||||
integrations.method.update({
|
||||
integrationID,
|
||||
method: { type: "env", names: [...item.env] },
|
||||
})
|
||||
if (item.env !== undefined) {
|
||||
integrations.method.update({
|
||||
integrationID,
|
||||
method: { type: "env", names: [...item.env] },
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
yield* ctx.catalog.transform(
|
||||
Effect.fn(function* (catalog) {
|
||||
const entries = yield* config.entries()
|
||||
const files = entries.filter((entry): entry is Config.Document => entry.type === "document")
|
||||
const configuredDefault = Config.latest(entries, "model")
|
||||
if (configuredDefault !== undefined) {
|
||||
const model = ModelV2.parse(configuredDefault)
|
||||
catalog.model.default.set(model.providerID, model.modelID)
|
||||
}
|
||||
for (const file of files) {
|
||||
for (const [id, item] of Object.entries(file.info.providers ?? {})) {
|
||||
const providerID = id
|
||||
catalog.provider.update(providerID, (provider) => {
|
||||
if (item.name !== undefined) provider.name = item.name
|
||||
if (item.api !== undefined) provider.api = { ...item.api }
|
||||
if (item.request !== undefined) {
|
||||
Object.assign(provider.request.settings, item.request.settings)
|
||||
Object.assign(provider.request.headers, item.request.headers)
|
||||
Object.assign(provider.request.body, item.request.body)
|
||||
}
|
||||
})
|
||||
for (const [id, config] of Object.entries(item.models ?? {})) {
|
||||
catalog.model.update(providerID, id, (model) => {
|
||||
if (config.family !== undefined) model.family = config.family
|
||||
if (config.name !== undefined) model.name = config.name
|
||||
if (config.api !== undefined) model.api = { ...model.api, ...config.api }
|
||||
if (config.capabilities !== undefined) {
|
||||
model.capabilities = {
|
||||
tools: config.capabilities.tools,
|
||||
input: [...config.capabilities.input],
|
||||
output: [...config.capabilities.output],
|
||||
}
|
||||
}
|
||||
if (config.request !== undefined) {
|
||||
Object.assign(model.request.settings, config.request.settings)
|
||||
Object.assign(model.request.headers, config.request.headers)
|
||||
Object.assign(model.request.body, config.request.body)
|
||||
if (config.request.variant !== undefined) model.request.variant = config.request.variant
|
||||
}
|
||||
if (config.variants !== undefined) {
|
||||
for (const variant of config.variants) {
|
||||
let existing = model.variants.find((item) => item.id === variant.id)
|
||||
if (!existing) {
|
||||
existing = {
|
||||
id: variant.id,
|
||||
settings: {},
|
||||
headers: {},
|
||||
body: {},
|
||||
}
|
||||
model.variants.push(existing)
|
||||
}
|
||||
Object.assign(existing.settings, variant.settings)
|
||||
Object.assign(existing.headers, variant.headers)
|
||||
Object.assign(existing.body, variant.body)
|
||||
}
|
||||
}
|
||||
if (config.cost !== undefined) {
|
||||
model.cost = (Array.isArray(config.cost) ? config.cost : [config.cost]).map((cost) => ({
|
||||
tier: cost.tier && { ...cost.tier },
|
||||
input: cost.input,
|
||||
output: cost.output,
|
||||
cache: {
|
||||
read: cost.cache?.read ?? 0,
|
||||
write: cost.cache?.write ?? 0,
|
||||
},
|
||||
}))
|
||||
}
|
||||
if (config.disabled !== undefined) model.enabled = !config.disabled
|
||||
if (config.limit !== undefined) model.limit = { ...model.limit, ...config.limit }
|
||||
})
|
||||
yield* ctx.catalog.transform((catalog) => {
|
||||
const files = loaded.entries.filter((entry): entry is Config.Document => entry.type === "document")
|
||||
const configuredDefault = Config.latest(loaded.entries, "model")
|
||||
if (configuredDefault !== undefined) {
|
||||
const model = ModelV2.parse(configuredDefault)
|
||||
catalog.model.default.set(model.providerID, model.modelID)
|
||||
}
|
||||
for (const file of files) {
|
||||
for (const [id, item] of Object.entries(file.info.providers ?? {})) {
|
||||
const providerID = id
|
||||
catalog.provider.update(providerID, (provider) => {
|
||||
if (item.name !== undefined) provider.name = item.name
|
||||
if (item.api !== undefined) provider.api = { ...item.api }
|
||||
if (item.request !== undefined) {
|
||||
Object.assign(provider.request.settings, item.request.settings)
|
||||
Object.assign(provider.request.headers, item.request.headers)
|
||||
Object.assign(provider.request.body, item.request.body)
|
||||
}
|
||||
})
|
||||
for (const [id, config] of Object.entries(item.models ?? {})) {
|
||||
catalog.model.update(providerID, id, (model) => {
|
||||
if (config.family !== undefined) model.family = config.family
|
||||
if (config.name !== undefined) model.name = config.name
|
||||
if (config.api !== undefined) model.api = { ...model.api, ...config.api }
|
||||
if (config.capabilities !== undefined) {
|
||||
model.capabilities = {
|
||||
tools: config.capabilities.tools,
|
||||
input: [...config.capabilities.input],
|
||||
output: [...config.capabilities.output],
|
||||
}
|
||||
}
|
||||
if (config.request !== undefined) {
|
||||
Object.assign(model.request.settings, config.request.settings)
|
||||
Object.assign(model.request.headers, config.request.headers)
|
||||
Object.assign(model.request.body, config.request.body)
|
||||
if (config.request.variant !== undefined) model.request.variant = config.request.variant
|
||||
}
|
||||
if (config.variants !== undefined) {
|
||||
for (const variant of config.variants) {
|
||||
let existing = model.variants.find((item) => item.id === variant.id)
|
||||
if (!existing) {
|
||||
existing = {
|
||||
id: variant.id,
|
||||
settings: {},
|
||||
headers: {},
|
||||
body: {},
|
||||
}
|
||||
model.variants.push(existing)
|
||||
}
|
||||
Object.assign(existing.settings, variant.settings)
|
||||
Object.assign(existing.headers, variant.headers)
|
||||
Object.assign(existing.body, variant.body)
|
||||
}
|
||||
}
|
||||
if (config.cost !== undefined) {
|
||||
model.cost = (Array.isArray(config.cost) ? config.cost : [config.cost]).map((cost) => ({
|
||||
tier: cost.tier && { ...cost.tier },
|
||||
input: cost.input,
|
||||
output: cost.output,
|
||||
cache: {
|
||||
read: cost.cache?.read ?? 0,
|
||||
write: cost.cache?.write ?? 0,
|
||||
},
|
||||
}))
|
||||
}
|
||||
if (config.disabled !== undefined) model.enabled = !config.disabled
|
||||
if (config.limit !== undefined) model.limit = { ...model.limit, ...config.limit }
|
||||
})
|
||||
}
|
||||
}
|
||||
}),
|
||||
}
|
||||
})
|
||||
yield* ctx.event.subscribe().pipe(
|
||||
Stream.filter((event) => event.type === "config.updated"),
|
||||
Stream.runForEach(() =>
|
||||
config.entries().pipe(
|
||||
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
|
||||
Effect.andThen(ctx.integration.reload()),
|
||||
Effect.andThen(ctx.catalog.reload()),
|
||||
),
|
||||
),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@ export * as ConfigReferencePlugin from "./reference"
|
||||
|
||||
import { define } from "../../plugin/internal"
|
||||
import path from "path"
|
||||
import { Effect } from "effect"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { Config } from "../../config"
|
||||
import { ConfigReference } from "../reference"
|
||||
import { Reference } from "../../reference"
|
||||
@@ -16,40 +16,47 @@ export const Plugin = define({
|
||||
const config = yield* Config.Service
|
||||
const location = yield* Location.Service
|
||||
const global = yield* Global.Service
|
||||
yield* ctx.reference.transform(
|
||||
Effect.fn(function* (draft) {
|
||||
const entries = new Map<string, Reference.Source>()
|
||||
for (const doc of (yield* config.entries()).filter(
|
||||
(entry): entry is Config.Document => entry.type === "document",
|
||||
)) {
|
||||
const directory = doc.path ? path.dirname(doc.path) : location.directory
|
||||
for (const [name, entry] of Object.entries(doc.info.references ?? {})) {
|
||||
if (!validAlias(name)) continue
|
||||
const description = typeof entry === "string" ? undefined : entry.description
|
||||
const hidden = typeof entry === "string" ? undefined : entry.hidden
|
||||
entries.set(
|
||||
name,
|
||||
local(entry)
|
||||
? Reference.LocalSource.make({
|
||||
type: "local",
|
||||
path: AbsolutePath.make(
|
||||
localPath(directory, global.home, typeof entry === "string" ? entry : entry.path),
|
||||
),
|
||||
...(description === undefined ? {} : { description }),
|
||||
...(hidden === undefined ? {} : { hidden }),
|
||||
})
|
||||
: Reference.GitSource.make({
|
||||
type: "git",
|
||||
repository: typeof entry === "string" ? entry : entry.repository,
|
||||
...(entry.branch === undefined ? {} : { branch: entry.branch }),
|
||||
...(description === undefined ? {} : { description }),
|
||||
...(hidden === undefined ? {} : { hidden }),
|
||||
}),
|
||||
)
|
||||
}
|
||||
const loaded = { entries: yield* config.entries() }
|
||||
yield* ctx.reference.transform((draft) => {
|
||||
const entries = new Map<string, Reference.Source>()
|
||||
for (const doc of loaded.entries.filter((entry): entry is Config.Document => entry.type === "document")) {
|
||||
const directory = doc.path ? path.dirname(doc.path) : location.directory
|
||||
for (const [name, entry] of Object.entries(doc.info.references ?? {})) {
|
||||
if (!validAlias(name)) continue
|
||||
const description = typeof entry === "string" ? undefined : entry.description
|
||||
const hidden = typeof entry === "string" ? undefined : entry.hidden
|
||||
entries.set(
|
||||
name,
|
||||
local(entry)
|
||||
? Reference.LocalSource.make({
|
||||
type: "local",
|
||||
path: AbsolutePath.make(
|
||||
localPath(directory, global.home, typeof entry === "string" ? entry : entry.path),
|
||||
),
|
||||
...(description === undefined ? {} : { description }),
|
||||
...(hidden === undefined ? {} : { hidden }),
|
||||
})
|
||||
: Reference.GitSource.make({
|
||||
type: "git",
|
||||
repository: typeof entry === "string" ? entry : entry.repository,
|
||||
...(entry.branch === undefined ? {} : { branch: entry.branch }),
|
||||
...(description === undefined ? {} : { description }),
|
||||
...(hidden === undefined ? {} : { hidden }),
|
||||
}),
|
||||
)
|
||||
}
|
||||
for (const [name, source] of entries) draft.add(name, source)
|
||||
}),
|
||||
}
|
||||
for (const [name, source] of entries) draft.add(name, source)
|
||||
})
|
||||
yield* ctx.event.subscribe().pipe(
|
||||
Stream.filter((event) => event.type === "config.updated"),
|
||||
Stream.runForEach(() =>
|
||||
config.entries().pipe(
|
||||
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
|
||||
Effect.andThen(ctx.reference.reload()),
|
||||
),
|
||||
),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@ export * as ConfigSkillPlugin from "./skill"
|
||||
|
||||
import { define } from "../../plugin/internal"
|
||||
import path from "path"
|
||||
import { Effect } from "effect"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { Config } from "../../config"
|
||||
import { AbsolutePath } from "../../schema"
|
||||
import { SkillV2 } from "../../skill"
|
||||
@@ -15,36 +15,44 @@ export const Plugin = define({
|
||||
const config = yield* Config.Service
|
||||
const global = yield* Global.Service
|
||||
const location = yield* Location.Service
|
||||
yield* ctx.skill.transform(
|
||||
Effect.fn(function* (draft) {
|
||||
const entries = yield* config.entries()
|
||||
const directories = entries.flatMap((entry) => (entry.type === "directory" ? [entry.path] : []))
|
||||
const items = entries.flatMap((entry) => (entry.type === "document" ? (entry.info.skills ?? []) : []))
|
||||
for (const directory of directories) {
|
||||
draft.source(
|
||||
SkillV2.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join(directory, "skill")) }),
|
||||
)
|
||||
draft.source(
|
||||
SkillV2.DirectorySource.make({
|
||||
type: "directory",
|
||||
path: AbsolutePath.make(path.join(directory, "skills")),
|
||||
}),
|
||||
)
|
||||
const loaded = { entries: yield* config.entries() }
|
||||
yield* ctx.skill.transform((draft) => {
|
||||
const directories = loaded.entries.flatMap((entry) => (entry.type === "directory" ? [entry.path] : []))
|
||||
const items = loaded.entries.flatMap((entry) => (entry.type === "document" ? (entry.info.skills ?? []) : []))
|
||||
for (const directory of directories) {
|
||||
draft.source(
|
||||
SkillV2.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join(directory, "skill")) }),
|
||||
)
|
||||
draft.source(
|
||||
SkillV2.DirectorySource.make({
|
||||
type: "directory",
|
||||
path: AbsolutePath.make(path.join(directory, "skills")),
|
||||
}),
|
||||
)
|
||||
}
|
||||
for (const item of items) {
|
||||
if (URL.canParse(item) && /^(https?:)$/.test(new URL(item).protocol)) {
|
||||
draft.source(SkillV2.UrlSource.make({ type: "url", url: item }))
|
||||
continue
|
||||
}
|
||||
for (const item of items) {
|
||||
if (URL.canParse(item) && /^(https?:)$/.test(new URL(item).protocol)) {
|
||||
draft.source(SkillV2.UrlSource.make({ type: "url", url: item }))
|
||||
continue
|
||||
}
|
||||
const expanded = item.startsWith("~/") ? path.join(global.home, item.slice(2)) : item
|
||||
draft.source(
|
||||
SkillV2.DirectorySource.make({
|
||||
type: "directory",
|
||||
path: AbsolutePath.make(path.isAbsolute(expanded) ? expanded : path.join(location.directory, expanded)),
|
||||
}),
|
||||
)
|
||||
}
|
||||
}),
|
||||
const expanded = item.startsWith("~/") ? path.join(global.home, item.slice(2)) : item
|
||||
draft.source(
|
||||
SkillV2.DirectorySource.make({
|
||||
type: "directory",
|
||||
path: AbsolutePath.make(path.isAbsolute(expanded) ? expanded : path.join(location.directory, expanded)),
|
||||
}),
|
||||
)
|
||||
}
|
||||
})
|
||||
yield* ctx.event.subscribe().pipe(
|
||||
Stream.filter((event) => event.type === "config.updated"),
|
||||
Stream.runForEach(() =>
|
||||
config.entries().pipe(
|
||||
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
|
||||
Effect.andThen(ctx.skill.reload()),
|
||||
),
|
||||
),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
export * as ConfigVariable from "./variable"
|
||||
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { Effect } from "effect"
|
||||
import { FSUtil } from "../fs-util"
|
||||
import { InvalidError } from "../v1/config/error"
|
||||
|
||||
type ParseSource =
|
||||
| {
|
||||
type: "path"
|
||||
path: string
|
||||
}
|
||||
| {
|
||||
type: "virtual"
|
||||
source: string
|
||||
dir: string
|
||||
}
|
||||
|
||||
type SubstituteInput = ParseSource & {
|
||||
text: string
|
||||
missing?: "error" | "empty"
|
||||
env?: Record<string, string>
|
||||
}
|
||||
|
||||
/** Apply {env:VAR} and {file:path} substitutions to config text. */
|
||||
export const substitute = Effect.fn("ConfigVariable.substitute")(function* (input: SubstituteInput) {
|
||||
const text = input.text.replace(
|
||||
/\{env:([^}]+)\}/g,
|
||||
(_, varName: string) => (input.env?.[varName] ?? process.env[varName]) || "",
|
||||
)
|
||||
if (!text.includes("{file:")) return text
|
||||
return yield* substituteFiles(input, text)
|
||||
})
|
||||
|
||||
const substituteFiles = Effect.fnUntraced(function* (input: SubstituteInput, text: string) {
|
||||
const fs = yield* FSUtil.Service
|
||||
const configDir = input.type === "path" ? path.dirname(input.path) : input.dir
|
||||
const configSource = input.type === "path" ? input.path : input.source
|
||||
const matches = Array.from(text.matchAll(/\{file:[^}]+\}/g))
|
||||
let out = ""
|
||||
let cursor = 0
|
||||
|
||||
for (const match of matches) {
|
||||
const token = match[0]
|
||||
const index = match.index
|
||||
out += text.slice(cursor, index)
|
||||
|
||||
const lineStart = text.lastIndexOf("\n", index - 1) + 1
|
||||
const prefix = text.slice(lineStart, index).trimStart()
|
||||
if (prefix.startsWith("//")) {
|
||||
out += token
|
||||
cursor = index + token.length
|
||||
continue
|
||||
}
|
||||
|
||||
const filePath = token.replace(/^\{file:/, "").replace(/\}$/, "")
|
||||
const expandedPath = filePath.startsWith("~/") ? path.join(os.homedir(), filePath.slice(2)) : filePath
|
||||
const resolvedPath = path.isAbsolute(expandedPath) ? expandedPath : path.resolve(configDir, expandedPath)
|
||||
const fileContent = yield* fs.readFileString(resolvedPath).pipe(
|
||||
Effect.catch((error) => {
|
||||
if (input.missing === "empty") return Effect.succeed("")
|
||||
|
||||
const message = `bad file reference: "${token}"`
|
||||
return Effect.fail(
|
||||
new InvalidError(
|
||||
{
|
||||
path: configSource,
|
||||
message:
|
||||
error._tag === "PlatformError" && error.reason._tag === "NotFound"
|
||||
? `${message} ${resolvedPath} does not exist`
|
||||
: message,
|
||||
},
|
||||
{ cause: error },
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
out += JSON.stringify(fileContent.trim()).slice(1, -1)
|
||||
cursor = index + token.length
|
||||
}
|
||||
|
||||
return out + text.slice(cursor)
|
||||
})
|
||||
+1
@@ -43,5 +43,6 @@ export const migrations = (
|
||||
import("./migration/20260702134641_add_session_context_entry"),
|
||||
import("./migration/20260703090000_reset_v2_event_rename_sweep"),
|
||||
import("./migration/20260703181610_event_created_column"),
|
||||
import("./migration/20260703190000_reset_v2_shell_event_payloads"),
|
||||
])
|
||||
).map((module) => module.default) satisfies DatabaseMigration.Migration[]
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
export default {
|
||||
id: "20260703190000_reset_v2_shell_event_payloads",
|
||||
up(tx) {
|
||||
return Effect.gen(function* () {
|
||||
yield* tx.run(`DELETE FROM \`session_input\`;`)
|
||||
yield* tx.run(`DELETE FROM \`session_message\`;`)
|
||||
yield* tx.run(`DELETE FROM \`event\`;`)
|
||||
yield* tx.run(`DELETE FROM \`event_sequence\`;`)
|
||||
})
|
||||
},
|
||||
} satisfies DatabaseMigration.Migration
|
||||
@@ -45,7 +45,7 @@ const FILES = [
|
||||
"**/.nyc_output/**",
|
||||
]
|
||||
|
||||
export const PATTERNS = [...FILES, ...FOLDERS]
|
||||
export const PATTERNS = [...FILES, ...FOLDERS, `**/{${Array.from(FOLDERS).join(",")}}/**`]
|
||||
|
||||
export function match(filepath: string, opts?: { extra?: string[]; whitelist?: string[] }) {
|
||||
for (const pattern of opts?.whitelist || []) {
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
export * as LocationWatcher from "./location-watcher"
|
||||
|
||||
import { makeLocationNode } from "../effect/app-node"
|
||||
import { Context, Effect, Layer, Stream } from "effect"
|
||||
import { FileSystem } from "@opencode-ai/schema/filesystem"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { Config } from "../config"
|
||||
import { EventV2 } from "../event"
|
||||
import { FSUtil } from "../fs-util"
|
||||
import { Git } from "../git"
|
||||
import { Location } from "../location"
|
||||
import { Watcher } from "./watcher"
|
||||
import { Ignore } from "./ignore"
|
||||
import { Protected } from "./protected"
|
||||
|
||||
function protecteds(dir: string) {
|
||||
return Protected.paths().filter((item) => {
|
||||
const relative = path.relative(dir, item)
|
||||
return relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative)
|
||||
})
|
||||
}
|
||||
|
||||
export interface Interface {}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/LocationWatcher") {}
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const location = yield* Location.Service
|
||||
const watcher = yield* Watcher.Service
|
||||
const events = yield* EventV2.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const git = yield* Git.Service
|
||||
const configService = yield* Config.Service
|
||||
const config = (yield* configService.entries())
|
||||
.filter((entry): entry is Config.Document => entry.type === "document")
|
||||
.flatMap((item) => item.info.watcher?.ignore ?? [])
|
||||
const publish = (update: { type: "create" | "update" | "delete"; path: string }) =>
|
||||
events.publish(FileSystem.Event.Changed, {
|
||||
file: update.path,
|
||||
event: update.type === "create" ? "add" : update.type === "update" ? "change" : "unlink",
|
||||
})
|
||||
|
||||
if (path.resolve(location.directory) !== path.resolve(os.homedir())) {
|
||||
yield* watcher
|
||||
.subscribe({
|
||||
path: location.directory,
|
||||
type: "directory",
|
||||
ignore: [...Ignore.PATTERNS, ...config, ...protecteds(location.directory)],
|
||||
})
|
||||
.pipe(Stream.runForEach(publish), Effect.forkScoped({ startImmediately: true }))
|
||||
} else {
|
||||
yield* Effect.logInfo("location watcher skipped home directory", { directory: location.directory })
|
||||
}
|
||||
|
||||
if (location.vcs?.type === "git") {
|
||||
const resolved = (yield* git.repo.discover(location.directory))?.gitDirectory
|
||||
const vcs = resolved ? yield* fs.realPath(resolved).pipe(Effect.catch(() => Effect.succeed(resolved))) : undefined
|
||||
if (vcs && !config.includes(".git") && !config.includes(vcs) && (!resolved || !config.includes(resolved))) {
|
||||
const ignore = (yield* fs.readDirectoryEntries(vcs).pipe(Effect.catch(() => Effect.succeed([])))).flatMap(
|
||||
(entry) => (entry.name === "HEAD" ? [] : [entry.name]),
|
||||
)
|
||||
yield* watcher
|
||||
.subscribe({ path: vcs, type: "directory", ignore })
|
||||
.pipe(Stream.runForEach(publish), Effect.forkScoped({ startImmediately: true }))
|
||||
}
|
||||
}
|
||||
|
||||
return Service.of({})
|
||||
}).pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.logError("failed to init location watcher service", { cause }).pipe(Effect.as(Service.of({}))),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [Watcher.node, FSUtil.node, Location.node, Config.node, Git.node, EventV2.node],
|
||||
})
|
||||
@@ -3,26 +3,20 @@ export * as Watcher from "./watcher"
|
||||
// @ts-ignore
|
||||
import { createWrapper } from "@parcel/watcher/wrapper"
|
||||
import type ParcelWatcher from "@parcel/watcher"
|
||||
import { makeLocationNode } from "../effect/app-node"
|
||||
import { Cause, Context, Effect, Layer } from "effect"
|
||||
import { FileSystemWatcher } from "@opencode-ai/schema/filesystem-watcher"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { Config } from "../config"
|
||||
import { EventV2 } from "../event"
|
||||
import { FileSystem } from "@opencode-ai/schema/filesystem"
|
||||
import { makeGlobalNode } from "../effect/app-node"
|
||||
import { Cause, Context, Effect, Layer, PubSub, Scope, Stream } from "effect"
|
||||
import { KeyedMutex } from "../effect/keyed-mutex"
|
||||
import { Flag } from "../flag/flag"
|
||||
import { FSUtil } from "../fs-util"
|
||||
import { Git } from "../git"
|
||||
import { Location } from "../location"
|
||||
import { lazy } from "../util/lazy"
|
||||
import { Ignore } from "./ignore"
|
||||
import { Protected } from "./protected"
|
||||
import { watch as watchFileSystem } from "node:fs"
|
||||
import path from "path"
|
||||
|
||||
declare const OPENCODE_LIBC: string | undefined
|
||||
|
||||
const SUBSCRIBE_TIMEOUT_MS = 10_000
|
||||
|
||||
export const Event = FileSystemWatcher.Event
|
||||
export const Event = { Updated: FileSystem.Event.Changed }
|
||||
|
||||
const watcher = lazy((): typeof import("@parcel/watcher") | undefined => {
|
||||
try {
|
||||
@@ -42,108 +36,134 @@ function getBackend() {
|
||||
if (process.platform === "linux") return "inotify"
|
||||
}
|
||||
|
||||
function protecteds(dir: string) {
|
||||
return Protected.paths().filter((item) => {
|
||||
const relative = path.relative(dir, item)
|
||||
return relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative)
|
||||
})
|
||||
export const hasNativeBinding = () => !!watcher()
|
||||
export type Update = ParcelWatcher.Event
|
||||
|
||||
export type WatchInput =
|
||||
| { readonly path: string; readonly type: "file" }
|
||||
| { readonly path: string; readonly type: "directory"; readonly ignore?: readonly string[] }
|
||||
|
||||
export interface Interface {
|
||||
readonly subscribe: (input: WatchInput) => Stream.Stream<Update>
|
||||
}
|
||||
|
||||
export const hasNativeBinding = () => !!watcher()
|
||||
|
||||
export interface Interface {}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/FileWatcher") {}
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Watcher") {}
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
if (Flag.OPENCODE_DISABLE_FILEWATCHER) return Service.of({})
|
||||
|
||||
const backend = getBackend()
|
||||
const location = yield* Location.Service
|
||||
if (path.resolve(location.directory) === path.resolve(os.homedir())) {
|
||||
yield* Effect.logInfo("watcher skipped home directory", { directory: location.directory })
|
||||
return Service.of({})
|
||||
}
|
||||
if (!backend) {
|
||||
yield* Effect.logError("watcher backend not supported", {
|
||||
directory: location.directory,
|
||||
platform: process.platform,
|
||||
})
|
||||
return Service.of({})
|
||||
const native = watcher()
|
||||
if (Flag.OPENCODE_DISABLE_FILEWATCHER) {
|
||||
return Service.of({ subscribe: () => Stream.empty })
|
||||
}
|
||||
|
||||
const w = watcher()
|
||||
if (!w) return Service.of({})
|
||||
|
||||
yield* Effect.logInfo("watcher backend", { directory: location.directory, platform: process.platform, backend })
|
||||
const events = yield* EventV2.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const git = yield* Git.Service
|
||||
const context = yield* Effect.context()
|
||||
const runFork = Effect.runForkWith(context)
|
||||
const subscriptions: ParcelWatcher.AsyncSubscription[] = []
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.promise(() => Promise.allSettled(subscriptions.map((subscription) => subscription.unsubscribe()))),
|
||||
)
|
||||
|
||||
const callback: ParcelWatcher.SubscribeCallback = (_error, updates) => {
|
||||
if (_error) runFork(Effect.logError("watcher callback failed", { error: _error }))
|
||||
for (const update of updates) {
|
||||
if (update.type === "create") runFork(events.publish(Event.Updated, { file: update.path, event: "add" }))
|
||||
if (update.type === "update") runFork(events.publish(Event.Updated, { file: update.path, event: "change" }))
|
||||
if (update.type === "delete") runFork(events.publish(Event.Updated, { file: update.path, event: "unlink" }))
|
||||
}
|
||||
type Entry = {
|
||||
readonly pubsub: PubSub.PubSub<Update>
|
||||
readonly subscription: { readonly unsubscribe: () => Promise<void> }
|
||||
refs: number
|
||||
}
|
||||
const entries = new Map<string, Entry>()
|
||||
const locks = KeyedMutex.makeUnsafe<string>()
|
||||
|
||||
const subscribe = (directory: string, ignore: string[]) => {
|
||||
const pending = w.subscribe(directory, callback, { ignore, backend })
|
||||
return Effect.promise(() => pending).pipe(
|
||||
Effect.tap((subscription) =>
|
||||
Effect.sync(() => subscriptions.push(subscription)).pipe(
|
||||
Effect.andThen(Effect.logInfo("watcher subscribed", { directory, backend, ignores: ignore.length })),
|
||||
),
|
||||
),
|
||||
Effect.timeout(SUBSCRIBE_TIMEOUT_MS),
|
||||
Effect.catchCause((cause) => {
|
||||
pending.then((subscription) => subscription.unsubscribe()).catch(() => {})
|
||||
return Effect.logError("failed to subscribe", { directory, cause: Cause.pretty(cause) })
|
||||
const acquire = Effect.fn("Watcher.acquire")(function* (input: WatchInput) {
|
||||
const scope = yield* Scope.Scope
|
||||
const target = path.resolve(input.path)
|
||||
const directory = input.type === "file" ? path.dirname(target) : target
|
||||
const ignore = [...new Set(input.type === "directory" ? (input.ignore ?? []) : [])].toSorted()
|
||||
const id = JSON.stringify([input.type, target, ignore])
|
||||
const pubsub = yield* locks.withLock(id)(
|
||||
Effect.gen(function* () {
|
||||
const existing = entries.get(id)
|
||||
if (existing) {
|
||||
existing.refs++
|
||||
return existing.pubsub
|
||||
}
|
||||
const pubsub = yield* PubSub.unbounded<Update>()
|
||||
const subscription = yield* input.type === "file"
|
||||
? Effect.sync(() => {
|
||||
const subscription = watchFileSystem(directory, { recursive: false }, (_event, file) => {
|
||||
if (file && path.resolve(directory, file.toString()) !== target) return
|
||||
PubSub.publishUnsafe(pubsub, {
|
||||
path: target,
|
||||
type: "update",
|
||||
} satisfies Update)
|
||||
})
|
||||
if ("on" in subscription && typeof subscription.on === "function") {
|
||||
subscription.on("error", (error: unknown) =>
|
||||
Effect.runFork(Effect.logError("watcher callback failed", { path: target, error })),
|
||||
)
|
||||
}
|
||||
return { unsubscribe: () => Promise.resolve(subscription.close()) }
|
||||
})
|
||||
: subscribeDirectory(native, backend, directory, ignore, pubsub)
|
||||
if (subscription) {
|
||||
entries.set(id, { pubsub, subscription, refs: 1 })
|
||||
yield* Effect.logInfo("watcher started", {
|
||||
path: target,
|
||||
type: input.type,
|
||||
backend: input.type === "file" ? "node" : backend,
|
||||
ignores: ignore.length,
|
||||
})
|
||||
return pubsub
|
||||
}
|
||||
yield* PubSub.shutdown(pubsub)
|
||||
return pubsub
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const configService = yield* Config.Service
|
||||
const config = (yield* configService.entries())
|
||||
.filter((entry): entry is Config.Document => entry.type === "document")
|
||||
.flatMap((item) => item.info.watcher?.ignore ?? [])
|
||||
yield* Effect.forkScoped(
|
||||
subscribe(location.directory, [...Ignore.PATTERNS, ...config, ...protecteds(location.directory)]),
|
||||
)
|
||||
|
||||
if (location.vcs?.type === "git") {
|
||||
const resolved = (yield* git.repo.discover(location.directory))?.gitDirectory
|
||||
const vcs = resolved ? yield* fs.realPath(resolved).pipe(Effect.catch(() => Effect.succeed(resolved))) : undefined
|
||||
if (vcs && !config.includes(".git") && !config.includes(vcs) && (!resolved || !config.includes(resolved))) {
|
||||
const ignore = (yield* fs.readDirectoryEntries(vcs).pipe(Effect.catch(() => Effect.succeed([])))).flatMap(
|
||||
(entry) => (entry.name === "HEAD" ? [] : [entry.name]),
|
||||
)
|
||||
yield* Effect.forkScoped(subscribe(vcs, ignore))
|
||||
}
|
||||
}
|
||||
|
||||
return Service.of({})
|
||||
}).pipe(
|
||||
Effect.catchCause((cause) => {
|
||||
return Effect.logError("failed to init watcher service", { cause: Cause.pretty(cause) }).pipe(
|
||||
Effect.as(Service.of({})),
|
||||
yield* Scope.addFinalizer(
|
||||
scope,
|
||||
locks.withLock(id)(
|
||||
Effect.gen(function* () {
|
||||
const entry = entries.get(id)
|
||||
if (!entry) return
|
||||
entry.refs--
|
||||
if (entry.refs > 0) return
|
||||
entries.delete(id)
|
||||
yield* Effect.promise(() => entry.subscription.unsubscribe()).pipe(Effect.ignore)
|
||||
yield* PubSub.shutdown(entry.pubsub)
|
||||
yield* Effect.logInfo("watcher stopped", { path: target, type: input.type })
|
||||
}),
|
||||
),
|
||||
)
|
||||
}),
|
||||
),
|
||||
return pubsub
|
||||
})
|
||||
|
||||
const subscribe = (input: WatchInput) =>
|
||||
Stream.unwrap(acquire(input).pipe(Effect.map((pubsub) => Stream.fromPubSub(pubsub))))
|
||||
|
||||
return Service.of({ subscribe })
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [FSUtil.node, Location.node, Config.node, Git.node, EventV2.node],
|
||||
})
|
||||
export const node = makeGlobalNode({ service: Service, layer, deps: [] })
|
||||
|
||||
function subscribeDirectory(
|
||||
native: typeof import("@parcel/watcher") | undefined,
|
||||
backend: ParcelWatcher.BackendType | undefined,
|
||||
directory: string,
|
||||
ignore: string[],
|
||||
pubsub: PubSub.PubSub<Update>,
|
||||
) {
|
||||
if (!native || !backend) {
|
||||
return Effect.logError("watcher backend not supported", { directory, platform: process.platform }).pipe(
|
||||
Effect.as(undefined),
|
||||
)
|
||||
}
|
||||
const callback: ParcelWatcher.SubscribeCallback = (error, updates) => {
|
||||
if (error) Effect.runFork(Effect.logError("watcher callback failed", { error }))
|
||||
for (const update of updates) PubSub.publishUnsafe(pubsub, update)
|
||||
}
|
||||
const pending = native.subscribe(directory, callback, { ignore, backend })
|
||||
return Effect.promise(() => pending).pipe(
|
||||
Effect.timeout(SUBSCRIBE_TIMEOUT_MS),
|
||||
Effect.catchCause((cause) => {
|
||||
pending.then((subscription) => subscription.unsubscribe()).catch(() => {})
|
||||
return Effect.logError("failed to subscribe", {
|
||||
directory,
|
||||
cause: Cause.pretty(cause),
|
||||
}).pipe(Effect.as(undefined))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import { FileSystem } from "./filesystem"
|
||||
import { FileSystemSearch } from "./filesystem/search"
|
||||
import { Generate } from "./generate"
|
||||
import { Form } from "./form"
|
||||
import { Watcher } from "./filesystem/watcher"
|
||||
import { LocationWatcher } from "./filesystem/location-watcher"
|
||||
import { Image } from "./image"
|
||||
import { Integration } from "./integration"
|
||||
import { Location } from "./location"
|
||||
@@ -21,7 +21,6 @@ import { MCP } from "./mcp/index"
|
||||
import { PermissionV2 } from "./permission"
|
||||
import { PluginV2 } from "./plugin"
|
||||
import { PluginInternal } from "./plugin/internal"
|
||||
import { Policy } from "./policy"
|
||||
import { ProjectCopy } from "./project/copy"
|
||||
import { Pty } from "./pty"
|
||||
import { QuestionV2 } from "./question"
|
||||
@@ -50,7 +49,6 @@ export { LocationServiceMap } from "./location-service-map"
|
||||
|
||||
const locationServiceNodes = [
|
||||
Location.node,
|
||||
Policy.node,
|
||||
Config.node,
|
||||
AgentV2.node,
|
||||
CommandV2.node,
|
||||
@@ -64,7 +62,7 @@ const locationServiceNodes = [
|
||||
ProjectCopy.refreshNode,
|
||||
FileSystemSearch.node,
|
||||
FileSystem.node,
|
||||
Watcher.node,
|
||||
LocationWatcher.node,
|
||||
Pty.node,
|
||||
Shell.node,
|
||||
SkillV2.node,
|
||||
|
||||
@@ -205,7 +205,7 @@ export const layer = Layer.effect(
|
||||
for (const [name, server] of Object.entries(entry.info.mcp?.servers ?? {})) {
|
||||
runtime.set(ServerName.make(name), {
|
||||
config: { ...server, timeout: { ...timeout, ...server.timeout } },
|
||||
status: { status: "disconnected" },
|
||||
status: { status: "pending" },
|
||||
startup: Deferred.makeUnsafe<void>(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
export * as PluginHost from "./host"
|
||||
|
||||
import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { EventManifest } from "@opencode-ai/schema/event-manifest"
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
import { AgentV2 } from "../agent"
|
||||
import { AISDK } from "../aisdk"
|
||||
import { Catalog } from "../catalog"
|
||||
import { CommandV2 } from "../command"
|
||||
import { Credential } from "../credential"
|
||||
import { EventV2 } from "../event"
|
||||
import { Integration } from "../integration"
|
||||
import { Location } from "../location"
|
||||
import { ModelV2 } from "../model"
|
||||
@@ -21,12 +23,14 @@ import { ToolHooks } from "../tool/hooks"
|
||||
import { WorkspaceV2 } from "../workspace"
|
||||
|
||||
const mutable = <T>(value: T) => value as DeepMutable<T>
|
||||
const isEvent = Schema.is(Schema.Union(EventManifest.ServerDefinitions))
|
||||
|
||||
export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Interface) {
|
||||
const agents = yield* AgentV2.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
const commands = yield* CommandV2.Service
|
||||
const events = yield* EventV2.Service
|
||||
const integration = yield* Integration.Service
|
||||
const location = yield* Location.Service
|
||||
const reference = yield* Reference.Service
|
||||
@@ -52,6 +56,8 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
||||
})
|
||||
const isCurrentLocation = (ref: Location.Ref) =>
|
||||
ref.directory === location.directory && ref.workspaceID === location.workspaceID
|
||||
const response = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
||||
effect.pipe(Effect.map((data) => ({ location: locationInfo(), data })))
|
||||
|
||||
return {
|
||||
options: {},
|
||||
@@ -63,15 +69,15 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
||||
},
|
||||
reload: agents.reload,
|
||||
transform: (callback) =>
|
||||
agents.transform((draft) =>
|
||||
agents.transform((draft) => {
|
||||
callback({
|
||||
list: () => mutable(draft.list()),
|
||||
get: (id) => mutable(draft.get(AgentV2.ID.make(id))),
|
||||
default: (id) => draft.default(id === undefined ? undefined : AgentV2.ID.make(id)),
|
||||
update: (id, update) => draft.update(AgentV2.ID.make(id), update),
|
||||
remove: (id) => draft.remove(AgentV2.ID.make(id)),
|
||||
}),
|
||||
),
|
||||
})
|
||||
}),
|
||||
},
|
||||
aisdk: {
|
||||
sdk: (callback) =>
|
||||
@@ -102,9 +108,26 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
||||
}),
|
||||
},
|
||||
catalog: {
|
||||
provider: {
|
||||
list: () => response(catalog.provider.available()),
|
||||
get: (input) =>
|
||||
catalog.provider
|
||||
.get(ProviderV2.ID.make(input.providerID))
|
||||
.pipe(
|
||||
Effect.flatMap((provider) =>
|
||||
provider === undefined
|
||||
? Effect.fail(new Error(`Provider not found: ${input.providerID}`))
|
||||
: response(Effect.succeed(provider)),
|
||||
),
|
||||
),
|
||||
},
|
||||
model: {
|
||||
list: () => response(catalog.model.available()),
|
||||
default: () => response(catalog.model.default()),
|
||||
},
|
||||
reload: catalog.reload,
|
||||
transform: (callback) =>
|
||||
catalog.transform((draft) =>
|
||||
catalog.transform((draft) => {
|
||||
callback({
|
||||
provider: {
|
||||
list: () => mutable(draft.provider.list()),
|
||||
@@ -125,14 +148,42 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
||||
draft.model.default.set(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID)),
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
})
|
||||
}),
|
||||
},
|
||||
command: {
|
||||
list: () => response(commands.list()),
|
||||
reload: commands.reload,
|
||||
transform: commands.transform,
|
||||
transform: (callback) =>
|
||||
commands.transform((draft) => {
|
||||
callback(draft)
|
||||
}),
|
||||
},
|
||||
event: {
|
||||
subscribe: () => events.live().pipe(Stream.filter(isEvent)),
|
||||
},
|
||||
integration: {
|
||||
list: () => response(integration.list()),
|
||||
get: (input) => response(integration.get(Integration.ID.make(input.integrationID))),
|
||||
connectKey: (input) =>
|
||||
integration.connection.key({
|
||||
integrationID: Integration.ID.make(input.integrationID),
|
||||
key: input.key,
|
||||
label: input.label,
|
||||
}),
|
||||
connectOauth: (input) =>
|
||||
response(
|
||||
integration.connection.oauth({
|
||||
integrationID: Integration.ID.make(input.integrationID),
|
||||
methodID: Integration.MethodID.make(input.methodID),
|
||||
inputs: input.inputs,
|
||||
label: input.label,
|
||||
}),
|
||||
),
|
||||
attemptStatus: (input) => response(integration.attempt.status(Integration.AttemptID.make(input.attemptID))),
|
||||
attemptComplete: (input) =>
|
||||
integration.attempt.complete({ attemptID: Integration.AttemptID.make(input.attemptID), code: input.code }),
|
||||
attemptCancel: (input) => integration.attempt.cancel(Integration.AttemptID.make(input.attemptID)),
|
||||
reload: integration.reload,
|
||||
connection: {
|
||||
active: (id) => integration.connection.active(Integration.ID.make(id)),
|
||||
@@ -142,7 +193,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
||||
),
|
||||
},
|
||||
transform: (callback) =>
|
||||
integration.transform((draft) =>
|
||||
integration.transform((draft) => {
|
||||
callback({
|
||||
list: () => mutable(draft.list()),
|
||||
get: (id) => mutable(draft.get(Integration.ID.make(id))),
|
||||
@@ -219,36 +270,39 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
||||
remove: (id, method) =>
|
||||
draft.method.remove(Integration.ID.make(id), Schema.decodeUnknownSync(Integration.Method)(method)),
|
||||
},
|
||||
}),
|
||||
),
|
||||
})
|
||||
}),
|
||||
},
|
||||
plugin: {
|
||||
list: () => response(plugin.list()),
|
||||
add: (input) => plugin.add(PluginV2.ID.make(input.id), input.effect),
|
||||
remove: (id) => plugin.remove(PluginV2.ID.make(id)),
|
||||
},
|
||||
reference: {
|
||||
list: () => response(reference.list()),
|
||||
reload: reference.reload,
|
||||
transform: (callback) =>
|
||||
reference.transform((draft) =>
|
||||
reference.transform((draft) => {
|
||||
callback({
|
||||
add: (name, source) => draft.add(name, Schema.decodeUnknownSync(Reference.Source)(source)),
|
||||
remove: draft.remove,
|
||||
list: draft.list,
|
||||
}),
|
||||
),
|
||||
})
|
||||
}),
|
||||
},
|
||||
skill: {
|
||||
list: () => response(skill.list()),
|
||||
reload: skill.reload,
|
||||
transform: (callback) =>
|
||||
skill.transform((draft) =>
|
||||
skill.transform((draft) => {
|
||||
callback({
|
||||
source: (source) => draft.source(Schema.decodeUnknownSync(SkillV2.Source)(source)),
|
||||
list: draft.list,
|
||||
}),
|
||||
),
|
||||
})
|
||||
}),
|
||||
},
|
||||
tool: {
|
||||
register: (input) => tools.register(input),
|
||||
register: (input, options) => tools.register(input, options),
|
||||
execute: {
|
||||
before: (callback) =>
|
||||
toolHooks.hook.before((event) => {
|
||||
|
||||
@@ -201,64 +201,65 @@ export const ModelsDevPlugin = define({
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const modelsDev = yield* ModelsDev.Service
|
||||
const events = yield* EventV2.Service
|
||||
yield* ctx.integration.transform(
|
||||
Effect.fn(function* (integrations) {
|
||||
const data = yield* modelsDev.get()
|
||||
for (const item of Object.values(data)) {
|
||||
if (item.env.length === 0) continue
|
||||
const integrationID = item.id
|
||||
integrations.update(integrationID, (integration) => (integration.name = item.name))
|
||||
integrations.method.update({
|
||||
integrationID,
|
||||
method: { type: "key" },
|
||||
})
|
||||
integrations.method.update({
|
||||
integrationID,
|
||||
method: { type: "env", names: [...item.env] },
|
||||
})
|
||||
}
|
||||
}),
|
||||
)
|
||||
yield* ctx.catalog.transform(
|
||||
Effect.fn(function* (catalog) {
|
||||
const data = yield* modelsDev.get()
|
||||
for (const item of Object.values(data)) {
|
||||
const providerID = ProviderV2.ID.make(item.id)
|
||||
catalog.provider.update(providerID, (provider) => {
|
||||
provider.name = item.name
|
||||
provider.api = item.npm
|
||||
? {
|
||||
type: "aisdk",
|
||||
package: item.npm,
|
||||
url: item.api,
|
||||
}
|
||||
: {
|
||||
type: "native",
|
||||
url: item.api,
|
||||
settings: {},
|
||||
}
|
||||
})
|
||||
const loaded = { data: yield* modelsDev.get() }
|
||||
yield* ctx.integration.transform((integrations) => {
|
||||
for (const item of Object.values(loaded.data)) {
|
||||
if (item.env.length === 0) continue
|
||||
const integrationID = item.id
|
||||
integrations.update(integrationID, (integration) => (integration.name = item.name))
|
||||
integrations.method.update({
|
||||
integrationID,
|
||||
method: { type: "key" },
|
||||
})
|
||||
integrations.method.update({
|
||||
integrationID,
|
||||
method: { type: "env", names: [...item.env] },
|
||||
})
|
||||
}
|
||||
})
|
||||
yield* ctx.catalog.transform((catalog) => {
|
||||
for (const item of Object.values(loaded.data)) {
|
||||
const providerID = ProviderV2.ID.make(item.id)
|
||||
catalog.provider.update(providerID, (provider) => {
|
||||
provider.name = item.name
|
||||
provider.api = item.npm
|
||||
? {
|
||||
type: "aisdk",
|
||||
package: item.npm,
|
||||
url: item.api,
|
||||
}
|
||||
: {
|
||||
type: "native",
|
||||
url: item.api,
|
||||
settings: {},
|
||||
}
|
||||
})
|
||||
|
||||
for (const model of Object.values(item.models)) {
|
||||
const baseCost = cost(model.cost)
|
||||
const variants = reasoningVariants(item, model)
|
||||
catalog.model.update(providerID, model.id, (draft) => applyModel(draft, model, { cost: baseCost, variants }))
|
||||
for (const [mode, options] of Object.entries(model.experimental?.modes ?? {})) {
|
||||
catalog.model.update(providerID, `${model.id}-${mode}`, (draft) =>
|
||||
applyModel(draft, model, {
|
||||
name: modeName(model, mode),
|
||||
cost: mergeCost(baseCost, options.cost),
|
||||
request: options.provider,
|
||||
variants,
|
||||
}),
|
||||
)
|
||||
}
|
||||
for (const model of Object.values(item.models)) {
|
||||
const baseCost = cost(model.cost)
|
||||
const variants = reasoningVariants(item, model)
|
||||
catalog.model.update(providerID, model.id, (draft) => applyModel(draft, model, { cost: baseCost, variants }))
|
||||
for (const [mode, options] of Object.entries(model.experimental?.modes ?? {})) {
|
||||
catalog.model.update(providerID, `${model.id}-${mode}`, (draft) =>
|
||||
applyModel(draft, model, {
|
||||
name: modeName(model, mode),
|
||||
cost: mergeCost(baseCost, options.cost),
|
||||
request: options.provider,
|
||||
variants,
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
})
|
||||
yield* events.subscribe(ModelsDev.Event.Refreshed).pipe(
|
||||
Stream.runForEach(() => ctx.integration.reload().pipe(Effect.andThen(ctx.catalog.reload()))),
|
||||
Stream.runForEach(() =>
|
||||
modelsDev.get().pipe(
|
||||
Effect.tap((data) => Effect.sync(() => (loaded.data = data))),
|
||||
Effect.andThen(ctx.integration.reload()),
|
||||
Effect.andThen(ctx.catalog.reload()),
|
||||
),
|
||||
),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
}),
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
export * as PluginPromise from "./promise"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
import type { Plugin, PluginContext, Registration } from "@opencode-ai/plugin/v2/promise"
|
||||
import { Effect, Scope } from "effect"
|
||||
import type { Plugin, PluginContext } from "@opencode-ai/plugin/v2/promise"
|
||||
import { Effect, Scope, Stream } from "effect"
|
||||
|
||||
// The Effect host hands back this registration shape; mirror it structurally so
|
||||
// we do not have to alias the Effect package's `Registration` against the Promise one.
|
||||
type HostRegistration = { readonly dispose: Effect.Effect<void> }
|
||||
type Registration = { readonly dispose: () => Promise<void> }
|
||||
|
||||
/**
|
||||
* Adapts a Promise plugin into an Effect plugin so the existing Effect-only
|
||||
@@ -31,20 +30,23 @@ export function fromPromise(plugin: Plugin) {
|
||||
dispose: () => Effect.runPromiseWith(context)(registration.dispose),
|
||||
}))
|
||||
|
||||
const run = (effect: Effect.Effect<void>) => Effect.runPromiseWith(context)(effect)
|
||||
const run = <A, E>(effect: Effect.Effect<A, E>) => Effect.runPromiseWith(context)(effect)
|
||||
|
||||
const transform =
|
||||
<Draft>(domain: {
|
||||
transform: (
|
||||
callback: (draft: Draft) => Effect.Effect<void> | void,
|
||||
) => Effect.Effect<HostRegistration, never, Scope.Scope>
|
||||
transform: (callback: (draft: Draft) => void) => Effect.Effect<HostRegistration, never, Scope.Scope>
|
||||
}) =>
|
||||
(callback: (draft: Draft) => Promise<void> | void) =>
|
||||
register(domain.transform((draft) => Effect.promise(() => Promise.resolve(callback(draft)))))
|
||||
(callback: (draft: Draft) => void) =>
|
||||
register(
|
||||
domain.transform((draft) => {
|
||||
callback(draft)
|
||||
}),
|
||||
)
|
||||
|
||||
const context2: PluginContext = {
|
||||
options: host.options,
|
||||
agent: {
|
||||
list: (input) => run(host.agent.list(input)),
|
||||
transform: transform(host.agent),
|
||||
reload: () => run(host.agent.reload()),
|
||||
},
|
||||
@@ -55,14 +57,33 @@ export function fromPromise(plugin: Plugin) {
|
||||
register(host.aisdk.language((event) => Effect.promise(() => Promise.resolve(callback(event))))),
|
||||
},
|
||||
catalog: {
|
||||
provider: {
|
||||
list: (input) => run(host.catalog.provider.list(input)),
|
||||
get: (input) => run(host.catalog.provider.get(input)),
|
||||
},
|
||||
model: {
|
||||
list: (input) => run(host.catalog.model.list(input)),
|
||||
default: (input) => run(host.catalog.model.default(input)),
|
||||
},
|
||||
transform: transform(host.catalog),
|
||||
reload: () => run(host.catalog.reload()),
|
||||
},
|
||||
command: {
|
||||
list: (input) => run(host.command.list(input)),
|
||||
transform: transform(host.command),
|
||||
reload: () => run(host.command.reload()),
|
||||
},
|
||||
event: {
|
||||
subscribe: () => Stream.toAsyncIterable(host.event.subscribe()),
|
||||
},
|
||||
integration: {
|
||||
list: (input) => run(host.integration.list(input)),
|
||||
get: (input) => run(host.integration.get(input)),
|
||||
connectKey: (input) => run(host.integration.connectKey(input)),
|
||||
connectOauth: (input) => run(host.integration.connectOauth(input)),
|
||||
attemptStatus: (input) => run(host.integration.attemptStatus(input)),
|
||||
attemptComplete: (input) => run(host.integration.attemptComplete(input)),
|
||||
attemptCancel: (input) => run(host.integration.attemptCancel(input)),
|
||||
transform: transform(host.integration),
|
||||
reload: () => run(host.integration.reload()),
|
||||
connection: {
|
||||
@@ -71,6 +92,7 @@ export function fromPromise(plugin: Plugin) {
|
||||
},
|
||||
},
|
||||
plugin: {
|
||||
list: (input) => run(host.plugin.list(input)),
|
||||
add: (input) => {
|
||||
const child = fromPromise(input)
|
||||
return run(host.plugin.add(child))
|
||||
@@ -78,13 +100,22 @@ export function fromPromise(plugin: Plugin) {
|
||||
remove: (id) => run(host.plugin.remove(id)),
|
||||
},
|
||||
reference: {
|
||||
list: (input) => run(host.reference.list(input)),
|
||||
transform: transform(host.reference),
|
||||
reload: () => run(host.reference.reload()),
|
||||
},
|
||||
skill: {
|
||||
list: (input) => run(host.skill.list(input)),
|
||||
transform: transform(host.skill),
|
||||
reload: () => run(host.skill.reload()),
|
||||
},
|
||||
session: {
|
||||
create: (input) => run(host.session.create(input)),
|
||||
get: (input) => run(host.session.get(input)),
|
||||
prompt: (input) => run(host.session.prompt(input)),
|
||||
command: (input) => run(host.session.command(input)),
|
||||
interrupt: (input) => run(host.session.interrupt(input)),
|
||||
},
|
||||
}
|
||||
|
||||
yield* Effect.promise(() => Promise.resolve(plugin.setup(context2)))
|
||||
|
||||
@@ -62,22 +62,20 @@ function selectMantleModel(sdk: MantleSDK, modelID: string) {
|
||||
export const AmazonBedrockPlugin = define({
|
||||
id: "amazon-bedrock",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.catalog.transform(
|
||||
Effect.fn(function* (evt) {
|
||||
for (const item of evt.provider.list()) {
|
||||
if (item.provider.api.type !== "aisdk") continue
|
||||
if (item.provider.api.package !== "@ai-sdk/amazon-bedrock") continue
|
||||
evt.provider.update(item.provider.id, (provider) => {
|
||||
if (provider.api.type !== "aisdk") return
|
||||
if (typeof provider.request.body.endpoint !== "string") return
|
||||
// The AI SDK expects a base URL, but users configure Bedrock private/VPC
|
||||
// endpoints as `endpoint`; move it into the catalog endpoint URL once.
|
||||
provider.api.url = provider.request.body.endpoint
|
||||
delete provider.request.body.endpoint
|
||||
})
|
||||
}
|
||||
}),
|
||||
)
|
||||
yield* ctx.catalog.transform((evt) => {
|
||||
for (const item of evt.provider.list()) {
|
||||
if (item.provider.api.type !== "aisdk") continue
|
||||
if (item.provider.api.package !== "@ai-sdk/amazon-bedrock") continue
|
||||
evt.provider.update(item.provider.id, (provider) => {
|
||||
if (provider.api.type !== "aisdk") return
|
||||
if (typeof provider.request.body.endpoint !== "string") return
|
||||
// The AI SDK expects a base URL, but users configure Bedrock private/VPC
|
||||
// endpoints as `endpoint`; move it into the catalog endpoint URL once.
|
||||
provider.api.url = provider.request.body.endpoint
|
||||
delete provider.request.body.endpoint
|
||||
})
|
||||
}
|
||||
})
|
||||
yield* ctx.aisdk.sdk(
|
||||
Effect.fn(function* (evt) {
|
||||
if (!["@ai-sdk/amazon-bedrock", "@ai-sdk/amazon-bedrock/mantle"].includes(evt.package)) return
|
||||
|
||||
@@ -4,18 +4,16 @@ import { define } from "../internal"
|
||||
export const AnthropicPlugin = define({
|
||||
id: "anthropic",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.catalog.transform(
|
||||
Effect.fn(function* (evt) {
|
||||
for (const item of evt.provider.list()) {
|
||||
if (item.provider.api.type !== "aisdk") continue
|
||||
if (item.provider.api.package !== "@ai-sdk/anthropic") continue
|
||||
evt.provider.update(item.provider.id, (provider) => {
|
||||
provider.request.headers["anthropic-beta"] =
|
||||
"interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14"
|
||||
})
|
||||
}
|
||||
}),
|
||||
)
|
||||
yield* ctx.catalog.transform((evt) => {
|
||||
for (const item of evt.provider.list()) {
|
||||
if (item.provider.api.type !== "aisdk") continue
|
||||
if (item.provider.api.package !== "@ai-sdk/anthropic") continue
|
||||
evt.provider.update(item.provider.id, (provider) => {
|
||||
provider.request.headers["anthropic-beta"] =
|
||||
"interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14"
|
||||
})
|
||||
}
|
||||
})
|
||||
yield* ctx.aisdk.sdk(
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/anthropic") return
|
||||
|
||||
@@ -13,21 +13,19 @@ function selectLanguage(sdk: any, modelID: string, useChat: boolean) {
|
||||
export const AzurePlugin = define({
|
||||
id: "azure",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.catalog.transform(
|
||||
Effect.fn(function* (evt) {
|
||||
for (const item of evt.provider.list()) {
|
||||
if (item.provider.api.type !== "aisdk") continue
|
||||
if (item.provider.api.package !== "@ai-sdk/azure") continue
|
||||
const configured = item.provider.request.body.resourceName
|
||||
const resourceName =
|
||||
typeof configured === "string" && configured.trim() !== "" ? configured : process.env.AZURE_RESOURCE_NAME
|
||||
if (!resourceName) continue
|
||||
evt.provider.update(item.provider.id, (provider) => {
|
||||
provider.request.body.resourceName = resourceName
|
||||
})
|
||||
}
|
||||
}),
|
||||
)
|
||||
yield* ctx.catalog.transform((evt) => {
|
||||
for (const item of evt.provider.list()) {
|
||||
if (item.provider.api.type !== "aisdk") continue
|
||||
if (item.provider.api.package !== "@ai-sdk/azure") continue
|
||||
const configured = item.provider.request.body.resourceName
|
||||
const resourceName =
|
||||
typeof configured === "string" && configured.trim() !== "" ? configured : process.env.AZURE_RESOURCE_NAME
|
||||
if (!resourceName) continue
|
||||
evt.provider.update(item.provider.id, (provider) => {
|
||||
provider.request.body.resourceName = resourceName
|
||||
})
|
||||
}
|
||||
})
|
||||
yield* ctx.aisdk.sdk(
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/azure") return
|
||||
@@ -58,20 +56,18 @@ export const AzurePlugin = define({
|
||||
export const AzureCognitiveServicesPlugin = define({
|
||||
id: "azure-cognitive-services",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.catalog.transform(
|
||||
Effect.fn(function* (evt) {
|
||||
const resourceName = process.env.AZURE_COGNITIVE_SERVICES_RESOURCE_NAME
|
||||
if (!resourceName) return
|
||||
for (const item of evt.provider.list()) {
|
||||
if (item.provider.api.type !== "aisdk") continue
|
||||
if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue
|
||||
if (!item.provider.id.includes("azure-cognitive-services")) continue
|
||||
evt.provider.update(item.provider.id, (provider) => {
|
||||
provider.request.body.baseURL = `https://${resourceName}.cognitiveservices.azure.com/openai`
|
||||
})
|
||||
}
|
||||
}),
|
||||
)
|
||||
yield* ctx.catalog.transform((evt) => {
|
||||
const resourceName = process.env.AZURE_COGNITIVE_SERVICES_RESOURCE_NAME
|
||||
if (!resourceName) return
|
||||
for (const item of evt.provider.list()) {
|
||||
if (item.provider.api.type !== "aisdk") continue
|
||||
if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue
|
||||
if (!item.provider.id.includes("azure-cognitive-services")) continue
|
||||
evt.provider.update(item.provider.id, (provider) => {
|
||||
provider.request.body.baseURL = `https://${resourceName}.cognitiveservices.azure.com/openai`
|
||||
})
|
||||
}
|
||||
})
|
||||
yield* ctx.aisdk.language(
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== ProviderV2.ID.make("azure-cognitive-services")) return
|
||||
|
||||
@@ -4,17 +4,15 @@ import { define } from "../internal"
|
||||
export const CerebrasPlugin = define({
|
||||
id: "cerebras",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.catalog.transform(
|
||||
Effect.fn(function* (evt) {
|
||||
for (const item of evt.provider.list()) {
|
||||
if (item.provider.api.type !== "aisdk") continue
|
||||
if (item.provider.api.package !== "@ai-sdk/cerebras") continue
|
||||
evt.provider.update(item.provider.id, (provider) => {
|
||||
provider.request.headers["X-Cerebras-3rd-Party-Integration"] = "opencode"
|
||||
})
|
||||
}
|
||||
}),
|
||||
)
|
||||
yield* ctx.catalog.transform((evt) => {
|
||||
for (const item of evt.provider.list()) {
|
||||
if (item.provider.api.type !== "aisdk") continue
|
||||
if (item.provider.api.package !== "@ai-sdk/cerebras") continue
|
||||
evt.provider.update(item.provider.id, (provider) => {
|
||||
provider.request.headers["X-Cerebras-3rd-Party-Integration"] = "opencode"
|
||||
})
|
||||
}
|
||||
})
|
||||
yield* ctx.aisdk.sdk(
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/cerebras") return
|
||||
|
||||
@@ -9,18 +9,16 @@ const providerID = ProviderV2.ID.make("cloudflare-workers-ai")
|
||||
export const CloudflareWorkersAIPlugin = define({
|
||||
id: "cloudflare-workers-ai",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.catalog.transform(
|
||||
Effect.fn(function* (evt) {
|
||||
const item = evt.provider.get(providerID)
|
||||
if (!item) return
|
||||
evt.provider.update(item.provider.id, (provider) => {
|
||||
if (provider.api.type !== "aisdk") return
|
||||
if (provider.api.url) return
|
||||
const accountId = resolveAccountId(provider.request.body)
|
||||
if (accountId) provider.api.url = workersEndpoint(accountId)
|
||||
})
|
||||
}),
|
||||
)
|
||||
yield* ctx.catalog.transform((evt) => {
|
||||
const item = evt.provider.get(providerID)
|
||||
if (!item) return
|
||||
evt.provider.update(item.provider.id, (provider) => {
|
||||
if (provider.api.type !== "aisdk") return
|
||||
if (provider.api.url) return
|
||||
const accountId = resolveAccountId(provider.request.body)
|
||||
if (accountId) provider.api.url = workersEndpoint(accountId)
|
||||
})
|
||||
})
|
||||
yield* ctx.aisdk.sdk(
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== providerID) return
|
||||
|
||||
@@ -14,17 +14,15 @@ function shouldUseResponses(modelID: string) {
|
||||
export const GithubCopilotPlugin = define({
|
||||
id: "github-copilot",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.catalog.transform(
|
||||
Effect.fn(function* (evt) {
|
||||
const item = evt.provider.get(ProviderV2.ID.githubCopilot)
|
||||
if (!item || !item.models.has(ModelV2.ID.make("gpt-5-chat-latest"))) return
|
||||
evt.model.update(item.provider.id, ModelV2.ID.make("gpt-5-chat-latest"), (model) => {
|
||||
// This chat-only alias conflicts with the Copilot GPT-5 Responses route,
|
||||
// so hide it only for Copilot rather than for every provider catalog.
|
||||
model.enabled = false
|
||||
})
|
||||
}),
|
||||
)
|
||||
yield* ctx.catalog.transform((evt) => {
|
||||
const item = evt.provider.get(ProviderV2.ID.githubCopilot)
|
||||
if (!item || !item.models.has(ModelV2.ID.make("gpt-5-chat-latest"))) return
|
||||
evt.model.update(item.provider.id, ModelV2.ID.make("gpt-5-chat-latest"), (model) => {
|
||||
// This chat-only alias conflicts with the Copilot GPT-5 Responses route,
|
||||
// so hide it only for Copilot rather than for every provider catalog.
|
||||
model.enabled = false
|
||||
})
|
||||
})
|
||||
yield* ctx.aisdk.sdk(
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/github-copilot") return
|
||||
|
||||
@@ -57,33 +57,31 @@ function authFetch(fetchWithRuntimeOptions?: unknown) {
|
||||
export const GoogleVertexPlugin = define({
|
||||
id: "google-vertex",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.catalog.transform(
|
||||
Effect.fn(function* (evt) {
|
||||
for (const item of evt.provider.list()) {
|
||||
if (item.provider.api.type !== "aisdk") continue
|
||||
if (
|
||||
item.provider.api.package !== "@ai-sdk/google-vertex" &&
|
||||
!(
|
||||
item.provider.id === ProviderV2.ID.googleVertex &&
|
||||
item.provider.api.package.includes("@ai-sdk/openai-compatible")
|
||||
)
|
||||
yield* ctx.catalog.transform((evt) => {
|
||||
for (const item of evt.provider.list()) {
|
||||
if (item.provider.api.type !== "aisdk") continue
|
||||
if (
|
||||
item.provider.api.package !== "@ai-sdk/google-vertex" &&
|
||||
!(
|
||||
item.provider.id === ProviderV2.ID.googleVertex &&
|
||||
item.provider.api.package.includes("@ai-sdk/openai-compatible")
|
||||
)
|
||||
continue
|
||||
const project = resolveProject(item.provider.request.body)
|
||||
const location = String(resolveLocation(item.provider.request.body))
|
||||
evt.provider.update(item.provider.id, (provider) => {
|
||||
if (project) provider.request.body.project = project
|
||||
provider.request.body.location = location
|
||||
if (provider.api.type === "aisdk" && provider.api.url) {
|
||||
provider.api.url = replaceVertexVars(provider.api.url, project, location)
|
||||
}
|
||||
if (provider.api.type === "aisdk" && provider.api.package.includes("@ai-sdk/openai-compatible")) {
|
||||
provider.request.body.fetch = authFetch(provider.request.body.fetch)
|
||||
}
|
||||
})
|
||||
}
|
||||
}),
|
||||
)
|
||||
)
|
||||
continue
|
||||
const project = resolveProject(item.provider.request.body)
|
||||
const location = String(resolveLocation(item.provider.request.body))
|
||||
evt.provider.update(item.provider.id, (provider) => {
|
||||
if (project) provider.request.body.project = project
|
||||
provider.request.body.location = location
|
||||
if (provider.api.type === "aisdk" && provider.api.url) {
|
||||
provider.api.url = replaceVertexVars(provider.api.url, project, location)
|
||||
}
|
||||
if (provider.api.type === "aisdk" && provider.api.package.includes("@ai-sdk/openai-compatible")) {
|
||||
provider.request.body.fetch = authFetch(provider.request.body.fetch)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
yield* ctx.aisdk.sdk(
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID === ProviderV2.ID.googleVertex && evt.package.includes("@ai-sdk/openai-compatible")) {
|
||||
@@ -115,28 +113,26 @@ export const GoogleVertexPlugin = define({
|
||||
export const GoogleVertexAnthropicPlugin = define({
|
||||
id: "google-vertex-anthropic",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.catalog.transform(
|
||||
Effect.fn(function* (evt) {
|
||||
for (const item of evt.provider.list()) {
|
||||
if (item.provider.api.type !== "aisdk") continue
|
||||
if (item.provider.api.package !== "@ai-sdk/google-vertex/anthropic") continue
|
||||
const project =
|
||||
item.provider.request.body.project ??
|
||||
process.env.GOOGLE_CLOUD_PROJECT ??
|
||||
process.env.GCP_PROJECT ??
|
||||
process.env.GCLOUD_PROJECT
|
||||
const location =
|
||||
item.provider.request.body.location ??
|
||||
process.env.GOOGLE_CLOUD_LOCATION ??
|
||||
process.env.VERTEX_LOCATION ??
|
||||
"global"
|
||||
evt.provider.update(item.provider.id, (provider) => {
|
||||
if (project) provider.request.body.project = project
|
||||
provider.request.body.location = location
|
||||
})
|
||||
}
|
||||
}),
|
||||
)
|
||||
yield* ctx.catalog.transform((evt) => {
|
||||
for (const item of evt.provider.list()) {
|
||||
if (item.provider.api.type !== "aisdk") continue
|
||||
if (item.provider.api.package !== "@ai-sdk/google-vertex/anthropic") continue
|
||||
const project =
|
||||
item.provider.request.body.project ??
|
||||
process.env.GOOGLE_CLOUD_PROJECT ??
|
||||
process.env.GCP_PROJECT ??
|
||||
process.env.GCLOUD_PROJECT
|
||||
const location =
|
||||
item.provider.request.body.location ??
|
||||
process.env.GOOGLE_CLOUD_LOCATION ??
|
||||
process.env.VERTEX_LOCATION ??
|
||||
"global"
|
||||
evt.provider.update(item.provider.id, (provider) => {
|
||||
if (project) provider.request.body.project = project
|
||||
provider.request.body.location = location
|
||||
})
|
||||
}
|
||||
})
|
||||
yield* ctx.aisdk.sdk(
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/google-vertex/anthropic") return
|
||||
|
||||
@@ -4,18 +4,16 @@ import { define } from "../internal"
|
||||
export const KiloPlugin = define({
|
||||
id: "kilo",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.catalog.transform(
|
||||
Effect.fn(function* (evt) {
|
||||
for (const item of evt.provider.list()) {
|
||||
if (item.provider.api.type !== "aisdk") continue
|
||||
if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue
|
||||
if (item.provider.api.url !== "https://api.kilo.ai/api/gateway") continue
|
||||
evt.provider.update(item.provider.id, (provider) => {
|
||||
provider.request.headers["HTTP-Referer"] = "https://opencode.ai/"
|
||||
provider.request.headers["X-Title"] = "opencode"
|
||||
})
|
||||
}
|
||||
}),
|
||||
)
|
||||
yield* ctx.catalog.transform((evt) => {
|
||||
for (const item of evt.provider.list()) {
|
||||
if (item.provider.api.type !== "aisdk") continue
|
||||
if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue
|
||||
if (item.provider.api.url !== "https://api.kilo.ai/api/gateway") continue
|
||||
evt.provider.update(item.provider.id, (provider) => {
|
||||
provider.request.headers["HTTP-Referer"] = "https://opencode.ai/"
|
||||
provider.request.headers["X-Title"] = "opencode"
|
||||
})
|
||||
}
|
||||
})
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -6,21 +6,20 @@ export const LLMGatewayPlugin = define({
|
||||
id: "llmgateway",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const integrations = yield* Integration.Service
|
||||
yield* ctx.catalog.transform(
|
||||
Effect.fn(function* (evt) {
|
||||
for (const item of evt.provider.list()) {
|
||||
if (item.provider.disabled) continue
|
||||
if (item.provider.api.type !== "aisdk") continue
|
||||
if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue
|
||||
if (item.provider.api.url !== "https://api.llmgateway.io/v1") continue
|
||||
if (!(yield* integrations.get(Integration.ID.make(item.provider.id)))) continue
|
||||
evt.provider.update(item.provider.id, (provider) => {
|
||||
provider.request.headers["HTTP-Referer"] = "https://opencode.ai/"
|
||||
provider.request.headers["X-Title"] = "opencode"
|
||||
provider.request.headers["X-Source"] = "opencode"
|
||||
})
|
||||
}
|
||||
}),
|
||||
)
|
||||
const configured = new Set((yield* integrations.list()).map((integration) => integration.id))
|
||||
yield* ctx.catalog.transform((evt) => {
|
||||
for (const item of evt.provider.list()) {
|
||||
if (item.provider.disabled) continue
|
||||
if (item.provider.api.type !== "aisdk") continue
|
||||
if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue
|
||||
if (item.provider.api.url !== "https://api.llmgateway.io/v1") continue
|
||||
if (!configured.has(Integration.ID.make(item.provider.id))) continue
|
||||
evt.provider.update(item.provider.id, (provider) => {
|
||||
provider.request.headers["HTTP-Referer"] = "https://opencode.ai/"
|
||||
provider.request.headers["X-Title"] = "opencode"
|
||||
provider.request.headers["X-Source"] = "opencode"
|
||||
})
|
||||
}
|
||||
})
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -4,19 +4,17 @@ import { define } from "../internal"
|
||||
export const NvidiaPlugin = define({
|
||||
id: "nvidia",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.catalog.transform(
|
||||
Effect.fn(function* (evt) {
|
||||
for (const item of evt.provider.list()) {
|
||||
if (item.provider.api.type !== "aisdk") continue
|
||||
if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue
|
||||
if (item.provider.api.url !== "https://integrate.api.nvidia.com/v1") continue
|
||||
evt.provider.update(item.provider.id, (provider) => {
|
||||
provider.request.headers["HTTP-Referer"] = "https://opencode.ai/"
|
||||
provider.request.headers["X-Title"] = "opencode"
|
||||
provider.request.headers["X-BILLING-INVOKE-ORIGIN"] ??= "OpenCode"
|
||||
})
|
||||
}
|
||||
}),
|
||||
)
|
||||
yield* ctx.catalog.transform((evt) => {
|
||||
for (const item of evt.provider.list()) {
|
||||
if (item.provider.api.type !== "aisdk") continue
|
||||
if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue
|
||||
if (item.provider.api.url !== "https://integrate.api.nvidia.com/v1") continue
|
||||
evt.provider.update(item.provider.id, (provider) => {
|
||||
provider.request.headers["HTTP-Referer"] = "https://opencode.ai/"
|
||||
provider.request.headers["X-Title"] = "opencode"
|
||||
provider.request.headers["X-BILLING-INVOKE-ORIGIN"] ??= "OpenCode"
|
||||
})
|
||||
}
|
||||
})
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -177,34 +177,32 @@ export const OpenAIPlugin = define({
|
||||
draft.method.update(browser)
|
||||
draft.method.update(headless)
|
||||
})
|
||||
yield* ctx.catalog.transform(
|
||||
Effect.fn(function* (evt) {
|
||||
for (const item of evt.provider.list()) {
|
||||
if (item.provider.api.type !== "aisdk") continue
|
||||
if (item.provider.api.package !== "@ai-sdk/openai") continue
|
||||
if (!item.models.has(ModelV2.ID.make("gpt-5-chat-latest"))) continue
|
||||
evt.model.update(item.provider.id, ModelV2.ID.make("gpt-5-chat-latest"), (model) => {
|
||||
// OpenAIPlugin sends OpenAI models through Responses; this alias is a
|
||||
// chat-completions-only model, so hide it only from OpenAI's catalog.
|
||||
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 = []
|
||||
})
|
||||
}
|
||||
}),
|
||||
)
|
||||
yield* ctx.catalog.transform((evt) => {
|
||||
for (const item of evt.provider.list()) {
|
||||
if (item.provider.api.type !== "aisdk") continue
|
||||
if (item.provider.api.package !== "@ai-sdk/openai") continue
|
||||
if (!item.models.has(ModelV2.ID.make("gpt-5-chat-latest"))) continue
|
||||
evt.model.update(item.provider.id, ModelV2.ID.make("gpt-5-chat-latest"), (model) => {
|
||||
// OpenAIPlugin sends OpenAI models through Responses; this alias is a
|
||||
// chat-completions-only model, so hide it only from OpenAI's catalog.
|
||||
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(
|
||||
|
||||
@@ -5,26 +5,24 @@ import { define } from "../internal"
|
||||
export const OpenRouterPlugin = define({
|
||||
id: "openrouter",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.catalog.transform(
|
||||
Effect.fn(function* (evt) {
|
||||
for (const item of evt.provider.list()) {
|
||||
if (item.provider.api.type !== "aisdk") continue
|
||||
if (item.provider.api.package !== "@openrouter/ai-sdk-provider") continue
|
||||
evt.provider.update(item.provider.id, (provider) => {
|
||||
provider.request.headers["HTTP-Referer"] = "https://opencode.ai/"
|
||||
provider.request.headers["X-Title"] = "opencode"
|
||||
yield* ctx.catalog.transform((evt) => {
|
||||
for (const item of evt.provider.list()) {
|
||||
if (item.provider.api.type !== "aisdk") continue
|
||||
if (item.provider.api.package !== "@openrouter/ai-sdk-provider") continue
|
||||
evt.provider.update(item.provider.id, (provider) => {
|
||||
provider.request.headers["HTTP-Referer"] = "https://opencode.ai/"
|
||||
provider.request.headers["X-Title"] = "opencode"
|
||||
})
|
||||
for (const modelID of [ModelV2.ID.make("gpt-5-chat-latest"), ModelV2.ID.make("openai/gpt-5-chat")]) {
|
||||
if (!item.models.has(modelID)) continue
|
||||
evt.model.update(item.provider.id, modelID, (model) => {
|
||||
// These are OpenRouter-specific OpenAI chat aliases that do not work
|
||||
// on the generic path. Keep custom providers with matching IDs untouched.
|
||||
model.enabled = false
|
||||
})
|
||||
for (const modelID of [ModelV2.ID.make("gpt-5-chat-latest"), ModelV2.ID.make("openai/gpt-5-chat")]) {
|
||||
if (!item.models.has(modelID)) continue
|
||||
evt.model.update(item.provider.id, modelID, (model) => {
|
||||
// These are OpenRouter-specific OpenAI chat aliases that do not work
|
||||
// on the generic path. Keep custom providers with matching IDs untouched.
|
||||
model.enabled = false
|
||||
})
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
})
|
||||
yield* ctx.aisdk.sdk(
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@openrouter/ai-sdk-provider") return
|
||||
|
||||
@@ -4,18 +4,16 @@ import { define } from "../internal"
|
||||
export const VercelPlugin = define({
|
||||
id: "vercel",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.catalog.transform(
|
||||
Effect.fn(function* (evt) {
|
||||
for (const item of evt.provider.list()) {
|
||||
if (item.provider.api.type !== "aisdk") continue
|
||||
if (item.provider.api.package !== "@ai-sdk/vercel") continue
|
||||
evt.provider.update(item.provider.id, (provider) => {
|
||||
provider.request.headers["http-referer"] = "https://opencode.ai/"
|
||||
provider.request.headers["x-title"] = "opencode"
|
||||
})
|
||||
}
|
||||
}),
|
||||
)
|
||||
yield* ctx.catalog.transform((evt) => {
|
||||
for (const item of evt.provider.list()) {
|
||||
if (item.provider.api.type !== "aisdk") continue
|
||||
if (item.provider.api.package !== "@ai-sdk/vercel") continue
|
||||
evt.provider.update(item.provider.id, (provider) => {
|
||||
provider.request.headers["http-referer"] = "https://opencode.ai/"
|
||||
provider.request.headers["x-title"] = "opencode"
|
||||
})
|
||||
}
|
||||
})
|
||||
yield* ctx.aisdk.sdk(
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/vercel") return
|
||||
|
||||
@@ -4,18 +4,16 @@ import { define } from "../internal"
|
||||
export const ZenmuxPlugin = define({
|
||||
id: "zenmux",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.catalog.transform(
|
||||
Effect.fn(function* (evt) {
|
||||
for (const item of evt.provider.list()) {
|
||||
if (item.provider.api.type !== "aisdk") continue
|
||||
if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue
|
||||
if (item.provider.api.url !== "https://zenmux.ai/api/v1") continue
|
||||
evt.provider.update(item.provider.id, (provider) => {
|
||||
provider.request.headers["HTTP-Referer"] ??= "https://opencode.ai/"
|
||||
provider.request.headers["X-Title"] ??= "opencode"
|
||||
})
|
||||
}
|
||||
}),
|
||||
)
|
||||
yield* ctx.catalog.transform((evt) => {
|
||||
for (const item of evt.provider.list()) {
|
||||
if (item.provider.api.type !== "aisdk") continue
|
||||
if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue
|
||||
if (item.provider.api.url !== "https://zenmux.ai/api/v1") continue
|
||||
evt.provider.update(item.provider.id, (provider) => {
|
||||
provider.request.headers["HTTP-Referer"] ??= "https://opencode.ai/"
|
||||
provider.request.headers["X-Title"] ??= "opencode"
|
||||
})
|
||||
}
|
||||
})
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
export * as Policy from "./policy"
|
||||
|
||||
import { makeLocationNode } from "./effect/app-node"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { Wildcard } from "./util/wildcard"
|
||||
import { Location } from "./location"
|
||||
|
||||
const PolicyEffect = Schema.Literals(["allow", "deny"]).annotate({ identifier: "Policy.Effect" })
|
||||
export { PolicyEffect as Effect }
|
||||
export type Effect = typeof PolicyEffect.Type
|
||||
|
||||
export class Info extends Schema.Class<Info>("Policy.Info")({
|
||||
action: Schema.String,
|
||||
effect: PolicyEffect,
|
||||
resource: Schema.String,
|
||||
}) {}
|
||||
|
||||
export interface Interface {
|
||||
readonly load: (statements: Info[]) => Effect.Effect<void>
|
||||
readonly evaluate: (action: string, resource: string, fallback: Effect) => Effect.Effect<Effect>
|
||||
readonly hasStatements: () => boolean
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Policy") {}
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
let statements: Info[] = []
|
||||
yield* Location.Service
|
||||
|
||||
return Service.of({
|
||||
load: Effect.fn("Policy.load")(function* (input) {
|
||||
statements = input
|
||||
}),
|
||||
hasStatements: () => statements.length > 0,
|
||||
evaluate: Effect.fn("Policy.evaluate")(function* (action, resource, fallback) {
|
||||
return (
|
||||
statements.findLast(
|
||||
(statement) => Wildcard.match(action, statement.action) && Wildcard.match(resource, statement.resource),
|
||||
)?.effect ?? fallback
|
||||
)
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [Location.node] })
|
||||
@@ -41,8 +41,8 @@ import type { EventLog } from "@opencode-ai/schema/event-log"
|
||||
import { SkillV2 } from "./skill"
|
||||
import { Job } from "./job"
|
||||
import { CommandV2 } from "./command"
|
||||
import { Identifier } from "./util/identifier"
|
||||
import { Shell } from "./shell"
|
||||
import { Shell as ShellSchema } from "@opencode-ai/schema/shell"
|
||||
import { KeyedMutex } from "./effect/keyed-mutex"
|
||||
|
||||
export const RevertState = Revert.State
|
||||
@@ -272,19 +272,6 @@ const layer = Layer.effect(
|
||||
),
|
||||
)
|
||||
|
||||
// Session shell is user-initiated and synchronous at the API boundary, while
|
||||
// the Location shell service owns process lifecycle and file-backed output.
|
||||
const runShellCommand = (command: string, cwd: string) =>
|
||||
Effect.gen(function* () {
|
||||
const shell = yield* Shell.Service
|
||||
const info = yield* shell.create({ command, cwd })
|
||||
yield* shell.wait(info.id)
|
||||
const output = yield* shell.output(info.id, { limit: SHELL_MAX_CAPTURE_BYTES })
|
||||
return output.output || "(no output)"
|
||||
}).pipe(
|
||||
Effect.catchTag("Shell.NotFoundError", () => Effect.succeed("Shell command output is no longer available.")),
|
||||
)
|
||||
|
||||
const result = Service.of({
|
||||
create: Effect.fn("V2Session.create")(function* (input) {
|
||||
const sessionID = input.id ?? SessionSchema.ID.create()
|
||||
@@ -550,23 +537,38 @@ const layer = Layer.effect(
|
||||
Effect.gen(function* () {
|
||||
activeShells.add(input.sessionID)
|
||||
if ((yield* execution.active).has(input.sessionID)) yield* execution.awaitIdle(input.sessionID)
|
||||
const callID = Identifier.ascending()
|
||||
const started = yield* Effect.gen(function* () {
|
||||
const shell = yield* Shell.Service
|
||||
return yield* shell.create({ command: input.command, cwd: session.location.directory })
|
||||
})
|
||||
.pipe(Effect.provide(locations.get(session.location)))
|
||||
yield* events.publish(
|
||||
SessionEvent.Shell.Started,
|
||||
{
|
||||
sessionID: input.sessionID,
|
||||
callID,
|
||||
command: input.command,
|
||||
shell: started,
|
||||
},
|
||||
{ id: input.id },
|
||||
)
|
||||
const output = yield* runShellCommand(input.command, session.location.directory).pipe(
|
||||
Effect.provide(locations.get(session.location)),
|
||||
)
|
||||
const completed = yield* Effect.gen(function* () {
|
||||
const shell = yield* Shell.Service
|
||||
const terminal = yield* shell.wait(started.id).pipe(
|
||||
Effect.map((info) => ({ info, retained: true as const })),
|
||||
Effect.catchTag("Shell.NotFoundError", () =>
|
||||
Effect.succeed({ info: synthesizeTerminalShellInfo(started), retained: false as const }),
|
||||
),
|
||||
)
|
||||
const output = terminal.retained
|
||||
? yield* shell
|
||||
.output(started.id, { limit: SHELL_MAX_CAPTURE_BYTES })
|
||||
.pipe(Effect.catchTag("Shell.NotFoundError", () => Effect.succeed(missingShellOutput())))
|
||||
: missingShellOutput()
|
||||
return { shell: terminal.info, output }
|
||||
}).pipe(Effect.provide(locations.get(session.location)))
|
||||
yield* events.publish(SessionEvent.Shell.Ended, {
|
||||
sessionID: input.sessionID,
|
||||
callID,
|
||||
output,
|
||||
shell: completed.shell,
|
||||
output: completed.output,
|
||||
})
|
||||
}).pipe(
|
||||
Effect.ensuring(
|
||||
@@ -706,6 +708,26 @@ const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
function missingShellOutput() {
|
||||
const output = "Shell command output is no longer available."
|
||||
return {
|
||||
output,
|
||||
cursor: Buffer.byteLength(output),
|
||||
size: Buffer.byteLength(output),
|
||||
truncated: false,
|
||||
}
|
||||
}
|
||||
|
||||
function synthesizeTerminalShellInfo(started: ShellSchema.Info): ShellSchema.Info {
|
||||
return {
|
||||
...started,
|
||||
// The Shell record was removed before waiters could observe it; publish a terminal
|
||||
// boundary instead of leaving the Session shell message permanently running.
|
||||
status: "killed",
|
||||
time: { ...started.time, completed: Date.now() },
|
||||
}
|
||||
}
|
||||
|
||||
const resolvePrompt = (input: PromptInput.Prompt) =>
|
||||
Prompt.make({
|
||||
text: input.text,
|
||||
|
||||
@@ -129,7 +129,7 @@ const serialize = (message: SessionMessage.Message) => {
|
||||
if (message.type === "system") return `[System update]: ${message.text}`
|
||||
if (message.type === "synthetic") return `[Synthetic context]: ${message.text}`
|
||||
if (message.type === "skill") return `[Skill activated: ${message.name}]\n${message.text}`
|
||||
if (message.type === "shell") return `[Shell]: ${message.command}\n${truncate(message.output)}`
|
||||
if (message.type === "shell") return `[Shell]: ${message.shell.command}\n${truncate(message.output?.output ?? "")}`
|
||||
return ""
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ 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.
|
||||
* input promotion so a blocked first step leaves pending inputs untouched.
|
||||
*/
|
||||
export const prepare = Effect.fn("SessionContextCheckpoint.prepare")(function* (
|
||||
db: DatabaseService,
|
||||
|
||||
@@ -20,7 +20,7 @@ const layer = Layer.effect(
|
||||
drain: Effect.fnUntraced(function* (sessionID: SessionSchema.ID, force) {
|
||||
const session = yield* store.get(sessionID)
|
||||
if (!session) return yield* Effect.die(new Error(`Session not found: ${sessionID}`))
|
||||
return yield* SessionRunner.Service.use((runner) => runner.run({ sessionID, force })).pipe(
|
||||
return yield* SessionRunner.Service.use((runner) => runner.drain({ sessionID, force })).pipe(
|
||||
Effect.provide(locations.get(session.location)),
|
||||
Effect.tapCause((cause) =>
|
||||
Cause.hasInterruptsOnly(cause)
|
||||
|
||||
@@ -34,7 +34,7 @@ const messageRows = Effect.fnUntraced(function* (
|
||||
and(
|
||||
eq(SessionMessageTable.session_id, sessionID),
|
||||
// Keep system updates visible in the gap between a completed compaction
|
||||
// and the next prepared turn's rebaseline, when their content is not yet
|
||||
// and the next prepared step's rebaseline, when their content is not yet
|
||||
// folded into a new baseline.
|
||||
compaction
|
||||
? or(
|
||||
|
||||
@@ -36,9 +36,9 @@ const layer = Layer.effect(
|
||||
// absolute paths, but the human-facing description shows paths relative to the project
|
||||
// root so opening a subdirectory still describes paths from the project root.
|
||||
const root = yield* fs.resolve(location.project.directory)
|
||||
// Same-turn parallel reads settle concurrently, so an in-memory claim guards each
|
||||
// Same-step parallel reads settle concurrently, so an in-memory claim guards each
|
||||
// Session/path pair before any filesystem work. The durable history check below covers
|
||||
// paths injected in earlier turns after this Location layer was reopened.
|
||||
// paths injected in earlier steps after this Location layer was reopened.
|
||||
const injected = yield* Ref.make<Map<SessionSchema.ID, Set<string>>>(new Map())
|
||||
|
||||
const load = Effect.fn("SessionInstructions.load")(function* (input: {
|
||||
|
||||
@@ -8,23 +8,37 @@ export type MemoryState = {
|
||||
}
|
||||
|
||||
export interface Adapter {
|
||||
readonly getCurrentAssistant: () => Effect.Effect<SessionMessage.Assistant | undefined>
|
||||
readonly getAssistant: (messageID: SessionMessage.ID) => Effect.Effect<SessionMessage.Assistant | undefined>
|
||||
readonly getCurrentShell: (callID: string) => Effect.Effect<SessionMessage.Shell | undefined>
|
||||
readonly updateAssistant: (assistant: SessionMessage.Assistant) => Effect.Effect<void>
|
||||
readonly updateShell: (shell: SessionMessage.Shell) => Effect.Effect<void>
|
||||
readonly appendMessage: (message: SessionMessage.Message) => Effect.Effect<void>
|
||||
readonly getModel: () => Effect.Effect<SessionMessage.ModelSelected["model"] | undefined, never, never>
|
||||
readonly getCurrentAssistant: () => Effect.Effect<SessionMessage.Assistant | undefined, never, never>
|
||||
readonly getAssistant: (
|
||||
messageID: SessionMessage.ID,
|
||||
) => Effect.Effect<SessionMessage.Assistant | undefined, never, never>
|
||||
readonly getShell: (
|
||||
shellID: SessionMessage.Shell["shell"]["id"],
|
||||
) => Effect.Effect<SessionMessage.Shell | undefined, never, never>
|
||||
readonly updateAssistant: (assistant: SessionMessage.Assistant) => Effect.Effect<void, never, never>
|
||||
readonly updateShell: (shell: SessionMessage.Shell) => Effect.Effect<void, never, never>
|
||||
readonly appendMessage: (message: SessionMessage.Message) => Effect.Effect<void, never, never>
|
||||
}
|
||||
|
||||
export function memory(state: MemoryState): Adapter {
|
||||
const assistantIndex = (messageID: SessionMessage.ID) =>
|
||||
state.messages.findLastIndex((message) => message.id === messageID)
|
||||
// A newer turn supersedes stale incomplete rows; never resume an older assistant projection.
|
||||
const shellIndex = (messageID: SessionMessage.ID) =>
|
||||
state.messages.findLastIndex((message) => message.id === messageID)
|
||||
// A newer step supersedes stale incomplete rows; never resume an older assistant projection.
|
||||
const latestAssistantIndex = () => state.messages.findLastIndex((message) => message.type === "assistant")
|
||||
const activeShellIndex = (callID: string) =>
|
||||
state.messages.findLastIndex((message) => message.type === "shell" && message.callID === callID)
|
||||
|
||||
return {
|
||||
getModel() {
|
||||
return Effect.sync(
|
||||
() =>
|
||||
state.messages.findLast(
|
||||
(message): message is SessionMessage.ModelSelected | SessionMessage.Assistant =>
|
||||
message.type === "model-switched" || message.type === "assistant",
|
||||
)?.model,
|
||||
)
|
||||
},
|
||||
getCurrentAssistant() {
|
||||
return Effect.sync(() => {
|
||||
const index = latestAssistantIndex()
|
||||
@@ -41,12 +55,11 @@ export function memory(state: MemoryState): Adapter {
|
||||
return assistant?.type === "assistant" ? assistant : undefined
|
||||
})
|
||||
},
|
||||
getCurrentShell(callID) {
|
||||
getShell(shellID) {
|
||||
return Effect.sync(() => {
|
||||
const index = activeShellIndex(callID)
|
||||
if (index < 0) return
|
||||
const shell = state.messages[index]
|
||||
return shell?.type === "shell" ? shell : undefined
|
||||
return state.messages.find((message): message is SessionMessage.Shell => {
|
||||
return message.type === "shell" && message.shell.id === shellID
|
||||
})
|
||||
})
|
||||
},
|
||||
updateAssistant(assistant) {
|
||||
@@ -60,7 +73,7 @@ export function memory(state: MemoryState): Adapter {
|
||||
},
|
||||
updateShell(shell) {
|
||||
return Effect.sync(() => {
|
||||
const index = activeShellIndex(shell.callID)
|
||||
const index = shellIndex(shell.id)
|
||||
if (index < 0) return
|
||||
const current = state.messages[index]
|
||||
if (current?.type !== "shell") return
|
||||
@@ -100,7 +113,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
||||
|
||||
return Effect.gen(function* () {
|
||||
yield* SessionEvent.All.match(event, {
|
||||
"agent.selected": (event) => {
|
||||
"session.agent.selected": (event) => {
|
||||
return adapter.appendMessage(
|
||||
SessionMessage.AgentSelected.make({
|
||||
id: SessionMessage.ID.fromEvent(event.id),
|
||||
@@ -111,23 +124,27 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
||||
}),
|
||||
)
|
||||
},
|
||||
"model.selected": (event) => {
|
||||
return adapter.appendMessage(
|
||||
SessionMessage.ModelSelected.make({
|
||||
id: SessionMessage.ID.fromEvent(event.id),
|
||||
type: "model-switched",
|
||||
metadata: event.metadata,
|
||||
model: event.data.model,
|
||||
time: { created: event.created },
|
||||
}),
|
||||
)
|
||||
"session.model.selected": (event) => {
|
||||
return Effect.gen(function* () {
|
||||
const previous = yield* adapter.getModel()
|
||||
yield* adapter.appendMessage(
|
||||
SessionMessage.ModelSelected.make({
|
||||
id: SessionMessage.ID.fromEvent(event.id),
|
||||
type: "model-switched",
|
||||
metadata: event.metadata,
|
||||
model: event.data.model,
|
||||
previous,
|
||||
time: { created: event.created },
|
||||
}),
|
||||
)
|
||||
})
|
||||
},
|
||||
"session.moved": () => Effect.void,
|
||||
renamed: () => Effect.void,
|
||||
forked: () => Effect.void,
|
||||
"prompt.promoted": () => Effect.void,
|
||||
"prompt.admitted": () => Effect.void,
|
||||
"execution.settled": () => Effect.void,
|
||||
"session.renamed": () => Effect.void,
|
||||
"session.forked": () => Effect.void,
|
||||
"session.prompt.promoted": () => Effect.void,
|
||||
"session.prompt.admitted": () => Effect.void,
|
||||
"session.execution.settled": () => Effect.void,
|
||||
"session.context.updated": (event) =>
|
||||
adapter.appendMessage(
|
||||
SessionMessage.System.make({
|
||||
@@ -137,7 +154,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
||||
time: { created: event.created },
|
||||
}),
|
||||
),
|
||||
synthetic: (event) => {
|
||||
"session.synthetic": (event) => {
|
||||
return adapter.appendMessage(
|
||||
SessionMessage.Synthetic.make({
|
||||
sessionID: event.data.sessionID,
|
||||
@@ -150,7 +167,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
||||
}),
|
||||
)
|
||||
},
|
||||
"skill.activated": (event) => {
|
||||
"session.skill.activated": (event) => {
|
||||
return adapter.appendMessage(
|
||||
SessionMessage.Skill.make({
|
||||
id: SessionMessage.ID.fromEvent(event.id),
|
||||
@@ -161,25 +178,24 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
||||
}),
|
||||
)
|
||||
},
|
||||
"shell.started": (event) => {
|
||||
"session.shell.started": (event) => {
|
||||
return adapter.appendMessage(
|
||||
SessionMessage.Shell.make({
|
||||
id: SessionMessage.ID.fromEvent(event.id),
|
||||
type: "shell",
|
||||
metadata: event.metadata,
|
||||
callID: event.data.callID,
|
||||
command: event.data.command,
|
||||
output: "",
|
||||
shell: event.data.shell,
|
||||
time: { created: event.created },
|
||||
}),
|
||||
)
|
||||
},
|
||||
"shell.ended": (event) => {
|
||||
"session.shell.ended": (event) => {
|
||||
return Effect.gen(function* () {
|
||||
const currentShell = yield* adapter.getCurrentShell(event.data.callID)
|
||||
const currentShell = yield* adapter.getShell(event.data.shell.id)
|
||||
if (currentShell) {
|
||||
yield* adapter.updateShell(
|
||||
produce(currentShell, (draft) => {
|
||||
draft.shell = castDraft(event.data.shell)
|
||||
draft.output = event.data.output
|
||||
draft.time.completed = event.created
|
||||
}),
|
||||
@@ -187,7 +203,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
||||
}
|
||||
})
|
||||
},
|
||||
"step.started": (event) => {
|
||||
"session.step.started": (event) => {
|
||||
return Effect.gen(function* () {
|
||||
const currentAssistant = yield* adapter.getCurrentAssistant()
|
||||
if (currentAssistant) {
|
||||
@@ -210,7 +226,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
||||
)
|
||||
})
|
||||
},
|
||||
"step.ended": (event) => {
|
||||
"session.step.ended": (event) => {
|
||||
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
||||
draft.time.completed = event.created
|
||||
draft.finish = event.data.finish
|
||||
@@ -224,33 +240,33 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
||||
}
|
||||
})
|
||||
},
|
||||
"step.failed": (event) => {
|
||||
"session.step.failed": (event) => {
|
||||
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
||||
draft.time.completed = event.created
|
||||
draft.finish = "error"
|
||||
draft.error = event.data.error
|
||||
})
|
||||
},
|
||||
"text.started": (event) => {
|
||||
"session.text.started": (event) => {
|
||||
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
||||
draft.content.push(
|
||||
castDraft(SessionMessage.AssistantText.make({ type: "text", id: event.data.textID, text: "" })),
|
||||
)
|
||||
})
|
||||
},
|
||||
"text.delta": (event) => {
|
||||
"session.text.delta": (event) => {
|
||||
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
||||
const match = latestText(draft, event.data.textID)
|
||||
if (match) match.text += event.data.delta
|
||||
})
|
||||
},
|
||||
"text.ended": (event) => {
|
||||
"session.text.ended": (event) => {
|
||||
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
||||
const match = latestText(draft, event.data.textID)
|
||||
if (match) match.text = event.data.text
|
||||
})
|
||||
},
|
||||
"tool.input.started": (event) => {
|
||||
"session.tool.input.started": (event) => {
|
||||
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
||||
draft.content.push(
|
||||
castDraft(
|
||||
@@ -265,14 +281,14 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
||||
)
|
||||
})
|
||||
},
|
||||
"tool.input.delta": () => Effect.void,
|
||||
"tool.input.ended": (event) => {
|
||||
"session.tool.input.delta": () => Effect.void,
|
||||
"session.tool.input.ended": (event) => {
|
||||
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
||||
const match = latestTool(draft, event.data.callID)
|
||||
if (match && match.state.status === "pending") match.state.input = event.data.text
|
||||
})
|
||||
},
|
||||
"tool.called": (event) => {
|
||||
"session.tool.called": (event) => {
|
||||
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
||||
const match = latestTool(draft, event.data.callID)
|
||||
if (match) {
|
||||
@@ -289,7 +305,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
||||
}
|
||||
})
|
||||
},
|
||||
"tool.progress": (event) => {
|
||||
"session.tool.progress": (event) => {
|
||||
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
||||
const match = latestTool(draft, event.data.callID)
|
||||
if (match && match.state.status === "running") {
|
||||
@@ -298,7 +314,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
||||
}
|
||||
})
|
||||
},
|
||||
"tool.success": (event) => {
|
||||
"session.tool.success": (event) => {
|
||||
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
||||
const match = latestTool(draft, event.data.callID)
|
||||
if (match && match.state.status === "running") {
|
||||
@@ -321,7 +337,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
||||
}
|
||||
})
|
||||
},
|
||||
"tool.failed": (event) => {
|
||||
"session.tool.failed": (event) => {
|
||||
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
||||
const match = latestTool(draft, event.data.callID)
|
||||
if (match && (match.state.status === "pending" || match.state.status === "running")) {
|
||||
@@ -344,7 +360,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
||||
}
|
||||
})
|
||||
},
|
||||
"reasoning.started": (event) => {
|
||||
"session.reasoning.started": (event) => {
|
||||
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
||||
draft.content.push(
|
||||
castDraft(
|
||||
@@ -359,13 +375,13 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
||||
)
|
||||
})
|
||||
},
|
||||
"reasoning.delta": (event) => {
|
||||
"session.reasoning.delta": (event) => {
|
||||
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
||||
const match = latestReasoning(draft, event.data.reasoningID)
|
||||
if (match) match.text += event.data.delta
|
||||
})
|
||||
},
|
||||
"reasoning.ended": (event) => {
|
||||
"session.reasoning.ended": (event) => {
|
||||
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
||||
const match = latestReasoning(draft, event.data.reasoningID)
|
||||
if (match) {
|
||||
@@ -375,10 +391,10 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
||||
}
|
||||
})
|
||||
},
|
||||
retried: () => Effect.void,
|
||||
"compaction.started": () => Effect.void,
|
||||
"compaction.delta": () => Effect.void,
|
||||
"compaction.ended": (event) => {
|
||||
"session.retried": () => Effect.void,
|
||||
"session.compaction.started": () => Effect.void,
|
||||
"session.compaction.delta": () => Effect.void,
|
||||
"session.compaction.ended": (event) => {
|
||||
return adapter.appendMessage(
|
||||
SessionMessage.Compaction.make({
|
||||
id: SessionMessage.ID.fromEvent(event.id),
|
||||
@@ -391,9 +407,9 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
||||
}),
|
||||
)
|
||||
},
|
||||
"revert.staged": () => Effect.void,
|
||||
"revert.cleared": () => Effect.void,
|
||||
"revert.committed": () => Effect.void,
|
||||
"session.revert.staged": () => Effect.void,
|
||||
"session.revert.cleared": () => Effect.void,
|
||||
"session.revert.committed": () => Effect.void,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { DateTime, Effect, Layer, Schema } from "effect"
|
||||
import { Database } from "../database/database"
|
||||
import { EventV2 } from "../event"
|
||||
import { makeGlobalNode } from "../effect/app-node"
|
||||
import { ModelV2 } from "../model"
|
||||
import { SessionEvent } from "./event"
|
||||
import { SessionV1 } from "../v1/session"
|
||||
import { WorkspaceTable } from "../control-plane/workspace.sql"
|
||||
@@ -168,7 +169,8 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
: undefined
|
||||
if (event.data.from && !boundary) return yield* Effect.die(new Error(`Fork boundary message not found: ${event.data.from}`))
|
||||
if (event.data.from && !boundary)
|
||||
return yield* Effect.die(new Error(`Fork boundary message not found: ${event.data.from}`))
|
||||
const copied = yield* db
|
||||
.select({ seq: SessionMessageTable.seq })
|
||||
.from(SessionMessageTable)
|
||||
@@ -355,9 +357,20 @@ function run(db: DatabaseService, event: MessageEvent) {
|
||||
}
|
||||
const appendMessage = (message: SessionMessage.Message) => insertMessage(db, event, message)
|
||||
const adapter: SessionMessageUpdater.Adapter = {
|
||||
getModel() {
|
||||
return db
|
||||
.select({ model: SessionTable.model })
|
||||
.from(SessionTable)
|
||||
.where(eq(SessionTable.id, event.data.sessionID))
|
||||
.get()
|
||||
.pipe(
|
||||
Effect.orDie,
|
||||
Effect.map((row) => (row?.model ? Schema.decodeUnknownSync(ModelV2.Ref)(row.model) : undefined)),
|
||||
)
|
||||
},
|
||||
getCurrentAssistant() {
|
||||
return Effect.gen(function* () {
|
||||
// A newer turn supersedes stale incomplete rows; never resume an older assistant projection.
|
||||
// A newer step supersedes stale incomplete rows; never resume an older assistant projection.
|
||||
const row = yield* db
|
||||
.select()
|
||||
.from(SessionMessageTable)
|
||||
@@ -392,18 +405,25 @@ function run(db: DatabaseService, event: MessageEvent) {
|
||||
return message.type === "assistant" ? message : undefined
|
||||
})
|
||||
},
|
||||
getCurrentShell(callID) {
|
||||
getShell(shellID) {
|
||||
return Effect.gen(function* () {
|
||||
const rows = yield* db
|
||||
const row = yield* db
|
||||
.select()
|
||||
.from(SessionMessageTable)
|
||||
.where(and(eq(SessionMessageTable.session_id, event.data.sessionID), eq(SessionMessageTable.type, "shell")))
|
||||
.where(
|
||||
and(
|
||||
eq(SessionMessageTable.session_id, event.data.sessionID),
|
||||
eq(SessionMessageTable.type, "shell"),
|
||||
sql`json_extract(${SessionMessageTable.data}, '$.shell.id') = ${shellID}`,
|
||||
),
|
||||
)
|
||||
.orderBy(desc(SessionMessageTable.seq))
|
||||
.all()
|
||||
.limit(1)
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
return rows
|
||||
.map(decodeRow)
|
||||
.find((message): message is SessionMessage.Shell => message.type === "shell" && message.callID === callID)
|
||||
if (!row) return
|
||||
const message = decodeRow(row)
|
||||
return message.type === "shell" ? message : undefined
|
||||
})
|
||||
},
|
||||
updateAssistant: updateMessage,
|
||||
@@ -562,13 +582,13 @@ const layer = Layer.effectDiscard(
|
||||
)
|
||||
yield* events.project(SessionEvent.ModelSelected, (event) =>
|
||||
Effect.gen(function* () {
|
||||
yield* run(db, event)
|
||||
yield* db
|
||||
.update(SessionTable)
|
||||
.set({ model: event.data.model, time_updated: DateTime.toEpochMillis(event.created) })
|
||||
.where(eq(SessionTable.id, event.data.sessionID))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* run(db, event)
|
||||
}),
|
||||
)
|
||||
yield* events.project(SessionEvent.Renamed, (event) =>
|
||||
|
||||
@@ -9,16 +9,12 @@ import type { SystemContext } from "../../system-context/index"
|
||||
import type { ToolOutputStore } from "../../tool-output-store"
|
||||
|
||||
export type RunError =
|
||||
| LLMError
|
||||
| SessionRunnerModel.Error
|
||||
| MessageDecodeError
|
||||
| SystemContext.InitializationBlocked
|
||||
| ToolOutputStore.Error
|
||||
LLMError | SessionRunnerModel.Error | MessageDecodeError | SystemContext.InitializationBlocked | ToolOutputStore.Error
|
||||
|
||||
/** Runs one local continuation from already-recorded Session history. */
|
||||
export interface Interface {
|
||||
/** Drains eligible durable work. Explicit runs perform one provider attempt even when no work is eligible. */
|
||||
readonly run: (input: {
|
||||
/** Drains eligible durable work. Explicit runs perform one physical attempt even when no work is eligible. */
|
||||
readonly drain: (input: {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly force: boolean
|
||||
}) => Effect.Effect<void, RunError>
|
||||
|
||||
@@ -61,11 +61,11 @@ import { llmClient } from "../../effect/app-node-platform"
|
||||
* - Runtime context assembly
|
||||
* - Track V1 runtime-context parity canonically in `specs/v2/session.md`.
|
||||
*
|
||||
* - One provider turn
|
||||
* - One step
|
||||
* - [x] Translate every projected V2 Session message variant into canonical
|
||||
* `@opencode-ai/llm` messages.
|
||||
* - [ ] Resolve policy-filtered built-in, MCP, plugin, and structured-output tool definitions.
|
||||
* - [x] Stream exactly one `llm.stream(request)` provider turn.
|
||||
* - [x] Stream exactly one `llm.stream(request)` physical attempt.
|
||||
* - [x] Persist assistant text and usage events incrementally as they arrive.
|
||||
* - [ ] Persist snapshots, patches, and retry notices incrementally as they arrive.
|
||||
* - [x] Persist reasoning, provider errors, and tool-call events incrementally as they arrive.
|
||||
@@ -77,8 +77,8 @@ import { llmClient } from "../../effect/app-node-platform"
|
||||
* - [x] Start each recorded local call eagerly and await all settlements before continuation.
|
||||
* - [ ] Add scoped runtime context, progress updates, attachment normalization,
|
||||
* plugins, and cancellation settlement.
|
||||
* - [x] Reload projected history and start the next explicit provider turn after local tool results.
|
||||
* - [x] Continue for durable user steering accepted during an active provider turn.
|
||||
* - [x] Reload projected history and start the next explicit step after local tool results.
|
||||
* - [x] Continue for durable user steering accepted during an active step.
|
||||
* - [ ] Continue for compaction or another continuation condition when required.
|
||||
*
|
||||
* - Post-run maintenance
|
||||
@@ -86,12 +86,12 @@ import { llmClient } from "../../effect/app-node-platform"
|
||||
* - [ ] Coalesce streamed deltas and add covering projected-history indexes.
|
||||
* - [ ] Update title, summaries, compaction state, and cleanup in bounded background work.
|
||||
*
|
||||
* Use `llm.stream(request)` for each provider turn. Keep tool execution and continuation here.
|
||||
* Use `llm.stream(request)` for each physical attempt. Keep tool execution and continuation here.
|
||||
* Durable continuation recovery remains a separate future slice with an explicit retry policy.
|
||||
*
|
||||
* The current slice loads V2 history, translates it, resolves a model through a core service, and persists one
|
||||
* provider turn. Registry definitions are advertised, local tool calls are settled durably, and an
|
||||
* explicit loop starts the next provider turn after local settlement. Configured agent step limits bound the loop.
|
||||
* step. Registry definitions are advertised, local tool calls are settled durably, and an
|
||||
* explicit loop starts the next step after local settlement. Configured agent step limits bound the loop.
|
||||
*/
|
||||
|
||||
const layer = Layer.effect(
|
||||
@@ -114,7 +114,7 @@ const layer = Layer.effect(
|
||||
const db = (yield* Database.Service).db
|
||||
const compaction = yield* SessionCompaction.Service
|
||||
const title = yield* SessionTitle.Service
|
||||
// Title generation is a side effect of the first turn; it must not delay turn continuation.
|
||||
// Title generation is a side effect of the first step; it must not delay step continuation.
|
||||
// Tracked per process so repeated wakes before the second user message arrives don't
|
||||
// re-fire a redundant LLM call; `SessionTitle` itself is idempotent based on durable history.
|
||||
const titleAttempted = new Set<SessionSchema.ID>()
|
||||
@@ -166,7 +166,7 @@ const layer = Layer.effect(
|
||||
{ concurrency: "unbounded" },
|
||||
).pipe(Effect.map(SystemContext.combine))
|
||||
|
||||
const runTurnAttempt = Effect.fn("SessionRunner.runTurnAttempt")(function* (
|
||||
const attemptStep = Effect.fn("SessionRunner.attemptStep")(function* (
|
||||
sessionID: SessionSchema.ID,
|
||||
promotion: SessionInput.Delivery | undefined,
|
||||
step: number,
|
||||
@@ -177,7 +177,7 @@ const layer = Layer.effect(
|
||||
return yield* Effect.interrupt
|
||||
const agent = yield* agents.select(session.agent)
|
||||
// Establish what the model knows before admitting what the user said, so
|
||||
// a blocked first turn leaves pending inputs untouched.
|
||||
// a blocked first step leaves pending inputs untouched.
|
||||
const checkpoint = yield* SessionContextCheckpoint.prepare(
|
||||
db,
|
||||
events,
|
||||
@@ -231,7 +231,7 @@ const layer = Layer.effect(
|
||||
snapshot: startSnapshot,
|
||||
})
|
||||
const publication = Semaphore.makeUnsafe(1)
|
||||
// Durable publishes are serialized so tool fibers and turn settlement never interleave
|
||||
// Durable publishes are serialized so tool fibers and step settlement never interleave
|
||||
// mid-event.
|
||||
const serialized = <A, E, R>(effect: Effect.Effect<A, E, R>) => publication.withPermit(effect)
|
||||
const publish = (event: LLMEvent, outputPaths: ReadonlyArray<string> = []) =>
|
||||
@@ -282,7 +282,7 @@ const layer = Layer.effect(
|
||||
Effect.ensuring(serialized(publisher.flush())),
|
||||
)
|
||||
|
||||
// Captures the end snapshot, diffs it against the turn's start, and durably ends the
|
||||
// Captures the end snapshot, diffs it against the step's start, and durably ends the
|
||||
// assistant step.
|
||||
const publishStepEnd = (settlement: NonNullable<ReturnType<typeof publisher.stepSettlement>>) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -316,7 +316,7 @@ const layer = Layer.effect(
|
||||
const streamInterrupted = stream._tag === "Failure" && Cause.hasInterrupts(stream.cause)
|
||||
|
||||
// A context overflow before any assistant output is recoverable: compact and
|
||||
// restart the turn instead of surfacing the provider error.
|
||||
// restart the step instead of surfacing the provider error.
|
||||
if (
|
||||
recoverOverflow &&
|
||||
!publisher.hasAssistantStarted() &&
|
||||
@@ -325,7 +325,7 @@ const layer = Layer.effect(
|
||||
)
|
||||
return { _tag: "RestartAfterOverflowCompaction", step: currentStep } as const
|
||||
|
||||
// An unrecovered held-back overflow becomes the turn's durable provider error. A
|
||||
// An unrecovered held-back overflow becomes the step's durable provider error. A
|
||||
// thrown LLM failure fails hosted tool calls and the assistant unless a provider
|
||||
// error was already recorded from the stream.
|
||||
if (overflowFailure) yield* publish(overflowFailure)
|
||||
@@ -346,12 +346,12 @@ const layer = Layer.effect(
|
||||
if (questionDismissed || streamInterrupted || toolsInterrupted) {
|
||||
yield* FiberSet.clear(toolFibers)
|
||||
yield* serialized(publisher.failUnsettledTools("Tool execution interrupted"))
|
||||
yield* serialized(publisher.failAssistant("Provider turn interrupted"))
|
||||
yield* serialized(publisher.failAssistant("Step interrupted"))
|
||||
// Match V1: dismissing a question halts the loop like an interruption.
|
||||
if (questionDismissed) return yield* Effect.interrupt
|
||||
}
|
||||
// A settled tool fiber failure is one of two things. A defect from a tool
|
||||
// implementation becomes a failed tool call the model can read, and the turn still
|
||||
// implementation becomes a failed tool call the model can read, and the step still
|
||||
// settles so the model may recover. A typed infrastructure failure (tool output
|
||||
// could not be persisted) also fails the assistant and then fails the drain.
|
||||
const settledFailure = settled._tag === "Failure" && !toolsInterrupted ? settled.cause : undefined
|
||||
@@ -387,7 +387,7 @@ const layer = Layer.effect(
|
||||
)
|
||||
}, Effect.scoped)
|
||||
|
||||
const runTurn = Effect.fnUntraced(function* (
|
||||
const runStep = Effect.fnUntraced(function* (
|
||||
sessionID: SessionSchema.ID,
|
||||
promotion: SessionInput.Delivery | undefined,
|
||||
step: number,
|
||||
@@ -399,7 +399,7 @@ const layer = Layer.effect(
|
||||
let currentPromotion = promotion
|
||||
let currentStep = step
|
||||
while (true) {
|
||||
const attempt = yield* runTurnAttempt(sessionID, currentPromotion, currentStep, recoverOverflow)
|
||||
const attempt = yield* attemptStep(sessionID, currentPromotion, currentStep, recoverOverflow)
|
||||
if (attempt._tag === "Completed") return { needsContinuation: attempt.needsContinuation, step: attempt.step }
|
||||
if (attempt._tag === "RestartAfterOverflowCompaction") recoverOverflow = undefined
|
||||
yield* Effect.yieldNow
|
||||
@@ -410,7 +410,7 @@ const layer = Layer.effect(
|
||||
|
||||
// ExecutionSettled is published per execution (busy period) by SessionExecution, not per
|
||||
// drain here.
|
||||
const run = Effect.fn("SessionRunner.run")(function* (input: {
|
||||
const drain = Effect.fn("SessionRunner.drain")(function* (input: {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly force: boolean
|
||||
}) {
|
||||
@@ -428,8 +428,8 @@ const layer = Layer.effect(
|
||||
// a provider error suppresses it. Pending steers also continue the loop so
|
||||
// interjections are answered before the session goes idle.
|
||||
while (needsContinuation) {
|
||||
const result = yield* runTurn(input.sessionID, promotion, step)
|
||||
// Steer/queue promotion inside runTurn has already made the pending input a visible
|
||||
const result = yield* runStep(input.sessionID, promotion, step)
|
||||
// Steer/queue promotion inside runStep has already made the pending input a visible
|
||||
// user message by this point, so the first-user-message check below is reliable.
|
||||
if (!titleAttempted.has(input.sessionID)) {
|
||||
titleAttempted.add(input.sessionID)
|
||||
@@ -445,7 +445,7 @@ const layer = Layer.effect(
|
||||
}
|
||||
})
|
||||
|
||||
return Service.of({ run })
|
||||
return Service.of({ drain })
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ const settledOutput = (value: ToolOutput | undefined, result: ToolResultValue):
|
||||
return { structured: record(settled.structured), content: settled.content }
|
||||
}
|
||||
|
||||
/** Persist one provider turn without executing tools or starting a continuation turn. */
|
||||
/** Persist one step without executing tools or starting a continuation step. */
|
||||
export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) => {
|
||||
const tools = new Map<
|
||||
string,
|
||||
|
||||
@@ -139,7 +139,7 @@ function toLLMMessage(message: SessionMessage.Message, model: Model): Message[]
|
||||
Message.make({
|
||||
id: message.id,
|
||||
role: "user",
|
||||
content: `Shell command: ${message.command}\n\n${message.output}`,
|
||||
content: `Shell command: ${message.shell.command}\n\n${message.output?.output ?? ""}`,
|
||||
metadata: message.metadata,
|
||||
}),
|
||||
]
|
||||
|
||||
@@ -3,7 +3,7 @@ export * as SkillV2 from "./skill"
|
||||
import { makeLocationNode } from "./effect/app-node"
|
||||
import path from "path"
|
||||
import { Context, Effect, Layer, Schema, Stream, Types } from "effect"
|
||||
import { FileSystemWatcher } from "@opencode-ai/schema/filesystem-watcher"
|
||||
import { FileSystem } from "@opencode-ai/schema/filesystem"
|
||||
import { Skill } from "@opencode-ai/schema/skill"
|
||||
import { AgentV2 } from "./agent"
|
||||
import { ConfigMarkdown } from "./config/markdown"
|
||||
@@ -153,7 +153,7 @@ const layer = Layer.effect(
|
||||
yield* events.publish(Event.Updated, {}).pipe(Effect.asVoid)
|
||||
})
|
||||
|
||||
yield* events.subscribe(FileSystemWatcher.Event.Updated).pipe(
|
||||
yield* events.subscribe(FileSystem.Event.Changed).pipe(
|
||||
Stream.runForEach((event) => invalidate(event.data.file)),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
|
||||
@@ -12,7 +12,7 @@ import { Effect, Option, Schema } from "effect"
|
||||
* The durable `Applied` record tracks what the model was last told, per source:
|
||||
* it is the model's current belief. Interpreters uphold one invariant —
|
||||
* `reconcile` never rewrites the baseline; it only narrates drift as update
|
||||
* text. Only `rebaseline` (compaction) and `initialize` (first turn) produce
|
||||
* text. Only `rebaseline` (compaction) and `initialize` (first step) produce
|
||||
* baseline text.
|
||||
*
|
||||
* Returning `unavailable` means observation failed temporarily. It differs from
|
||||
|
||||
@@ -28,7 +28,8 @@ Leaves own resolution, permission, and side-effect ordering. Translate only expe
|
||||
|
||||
## Registration
|
||||
|
||||
Built-ins and plugin tools register through `Tools.Service.register({ [name]: tool })`.
|
||||
Built-ins and plugin tools register through `Tools.Service.register({ [name]: tool })`. Registrations may provide a
|
||||
group, which flattens direct model names to `<group>_<tool>`, and may be deferred from direct model exposure.
|
||||
|
||||
Registrations are scoped:
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
|
||||
import { Effect, Schema } from "effect"
|
||||
import path from "path"
|
||||
import { FileSystem } from "../filesystem"
|
||||
import { FSUtil } from "../fs-util"
|
||||
import { Location } from "../location"
|
||||
import { Ripgrep } from "../ripgrep"
|
||||
import { RelativePath } from "../schema"
|
||||
@@ -36,6 +37,7 @@ export const toModelOutput = (output: ModelOutput) => {
|
||||
export const Plugin = {
|
||||
id: "core-glob-tool",
|
||||
effect: Effect.fn("GlobTool.Plugin")(function* (ctx: PluginContext) {
|
||||
const fs = yield* FSUtil.Service
|
||||
const ripgrep = yield* Ripgrep.Service
|
||||
const location = yield* Location.Service
|
||||
const permission = yield* PermissionV2.Service
|
||||
@@ -71,6 +73,13 @@ export const Plugin = {
|
||||
source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
|
||||
})
|
||||
const cwd = path.resolve(location.directory, input.path ?? ".")
|
||||
yield* fs
|
||||
.stat(cwd)
|
||||
.pipe(
|
||||
Effect.catchReason("PlatformError", "NotFound", () =>
|
||||
Effect.fail(new ToolFailure({ message: `Search path does not exist: ${input.path ?? "."}` })),
|
||||
),
|
||||
)
|
||||
return yield* ripgrep
|
||||
.glob({
|
||||
cwd,
|
||||
@@ -88,7 +97,11 @@ export const Plugin = {
|
||||
),
|
||||
)
|
||||
}).pipe(
|
||||
Effect.mapError(() => new ToolFailure({ message: `Unable to find files matching ${input.pattern}` })),
|
||||
Effect.mapError((error) =>
|
||||
error instanceof ToolFailure
|
||||
? error
|
||||
: new ToolFailure({ message: `Unable to find files matching ${input.pattern}` }),
|
||||
),
|
||||
),
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -91,7 +91,13 @@ export const Plugin = {
|
||||
source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
|
||||
})
|
||||
const target = path.resolve(location.directory, input.path ?? ".")
|
||||
const info = yield* fs.stat(target).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
const info = yield* fs
|
||||
.stat(target)
|
||||
.pipe(
|
||||
Effect.catchReason("PlatformError", "NotFound", () =>
|
||||
Effect.fail(new ToolFailure({ message: `Search path does not exist: ${input.path ?? "."}` })),
|
||||
),
|
||||
)
|
||||
return yield* ripgrep
|
||||
.grep({
|
||||
cwd: info?.type === "Directory" ? target : path.dirname(target),
|
||||
@@ -121,7 +127,13 @@ export const Plugin = {
|
||||
),
|
||||
),
|
||||
)
|
||||
}).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to grep for ${input.pattern}` }))),
|
||||
}).pipe(
|
||||
Effect.mapError((error) =>
|
||||
error instanceof ToolFailure
|
||||
? error
|
||||
: new ToolFailure({ message: `Unable to grep for ${input.pattern}` }),
|
||||
),
|
||||
),
|
||||
}),
|
||||
})
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
export * as McpTool from "./mcp"
|
||||
|
||||
import { createHash } from "node:crypto"
|
||||
import { ToolFailure } from "@opencode-ai/llm"
|
||||
import { McpEvent } from "@opencode-ai/schema/mcp-event"
|
||||
import { Effect, Exit, type JsonSchema, Layer, Scope, Semaphore, Stream } from "effect"
|
||||
@@ -11,35 +10,11 @@ import { Tool } from "./tool"
|
||||
import { Tools } from "./tools"
|
||||
import { ToolRegistry } from "./registry"
|
||||
|
||||
const MAX_NAME_LENGTH = 64
|
||||
const HASH_LENGTH = 8
|
||||
|
||||
const sanitize = (value: string) => value.replace(/[^A-Za-z0-9_-]/g, "_")
|
||||
|
||||
// Deterministic short suffix used to keep overlong or colliding names unique and stable across restarts.
|
||||
const hashSuffix = (raw: string) => "_" + createHash("sha1").update(raw).digest("hex").slice(0, HASH_LENGTH)
|
||||
|
||||
const fit = (base: string, raw: string) => base.slice(0, MAX_NAME_LENGTH - HASH_LENGTH - 1) + hashSuffix(raw)
|
||||
|
||||
/**
|
||||
* Registry/permission action name for an MCP tool: V1-compatible `<server>_<tool>` so existing deny
|
||||
* rules keep working. Sanitized to a valid tool name, prefixed when it would not start with a letter,
|
||||
* and hashed down when it would exceed the 64-char limit.
|
||||
* Registry and permission action name for an MCP tool.
|
||||
*/
|
||||
export const name = (server: string, tool: string) => {
|
||||
const joined = sanitize(server) + "_" + sanitize(tool)
|
||||
const base = /^[A-Za-z]/.test(joined) ? joined : "mcp_" + joined
|
||||
return base.length > MAX_NAME_LENGTH ? fit(base, `${server}\u0000${tool}`) : base
|
||||
}
|
||||
|
||||
const toContent = (part: MCP.ToolResultContent): Tool.Content =>
|
||||
part.type === "text" ? { type: "text", text: part.text } : { type: "file", data: part.data, mime: part.mimeType }
|
||||
|
||||
const errorText = (content: ReadonlyArray<MCP.ToolResultContent>) =>
|
||||
content
|
||||
.flatMap((part) => (part.type === "text" ? [part.text] : []))
|
||||
.join("\n")
|
||||
.trim()
|
||||
export const name = (server: string, tool: string) =>
|
||||
`${server.replace(/[^a-zA-Z0-9_-]/g, "_")}_${tool.replace(/[^a-zA-Z0-9_-]/g, "_")}`
|
||||
|
||||
export const layer = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
@@ -50,47 +25,71 @@ export const layer = Layer.effectDiscard(
|
||||
const lock = Semaphore.makeUnsafe(1)
|
||||
let current: Scope.Closeable | undefined
|
||||
|
||||
const make = (server: MCP.ServerName, tool: MCP.Tool) =>
|
||||
Tool.make({
|
||||
description: tool.description ?? "",
|
||||
jsonSchema: (tool.inputSchema as JsonSchema.JsonSchema | undefined) ?? { type: "object", properties: {} },
|
||||
execute: (input) =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* mcp.callTool({ server, name: tool.name, args: (input ?? {}) as Record<string, unknown> }).pipe(
|
||||
Effect.catchTags({
|
||||
"MCP.NotFoundError": (error) => new ToolFailure({ message: `MCP server "${error.server}" is not available` }),
|
||||
"MCP.ToolCallError": (error) => new ToolFailure({ message: error.message }),
|
||||
}),
|
||||
)
|
||||
if (result.isError)
|
||||
return yield* new ToolFailure({ message: errorText(result.content) || "MCP tool returned an error" })
|
||||
return { structured: result.structured ?? {}, content: result.content.map(toContent) }
|
||||
}),
|
||||
})
|
||||
|
||||
// Register the current tool set under a fresh child scope, then close the previous one so the
|
||||
// registry never has a gap where MCP tools disappear mid-swap.
|
||||
const reconcile = lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
const used = new Set<string>()
|
||||
const record: Record<string, Tool.AnyTool> = {}
|
||||
const groups = new Map<string, Record<string, Tool.AnyTool>>()
|
||||
for (const tool of yield* mcp.tools()) {
|
||||
const initial = name(tool.server, tool.name)
|
||||
const key = used.has(initial) ? fit(initial, `${tool.server}\u0000${tool.name}`) : initial
|
||||
used.add(key)
|
||||
record[key] = make(tool.server, tool)
|
||||
const group = groups.get(tool.server) ?? {}
|
||||
const schema = (tool.inputSchema ?? {}) as JsonSchema.JsonSchema
|
||||
group[tool.name] = Tool.make({
|
||||
description: tool.description ?? "",
|
||||
jsonSchema: {
|
||||
...schema,
|
||||
type: "object",
|
||||
properties: schema.properties ?? {},
|
||||
additionalProperties: false,
|
||||
},
|
||||
execute: (input) =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* mcp
|
||||
.callTool({
|
||||
server: tool.server,
|
||||
name: tool.name,
|
||||
args: (input ?? {}) as Record<string, unknown>,
|
||||
})
|
||||
.pipe(
|
||||
Effect.catchTags({
|
||||
"MCP.NotFoundError": (error) =>
|
||||
new ToolFailure({ message: `MCP server "${error.server}" is not available` }),
|
||||
"MCP.ToolCallError": (error) => new ToolFailure({ message: error.message }),
|
||||
}),
|
||||
)
|
||||
if (result.isError)
|
||||
return yield* new ToolFailure({
|
||||
message:
|
||||
result.content
|
||||
.flatMap((part) => (part.type === "text" ? [part.text] : []))
|
||||
.join("\n")
|
||||
.trim() || "MCP tool returned an error",
|
||||
})
|
||||
return {
|
||||
structured: result.structured ?? {},
|
||||
content: result.content.map((part) =>
|
||||
part.type === "text"
|
||||
? { type: "text" as const, text: part.text }
|
||||
: { type: "file" as const, data: part.data, mime: part.mimeType },
|
||||
),
|
||||
}
|
||||
}),
|
||||
})
|
||||
groups.set(tool.server, group)
|
||||
}
|
||||
const next = yield* Scope.fork(scope)
|
||||
yield* tools.register(record).pipe(Scope.provide(next), Effect.orDie)
|
||||
yield* Effect.forEach(groups, ([group, record]) => tools.register(record, { group }), {
|
||||
discard: true,
|
||||
}).pipe(Scope.provide(next), Effect.orDie)
|
||||
if (current) yield* Scope.close(current, Exit.void)
|
||||
current = next
|
||||
}),
|
||||
)
|
||||
|
||||
yield* reconcile.pipe(Effect.forkScoped)
|
||||
yield* events
|
||||
.subscribe(McpEvent.ToolsChanged)
|
||||
.pipe(Stream.runForEach(() => reconcile), Effect.forkScoped({ startImmediately: true }))
|
||||
yield* events.subscribe(McpEvent.ToolsChanged).pipe(
|
||||
Stream.runForEach(() => reconcile),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -23,7 +23,10 @@ export type ExecuteInput = {
|
||||
export interface Interface {
|
||||
readonly materialize: (input: MaterializeInput) => Effect.Effect<Materialization>
|
||||
/** Internal registration capability exposed publicly only through Tools.Service. */
|
||||
readonly register: (tools: Readonly<Record<string, AnyTool>>) => Effect.Effect<void, RegistrationError, Scope.Scope>
|
||||
readonly register: (
|
||||
tools: Readonly<Record<string, AnyTool>>,
|
||||
options?: Tools.RegisterOptions,
|
||||
) => Effect.Effect<void, RegistrationError, Scope.Scope>
|
||||
}
|
||||
|
||||
export interface MaterializeInput {
|
||||
@@ -49,7 +52,13 @@ const registryLayer = Layer.effect(
|
||||
Effect.gen(function* () {
|
||||
const resources = yield* ToolOutputStore.Service
|
||||
const toolHooks = yield* ToolHooks.Service
|
||||
type Registration = { readonly identity: object; readonly tool: AnyTool }
|
||||
type Registration = {
|
||||
readonly identity: object
|
||||
readonly tool: AnyTool
|
||||
readonly name: string
|
||||
readonly group?: string
|
||||
readonly deferred: boolean
|
||||
}
|
||||
const local = new Map<string, Array<{ readonly token: object; readonly registration: Registration }>>()
|
||||
|
||||
const settleWith = Effect.fn("ToolRegistry.settle")(function* (input: ExecuteInput, advertised?: object) {
|
||||
@@ -73,12 +82,16 @@ const registryLayer = Layer.effect(
|
||||
input: input.call.input,
|
||||
}
|
||||
yield* toolHooks.runBefore(beforeEvent)
|
||||
const pending = yield* settle(registration.tool, { ...input.call, input: beforeEvent.input }, {
|
||||
sessionID: input.sessionID,
|
||||
agent: input.agent,
|
||||
assistantMessageID: input.assistantMessageID,
|
||||
toolCallID: input.call.id,
|
||||
}).pipe(
|
||||
const pending = yield* settle(
|
||||
registration.tool,
|
||||
{ ...input.call, input: beforeEvent.input },
|
||||
{
|
||||
sessionID: input.sessionID,
|
||||
agent: input.agent,
|
||||
assistantMessageID: input.assistantMessageID,
|
||||
toolCallID: input.call.id,
|
||||
},
|
||||
).pipe(
|
||||
Effect.map((output) => ({ output })),
|
||||
Effect.catchTag("LLM.ToolFailure", (failure) =>
|
||||
Effect.succeed({ result: { type: "error" as const, value: failure.message } }),
|
||||
@@ -88,7 +101,11 @@ const registryLayer = Layer.effect(
|
||||
if ("result" in pending) {
|
||||
settlement = pending
|
||||
} else {
|
||||
const bounded = yield* resources.bound({ sessionID: input.sessionID, toolCallID: input.call.id, output: pending.output })
|
||||
const bounded = yield* resources.bound({
|
||||
sessionID: input.sessionID,
|
||||
toolCallID: input.call.id,
|
||||
output: pending.output,
|
||||
})
|
||||
const result = ToolOutput.toResultValue(bounded.output)
|
||||
settlement =
|
||||
result.type === "error"
|
||||
@@ -119,20 +136,33 @@ const registryLayer = Layer.effect(
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
register: Effect.fn("ToolRegistry.register")(function* (tools) {
|
||||
const entries = registrationEntries(tools)
|
||||
register: Effect.fn("ToolRegistry.register")(function* (tools, options) {
|
||||
const entries = registrationEntries(tools, options?.group)
|
||||
if (entries.length === 0) return
|
||||
yield* Effect.uninterruptible(
|
||||
Effect.gen(function* () {
|
||||
const token = {}
|
||||
for (const [name, tool] of entries)
|
||||
local.set(name, [...(local.get(name) ?? []), { token, registration: { identity: {}, tool } }])
|
||||
for (const entry of entries)
|
||||
local.set(entry.key, [
|
||||
...(local.get(entry.key) ?? []),
|
||||
{
|
||||
token,
|
||||
registration: {
|
||||
identity: {},
|
||||
tool: entry.tool,
|
||||
name: entry.name,
|
||||
group: entry.group,
|
||||
deferred: options?.deferred ?? false,
|
||||
},
|
||||
},
|
||||
])
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.sync(() => {
|
||||
for (const [name] of entries) {
|
||||
const registrations = local.get(name)?.filter((registration) => registration.token !== token) ?? []
|
||||
if (registrations.length > 0) local.set(name, registrations)
|
||||
else local.delete(name)
|
||||
for (const entry of entries) {
|
||||
const registrations =
|
||||
local.get(entry.key)?.filter((registration) => registration.token !== token) ?? []
|
||||
if (registrations.length > 0) local.set(entry.key, registrations)
|
||||
else local.delete(entry.key)
|
||||
}
|
||||
}),
|
||||
)
|
||||
@@ -149,7 +179,11 @@ const registryLayer = Layer.effect(
|
||||
const usePatch = input.model.provider.toLowerCase() === "openai" || input.model.id.toLowerCase().includes("gpt")
|
||||
for (const [name, registration] of registrations) {
|
||||
const wrongEditTool = name === "apply_patch" ? !usePatch : (name === "edit" || name === "write") && usePatch
|
||||
if (wrongEditTool || whollyDisabled(permission(registration.tool, name), input.permissions ?? []))
|
||||
if (
|
||||
registration.deferred ||
|
||||
wrongEditTool ||
|
||||
whollyDisabled(permission(registration.tool, name), input.permissions ?? [])
|
||||
)
|
||||
registrations.delete(name)
|
||||
}
|
||||
return {
|
||||
|
||||
@@ -18,8 +18,9 @@ export const DEFAULT_TIMEOUT_MS = 2 * 60 * 1_000
|
||||
export const MAX_TIMEOUT_MS = 10 * 60 * 1_000
|
||||
export const MAX_CAPTURE_BYTES = 1024 * 1024
|
||||
|
||||
const BACKGROUND_STARTED =
|
||||
"The command has not completed; it is now running in the background."
|
||||
const BACKGROUND_STARTED = "The command was moved to the background."
|
||||
const BACKGROUND_INSTRUCTION =
|
||||
"You will be notified automatically when the command finishes. DO NOT sleep, poll, or proactively check on its progress."
|
||||
|
||||
export const Input = Schema.Struct({
|
||||
command: Schema.String.annotate({ description: "Shell command string to execute" }),
|
||||
@@ -54,10 +55,11 @@ const Output = Schema.Struct({
|
||||
type Output = typeof Output.Type
|
||||
|
||||
const modelOutput = (output: Output): string | undefined => {
|
||||
if (output.status === "running") return undefined
|
||||
const warnings = output.warnings?.length
|
||||
? `\n\nWarnings:\n${output.warnings.map((warning) => `- ${warning}`).join("\n")}`
|
||||
: ""
|
||||
if (output.status === "running")
|
||||
return `${warnings.trimStart()}${warnings ? "\n\n" : ""}${BACKGROUND_INSTRUCTION}`
|
||||
if (output.timeout) return `${warnings.trimStart()}${warnings ? "\n\n" : ""}Command timed out before completion.`
|
||||
return `${warnings.trimStart()}${warnings ? "\n\n" : ""}Command exited with code ${output.exit}.`
|
||||
}
|
||||
|
||||
@@ -3,9 +3,12 @@ export * as Tools from "./tools"
|
||||
import { Context, Effect, Scope } from "effect"
|
||||
import { Tool } from "./tool"
|
||||
|
||||
export type RegisterOptions = Tool.RegisterOptions
|
||||
|
||||
export interface Interface {
|
||||
readonly register: (
|
||||
tools: Readonly<Record<string, Tool.AnyTool>>,
|
||||
options?: Tool.RegisterOptions,
|
||||
) => Effect.Effect<void, Tool.RegistrationError, Scope.Scope>
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ export * as ConfigV1 from "./config"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { NonNegativeInt, PositiveInt, type DeepMutable } from "../../schema"
|
||||
import { ConfigExperimental } from "../../config/experimental"
|
||||
import { ConfigReference } from "../../config/reference"
|
||||
import { ConfigAgentV1 } from "./agent"
|
||||
import { ConfigAttachmentV1 } from "./attachment"
|
||||
@@ -179,9 +178,6 @@ export const Info = Schema.Struct({
|
||||
mcp_timeout: Schema.optional(PositiveInt).annotate({
|
||||
description: "Timeout in milliseconds for model context protocol (MCP) requests",
|
||||
}),
|
||||
policies: Schema.optional(Schema.mutable(Schema.Array(ConfigExperimental.Policy))).annotate({
|
||||
description: "Policy statements applied to supported resources, such as provider access",
|
||||
}),
|
||||
}),
|
||||
),
|
||||
}).annotate({ identifier: "Config" })
|
||||
|
||||
@@ -78,7 +78,6 @@ export function migrate(info: typeof ConfigV1.Info.Type) {
|
||||
plugins: info.plugin?.map((plugin) =>
|
||||
typeof plugin === "string" ? plugin : { package: plugin[0], options: plugin[1] },
|
||||
),
|
||||
experimental: info.experimental?.policies && { policies: info.experimental.policies },
|
||||
providers: providers(info.provider),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { Policy } from "@opencode-ai/core/policy"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { location } from "./fixture/location"
|
||||
@@ -24,7 +23,7 @@ const locationLayer = Layer.succeed(
|
||||
Location.Service.of(location({ directory: AbsolutePath.make("test") })),
|
||||
)
|
||||
const catalogLayer = AppNodeBuilder.build(
|
||||
LayerNode.group([Catalog.node, EventV2.node, Credential.node, Integration.node, Policy.node]),
|
||||
LayerNode.group([Catalog.node, EventV2.node, Credential.node, Integration.node]),
|
||||
[[Location.node, locationLayer]],
|
||||
)
|
||||
const it = testEffect(catalogLayer)
|
||||
@@ -333,21 +332,4 @@ describe("CatalogV2", () => {
|
||||
expect((yield* catalog.model.small(providerID))?.id).toMatch("expensive-mini")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("removes providers denied by policy after loading", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const policy = yield* Policy.Service
|
||||
const providerID = ProviderV2.ID.make("blocked")
|
||||
yield* policy.load([new Policy.Info({ effect: "deny", action: "provider.use", resource: "blocked" })])
|
||||
yield* catalog.transform((catalog) => {
|
||||
catalog.provider.update(providerID, () => {})
|
||||
catalog.model.update(providerID, ModelV2.ID.make("model"), () => {})
|
||||
})
|
||||
|
||||
expect(yield* catalog.provider.all()).toEqual([])
|
||||
expect(yield* catalog.model.all()).toEqual([])
|
||||
expect(yield* catalog.provider.get(providerID)).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Effect, PubSub, Schema, Stream } from "effect"
|
||||
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
|
||||
import { CommandV2 } from "@opencode-ai/core/command"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { ConfigCommandPlugin } from "@opencode-ai/core/config/plugin/command"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { MCP } from "@opencode-ai/core/mcp/index"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
@@ -19,7 +21,7 @@ import { testEffect } from "../lib/effect"
|
||||
import { host } from "../plugin/host"
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([CommandV2.node, FSUtil.node]), [
|
||||
AppNodeBuilder.build(LayerNode.group([CommandV2.node, EventV2.node, FSUtil.node]), [
|
||||
[MCP.node, emptyMcpLayer],
|
||||
[Config.node, emptyConfigLayer],
|
||||
[Location.node, testLocationLayer],
|
||||
@@ -53,7 +55,19 @@ Review files`,
|
||||
})
|
||||
|
||||
const command = yield* CommandV2.Service
|
||||
yield* ConfigCommandPlugin.Plugin.effect(host({ command: { ...command, reload: command.reload } })).pipe(
|
||||
const events = yield* EventV2.Service
|
||||
const update = yield* events.publish(ConfigSchema.Event.Updated, {})
|
||||
const updates = yield* PubSub.unbounded<typeof update>()
|
||||
yield* ConfigCommandPlugin.Plugin.effect(
|
||||
host({
|
||||
command: {
|
||||
list: () => Effect.die("unused command.list"),
|
||||
transform: command.transform,
|
||||
reload: command.reload,
|
||||
},
|
||||
event: { subscribe: () => Stream.fromPubSub(updates) },
|
||||
}),
|
||||
).pipe(
|
||||
Effect.provideService(
|
||||
Config.Service,
|
||||
Config.Service.of({
|
||||
@@ -85,6 +99,15 @@ Review files`,
|
||||
CommandV2.Info.make({ name: "empty", template: "" }),
|
||||
CommandV2.Info.make({ name: "nested/docs", template: "Write docs" }),
|
||||
])
|
||||
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, "commands", "review.md"), "Review again"))
|
||||
yield* Effect.sleep("10 millis")
|
||||
yield* PubSub.publish(updates, update)
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
if ((yield* command.get("review"))?.template === "Review again") break
|
||||
yield* Effect.sleep("10 millis")
|
||||
}
|
||||
expect((yield* command.get("review"))?.template).toBe("Review again")
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { Effect, Fiber, Layer, PubSub, Schema, Stream } from "effect"
|
||||
import { FastCheck } from "effect/testing"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
|
||||
import { ConfigProvider } from "@opencode-ai/core/config/provider"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { ConfigMigrateV1 } from "@opencode-ai/core/v1/config/migrate"
|
||||
import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Policy } from "@opencode-ai/core/policy"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { location } from "../fixture/location"
|
||||
@@ -26,6 +28,7 @@ function testLayer(
|
||||
globalDirectory = path.join(directory, "global"),
|
||||
projectDirectory = directory,
|
||||
vcs?: Project.Vcs,
|
||||
watcher?: Layer.Layer<Watcher.Service>,
|
||||
) {
|
||||
const locationLayer = Layer.succeed(
|
||||
Location.Service,
|
||||
@@ -36,9 +39,10 @@ function testLayer(
|
||||
),
|
||||
),
|
||||
)
|
||||
return AppNodeBuilder.build(LayerNode.group([Config.node, Policy.node]), [
|
||||
return AppNodeBuilder.build(LayerNode.group([Config.node, EventV2.node]), [
|
||||
[Location.node, locationLayer],
|
||||
[Global.node, Global.layerWith({ config: globalDirectory })],
|
||||
...(watcher ? ([[Watcher.node, watcher]] as const) : []),
|
||||
])
|
||||
}
|
||||
|
||||
@@ -52,6 +56,52 @@ const provider = {
|
||||
}
|
||||
|
||||
describe("Config", () => {
|
||||
it.live("reloads external config and publishes directory updates", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const global = path.join(tmp.path, "global")
|
||||
const project = path.join(tmp.path, "project")
|
||||
const file = path.join(global, "opencode.json")
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(global, { recursive: true })
|
||||
await fs.mkdir(project, { recursive: true })
|
||||
await fs.writeFile(file, JSON.stringify({ shell: "first" }))
|
||||
})
|
||||
const updates = yield* PubSub.unbounded<Watcher.Update>()
|
||||
const watcher = Layer.succeed(
|
||||
Watcher.Service,
|
||||
Watcher.Service.of({
|
||||
subscribe: () => Stream.fromPubSub(updates),
|
||||
}),
|
||||
)
|
||||
|
||||
return yield* Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const events = yield* EventV2.Service
|
||||
const changed = yield* events
|
||||
.subscribe(ConfigSchema.Event.Updated)
|
||||
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
||||
yield* Effect.sleep("10 millis")
|
||||
|
||||
yield* PubSub.publish(updates, {
|
||||
type: "update",
|
||||
path: path.join(global, "commands", "review.md"),
|
||||
} satisfies Watcher.Update)
|
||||
yield* Effect.promise(() => fs.writeFile(file, JSON.stringify({ shell: "second" })))
|
||||
yield* PubSub.publish(updates, { type: "update", path: file } satisfies Watcher.Update)
|
||||
|
||||
expect(yield* Fiber.join(changed)).toHaveLength(1)
|
||||
expect(Config.latest(yield* config.entries(), "shell")).toBe("second")
|
||||
}).pipe(Effect.provide(testLayer(project, global, project, undefined, watcher)))
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("returns the latest defined scalar from priority-ordered documents", () =>
|
||||
Effect.sync(() => {
|
||||
const entries = [
|
||||
@@ -242,6 +292,72 @@ describe("Config", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("substitutes environment variables and relative file contents", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const previous = {
|
||||
token: process.env.OPENCODE_TEST_MCP_TOKEN,
|
||||
missing: process.env.OPENCODE_TEST_MISSING,
|
||||
}
|
||||
process.env.OPENCODE_TEST_MCP_TOKEN = "secret"
|
||||
delete process.env.OPENCODE_TEST_MISSING
|
||||
return previous
|
||||
}),
|
||||
() =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() =>
|
||||
Promise.all([
|
||||
fs.writeFile(path.join(tmp.path, "token.txt"), 'file\n"token"\n'),
|
||||
fs.writeFile(
|
||||
path.join(tmp.path, "opencode.jsonc"),
|
||||
`{
|
||||
// Ignored reference: {file:missing.txt}
|
||||
"username": "user-{env:OPENCODE_TEST_MISSING}",
|
||||
"mcp": {
|
||||
"servers": {
|
||||
"remote": {
|
||||
"type": "remote",
|
||||
"url": "https://example.com/mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer {env:OPENCODE_TEST_MCP_TOKEN}",
|
||||
"X-Token": "{file:token.txt}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
),
|
||||
]),
|
||||
)
|
||||
|
||||
return yield* Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const document = (yield* config.entries()).find((entry) => entry.type === "document")
|
||||
expect(document?.info.username).toBe("user-")
|
||||
const remote = document?.info.mcp?.servers?.remote
|
||||
expect(remote?.type).toBe("remote")
|
||||
if (remote?.type !== "remote") return
|
||||
expect(remote.headers).toEqual({
|
||||
Authorization: "Bearer secret",
|
||||
"X-Token": 'file\n"token"',
|
||||
})
|
||||
}).pipe(Effect.provide(testLayer(tmp.path)))
|
||||
}),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
),
|
||||
(previous) =>
|
||||
Effect.sync(() => {
|
||||
if (previous.token === undefined) delete process.env.OPENCODE_TEST_MCP_TOKEN
|
||||
else process.env.OPENCODE_TEST_MCP_TOKEN = previous.token
|
||||
if (previous.missing === undefined) delete process.env.OPENCODE_TEST_MISSING
|
||||
else process.env.OPENCODE_TEST_MISSING = previous.missing
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("does not load legacy config.json files", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
@@ -274,7 +390,6 @@ describe("Config", () => {
|
||||
const file = path.join(tmp.path, "opencode.json")
|
||||
const contents = JSON.stringify({
|
||||
shell: "/bin/zsh",
|
||||
experimental: { policies: [{ effect: "deny", action: "provider.use", resource: "openai" }] },
|
||||
providers: { local: provider },
|
||||
})
|
||||
yield* Effect.promise(() => fs.writeFile(file, contents))
|
||||
@@ -285,11 +400,6 @@ describe("Config", () => {
|
||||
|
||||
expect(documents[0]?.info.$schema).toBeUndefined()
|
||||
expect(documents[0]?.info.shell).toBe("/bin/zsh")
|
||||
expect(documents[0]?.info.experimental?.policies?.[0]).toEqual({
|
||||
effect: "deny",
|
||||
action: "provider.use",
|
||||
resource: "openai",
|
||||
})
|
||||
expect(yield* Effect.promise(() => fs.readFile(file, "utf8"))).toBe(contents)
|
||||
}).pipe(Effect.provide(testLayer(tmp.path)))
|
||||
}),
|
||||
@@ -723,40 +833,6 @@ describe("Config", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("loads policy statements in reverse config order", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) => {
|
||||
const global = path.join(tmp.path, "global")
|
||||
return Effect.gen(function* () {
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(global, { recursive: true })
|
||||
await fs.writeFile(
|
||||
path.join(global, "opencode.json"),
|
||||
JSON.stringify({
|
||||
experimental: { policies: [{ effect: "deny", action: "provider.use", resource: "openai" }] },
|
||||
}),
|
||||
)
|
||||
await fs.writeFile(
|
||||
path.join(tmp.path, "opencode.json"),
|
||||
JSON.stringify({
|
||||
experimental: { policies: [{ effect: "allow", action: "provider.use", resource: "openai" }] },
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
return yield* Effect.gen(function* () {
|
||||
const policy = yield* Policy.Service
|
||||
|
||||
expect(yield* policy.evaluate("provider.use", "openai", "allow")).toBe("deny")
|
||||
}).pipe(Effect.provide(testLayer(tmp.path, global)))
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("loads global, ancestor, and .opencode configuration up to the project boundary", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import path from "path"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { CommandV2 } from "@opencode-ai/core/command"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { ConfigAgentPlugin } from "@opencode-ai/core/config/plugin/agent"
|
||||
import { ConfigCommandPlugin } from "@opencode-ai/core/config/plugin/command"
|
||||
import { ConfigExternalPlugin } from "@opencode-ai/core/config/plugin/external"
|
||||
import { ConfigProviderPlugin } from "@opencode-ai/core/config/plugin/provider"
|
||||
import { ConfigReferencePlugin } from "@opencode-ai/core/config/plugin/reference"
|
||||
import { ConfigSkillPlugin } from "@opencode-ai/core/config/plugin/skill"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { Reference } from "@opencode-ai/core/reference"
|
||||
import { SkillV2 } from "@opencode-ai/core/skill"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "../plugin/fixture"
|
||||
|
||||
const it = testEffect(PluginTestLayer)
|
||||
const decode = Schema.decodeUnknownSync(Config.Info)
|
||||
const document = path.join(import.meta.dir, "opencode.json")
|
||||
|
||||
describe("config plugin reloads", () => {
|
||||
it.live("reloads config-backed domains without reloading external plugins", () =>
|
||||
Effect.gen(function* () {
|
||||
const agents = yield* AgentV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
const commands = yield* CommandV2.Service
|
||||
const events = yield* EventV2.Service
|
||||
const plugins = yield* PluginV2.Service
|
||||
const references = yield* Reference.Service
|
||||
const skills = yield* SkillV2.Service
|
||||
const host = yield* PluginHost.make(plugins)
|
||||
let entries: Config.Entry[] = [config("first", "First plugin")]
|
||||
const service = Config.Service.of({ entries: () => Effect.sync(() => entries) })
|
||||
const setup = <R>(effect: Effect.Effect<void, never, R>) =>
|
||||
effect.pipe(Effect.provideService(Config.Service, service))
|
||||
|
||||
yield* setup(ConfigAgentPlugin.Plugin.effect(host))
|
||||
yield* setup(ConfigCommandPlugin.Plugin.effect(host))
|
||||
yield* setup(ConfigSkillPlugin.Plugin.effect(host))
|
||||
yield* setup(ConfigReferencePlugin.Plugin.effect(host))
|
||||
yield* setup(ConfigProviderPlugin.Plugin.effect(host))
|
||||
yield* setup(ConfigExternalPlugin.Plugin.effect(host))
|
||||
|
||||
expect((yield* agents.get(AgentV2.ID.make("first")))?.description).toBe("First agent")
|
||||
expect((yield* commands.get("first"))?.description).toBe("First command")
|
||||
expect(
|
||||
(yield* skills.sources()).some((source) => source.type === "directory" && source.path === "/skills/first"),
|
||||
).toBe(true)
|
||||
expect((yield* references.list()).map((reference) => reference.name)).toEqual(["first"])
|
||||
expect(yield* catalog.provider.get(ProviderV2.ID.make("first"))).toBeDefined()
|
||||
expect((yield* agents.get(AgentV2.ID.make("configured")))?.description).toBe("First plugin")
|
||||
|
||||
entries = [config("second", "Second plugin")]
|
||||
yield* events.publish(ConfigSchema.Event.Updated, {})
|
||||
yield* waitUntil(
|
||||
Effect.gen(function* () {
|
||||
return (
|
||||
(yield* agents.get(AgentV2.ID.make("first"))) === undefined &&
|
||||
(yield* agents.get(AgentV2.ID.make("second")))?.description === "Second agent" &&
|
||||
(yield* commands.get("first")) === undefined &&
|
||||
(yield* commands.get("second"))?.description === "Second command" &&
|
||||
(yield* references.list()).some((reference) => reference.name === "second") &&
|
||||
(yield* catalog.provider.get(ProviderV2.ID.make("first"))) === undefined &&
|
||||
(yield* catalog.provider.get(ProviderV2.ID.make("second"))) !== undefined
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
expect(
|
||||
(yield* skills.sources()).some((source) => source.type === "directory" && source.path === "/skills/first"),
|
||||
).toBe(false)
|
||||
expect(
|
||||
(yield* skills.sources()).some((source) => source.type === "directory" && source.path === "/skills/second"),
|
||||
).toBe(true)
|
||||
expect((yield* agents.get(AgentV2.ID.make("configured")))?.description).toBe("First plugin")
|
||||
}).pipe(Effect.provideService(Global.Service, Global.Service.of(Global.make()))),
|
||||
)
|
||||
})
|
||||
|
||||
function config(name: string, pluginDescription?: string) {
|
||||
return new Config.Document({
|
||||
type: "document",
|
||||
path: document,
|
||||
info: decode({
|
||||
agents: { [name]: { description: `${title(name)} agent`, mode: "subagent" } },
|
||||
commands: { [name]: { template: `${title(name)} command`, description: `${title(name)} command` } },
|
||||
skills: [`/skills/${name}`],
|
||||
references: { [name]: `/references/${name}` },
|
||||
providers: { [name]: { models: { chat: { name: `${title(name)} model` } } } },
|
||||
plugins:
|
||||
pluginDescription === undefined
|
||||
? []
|
||||
: [
|
||||
{
|
||||
package: "../plugin/fixtures/config-promise-plugin.ts",
|
||||
options: { description: pluginDescription },
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
function title(value: string) {
|
||||
return value.charAt(0).toUpperCase() + value.slice(1)
|
||||
}
|
||||
|
||||
const waitUntil = Effect.fnUntraced(function* (condition: Effect.Effect<boolean>) {
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
if (yield* condition) return
|
||||
yield* Effect.sleep("10 millis")
|
||||
}
|
||||
return yield* Effect.die("Timed out waiting for config plugin reloads")
|
||||
})
|
||||
@@ -36,7 +36,7 @@ describe("ConfigSkillPlugin.Plugin", () => {
|
||||
|
||||
yield* ConfigSkillPlugin.Plugin.effect(
|
||||
host({
|
||||
skill: { transform, reload: () => Effect.void },
|
||||
skill: { list: () => Effect.die("unused skill.list"), transform, reload: () => Effect.void },
|
||||
}),
|
||||
).pipe(
|
||||
Effect.provideService(Global.Service, Global.Service.of({ ...Global.make(), home: "/home/test" })),
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { Ignore } from "@opencode-ai/core/filesystem/ignore"
|
||||
// @ts-ignore
|
||||
import { createWrapper } from "@parcel/watcher/wrapper"
|
||||
|
||||
test("match nested and non-nested", () => {
|
||||
expect(Ignore.match("node_modules/index.js")).toBe(true)
|
||||
@@ -8,3 +10,30 @@ test("match nested and non-nested", () => {
|
||||
expect(Ignore.match("node_modules/bar")).toBe(true)
|
||||
expect(Ignore.match("node_modules/bar/")).toBe(true)
|
||||
})
|
||||
|
||||
test("parcel patterns ignore built-in folders at any depth", async () => {
|
||||
let ignoreGlobs: string[] = []
|
||||
const watcher = createWrapper({
|
||||
subscribe: async (
|
||||
_directory: string,
|
||||
_callback: (...args: unknown[]) => unknown,
|
||||
options: { ignoreGlobs?: string[] },
|
||||
) => {
|
||||
ignoreGlobs = options.ignoreGlobs ?? []
|
||||
},
|
||||
})
|
||||
await watcher.subscribe("/tmp/project", () => {}, { ignore: Ignore.PATTERNS })
|
||||
const patterns = ignoreGlobs.map((source) => new RegExp(source))
|
||||
|
||||
for (const path of [
|
||||
"nested/node_modules",
|
||||
"nested/node_modules/package/index.js",
|
||||
"nested/.git",
|
||||
"nested/.git/HEAD",
|
||||
"nested/dist",
|
||||
"nested/dist/index.js",
|
||||
]) {
|
||||
expect(patterns.some((pattern) => pattern.test(path))).toBe(true)
|
||||
}
|
||||
expect(patterns.some((pattern) => pattern.test("nested/src/index.ts"))).toBe(false)
|
||||
})
|
||||
|
||||
@@ -2,13 +2,15 @@ import { $ } from "bun"
|
||||
import { describe, expect } from "bun:test"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { Deferred, Duration, Effect, Fiber, Layer, Option, Stream } from "effect"
|
||||
import { Deferred, Duration, Effect, Fiber, Layer, Option, Schedule, Stream } from "effect"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { LocationWatcher } from "@opencode-ai/core/filesystem/location-watcher"
|
||||
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
|
||||
import { FileSystem } from "@opencode-ai/schema/filesystem"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { location } from "../fixture/location"
|
||||
@@ -34,7 +36,7 @@ function provide(directory: string, vcs?: Location.Interface["vcs"]) {
|
||||
Location.Service.of(location({ directory: AbsolutePath.make(directory) }, { vcs })),
|
||||
)
|
||||
return Effect.provide(
|
||||
AppNodeBuilder.build(Watcher.node, [
|
||||
AppNodeBuilder.build(LocationWatcher.node, [
|
||||
[Config.node, configLayer],
|
||||
[Location.node, locationLayer],
|
||||
]),
|
||||
@@ -66,7 +68,7 @@ function wait(check: (event: WatcherEvent) => boolean) {
|
||||
return Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const deferred = yield* Deferred.make<WatcherEvent>()
|
||||
const fiber = yield* events.subscribe(Watcher.Event.Updated).pipe(
|
||||
const fiber = yield* events.subscribe(FileSystem.Event.Changed).pipe(
|
||||
Stream.runForEach((event) => {
|
||||
if (!check(event.data)) return Effect.void
|
||||
return Deferred.succeed(deferred, event.data).pipe(Effect.asVoid)
|
||||
@@ -136,7 +138,29 @@ function ready(directory: string) {
|
||||
})
|
||||
}
|
||||
|
||||
describeWatcher("Watcher", () => {
|
||||
describeWatcher("LocationWatcher", () => {
|
||||
it.live("limits file watches to the exact target", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const watcher = yield* Watcher.Service
|
||||
const target = path.join(directory, "opencode.json")
|
||||
const sibling = path.join(directory, "other.json")
|
||||
const update = yield* watcher
|
||||
.subscribe({ path: target, type: "file" })
|
||||
.pipe(Stream.take(1), Stream.runHead, Effect.forkScoped({ startImmediately: true }))
|
||||
yield* fs.writeFileString(sibling, "sibling")
|
||||
const writes = yield* Effect.suspend(() => fs.writeFileString(target, `target-${Math.random()}`)).pipe(
|
||||
Effect.repeat(Schedule.spaced("10 millis")),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
const event = yield* Fiber.join(update).pipe(Effect.ensuring(Fiber.interrupt(writes)))
|
||||
|
||||
expect(event.valueOrUndefined?.path).toBe(target)
|
||||
}).pipe(Effect.provide(AppNodeBuilder.build(Watcher.node))),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("publishes root create, update, and delete events", () =>
|
||||
withTmp(
|
||||
(directory) =>
|
||||
@@ -175,6 +199,24 @@ describeWatcher("Watcher", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("ignores dependency, VCS, and build directories at any depth", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const afs = yield* FSUtil.Service
|
||||
yield* ready(directory)
|
||||
const roots = ["node_modules", ".git", "dist"].map((name) => path.join(directory, "nested", name))
|
||||
const files = roots.map((root) => path.join(root, "package", "index.js"))
|
||||
yield* noUpdate(
|
||||
(event) => roots.some((root) => event.file === root || event.file.startsWith(`${root}${path.sep}`)),
|
||||
Effect.forEach(files, (file) => afs.writeWithDirs(file, "ignored"), {
|
||||
concurrency: "unbounded",
|
||||
discard: true,
|
||||
}),
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("cleanup stops publishing events", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
|
||||
@@ -51,27 +51,18 @@ describe("LocationServiceMap", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("isolates location state while sharing location policy with catalog", () =>
|
||||
it.live("isolates catalog state by location", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
|
||||
(dirs) => Effect.promise(() => Promise.all(dirs.map((dir) => dir[Symbol.asyncDispose]())).then(() => undefined)),
|
||||
).pipe(
|
||||
Effect.flatMap(([blocked, allowed]) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() =>
|
||||
fs.writeFile(
|
||||
path.join(blocked.path, "opencode.json"),
|
||||
JSON.stringify({
|
||||
experimental: { policies: [{ effect: "deny", action: "provider.use", resource: "test" }] },
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const update = (directory: string) =>
|
||||
const update = (directory: string, providerID: ProviderV2.ID) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Reference.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* catalog.transform((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {}))
|
||||
yield* catalog.transform((editor) => editor.provider.update(providerID, () => {}))
|
||||
const registry = yield* ToolRegistry.Service
|
||||
// Tool plugins register during the forked PluginInternal boot; wait for
|
||||
// every expected tool rather than relying on batch ordering.
|
||||
@@ -103,8 +94,11 @@ describe("LocationServiceMap", () => {
|
||||
),
|
||||
)
|
||||
|
||||
const blockedState = yield* update(blocked.path)
|
||||
expect(blockedState.providers.some((provider) => provider.id === ProviderV2.ID.make("test"))).toBe(false)
|
||||
const blockedID = ProviderV2.ID.make("blocked-location")
|
||||
const allowedID = ProviderV2.ID.make("allowed-location")
|
||||
const blockedState = yield* update(blocked.path, blockedID)
|
||||
expect(blockedState.providers.some((provider) => provider.id === blockedID)).toBe(true)
|
||||
expect(blockedState.providers.some((provider) => provider.id === allowedID)).toBe(false)
|
||||
expect(blockedState.tools.map((tool) => tool.name).sort()).toEqual([
|
||||
"edit",
|
||||
"glob",
|
||||
@@ -119,8 +113,9 @@ describe("LocationServiceMap", () => {
|
||||
"websearch",
|
||||
"write",
|
||||
])
|
||||
const allowedState = yield* update(allowed.path)
|
||||
expect(allowedState.providers.some((provider) => provider.id === ProviderV2.ID.make("test"))).toBe(true)
|
||||
const allowedState = yield* update(allowed.path, allowedID)
|
||||
expect(allowedState.providers.some((provider) => provider.id === allowedID)).toBe(true)
|
||||
expect(allowedState.providers.some((provider) => provider.id === blockedID)).toBe(false)
|
||||
expect(allowedState.tools.map((tool) => tool.name).sort()).toEqual([
|
||||
"edit",
|
||||
"glob",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { MCP } from "@opencode-ai/core/mcp/index"
|
||||
import { MCPClient } from "@opencode-ai/core/mcp/client"
|
||||
import { McpTool } from "@opencode-ai/core/tool/mcp"
|
||||
|
||||
describe("MCP errors", () => {
|
||||
test("expose useful messages", () => {
|
||||
@@ -12,3 +13,7 @@ describe("MCP errors", () => {
|
||||
expect(new MCPClient.ConnectError({ server: "demo", message: "offline" }).message).toBe("offline")
|
||||
})
|
||||
})
|
||||
|
||||
test("MCP tool names match V1 sanitization", () => {
|
||||
expect(McpTool.name("context 7", "resolve.library/id")).toBe("context_7_resolve_library_id")
|
||||
})
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Exit, Fiber, Schema } from "effect"
|
||||
import { Effect, Exit, Fiber, Schema, Stream } from "effect"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { Tool } from "@opencode-ai/core/tool/tool"
|
||||
@@ -14,6 +17,24 @@ import { PluginTestLayer } from "./plugin/fixture"
|
||||
const it = testEffect(PluginTestLayer)
|
||||
|
||||
describe("PluginV2", () => {
|
||||
it.live("exposes public events through the plugin context", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* PluginV2.Service
|
||||
const events = yield* EventV2.Service
|
||||
const host = yield* PluginHost.make(plugins)
|
||||
const received = yield* host.event.subscribe().pipe(
|
||||
Stream.filter((event) => event.type === "config.updated"),
|
||||
Stream.runHead,
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
yield* Effect.sleep("10 millis")
|
||||
|
||||
yield* events.publish(ConfigSchema.Event.Updated, {})
|
||||
|
||||
expect((yield* Fiber.join(received)).valueOrUndefined?.type).toBe("config.updated")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("waits for a plugin and returns immediately once active", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* PluginV2.Service
|
||||
@@ -105,6 +126,38 @@ describe("PluginV2", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("groups tool names and defers registrations from direct exposure", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* PluginV2.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const tool = (description: string) =>
|
||||
Tool.make({
|
||||
description,
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.Struct({ ok: Schema.Boolean }),
|
||||
execute: () => Effect.succeed({ ok: true }),
|
||||
})
|
||||
const plugin = define({
|
||||
id: "grouped-tools",
|
||||
effect: (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
yield* ctx.tool.register({ plain: tool("Plain") }).pipe(Effect.orDie)
|
||||
yield* ctx.tool.register({ "look/up": tool("Lookup") }, { group: "context 7" }).pipe(Effect.orDie)
|
||||
yield* ctx.tool
|
||||
.register({ search: tool("Search") }, { group: "context 7", deferred: true })
|
||||
.pipe(Effect.orDie)
|
||||
}),
|
||||
})
|
||||
|
||||
yield* plugins.add(PluginV2.ID.make(plugin.id), plugin.effect)
|
||||
|
||||
expect((yield* registry.materialize({ model: testModel })).definitions.map((tool) => tool.name)).toEqual([
|
||||
"plain",
|
||||
"context_7_look_up",
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("fires before/after tool hooks with mutable events around settlement", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* PluginV2.Service
|
||||
|
||||
@@ -23,7 +23,11 @@ describe("CommandPlugin.Plugin", () => {
|
||||
const command = yield* CommandV2.Service
|
||||
yield* CommandPlugin.Plugin.effect(
|
||||
host({
|
||||
command: { transform: command.transform, reload: command.reload },
|
||||
command: {
|
||||
list: () => Effect.die("unused command.list"),
|
||||
transform: command.transform,
|
||||
reload: command.reload,
|
||||
},
|
||||
}),
|
||||
).pipe(
|
||||
Effect.provideService(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user