Compare commits

..

7 Commits

Author SHA1 Message Date
James Long 7c5b65b2b6 Remove console.log 2026-04-09 15:27:50 -04:00
James Long 202b14353d wip 2026-04-09 15:27:31 -04:00
James Long e9e8aa5c94 Fix global project event 2026-04-09 14:25:25 -04:00
James Long 2b0fdcc449 Add some tests 2026-04-09 14:22:39 -04:00
James Long 5d68c61d31 Fix types 2026-04-09 13:27:01 -04:00
James Long 5a1be09727 Clear sessions 2026-04-09 13:24:45 -04:00
James Long 165481813a events 2026-04-09 13:24:43 -04:00
150 changed files with 5242 additions and 5939 deletions
+3 -6
View File
@@ -114,7 +114,7 @@ jobs:
- build-cli
- version
runs-on: blacksmith-4vcpu-windows-2025
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'beta'
if: github.repository == 'anomalyco/opencode'
env:
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
@@ -213,7 +213,6 @@ jobs:
needs:
- build-cli
- version
if: github.ref_name != 'beta'
continue-on-error: false
env:
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
@@ -390,7 +389,6 @@ jobs:
needs:
- build-cli
- version
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'beta'
continue-on-error: false
env:
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
@@ -423,6 +421,7 @@ jobs:
target: aarch64-unknown-linux-gnu
platform_flag: --linux
runs-on: ${{ matrix.settings.host }}
# if: github.ref_name == 'beta'
steps:
- uses: actions/checkout@v3
@@ -548,7 +547,6 @@ jobs:
- sign-cli-windows
- build-tauri
- build-electron
if: always() && !failure() && !cancelled()
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- uses: actions/checkout@v3
@@ -591,13 +589,12 @@ jobs:
path: packages/opencode/dist
- uses: actions/download-artifact@v4
if: github.ref_name != 'beta'
with:
name: opencode-cli-signed-windows
path: packages/opencode/dist
- uses: actions/download-artifact@v4
if: needs.version.outputs.release && github.ref_name != 'beta'
if: needs.version.outputs.release
with:
pattern: latest-yml-*
path: /tmp/latest-yml
-13
View File
@@ -17,9 +17,6 @@ permissions:
contents: read
checks: write
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
unit:
name: unit (${{ matrix.settings.name }})
@@ -41,11 +38,6 @@ jobs:
with:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "24"
- name: Setup Bun
uses: ./.github/actions/setup-bun
@@ -110,11 +102,6 @@ jobs:
with:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "24"
- name: Setup Bun
uses: ./.github/actions/setup-bun
+16 -16
View File
@@ -27,7 +27,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.4.3",
"version": "1.4.2",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -81,7 +81,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.4.3",
"version": "1.4.2",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -115,7 +115,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.4.3",
"version": "1.4.2",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -142,7 +142,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.4.3",
"version": "1.4.2",
"dependencies": {
"@ai-sdk/anthropic": "3.0.64",
"@ai-sdk/openai": "3.0.48",
@@ -166,7 +166,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.4.3",
"version": "1.4.2",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -190,7 +190,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.4.3",
"version": "1.4.2",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -223,7 +223,7 @@
},
"packages/desktop-electron": {
"name": "@opencode-ai/desktop-electron",
"version": "1.4.3",
"version": "1.4.2",
"dependencies": {
"effect": "catalog:",
"electron-context-menu": "4.1.2",
@@ -266,7 +266,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.4.3",
"version": "1.4.2",
"dependencies": {
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
@@ -295,7 +295,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.4.3",
"version": "1.4.2",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -311,7 +311,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.4.3",
"version": "1.4.2",
"bin": {
"opencode": "./bin/opencode",
},
@@ -447,7 +447,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.4.3",
"version": "1.4.2",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"zod": "catalog:",
@@ -481,7 +481,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.4.3",
"version": "1.4.2",
"dependencies": {
"cross-spawn": "catalog:",
},
@@ -496,7 +496,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.4.3",
"version": "1.4.2",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -531,7 +531,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.4.3",
"version": "1.4.2",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -580,7 +580,7 @@
},
"packages/util": {
"name": "@opencode-ai/util",
"version": "1.4.3",
"version": "1.4.2",
"dependencies": {
"zod": "catalog:",
},
@@ -591,7 +591,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.4.3",
"version": "1.4.2",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
+3 -7
View File
@@ -44,12 +44,8 @@ async function waitForHealth(url: string, probe = "/global/health") {
throw new Error(`Timed out waiting for backend health at ${url}${probe}${last ? ` (${last})` : ""}`)
}
function done(proc: ReturnType<typeof spawn>) {
return proc.exitCode !== null || proc.signalCode !== null
}
async function waitExit(proc: ReturnType<typeof spawn>, timeout = 10_000) {
if (done(proc)) return
if (proc.exitCode !== null) return
await Promise.race([
new Promise<void>((resolve) => proc.once("exit", () => resolve())),
new Promise<void>((resolve) => setTimeout(resolve, timeout)),
@@ -127,11 +123,11 @@ export async function startBackend(label: string, input?: { llmUrl?: string }):
return {
url,
async stop() {
if (!done(proc)) {
if (proc.exitCode === null) {
proc.kill("SIGTERM")
await waitExit(proc)
}
if (!done(proc)) {
if (proc.exitCode === null) {
proc.kill("SIGKILL")
await waitExit(proc)
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.4.3",
"version": "1.4.2",
"description": "",
"type": "module",
"exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.4.3",
"version": "1.4.2",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.4.3",
"version": "1.4.2",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.4.3",
"version": "1.4.2",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.4.3",
"version": "1.4.2",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop-electron",
"private": true,
"version": "1.4.3",
"version": "1.4.2",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.4.3",
"version": "1.4.2",
"type": "module",
"license": "MIT",
"scripts": {
@@ -21,7 +21,7 @@ const releaseId = process.env.OPENCODE_RELEASE
if (!releaseId) throw new Error("OPENCODE_RELEASE is required")
const version = process.env.OPENCODE_VERSION
if (!version) throw new Error("OPENCODE_VERSION is required")
if (!releaseId) throw new Error("OPENCODE_VERSION is required")
const token = process.env.GH_TOKEN ?? process.env.GITHUB_TOKEN
if (!token) throw new Error("GH_TOKEN or GITHUB_TOKEN is required")
@@ -54,10 +54,7 @@ const assets = release.assets ?? []
const assetByName = new Map(assets.map((asset) => [asset.name, asset]))
const latestAsset = assetByName.get("latest.json")
if (!latestAsset) {
console.log("latest.json not found, skipping tauri finalization")
process.exit(0)
}
if (!latestAsset) throw new Error("latest.json asset not found")
const latestRes = await fetch(latestAsset.url, {
headers: {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.4.3",
"version": "1.4.2",
"private": true,
"type": "module",
"license": "MIT",
+6 -6
View File
@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
version = "1.4.3"
version = "1.4.2"
schema_version = 1
authors = ["Anomaly"]
repository = "https://github.com/anomalyco/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.3/opencode-darwin-arm64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.2/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.3/opencode-darwin-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.2/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.3/opencode-linux-arm64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.2/opencode-linux-arm64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.3/opencode-linux-x64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.2/opencode-linux-x64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.3/opencode-windows-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.2/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.4.3",
"version": "1.4.2",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.4.3",
"version": "1.4.2",
"name": "opencode",
"type": "module",
"license": "MIT",
+1 -32
View File
@@ -219,11 +219,11 @@ Fully migrated (single namespace, InstanceState where needed, flattened facade):
- [x] `Instruction` — `session/instruction.ts`
- [x] `Provider` — `provider/provider.ts`
- [x] `Storage` — `storage/storage.ts`
- [x] `ShareNext` — `share/share-next.ts`
Still open:
- [ ] `SessionTodo` — `session/todo.ts`
- [ ] `ShareNext` — `share/share-next.ts`
- [ ] `SyncEvent` — `sync/index.ts`
- [ ] `Workspace` — `control-plane/workspace.ts`
@@ -308,34 +308,3 @@ Current raw fs users that will convert during tool migration:
- [ ] `util/flock.ts` — file-based distributed lock with heartbeat → Effect.repeat + addFinalizer
- [ ] `util/process.ts` — child process spawn wrapper → return Effect instead of Promise
- [ ] `util/lazy.ts` — replace uses in Effect code with Effect.cached; keep for sync-only code
## Destroying the facades
Every service currently exports async facade functions at the bottom of its namespace — `export async function read(...) { return runPromise(...) }` — backed by a per-service `makeRuntime`. These exist because cyclic imports used to force each service to build its own independent runtime. Now that the layer DAG is acyclic and `AppRuntime` (`src/effect/app-runtime.ts`) composes everything into one `ManagedRuntime`, we're removing them.
### Process
For each service, the migration is roughly:
1. **Find callers.** `grep -n "Namespace\.(methodA|methodB|...)"` across `src/` and `test/`. Skip the service file itself.
2. **Migrate production callers.** For each effectful caller that does `Effect.tryPromise(() => Namespace.method(...))`:
- Add the service to the caller's layer R type (`Layer.Layer<Self, never, ... | Namespace.Service>`)
- Yield it at the top of the layer: `const ns = yield* Namespace.Service`
- Replace `Effect.tryPromise(() => Namespace.method(...))` with `yield* ns.method(...)` (or `ns.method(...).pipe(Effect.orElseSucceed(...))` for the common fallback case)
- Add `Layer.provide(Namespace.defaultLayer)` to the caller's own `defaultLayer` chain
3. **Fix tests that used the caller's raw `.layer`.** Any test that composes `Caller.layer` (not `defaultLayer`) needs to also provide the newly-required service tag. The fastest fix is usually switching to `Caller.defaultLayer` since it now pulls in the new dependency.
4. **Migrate test callers of the facade.** Tests calling `Namespace.method(...)` directly get converted to full effectful style using `testEffect(Namespace.defaultLayer)` + `it.live` / `it.effect` + `yield* svc.method(...)`. Don't wrap the test body in `Effect.promise(async () => {...})` — do the whole thing in `Effect.gen` and use `AppFileSystem.Service` / `tmpdirScoped` / `Effect.addFinalizer` for what used to be raw `fs` / `Bun.write` / `try/finally`.
5. **Delete the facades.** Once `grep` shows zero callers, remove the `export async function` block AND the `makeRuntime(...)` line from the service namespace. Also remove the now-unused `import { makeRuntime }`.
### Pitfalls
- **Layer caching inside tests.** `testEffect(layer)` constructs the Storage (or whatever) service once and memoizes it. If a test then tries `inner.pipe(Effect.provide(customStorage))` to swap in a differently-configured Storage, the outer cached one wins and the inner provision is a no-op. Fix: wrap the overriding layer in `Layer.fresh(...)`, which forces a new instance to be built instead of hitting the memoMap cache. This lets a single `testEffect(...)` serve both simple and per-test-customized cases.
- **`Effect.tryPromise` → `yield*` drops the Promise layer.** The old code was `Effect.tryPromise(() => Storage.read(...))` — a `tryPromise` wrapper because the facade returned a Promise. The new code is `yield* storage.read(...)` directly — the service method already returns an Effect, so no wrapper is needed. Don't reach for `Effect.promise` or `Effect.tryPromise` during migration; if you're using them on a service method call, you're doing it wrong.
- **Raw `.layer` test callers break silently in the type checker.** When you add a new R requirement to a service's `.layer`, any test that composes it raw (not `defaultLayer`) becomes under-specified. `tsgo` will flag this — the error looks like `Type 'Storage.Service' is not assignable to type '... | Service | TestConsole'`. Usually the fix is to switch that composition to `defaultLayer`, or add `Layer.provide(NewDep.defaultLayer)` to the custom composition.
- **Tests that do async setup with `fs`, `Bun.write`, `tmpdir`.** Convert these to `AppFileSystem.Service` calls inside `Effect.gen`, and use `tmpdirScoped()` instead of `tmpdir()` so cleanup happens via the scope finalizer. For file operations on the actual filesystem (not via a service), a small helper like `const writeJson = Effect.fnUntraced(function* (file, value) { const fs = yield* AppFileSystem.Service; yield* fs.makeDirectory(path.dirname(file), { recursive: true }); yield* fs.writeFileString(file, JSON.stringify(value, null, 2)) })` keeps the migration tests clean.
### Migration log
- `SessionStatus` — migrated 2026-04-11. Replaced the last route and retry-policy callers with `AppRuntime.runPromise(SessionStatus.Service.use(...))` and removed the `makeRuntime(...)` facade.
- `ShareNext` — migrated 2026-04-11. Swapped remaining async callers to `AppRuntime.runPromise(ShareNext.Service.use(...))`, removed the `makeRuntime(...)` facade, and kept instance bootstrap on the shared app runtime.
- `Storage` — migrated 2026-04-10. One production caller (`Session.diff`) and all storage.test.ts tests converted to effectful style. Facades and `makeRuntime` removed.
+4 -1
View File
@@ -202,7 +202,7 @@ Top-level API groups exposed to `tui(api, options, meta)`:
- `api.kv.get`, `set`, `ready`
- `api.state`
- `api.theme.current`, `selected`, `has`, `set`, `install`, `mode`, `ready`
- `api.client`
- `api.client`, `api.scopedClient(workspaceID?)`, `api.workspace.current()`, `api.workspace.set(workspaceID?)`
- `api.event.on(type, handler)`
- `api.renderer`
- `api.slots.register(plugin)`
@@ -270,6 +270,7 @@ Command behavior:
- `provider`
- `path.{state,config,worktree,directory}`
- `vcs?.branch`
- `workspace.list()` / `workspace.get(workspaceID)`
- `session.count()`
- `session.diff(sessionID)`
- `session.todo(sessionID)`
@@ -281,6 +282,8 @@ Command behavior:
- `lsp()`
- `mcp()`
- `api.client` always reflects the current runtime client.
- `api.scopedClient(workspaceID?)` creates or reuses a client bound to a workspace.
- `api.workspace.set(...)` rebinds the active workspace; `api.client` follows that rebind.
- `api.event.on(type, handler)` subscribes to the TUI event stream and returns an unsubscribe function.
- `api.renderer` exposes the raw `CliRenderer`.
@@ -0,0 +1,44 @@
## Summary
This refactor moves the TUI toward a single source of truth for project and workspace state, and aligns event handling around a global event stream instead of per-directory subscriptions.
The main goal is to make workspace switching and session loading behave consistently across the TUI, while simplifying the data flow between the frontend, worker transport, and backend.
## Why
The previous shape was splitting related state across multiple places:
- some workspace/path state lived in sync state
- some UI behavior depended on route state
- event consumers were effectively relying on instance-scoped subscriptions
That made workspace-aware behavior harder to reason about and more fragile when switching contexts.
This change centralizes the active project/workspace/path state, makes sync react to that state instead of owning it, and updates the event pipeline so the backend emits richer global events and the TUI filters them based on the current context. The intent is to make the system easier to evolve as workspace support expands.
## What Changed
- centralized active project/workspace/path state in the TUI
- made sync derive from that state and re-bootstrap when the active workspace changes
- switched TUI event consumption to a global event stream filtered client-side
- propagated workspace/project metadata through the backend event path and runtime context
- updated the SDK/OpenAPI contract to reflect the richer global event shape
- added targeted TUI tests around workspace-driven sync behavior and event filtering
## Risk
This touches several cross-cutting paths, so the main risks are around behavior rather than typing:
- workspace changes may still expose subtle ordering/race issues if older async bootstrap work finishes after newer state is selected
- event filtering is now more centralized, which is good, but also means mistakes there can hide or misroute UI updates
- session state now depends more heavily on the active workspace context being correct at the right time
- backend/frontend assumptions about global event metadata need to stay aligned, or certain updates may quietly stop appearing in the TUI
Overall, the biggest risk is regressions during workspace transitions rather than steady-state usage.
## Validation
- added focused tests for reactive sync behavior on workspace changes
- added focused tests for `useEvent()` filtering behavior
- ran `bun typecheck`
- ran targeted TUI tests for the new coverage
+17
View File
@@ -461,11 +461,28 @@ export namespace Account {
return Option.getOrUndefined(await runPromise((service) => service.active()))
}
export async function list(): Promise<Info[]> {
return runPromise((service) => service.list())
}
export async function activeOrg(): Promise<ActiveOrg | undefined> {
return Option.getOrUndefined(await runPromise((service) => service.activeOrg()))
}
export async function orgsByAccount(): Promise<readonly AccountOrgs[]> {
return runPromise((service) => service.orgsByAccount())
}
export async function orgs(accountID: AccountID): Promise<readonly Org[]> {
return runPromise((service) => service.orgs(accountID))
}
export async function switchOrg(accountID: AccountID, orgID: OrgID) {
return runPromise((service) => service.use(accountID, Option.some(orgID)))
}
export async function token(accountID: AccountID): Promise<AccessToken | undefined> {
const t = await runPromise((service) => service.token(accountID))
return Option.getOrUndefined(t)
}
}
+14 -21
View File
@@ -341,10 +341,6 @@ export namespace Agent {
)
const existing = yield* InstanceState.useEffect(state, (s) => s.list())
// TODO: clean this up so provider specific logic doesnt bleed over
const authInfo = yield* auth.get(model.providerID).pipe(Effect.orDie)
const isOpenaiOauth = model.providerID === "openai" && authInfo?.type === "oauth"
const params = {
experimental_telemetry: {
isEnabled: cfg.experimental?.openTelemetry,
@@ -354,14 +350,12 @@ export namespace Agent {
},
temperature: 0.3,
messages: [
...(isOpenaiOauth
? []
: system.map(
(item): ModelMessage => ({
role: "system",
content: item,
}),
)),
...system.map(
(item): ModelMessage => ({
role: "system",
content: item,
}),
),
{
role: "user",
content: `Create an agent configuration based on this request: \"${input.description}\".\n\nIMPORTANT: The following identifiers already exist and must NOT be used: ${existing.map((i) => i.name).join(", ")}\n Return ONLY the JSON object, no other text, do not wrap in backticks`,
@@ -375,12 +369,13 @@ export namespace Agent {
}),
} satisfies Parameters<typeof generateObject>[0]
if (isOpenaiOauth) {
// TODO: clean this up so provider specific logic doesnt bleed over
const authInfo = yield* auth.get(model.providerID).pipe(Effect.orDie)
if (model.providerID === "openai" && authInfo?.type === "oauth") {
return yield* Effect.promise(async () => {
const result = streamObject({
...params,
providerOptions: ProviderTransform.providerOptions(resolved, {
instructions: system.join("\n"),
store: false,
}),
onError: () => {},
@@ -398,13 +393,11 @@ export namespace Agent {
}),
)
export const defaultLayer = Layer.suspend(() =>
layer.pipe(
Layer.provide(Provider.defaultLayer),
Layer.provide(Auth.defaultLayer),
Layer.provide(Config.defaultLayer),
Layer.provide(Skill.defaultLayer),
),
export const defaultLayer = layer.pipe(
Layer.provide(Provider.defaultLayer),
Layer.provide(Auth.defaultLayer),
Layer.provide(Config.defaultLayer),
Layer.provide(Skill.defaultLayer),
)
const { runPromise } = makeRuntime(Service, defaultLayer)
-2
View File
@@ -169,8 +169,6 @@ export namespace Bus {
}),
)
export const defaultLayer = layer
const { runPromise, runSync } = makeRuntime(Service, layer)
// runSync is safe here because the subscribe chain (InstanceState.get, PubSub.subscribe,
+6 -9
View File
@@ -1,6 +1,5 @@
import { EOL } from "os"
import { basename } from "path"
import { Effect } from "effect"
import { Agent } from "../../../agent/agent"
import { Provider } from "../../../provider/provider"
import { Session } from "../../../session"
@@ -159,15 +158,13 @@ async function createToolContext(agent: Agent.Info) {
abort: new AbortController().signal,
messages: [],
metadata: () => {},
ask(req: Omit<Permission.Request, "id" | "sessionID" | "tool">) {
return Effect.sync(() => {
for (const pattern of req.patterns) {
const rule = Permission.evaluate(req.permission, pattern, ruleset)
if (rule.action === "deny") {
throw new Permission.DeniedError({ ruleset })
}
async ask(req: Omit<Permission.Request, "id" | "sessionID" | "tool">) {
for (const pattern of req.patterns) {
const rule = Permission.evaluate(req.permission, pattern, ruleset)
if (rule.action === "deny") {
throw new Permission.DeniedError({ ruleset })
}
})
}
},
}
}
+1 -2
View File
@@ -21,7 +21,6 @@ import { cmd } from "./cmd"
import { ModelsDev } from "../../provider/models"
import { Instance } from "@/project/instance"
import { bootstrap } from "../bootstrap"
import { SessionShare } from "@/share/session"
import { Session } from "../../session"
import type { SessionID } from "../../session/schema"
import { MessageID, PartID } from "../../session/schema"
@@ -560,7 +559,7 @@ export const GithubRunCommand = cmd({
shareId = await (async () => {
if (share === false) return
if (!share && repoData.data.private) return
await SessionShare.share(session.id)
await Session.share(session.id)
return session.id.slice(-8)
})()
console.log("opencode session", session.id)
+2 -3
View File
@@ -10,7 +10,6 @@ import { Instance } from "../../project/instance"
import { ShareNext } from "../../share/share-next"
import { EOL } from "os"
import { Filesystem } from "../../util/filesystem"
import { AppRuntime } from "@/effect/app-runtime"
/** Discriminated union returned by the ShareNext API (GET /api/shares/:id/data) */
export type ShareData =
@@ -101,7 +100,7 @@ export const ImportCommand = cmd({
if (isUrl) {
const slug = parseShareUrl(args.file)
if (!slug) {
const baseUrl = await AppRuntime.runPromise(ShareNext.Service.use((svc) => svc.url()))
const baseUrl = await ShareNext.url()
process.stdout.write(`Invalid URL format. Expected: ${baseUrl}/share/<slug>`)
process.stdout.write(EOL)
return
@@ -109,7 +108,7 @@ export const ImportCommand = cmd({
const parsed = new URL(args.file)
const baseUrl = parsed.origin
const req = await AppRuntime.runPromise(ShareNext.Service.use((svc) => svc.request()))
const req = await ShareNext.request()
const headers = shouldAttachShareAuthHeaders(args.file, req.baseUrl) ? req.headers : {}
const dataPath = req.api.data(slug)
+1 -1
View File
@@ -22,7 +22,7 @@ import { DialogProvider, useDialog } from "@tui/ui/dialog"
import { DialogProvider as DialogProviderList } from "@tui/component/dialog-provider"
import { ErrorComponent } from "@tui/component/error-component"
import { PluginRouteMissing } from "@tui/component/plugin-route-missing"
import { ProjectProvider, useProject } from "@tui/context/project"
import { ProjectProvider } from "@tui/context/project"
import { useEvent } from "@tui/context/event"
import { SDKProvider, useSDK } from "@tui/context/sdk"
import { StartupLoading } from "@tui/component/startup-loading"
@@ -1,28 +1,54 @@
import { useDialog } from "@tui/ui/dialog"
import { DialogSelect } from "@tui/ui/dialog-select"
import { RGBA } from "@opentui/core"
import { useRoute } from "@tui/context/route"
import { useSync } from "@tui/context/sync"
import { useDialog } from "@tui/ui/dialog"
import { DialogSelect } from "@tui/ui/dialog-select"
import { createMemo, createResource, createSignal, onMount } from "solid-js"
import { Locale } from "@/util/locale"
import { useProject } from "@tui/context/project"
import { useKeybind } from "../context/keybind"
import { useTheme } from "../context/theme"
import { useSDK } from "../context/sdk"
import { Flag } from "@/flag/flag"
import { DialogSessionRename } from "./dialog-session-rename"
import { Keybind } from "@/util/keybind"
import { useTheme } from "../context/theme"
import { createDebouncedSignal } from "../util/signal"
import { useToast } from "../ui/toast"
import { DialogSessionRename } from "./dialog-session-rename"
import { DialogWorkspaceCreate, openWorkspaceSession } from "./dialog-workspace-create"
import { Spinner } from "./spinner"
type WorkspaceStatus = "connected" | "connecting" | "disconnected" | "error"
const color = [
RGBA.fromHex("#ff7a90"),
RGBA.fromHex("#f8c555"),
RGBA.fromHex("#70d6a3"),
RGBA.fromHex("#57c7ff"),
RGBA.fromHex("#bb9af7"),
RGBA.fromHex("#ff9e64"),
]
const shape = ["■", "◆", "▲", "▶", "▼", "◀", "●", "◉", "◈", "◊"]
const action = "__workspace_new__"
function hash(text: string) {
let sum = 0
for (const char of text) {
sum = (sum * 31 + char.charCodeAt(0)) >>> 0
}
return sum
}
function mark(id?: string) {
if (!id) {
return
}
const sum = hash(id)
return {
fg: color[sum % color.length]!,
text: shape[sum % shape.length]!,
}
}
export function DialogSessionList() {
const dialog = useDialog()
const route = useRoute()
const sync = useSync()
const project = useProject()
const keybind = useKeybind()
const { theme } = useTheme()
const sdk = useSDK()
@@ -30,92 +56,56 @@ export function DialogSessionList() {
const [toDelete, setToDelete] = createSignal<string>()
const [search, setSearch] = createDebouncedSignal("", 150)
const [searchResults] = createResource(search, async (query) => {
if (!query) return undefined
const result = await sdk.client.session.list({ search: query, limit: 30 })
const load = async (search?: string) => {
const result = await sdk.client.session.list({
roots: true,
...(search ? { search, limit: 30 } : {}),
})
return result.data ?? []
}
const [listed, listedActions] = createResource(async () => load())
const [found] = createResource(search, async (query) => {
if (!query) return undefined
return load(query)
})
const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined))
const sessions = createMemo(() => searchResults() ?? sync.data.session)
function createWorkspace() {
dialog.replace(() => (
<DialogWorkspaceCreate
onSelect={(workspaceID) =>
openWorkspaceSession({
dialog,
route,
sdk,
sync,
toast,
workspaceID,
})
}
/>
))
}
const current = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined))
const sessions = createMemo(() => found() ?? listed() ?? sync.data.session)
const options = createMemo(() => {
const today = new Date().toDateString()
return sessions()
.filter((x) => x.parentID === undefined)
.toSorted((a, b) => b.time.updated - a.time.updated)
.map((x) => {
const workspace = x.workspaceID ? project.workspace.get(x.workspaceID) : undefined
let workspaceStatus: WorkspaceStatus | null = null
if (x.workspaceID) {
workspaceStatus = project.workspace.status(x.workspaceID) || "error"
}
let footer = ""
if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
if (x.workspaceID) {
let desc = "unknown"
if (workspace) {
desc = `${workspace.type}: ${workspace.name}`
}
footer = (
<>
{desc}{" "}
<span
style={{
fg:
workspaceStatus === "error"
? theme.error
: workspaceStatus === "disconnected"
? theme.textMuted
: theme.success,
}}
>
■
</span>
</>
)
return [
{
title: "+ New workspace session",
value: action,
category: "Actions",
description: "Create a new workspace, then open a session there",
},
...sessions()
.filter((item) => item.parentID === undefined)
.toSorted((a, b) => b.time.updated - a.time.updated)
.map((item) => {
const badge = mark(item.workspaceID)
const date = new Date(item.time.updated)
let category = date.toDateString()
if (category === today) {
category = "Today"
}
} else {
footer = Locale.time(x.time.updated)
}
const date = new Date(x.time.updated)
let category = date.toDateString()
if (category === today) {
category = "Today"
}
const isDeleting = toDelete() === x.id
const status = sync.data.session_status?.[x.id]
const isWorking = status?.type === "busy"
return {
title: isDeleting ? `Press ${keybind.print("session_delete")} again to confirm` : x.title,
bg: isDeleting ? theme.error : undefined,
value: x.id,
category,
footer,
gutter: isWorking ? <Spinner /> : undefined,
}
})
const deleting = toDelete() === item.id
const status = sync.data.session_status?.[item.id]
const working = status?.type === "busy"
return {
title: deleting ? `Press ${keybind.print("session_delete")} again to confirm` : item.title,
bg: deleting ? theme.error : undefined,
value: item.id,
category,
footer: Locale.time(item.time.updated),
gutter: working ? <Spinner /> : undefined,
margin: badge ? <text fg={badge.fg}>{badge.text}</text> : undefined,
}
}),
]
})
onMount(() => {
@@ -127,12 +117,29 @@ export function DialogSessionList() {
title="Sessions"
options={options()}
skipFilter={true}
current={currentSessionID()}
current={current()}
onFilter={setSearch}
onMove={() => {
setToDelete(undefined)
}}
onSelect={(option) => {
if (option.value === action) {
dialog.replace(() => (
<DialogWorkspaceCreate
onSelect={(workspaceID) =>
openWorkspaceSession({
dialog,
route,
sdk,
sync,
toast,
workspaceID,
})
}
/>
))
return
}
route.navigate({
type: "session",
sessionID: option.value,
@@ -144,10 +151,27 @@ export function DialogSessionList() {
keybind: keybind.all.session_delete?.[0],
title: "delete",
onTrigger: async (option) => {
if (option.value === action) return
if (toDelete() === option.value) {
sdk.client.session.delete({
sessionID: option.value,
})
const deleted = await sdk.client.session
.delete({
sessionID: option.value,
})
.then(() => true)
.catch(() => false)
if (!deleted) {
toast.show({
message: "Failed to delete session",
variant: "error",
})
setToDelete(undefined)
return
}
listedActions.mutate((items) => items?.filter((item) => item.id !== option.value))
sync.set(
"session",
sync.data.session.filter((item) => item.id !== option.value),
)
setToDelete(undefined)
return
}
@@ -158,18 +182,10 @@ export function DialogSessionList() {
keybind: keybind.all.session_rename?.[0],
title: "rename",
onTrigger: async (option) => {
if (option.value === action) return
dialog.replace(() => <DialogSessionRename session={option.value} />)
},
},
{
keybind: Keybind.parse("ctrl+w")[0],
title: "new workspace",
side: "right",
disabled: !Flag.OPENCODE_EXPERIMENTAL_WORKSPACES,
onTrigger: () => {
createWorkspace()
},
},
]}
/>
)
@@ -3,7 +3,6 @@ import { useDialog } from "@tui/ui/dialog"
import { DialogSelect } from "@tui/ui/dialog-select"
import { useRoute } from "@tui/context/route"
import { useSync } from "@tui/context/sync"
import { useProject } from "@tui/context/project"
import { createMemo, createSignal, onMount } from "solid-js"
import { setTimeout as sleep } from "node:timers/promises"
import { useSDK } from "../context/sdk"
@@ -59,7 +58,6 @@ export async function openWorkspaceSession(input: {
export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) => Promise<void> | void }) {
const dialog = useDialog()
const sync = useSync()
const project = useProject()
const sdk = useSDK()
const toast = useToast()
const [creating, setCreating] = createSignal<string>()
@@ -102,7 +100,7 @@ export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) =
})
return
}
await project.workspace.sync()
await sync.workspace.sync()
await props.onSelect(workspace.id)
setCreating(undefined)
}
@@ -1,11 +1,9 @@
import { batch } from "solid-js"
import type { Path, Workspace } from "@opencode-ai/sdk/v2"
import type { Path } from "@opencode-ai/sdk"
import { createStore, reconcile } from "solid-js/store"
import { createSimpleContext } from "./helper"
import { useSDK } from "./sdk"
type WorkspaceStatus = "connected" | "connecting" | "disconnected" | "error"
export const { use: useProject, provider: ProjectProvider } = createSimpleContext({
name: "Project",
init: () => {
@@ -16,22 +14,17 @@ export const { use: useProject, provider: ProjectProvider } = createSimpleContex
},
instance: {
path: {
home: "",
state: "",
config: "",
worktree: "",
directory: sdk.directory ?? "",
} satisfies Path,
},
workspace: {
current: undefined as string | undefined,
list: [] as Workspace[],
status: {} as Record<string, WorkspaceStatus>,
},
workspace: undefined as string | undefined,
})
async function sync() {
const workspace = store.workspace.current
const workspace = store.workspace
const [path, project] = await Promise.all([
sdk.client.path.get({ workspace }),
sdk.client.project.current({ workspace }),
@@ -43,27 +36,6 @@ export const { use: useProject, provider: ProjectProvider } = createSimpleContex
})
}
async function syncWorkspace() {
const listed = await sdk.client.experimental.workspace.list().catch(() => undefined)
if (!listed?.data) return
const status = await sdk.client.experimental.workspace.status().catch(() => undefined)
const next = Object.fromEntries((status?.data ?? []).map((item) => [item.workspaceID, item.status]))
batch(() => {
setStore("workspace", "list", reconcile(listed.data))
setStore("workspace", "status", reconcile(next))
if (!listed.data.some((item) => item.id === store.workspace.current)) {
setStore("workspace", "current", undefined)
}
})
}
sdk.event.on("event", (event) => {
if (event.payload.type === "workspace.status") {
setStore("workspace", "status", event.payload.properties.workspaceID, event.payload.properties.status)
}
})
return {
data: store,
project() {
@@ -79,26 +51,13 @@ export const { use: useProject, provider: ProjectProvider } = createSimpleContex
},
workspace: {
current() {
return store.workspace.current
return store.workspace
},
set(next?: string | null) {
const workspace = next ?? undefined
if (store.workspace.current === workspace) return
setStore("workspace", "current", workspace)
if (store.workspace === workspace) return
setStore("workspace", workspace)
},
list() {
return store.workspace.list
},
get(workspaceID: string) {
return store.workspace.list.find((item) => item.id === workspaceID)
},
status(workspaceID: string) {
return store.workspace.status[workspaceID]
},
statuses() {
return store.workspace.status
},
sync: syncWorkspace,
},
sync,
}
@@ -17,6 +17,7 @@ import type {
ProviderListResponse,
ProviderAuthMethod,
VcsInfo,
Workspace,
} from "@opencode-ai/sdk/v2"
import { createStore, produce, reconcile } from "solid-js/store"
import { useProject } from "@tui/context/project"
@@ -74,6 +75,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
[key: string]: McpResource
}
formatter: FormatterStatus[]
workspaceList: Workspace[]
vcs: VcsInfo | undefined
}>({
provider_next: {
@@ -101,6 +103,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
mcp: {},
mcp_resource: {},
formatter: [],
workspaceList: [],
vcs: undefined,
})
@@ -108,6 +111,16 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const project = useProject()
const sdk = useSDK()
async function syncWorkspaces() {
const workspace = project.workspace.current()
const result = await sdk.client.experimental.workspace.list().catch(() => undefined)
if (!result?.data) return
setStore("workspaceList", reconcile(result.data))
if (!result.data.some((item) => item.id === workspace)) {
project.workspace.set(undefined)
}
}
event.subscribe((event) => {
switch (event.type) {
case "server.instance.disposed":
@@ -355,7 +368,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const workspace = project.workspace.current()
const start = Date.now() - 30 * 24 * 60 * 60 * 1000
const sessionListPromise = sdk.client.session
.list({ start: start })
.list({ start: start, workspace })
.then((x) => (x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id)))
// blocking - include session.list when continuing a session
@@ -430,7 +443,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
}),
sdk.client.provider.auth({ workspace }).then((x) => setStore("provider_auth", reconcile(x.data ?? {}))),
sdk.client.vcs.get({ workspace }).then((x) => setStore("vcs", reconcile(x.data))),
project.workspace.sync(),
syncWorkspaces(),
]).then(() => {
setStore("status", "complete")
})
@@ -509,6 +522,15 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
fullSyncedSessions.add(sessionID)
},
},
workspace: {
list() {
return store.workspaceList
},
get(workspaceID: string) {
return store.workspaceList.find((item) => item.id === workspaceID)
},
sync: syncWorkspaces,
},
bootstrap,
}
return result
@@ -146,6 +146,14 @@ function stateApi(sync: ReturnType<typeof useSync>): TuiPluginApi["state"] {
branch: sync.data.vcs.branch,
}
},
workspace: {
list() {
return sync.data.workspaceList
},
get(workspaceID) {
return sync.workspace.get(workspaceID)
},
},
session: {
count() {
return sync.data.session.length
@@ -26,7 +26,6 @@ export interface DialogSelectProps<T> {
keybind?: {
keybind?: Keybind.Info
title: string
side?: "left" | "right"
disabled?: boolean
onTrigger: (option: DialogSelectOption<T>) => void
}[]
@@ -236,8 +235,6 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
props.ref?.(ref)
const keybinds = createMemo(() => props.keybind?.filter((x) => !x.disabled && x.keybind) ?? [])
const left = createMemo(() => keybinds().filter((item) => item.side !== "right"))
const right = createMemo(() => keybinds().filter((item) => item.side === "right"))
return (
<box gap={1} paddingBottom={1}>
@@ -363,38 +360,17 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
</scrollbox>
</Show>
<Show when={keybinds().length} fallback={<box flexShrink={0} />}>
<box
paddingRight={2}
paddingLeft={4}
flexDirection="row"
justifyContent="space-between"
flexShrink={0}
paddingTop={1}
>
<box flexDirection="row" gap={2}>
<For each={left()}>
{(item) => (
<text>
<span style={{ fg: theme.text }}>
<b>{item.title}</b>{" "}
</span>
<span style={{ fg: theme.textMuted }}>{Keybind.toString(item.keybind)}</span>
</text>
)}
</For>
</box>
<box flexDirection="row" gap={2}>
<For each={right()}>
{(item) => (
<text>
<span style={{ fg: theme.text }}>
<b>{item.title}</b>{" "}
</span>
<span style={{ fg: theme.textMuted }}>{Keybind.toString(item.keybind)}</span>
</text>
)}
</For>
</box>
<box paddingRight={2} paddingLeft={4} flexDirection="row" gap={2} flexShrink={0} paddingTop={1}>
<For each={keybinds()}>
{(item) => (
<text>
<span style={{ fg: theme.text }}>
<b>{item.title}</b>{" "}
</span>
<span style={{ fg: theme.textMuted }}>{Keybind.toString(item.keybind)}</span>
</text>
)}
</For>
</box>
</Show>
</box>
+41 -102
View File
@@ -5,9 +5,7 @@ import { Database, eq } from "@/storage/db"
import { Project } from "@/project/project"
import { BusEvent } from "@/bus/bus-event"
import { GlobalBus } from "@/bus/global"
import { SyncEvent } from "@/sync"
import { Log } from "@/util/log"
import { Filesystem } from "@/util/filesystem"
import { ProjectID } from "@/project/schema"
import { WorkspaceTable } from "./workspace.sql"
import { getAdaptor } from "./adaptors"
@@ -16,18 +14,6 @@ import { WorkspaceID } from "./schema"
import { parseSSE } from "./sse"
export namespace Workspace {
export const Info = WorkspaceInfo.meta({
ref: "Workspace",
})
export type Info = z.infer<typeof Info>
export const ConnectionStatus = z.object({
workspaceID: WorkspaceID.zod,
status: z.enum(["connected", "connecting", "disconnected", "error"]),
error: z.string().optional(),
})
export type ConnectionStatus = z.infer<typeof ConnectionStatus>
export const Event = {
Ready: BusEvent.define(
"workspace.ready",
@@ -41,9 +27,13 @@ export namespace Workspace {
message: z.string(),
}),
),
Status: BusEvent.define("workspace.status", ConnectionStatus),
}
export const Info = WorkspaceInfo.meta({
ref: "Workspace",
})
export type Info = z.infer<typeof Info>
function fromRow(row: typeof WorkspaceTable.$inferSelect): Info {
return {
id: row.id,
@@ -95,9 +85,6 @@ export namespace Workspace {
})
await adaptor.create(config)
startSync(info)
return info
})
@@ -105,24 +92,18 @@ export namespace Workspace {
const rows = Database.use((db) =>
db.select().from(WorkspaceTable).where(eq(WorkspaceTable.project_id, project.id)).all(),
)
const spaces = rows.map(fromRow).sort((a, b) => a.id.localeCompare(b.id))
for (const space of spaces) startSync(space)
return spaces
return rows.map(fromRow).sort((a, b) => a.id.localeCompare(b.id))
}
export const get = fn(WorkspaceID.zod, async (id) => {
const row = Database.use((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get())
if (!row) return
const space = fromRow(row)
startSync(space)
return space
return fromRow(row)
})
export const remove = fn(WorkspaceID.zod, async (id) => {
const row = Database.use((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get())
if (row) {
stopSync(id)
const info = fromRow(row)
const adaptor = await getAdaptor(row.type)
adaptor.remove(info)
@@ -130,100 +111,58 @@ export namespace Workspace {
return info
}
})
const connections = new Map<WorkspaceID, ConnectionStatus>()
const aborts = new Map<WorkspaceID, AbortController>()
function setStatus(id: WorkspaceID, status: ConnectionStatus["status"], error?: string) {
const prev = connections.get(id)
if (prev?.status === status && prev?.error === error) return
const next = { workspaceID: id, status, error }
connections.set(id, next)
GlobalBus.emit("event", {
directory: "global",
workspace: id,
payload: {
type: Event.Status.type,
properties: next,
},
})
}
export function status(): ConnectionStatus[] {
return [...connections.values()]
}
const log = Log.create({ service: "workspace-sync" })
async function workspaceEventLoop(space: Info, signal: AbortSignal) {
log.info("starting sync: " + space.id)
while (!signal.aborted) {
log.info("connecting to sync: " + space.id)
setStatus(space.id, "connecting")
async function workspaceEventLoop(space: Info, stop: AbortSignal) {
while (!stop.aborted) {
const adaptor = await getAdaptor(space.type)
const target = await adaptor.target(space)
const target = await Promise.resolve(adaptor.target(space))
if (target.type === "local") return
if (target.type === "local") {
return
}
const res = await fetch(target.url + "/sync/event", { method: "GET", signal }).catch((err: unknown) => {
setStatus(space.id, "error", String(err))
return undefined
const baseURL = String(target.url).replace(/\/?$/, "/")
const res = await fetch(new URL(baseURL + "/event"), {
method: "GET",
signal: stop,
})
if (!res || !res.ok || !res.body) {
log.info("failed to connect to sync: " + res?.status)
setStatus(space.id, "error", res ? `HTTP ${res.status}` : "no response")
if (!res.ok || !res.body) {
await sleep(1000)
continue
}
setStatus(space.id, "connected")
await parseSSE(res.body, signal, (evt) => {
const event = evt as SyncEvent.SerializedEvent
try {
if (!event.type.startsWith("server.")) {
SyncEvent.replay(event)
}
} catch (err) {
log.warn("failed to replay sync event", {
workspaceID: space.id,
error: err,
})
}
})
setStatus(space.id, "disconnected")
log.info("disconnected to sync: " + space.id)
// await parseSSE(res.body, stop, (event) => {
// GlobalBus.emit("event", {
// directory: space.id,
// payload: event,
// })
// })
// Wait 250ms and retry if SSE connection fails
await sleep(250)
}
}
function startSync(space: Info) {
if (space.type === "worktree") {
void Filesystem.exists(space.directory!).then((exists) => {
setStatus(space.id, exists ? "connected" : "error", exists ? undefined : "directory does not exist")
})
return
}
export function startSyncing(project: Project.Info) {
const stop = new AbortController()
const spaces = list(project).filter((space) => space.type !== "worktree")
if (aborts.has(space.id)) return
const abort = new AbortController()
aborts.set(space.id, abort)
setStatus(space.id, "disconnected")
void workspaceEventLoop(space, abort.signal).catch((error) => {
setStatus(space.id, "error", String(error))
log.warn("workspace sync listener failed", {
workspaceID: space.id,
error,
spaces.forEach((space) => {
void workspaceEventLoop(space, stop.signal).catch((error) => {
log.warn("workspace sync listener failed", {
workspaceID: space.id,
error,
})
})
})
}
function stopSync(id: WorkspaceID) {
aborts.get(id)?.abort()
aborts.delete(id)
connections.delete(id)
return {
async stop() {
stop.abort()
},
}
}
}
-100
View File
@@ -1,100 +0,0 @@
import { Layer, ManagedRuntime } from "effect"
import { memoMap } from "./run-service"
import { Observability } from "./oltp"
import { AppFileSystem } from "@/filesystem"
import { Bus } from "@/bus"
import { Auth } from "@/auth"
import { Account } from "@/account"
import { Config } from "@/config/config"
import { Git } from "@/git"
import { Ripgrep } from "@/file/ripgrep"
import { FileTime } from "@/file/time"
import { File } from "@/file"
import { FileWatcher } from "@/file/watcher"
import { Storage } from "@/storage/storage"
import { Snapshot } from "@/snapshot"
import { Plugin } from "@/plugin"
import { Provider } from "@/provider/provider"
import { ProviderAuth } from "@/provider/auth"
import { Agent } from "@/agent/agent"
import { Skill } from "@/skill"
import { Discovery } from "@/skill/discovery"
import { Question } from "@/question"
import { Permission } from "@/permission"
import { Todo } from "@/session/todo"
import { Session } from "@/session"
import { SessionStatus } from "@/session/status"
import { SessionRunState } from "@/session/run-state"
import { SessionProcessor } from "@/session/processor"
import { SessionCompaction } from "@/session/compaction"
import { SessionRevert } from "@/session/revert"
import { SessionSummary } from "@/session/summary"
import { SessionPrompt } from "@/session/prompt"
import { Instruction } from "@/session/instruction"
import { LLM } from "@/session/llm"
import { LSP } from "@/lsp"
import { MCP } from "@/mcp"
import { McpAuth } from "@/mcp/auth"
import { Command } from "@/command"
import { Truncate } from "@/tool/truncate"
import { ToolRegistry } from "@/tool/registry"
import { Format } from "@/format"
import { Project } from "@/project/project"
import { Vcs } from "@/project/vcs"
import { Worktree } from "@/worktree"
import { Pty } from "@/pty"
import { Installation } from "@/installation"
import { ShareNext } from "@/share/share-next"
import { SessionShare } from "@/share/session"
export const AppLayer = Layer.mergeAll(
Observability.layer,
AppFileSystem.defaultLayer,
Bus.defaultLayer,
Auth.defaultLayer,
Account.defaultLayer,
Config.defaultLayer,
Git.defaultLayer,
Ripgrep.defaultLayer,
FileTime.defaultLayer,
File.defaultLayer,
FileWatcher.defaultLayer,
Storage.defaultLayer,
Snapshot.defaultLayer,
Plugin.defaultLayer,
Provider.defaultLayer,
ProviderAuth.defaultLayer,
Agent.defaultLayer,
Skill.defaultLayer,
Discovery.defaultLayer,
Question.defaultLayer,
Permission.defaultLayer,
Todo.defaultLayer,
Session.defaultLayer,
SessionStatus.defaultLayer,
SessionRunState.defaultLayer,
SessionProcessor.defaultLayer,
SessionCompaction.defaultLayer,
SessionRevert.defaultLayer,
SessionSummary.defaultLayer,
SessionPrompt.defaultLayer,
Instruction.defaultLayer,
LLM.defaultLayer,
LSP.defaultLayer,
MCP.defaultLayer,
McpAuth.defaultLayer,
Command.defaultLayer,
Truncate.defaultLayer,
ToolRegistry.defaultLayer,
Format.defaultLayer,
Project.defaultLayer,
Vcs.defaultLayer,
Worktree.defaultLayer,
Pty.defaultLayer,
Installation.defaultLayer,
ShareNext.defaultLayer,
SessionShare.defaultLayer,
)
export const AppRuntime = ManagedRuntime.make(AppLayer, { memoMap })
@@ -499,3 +499,4 @@ const rt = lazy(async () => {
type RT = Awaited<ReturnType<typeof rt>>
export const runPromiseExit: RT["runPromiseExit"] = async (...args) => (await rt()).runPromiseExit(...(args as [any]))
export const runPromise: RT["runPromise"] = async (...args) => (await rt()).runPromise(...(args as [any]))
@@ -78,4 +78,10 @@ export namespace InstanceState {
Effect.gen(function* () {
return yield* ScopedCache.invalidate(self.cache, yield* directory)
})
/**
* Effect finalizers run on the fiber scheduler after the original async
* boundary, so ALS reads like Instance.directory can be gone by then.
*/
export const withALS = <T>(fn: () => T) => Effect.map(context, (ctx) => Instance.restore(ctx, fn))
}
-67
View File
@@ -1,67 +0,0 @@
import { Cause, Effect, Logger, References } from "effect"
import { Log } from "@/util/log"
export namespace EffectLogger {
type Fields = Record<string, unknown>
export interface Handle {
readonly debug: (msg?: unknown, extra?: Fields) => Effect.Effect<void>
readonly info: (msg?: unknown, extra?: Fields) => Effect.Effect<void>
readonly warn: (msg?: unknown, extra?: Fields) => Effect.Effect<void>
readonly error: (msg?: unknown, extra?: Fields) => Effect.Effect<void>
readonly with: (extra: Fields) => Handle
}
const clean = (input?: Fields): Fields =>
Object.fromEntries(Object.entries(input ?? {}).filter((entry) => entry[1] !== undefined && entry[1] !== null))
const text = (input: unknown): string => {
if (Array.isArray(input)) return input.map((item) => String(item)).join(" ")
return input === undefined ? "" : String(input)
}
const call = (run: (msg?: unknown) => Effect.Effect<void>, base: Fields, msg?: unknown, extra?: Fields) => {
const ann = clean({ ...base, ...extra })
const fx = run(msg)
return Object.keys(ann).length ? Effect.annotateLogs(fx, ann) : fx
}
export const logger = Logger.make((opts) => {
const extra = clean(opts.fiber.getRef(References.CurrentLogAnnotations))
const now = opts.date.getTime()
for (const [key, start] of opts.fiber.getRef(References.CurrentLogSpans)) {
extra[`logSpan.${key}`] = `${now - start}ms`
}
if (opts.cause.reasons.length > 0) {
extra.cause = Cause.pretty(opts.cause)
}
const svc = typeof extra.service === "string" ? extra.service : undefined
if (svc) delete extra.service
const log = svc ? Log.create({ service: svc }) : Log.Default
const msg = text(opts.message)
switch (opts.logLevel) {
case "Trace":
case "Debug":
return log.debug(msg, extra)
case "Warn":
return log.warn(msg, extra)
case "Error":
case "Fatal":
return log.error(msg, extra)
default:
return log.info(msg, extra)
}
})
export const layer = Logger.layer([logger], { mergeWithExisting: false })
export const create = (base: Fields = {}): Handle => ({
debug: (msg, extra) => call((item) => Effect.logDebug(item), base, msg, extra),
info: (msg, extra) => call((item) => Effect.logInfo(item), base, msg, extra),
warn: (msg, extra) => call((item) => Effect.logWarning(item), base, msg, extra),
error: (msg, extra) => call((item) => Effect.logError(item), base, msg, extra),
with: (extra) => create({ ...base, ...extra }),
})
}
+25 -32
View File
@@ -1,41 +1,34 @@
import { Duration, Layer } from "effect"
import { Layer } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
import { Otlp } from "effect/unstable/observability"
import { EffectLogger } from "@/effect/logger"
import { Flag } from "@/flag/flag"
import { CHANNEL, VERSION } from "@/installation/meta"
export namespace Observability {
const base = Flag.OTEL_EXPORTER_OTLP_ENDPOINT
export const enabled = !!base
export const enabled = !!Flag.OTEL_EXPORTER_OTLP_ENDPOINT
const resource = {
serviceName: "opencode",
serviceVersion: VERSION,
attributes: {
"deployment.environment.name": CHANNEL === "local" ? "local" : CHANNEL,
"opencode.client": Flag.OPENCODE_CLIENT,
},
}
const headers = Flag.OTEL_EXPORTER_OTLP_HEADERS
? Flag.OTEL_EXPORTER_OTLP_HEADERS.split(",").reduce(
(acc, x) => {
const [key, value] = x.split("=")
acc[key] = value
return acc
},
{} as Record<string, string>,
)
: undefined
export const layer = !base
? EffectLogger.layer
export const layer = !Flag.OTEL_EXPORTER_OTLP_ENDPOINT
? Layer.empty
: Otlp.layerJson({
baseUrl: base,
loggerExportInterval: Duration.seconds(1),
loggerMergeWithExisting: true,
resource,
headers,
}).pipe(Layer.provide(EffectLogger.layer), Layer.provide(FetchHttpClient.layer))
baseUrl: Flag.OTEL_EXPORTER_OTLP_ENDPOINT,
loggerMergeWithExisting: false,
resource: {
serviceName: "opencode",
serviceVersion: VERSION,
attributes: {
"deployment.environment.name": CHANNEL === "local" ? "local" : CHANNEL,
"opencode.client": Flag.OPENCODE_CLIENT,
},
},
headers: Flag.OTEL_EXPORTER_OTLP_HEADERS
? Flag.OTEL_EXPORTER_OTLP_HEADERS.split(",").reduce(
(acc, x) => {
const [key, value] = x.split("=")
acc[key] = value
return acc
},
{} as Record<string, string>,
)
: undefined,
}).pipe(Layer.provide(FetchHttpClient.layer))
}
+1 -1
View File
@@ -8,7 +8,7 @@ import { WorkspaceContext } from "@/control-plane/workspace-context"
export const memoMap = Layer.makeMemoMapUnsafe()
export function attach<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> {
function attach<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> {
try {
const ctx = Instance.current
const workspaceID = WorkspaceContext.workspaceID
+105 -88
View File
@@ -11,6 +11,7 @@ import path from "path"
import z from "zod"
import { Global } from "../global"
import { Instance } from "../project/instance"
import { Filesystem } from "../util/filesystem"
import { Log } from "../util/log"
import { Protected } from "./protected"
import { Ripgrep } from "./ripgrep"
@@ -343,7 +344,6 @@ export namespace File {
Service,
Effect.gen(function* () {
const appFs = yield* AppFileSystem.Service
const git = yield* Git.Service
const state = yield* InstanceState.make<State>(
Effect.fn("File.state")(() =>
@@ -410,10 +410,6 @@ export namespace File {
cachedScan = yield* Effect.cached(scan().pipe(Effect.catchCause(() => Effect.void)))
})
const gitText = Effect.fnUntraced(function* (args: string[]) {
return (yield* git.run(args, { cwd: Instance.directory })).text()
})
const init = Effect.fn("File.init")(function* () {
yield* ensure()
})
@@ -421,87 +417,100 @@ export namespace File {
const status = Effect.fn("File.status")(function* () {
if (Instance.project.vcs !== "git") return []
const diffOutput = yield* gitText([
"-c",
"core.fsmonitor=false",
"-c",
"core.quotepath=false",
"diff",
"--numstat",
"HEAD",
])
const changed: File.Info[] = []
if (diffOutput.trim()) {
for (const line of diffOutput.trim().split("\n")) {
const [added, removed, file] = line.split("\t")
changed.push({
path: file,
added: added === "-" ? 0 : parseInt(added, 10),
removed: removed === "-" ? 0 : parseInt(removed, 10),
status: "modified",
return yield* Effect.promise(async () => {
const diffOutput = (
await Git.run(["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "diff", "--numstat", "HEAD"], {
cwd: Instance.directory,
})
).text()
const changed: File.Info[] = []
if (diffOutput.trim()) {
for (const line of diffOutput.trim().split("\n")) {
const [added, removed, file] = line.split("\t")
changed.push({
path: file,
added: added === "-" ? 0 : parseInt(added, 10),
removed: removed === "-" ? 0 : parseInt(removed, 10),
status: "modified",
})
}
}
}
const untrackedOutput = yield* gitText([
"-c",
"core.fsmonitor=false",
"-c",
"core.quotepath=false",
"ls-files",
"--others",
"--exclude-standard",
])
const untrackedOutput = (
await Git.run(
[
"-c",
"core.fsmonitor=false",
"-c",
"core.quotepath=false",
"ls-files",
"--others",
"--exclude-standard",
],
{
cwd: Instance.directory,
},
)
).text()
if (untrackedOutput.trim()) {
for (const file of untrackedOutput.trim().split("\n")) {
const content = yield* appFs
.readFileString(path.join(Instance.directory, file))
.pipe(Effect.catch(() => Effect.succeed<string | undefined>(undefined)))
if (content === undefined) continue
changed.push({
path: file,
added: content.split("\n").length,
removed: 0,
status: "added",
})
if (untrackedOutput.trim()) {
for (const file of untrackedOutput.trim().split("\n")) {
try {
const content = await Filesystem.readText(path.join(Instance.directory, file))
changed.push({
path: file,
added: content.split("\n").length,
removed: 0,
status: "added",
})
} catch {
continue
}
}
}
}
const deletedOutput = yield* gitText([
"-c",
"core.fsmonitor=false",
"-c",
"core.quotepath=false",
"diff",
"--name-only",
"--diff-filter=D",
"HEAD",
])
const deletedOutput = (
await Git.run(
[
"-c",
"core.fsmonitor=false",
"-c",
"core.quotepath=false",
"diff",
"--name-only",
"--diff-filter=D",
"HEAD",
],
{
cwd: Instance.directory,
},
)
).text()
if (deletedOutput.trim()) {
for (const file of deletedOutput.trim().split("\n")) {
changed.push({
path: file,
added: 0,
removed: 0,
status: "deleted",
})
if (deletedOutput.trim()) {
for (const file of deletedOutput.trim().split("\n")) {
changed.push({
path: file,
added: 0,
removed: 0,
status: "deleted",
})
}
}
}
return changed.map((item) => {
const full = path.isAbsolute(item.path) ? item.path : path.join(Instance.directory, item.path)
return {
...item,
path: path.relative(Instance.directory, full),
}
return changed.map((item) => {
const full = path.isAbsolute(item.path) ? item.path : path.join(Instance.directory, item.path)
return {
...item,
path: path.relative(Instance.directory, full),
}
})
})
})
const read: Interface["read"] = Effect.fn("File.read")(function* (file: string) {
const read = Effect.fn("File.read")(function* (file: string) {
using _ = log.time("read", { file })
const full = path.join(Instance.directory, file)
@@ -549,19 +558,27 @@ export namespace File {
)
if (Instance.project.vcs === "git") {
let diff = yield* gitText(["-c", "core.fsmonitor=false", "diff", "--", file])
if (!diff.trim()) {
diff = yield* gitText(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file])
}
if (diff.trim()) {
const original = yield* git.show(Instance.directory, "HEAD", file)
const patch = structuredPatch(file, file, original, content, "old", "new", {
context: Infinity,
ignoreWhitespace: true,
})
return { type: "text" as const, content, patch, diff: formatPatch(patch) }
}
return { type: "text" as const, content }
return yield* Effect.promise(async (): Promise<File.Content> => {
let diff = (
await Git.run(["-c", "core.fsmonitor=false", "diff", "--", file], { cwd: Instance.directory })
).text()
if (!diff.trim()) {
diff = (
await Git.run(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file], {
cwd: Instance.directory,
})
).text()
}
if (diff.trim()) {
const original = (await Git.run(["show", `HEAD:${file}`], { cwd: Instance.directory })).text()
const patch = structuredPatch(file, file, original, content, "old", "new", {
context: Infinity,
ignoreWhitespace: true,
})
return { type: "text", content, patch, diff: formatPatch(patch) }
}
return { type: "text", content }
})
}
return { type: "text" as const, content }
@@ -643,7 +660,7 @@ export namespace File {
}),
)
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Git.defaultLayer))
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
const { runPromise } = makeRuntime(Service, defaultLayer)
-70
View File
@@ -3,17 +3,10 @@ import path from "path"
import { Global } from "../global"
import fs from "fs/promises"
import z from "zod"
import { Effect, Layer, ServiceMap } from "effect"
import * as Stream from "effect/Stream"
import { ChildProcess } from "effect/unstable/process"
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
import type { PlatformError } from "effect/PlatformError"
import { NamedError } from "@opencode-ai/util/error"
import { lazy } from "../util/lazy"
import { Filesystem } from "../util/filesystem"
import { AppFileSystem } from "../filesystem"
import { Process } from "../util/process"
import { which } from "../util/which"
import { text } from "node:stream/consumers"
@@ -281,69 +274,6 @@ export namespace Ripgrep {
input.signal?.throwIfAborted()
}
export interface Interface {
readonly files: (input: {
cwd: string
glob?: string[]
hidden?: boolean
follow?: boolean
maxDepth?: number
}) => Stream.Stream<string, PlatformError>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Ripgrep") {}
export const layer: Layer.Layer<Service, never, ChildProcessSpawner | AppFileSystem.Service> = Layer.effect(
Service,
Effect.gen(function* () {
const spawner = yield* ChildProcessSpawner
const afs = yield* AppFileSystem.Service
const files = Effect.fn("Ripgrep.files")(function* (input: {
cwd: string
glob?: string[]
hidden?: boolean
follow?: boolean
maxDepth?: number
}) {
const rgPath = yield* Effect.promise(() => filepath())
const isDir = yield* afs.isDir(input.cwd)
if (!isDir) {
return yield* Effect.die(
Object.assign(new Error(`No such file or directory: '${input.cwd}'`), {
code: "ENOENT" as const,
errno: -2,
path: input.cwd,
}),
)
}
const args = [rgPath, "--files", "--glob=!.git/*"]
if (input.follow) args.push("--follow")
if (input.hidden !== false) args.push("--hidden")
if (input.maxDepth !== undefined) args.push(`--max-depth=${input.maxDepth}`)
if (input.glob) {
for (const g of input.glob) {
args.push(`--glob=${g}`)
}
}
return spawner
.streamLines(ChildProcess.make(args[0], args.slice(1), { cwd: input.cwd }))
.pipe(Stream.filter((line: string) => line.length > 0))
})
return Service.of({
files: (input) => Stream.unwrap(files(input)),
})
}),
)
export const defaultLayer = layer.pipe(
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(CrossSpawnSpawner.defaultLayer),
)
export async function tree(input: { cwd: string; limit?: number; signal?: AbortSignal }) {
log.info("tree", input)
const files = await Array.fromAsync(Ripgrep.files({ cwd: input.cwd, signal: input.signal }))
+4 -4
View File
@@ -34,7 +34,7 @@ export namespace FileTime {
readonly read: (sessionID: SessionID, file: string) => Effect.Effect<void>
readonly get: (sessionID: SessionID, file: string) => Effect.Effect<Date | undefined>
readonly assert: (sessionID: SessionID, filepath: string) => Effect.Effect<void>
readonly withLock: <T>(filepath: string, fn: () => Effect.Effect<T>) => Effect.Effect<T>
readonly withLock: <T>(filepath: string, fn: () => Promise<T>) => Effect.Effect<T>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/FileTime") {}
@@ -103,8 +103,8 @@ export namespace FileTime {
)
})
const withLock = Effect.fn("FileTime.withLock")(function* <T>(filepath: string, fn: () => Effect.Effect<T>) {
return yield* fn().pipe((yield* getLock(filepath)).withPermits(1))
const withLock = Effect.fn("FileTime.withLock")(function* <T>(filepath: string, fn: () => Promise<T>) {
return yield* Effect.promise(fn).pipe((yield* getLock(filepath)).withPermits(1))
})
return Service.of({ read, get, assert, withLock })
@@ -128,6 +128,6 @@ export namespace FileTime {
}
export async function withLock<T>(filepath: string, fn: () => Promise<T>): Promise<T> {
return runPromise((s) => s.withLock(filepath, () => Effect.promise(fn)))
return runPromise((s) => s.withLock(filepath, fn))
}
}
+6 -5
View File
@@ -71,7 +71,6 @@ export namespace FileWatcher {
Service,
Effect.gen(function* () {
const config = yield* Config.Service
const git = yield* Git.Service
const state = yield* InstanceState.make(
Effect.fn("FileWatcher.state")(
@@ -132,9 +131,11 @@ export namespace FileWatcher {
}
if (Instance.project.vcs === "git") {
const result = yield* git.run(["rev-parse", "--git-dir"], {
cwd: Instance.project.worktree,
})
const result = yield* Effect.promise(() =>
Git.run(["rev-parse", "--git-dir"], {
cwd: Instance.project.worktree,
}),
)
const vcsDir =
result.exitCode === 0 ? path.resolve(Instance.project.worktree, result.text().trim()) : undefined
if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir)) {
@@ -160,7 +161,7 @@ export namespace FileWatcher {
}),
)
export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer), Layer.provide(Git.defaultLayer))
export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer))
const { runPromise } = makeRuntime(Service, defaultLayer)
-7
View File
@@ -51,13 +51,6 @@ export namespace Format {
formatters[item.name] = item
}
for (const [name, item] of Object.entries(cfg.formatter ?? {})) {
// Ruff and uv are both the same formatter, so disabling either should disable both.
if (["ruff", "uv"].includes(name) && (cfg.formatter?.ruff?.disabled || cfg.formatter?.uv?.disabled)) {
// TODO combine formatters so shared backends like Ruff/uv don't need linked disable handling here.
delete formatters.ruff
delete formatters.uv
continue
}
if (item.disabled) {
delete formatters[name]
continue
+32
View File
@@ -265,7 +265,39 @@ export namespace Git {
return runPromise((git) => git.run(args, opts))
}
export async function branch(cwd: string) {
return runPromise((git) => git.branch(cwd))
}
export async function prefix(cwd: string) {
return runPromise((git) => git.prefix(cwd))
}
export async function defaultBranch(cwd: string) {
return runPromise((git) => git.defaultBranch(cwd))
}
export async function hasHead(cwd: string) {
return runPromise((git) => git.hasHead(cwd))
}
export async function mergeBase(cwd: string, base: string, head?: string) {
return runPromise((git) => git.mergeBase(cwd, base, head))
}
export async function show(cwd: string, ref: string, file: string, prefix?: string) {
return runPromise((git) => git.show(cwd, ref, file, prefix))
}
export async function status(cwd: string) {
return runPromise((git) => git.status(cwd))
}
export async function diff(cwd: string, ref: string) {
return runPromise((git) => git.diff(cwd, ref))
}
export async function stats(cwd: string, ref: string) {
return runPromise((git) => git.stats(cwd, ref))
}
}
-11
View File
@@ -540,8 +540,6 @@ export namespace LSP {
export const outgoingCalls = async (input: LocInput) => runPromise((svc) => svc.outgoingCalls(input))
export namespace Diagnostic {
const MAX_PER_FILE = 20
export function pretty(diagnostic: LSPClient.Diagnostic) {
const severityMap = {
1: "ERROR",
@@ -556,14 +554,5 @@ export namespace LSP {
return `${severity} [${line}:${col}] ${diagnostic.message}`
}
export function report(file: string, issues: LSPClient.Diagnostic[]) {
const errors = issues.filter((item) => item.severity === 1)
if (errors.length === 0) return ""
const limited = errors.slice(0, MAX_PER_FILE)
const more = errors.length - MAX_PER_FILE
const suffix = more > 0 ? `\n... and ${more} more` : ""
return `<diagnostics file="${file}">\n${limited.map(pretty).join("\n")}${suffix}\n</diagnostics>`
}
}
}
+11 -1
View File
@@ -105,7 +105,17 @@ export namespace LSPServer {
if (!tsserver) return
const bin = await Npm.which("typescript-language-server")
if (!bin) return
const proc = spawn(bin, ["--stdio"], {
const args = ["--stdio", "--tsserver-log-verbosity", "off", "--tsserver-path", tsserver]
if (
!(await pathExists(path.join(root, "tsconfig.json"))) &&
!(await pathExists(path.join(root, "jsconfig.json")))
) {
args.push("--ignore-node-modules")
}
const proc = spawn(bin, args, {
cwd: root,
env: {
...process.env,
+1 -1
View File
@@ -141,7 +141,7 @@ export namespace McpAuth {
}),
)
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
const { runPromise } = makeRuntime(Service, defaultLayer)
+3
View File
@@ -906,6 +906,9 @@ export namespace MCP {
export const disconnect = async (name: string) => runPromise((svc) => svc.disconnect(name))
export const getPrompt = async (clientName: string, name: string, args?: Record<string, string>) =>
runPromise((svc) => svc.getPrompt(clientName, name, args))
export const startAuth = async (mcpName: string) => runPromise((svc) => svc.startAuth(mcpName))
export const authenticate = async (mcpName: string) => runPromise((svc) => svc.authenticate(mcpName))
+1 -2
View File
@@ -10,13 +10,12 @@ import { Bus } from "../bus"
import { Command } from "../command"
import { Instance } from "./instance"
import { Log } from "@/util/log"
import { AppRuntime } from "@/effect/app-runtime"
import { ShareNext } from "@/share/share-next"
export async function InstanceBootstrap() {
Log.Default.info("bootstrapping", { directory: Instance.directory })
await Plugin.init()
void AppRuntime.runPromise(ShareNext.Service.use((svc) => svc.init()))
ShareNext.init()
Format.init()
await LSP.init()
File.init()
+31 -29
View File
@@ -161,37 +161,39 @@ export namespace Vcs {
const bus = yield* Bus.Service
const state = yield* InstanceState.make<State>(
Effect.fn("Vcs.state")(function* (ctx) {
if (ctx.project.vcs !== "git") {
return { current: undefined, root: undefined }
}
Effect.fn("Vcs.state")((ctx) =>
Effect.gen(function* () {
if (ctx.project.vcs !== "git") {
return { current: undefined, root: undefined }
}
const get = Effect.fnUntraced(function* () {
return yield* git.branch(ctx.directory)
})
const [current, root] = yield* Effect.all([git.branch(ctx.directory), git.defaultBranch(ctx.directory)], {
concurrency: 2,
})
const value = { current, root }
log.info("initialized", { branch: value.current, default_branch: value.root?.name })
const get = Effect.fnUntraced(function* () {
return yield* git.branch(ctx.directory)
})
const [current, root] = yield* Effect.all([git.branch(ctx.directory), git.defaultBranch(ctx.directory)], {
concurrency: 2,
})
const value = { current, root }
log.info("initialized", { branch: value.current, default_branch: value.root?.name })
yield* bus.subscribe(FileWatcher.Event.Updated).pipe(
Stream.filter((evt) => evt.properties.file.endsWith("HEAD")),
Stream.runForEach((_evt) =>
Effect.gen(function* () {
const next = yield* get()
if (next !== value.current) {
log.info("branch changed", { from: value.current, to: next })
value.current = next
yield* bus.publish(Event.BranchUpdated, { branch: next })
}
}),
),
Effect.forkScoped,
)
yield* bus.subscribe(FileWatcher.Event.Updated).pipe(
Stream.filter((evt) => evt.properties.file.endsWith("HEAD")),
Stream.runForEach((_evt) =>
Effect.gen(function* () {
const next = yield* get()
if (next !== value.current) {
log.info("branch changed", { from: value.current, to: next })
value.current = next
yield* bus.publish(Event.BranchUpdated, { branch: next })
}
}),
),
Effect.forkScoped,
)
return value
}),
return value
}),
),
)
return Service.of({
@@ -226,7 +228,7 @@ export namespace Vcs {
}),
)
export const defaultLayer = layer.pipe(
const defaultLayer = layer.pipe(
Layer.provide(Git.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Bus.layer),
+5 -1
View File
@@ -359,7 +359,7 @@ export namespace Pty {
}),
)
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(Plugin.defaultLayer))
const defaultLayer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(Plugin.defaultLayer))
const { runPromise } = makeRuntime(Service, defaultLayer)
@@ -371,6 +371,10 @@ export namespace Pty {
return runPromise((svc) => svc.get(id))
}
export async function resize(id: PtyID, cols: number, rows: number) {
return runPromise((svc) => svc.resize(id, cols, rows))
}
export async function write(id: PtyID, data: string) {
return runPromise((svc) => svc.write(id, data))
}
+2 -20
View File
@@ -29,20 +29,13 @@ function local(method: string, path: string) {
return false
}
function getSessionID(url: URL) {
async function getSessionWorkspace(url: URL) {
if (url.pathname === "/session/status") return null
const id = url.pathname.match(/^\/session\/([^/]+)(?:\/|$)/)?.[1]
if (!id) return null
return SessionID.make(id)
}
async function getSessionWorkspace(url: URL) {
const id = getSessionID(url)
if (!id) return null
const session = await Session.get(id).catch(() => undefined)
const session = await Session.get(SessionID.make(id)).catch(() => undefined)
return session?.workspaceID
}
@@ -78,18 +71,7 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
}
const workspace = await Workspace.get(WorkspaceID.make(workspaceID))
if (!workspace) {
// Special-case deleting a session in case user's data in a
// weird state. Allow them to forcefully delete a synced session
// even if the remote workspace is not in their data.
//
// The lets the `DELETE /session/:id` endpoint through and we've
// made sure that it will run without an instance
if (url.pathname.match(/\/session\/[^/]+$/) && c.req.method === "DELETE") {
return routes().fetch(c.req.raw, c.env)
}
return new Response(`Workspace not found: ${workspaceID}`, {
status: 500,
headers: {
+10 -28
View File
@@ -6,17 +6,13 @@ import z from "zod"
import { Session } from "../../session"
import { MessageV2 } from "../../session/message-v2"
import { SessionPrompt } from "../../session/prompt"
import { SessionRunState } from "@/session/run-state"
import { SessionCompaction } from "../../session/compaction"
import { SessionRevert } from "../../session/revert"
import { SessionShare } from "@/share/session"
import { SessionStatus } from "@/session/status"
import { SessionSummary } from "@/session/summary"
import { Todo } from "../../session/todo"
import { AppRuntime } from "../../effect/app-runtime"
import { Agent } from "../../agent/agent"
import { Snapshot } from "@/snapshot"
import { Command } from "../../command"
import { Log } from "../../util/log"
import { Permission } from "@/permission"
import { PermissionID } from "@/permission/schema"
@@ -94,7 +90,7 @@ export const SessionRoutes = lazy(() =>
},
}),
async (c) => {
const result = await AppRuntime.runPromise(SessionStatus.Service.use((svc) => svc.list()))
const result = await SessionStatus.list()
return c.json(Object.fromEntries(result))
},
)
@@ -186,7 +182,7 @@ export const SessionRoutes = lazy(() =>
),
async (c) => {
const sessionID = c.req.valid("param").sessionID
const todos = await AppRuntime.runPromise(Todo.Service.use((svc) => svc.get(sessionID)))
const todos = await Todo.get(sessionID)
return c.json(todos)
},
)
@@ -208,10 +204,10 @@ export const SessionRoutes = lazy(() =>
},
},
}),
validator("json", Session.create.schema),
validator("json", Session.create.schema.optional()),
async (c) => {
const body = c.req.valid("json") ?? {}
const session = await SessionShare.create(body)
const session = await Session.create(body)
return c.json(session)
},
)
@@ -295,7 +291,6 @@ export const SessionRoutes = lazy(() =>
return c.json(session)
},
)
// TODO(v2): remove this dedicated route and rely on the normal `/init` command flow.
.post(
"/:sessionID/init",
describeRoute({
@@ -321,24 +316,11 @@ export const SessionRoutes = lazy(() =>
sessionID: SessionID.zod,
}),
),
validator(
"json",
z.object({
modelID: ModelID.zod,
providerID: ProviderID.zod,
messageID: MessageID.zod,
}),
),
validator("json", Session.initialize.schema.omit({ sessionID: true })),
async (c) => {
const sessionID = c.req.valid("param").sessionID
const body = c.req.valid("json")
await SessionPrompt.command({
sessionID,
messageID: body.messageID,
model: body.providerID + "/" + body.modelID,
command: Command.Default.INIT,
arguments: "",
})
await Session.initialize({ ...body, sessionID })
return c.json(true)
},
)
@@ -428,7 +410,7 @@ export const SessionRoutes = lazy(() =>
),
async (c) => {
const sessionID = c.req.valid("param").sessionID
await SessionShare.share(sessionID)
await Session.share(sessionID)
const session = await Session.get(sessionID)
return c.json(session)
},
@@ -493,12 +475,12 @@ export const SessionRoutes = lazy(() =>
validator(
"param",
z.object({
sessionID: SessionID.zod,
sessionID: Session.unshare.schema,
}),
),
async (c) => {
const sessionID = c.req.valid("param").sessionID
await SessionShare.unshare(sessionID)
await Session.unshare(sessionID)
const session = await Session.get(sessionID)
return c.json(session)
},
@@ -716,7 +698,7 @@ export const SessionRoutes = lazy(() =>
),
async (c) => {
const params = c.req.valid("param")
await SessionRunState.assertNotBusy(params.sessionID)
await SessionPrompt.assertNotBusy(params.sessionID)
await Session.removeMessage({
sessionID: params.sessionID,
messageID: params.messageID,
@@ -62,28 +62,6 @@ export const WorkspaceRoutes = lazy(() =>
return c.json(Workspace.list(Instance.project))
},
)
.get(
"/status",
describeRoute({
summary: "Workspace status",
description: "Get connection status for workspaces in the current project.",
operationId: "experimental.workspace.status",
responses: {
200: {
description: "Workspace status",
content: {
"application/json": {
schema: resolver(z.array(Workspace.ConnectionStatus)),
},
},
},
},
}),
async (c) => {
const ids = new Set(Workspace.list(Instance.project).map((item) => item.id))
return c.json(Workspace.status().filter((item) => ids.has(item.workspaceID)))
},
)
.delete(
"/:id",
describeRoute({
+22 -9
View File
@@ -377,15 +377,17 @@ When constructing the summary, try to stick to this template:
}),
)
export const defaultLayer = Layer.suspend(() =>
layer.pipe(
Layer.provide(Provider.defaultLayer),
Layer.provide(Session.defaultLayer),
Layer.provide(SessionProcessor.defaultLayer),
Layer.provide(Agent.defaultLayer),
Layer.provide(Plugin.defaultLayer),
Layer.provide(Bus.layer),
Layer.provide(Config.defaultLayer),
export const defaultLayer = Layer.unwrap(
Effect.sync(() =>
layer.pipe(
Layer.provide(Provider.defaultLayer),
Layer.provide(Session.defaultLayer),
Layer.provide(SessionProcessor.defaultLayer),
Layer.provide(Agent.defaultLayer),
Layer.provide(Plugin.defaultLayer),
Layer.provide(Bus.layer),
Layer.provide(Config.defaultLayer),
),
),
)
@@ -399,6 +401,17 @@ When constructing the summary, try to stick to this template:
return runPromise((svc) => svc.prune(input))
}
export const process = fn(
z.object({
parentID: MessageID.zod,
messages: z.custom<MessageV2.WithParts[]>(),
sessionID: SessionID.zod,
auto: z.boolean(),
overflow: z.boolean().optional(),
}),
(input) => runPromise((svc) => svc.process(input)),
)
export const create = fn(
z.object({
sessionID: SessionID.zod,
+89 -20
View File
@@ -5,6 +5,7 @@ import { Bus } from "@/bus"
import { Decimal } from "decimal.js"
import z from "zod"
import { type ProviderMetadata } from "ai"
import { Config } from "../config/config"
import { Flag } from "../flag/flag"
import { Installation } from "../installation"
@@ -19,17 +20,20 @@ import { updateSchema } from "../util/update-schema"
import { MessageV2 } from "./message-v2"
import { Instance } from "../project/instance"
import { InstanceState } from "@/effect/instance-state"
import { SessionPrompt } from "./prompt"
import { fn } from "@/util/fn"
import { Command } from "../command"
import { Snapshot } from "@/snapshot"
import { ProjectID } from "../project/schema"
import { WorkspaceID } from "../control-plane/schema"
import { SessionID, MessageID, PartID } from "./schema"
import type { Provider } from "@/provider/provider"
import { ModelID, ProviderID } from "@/provider/schema"
import { Permission } from "@/permission"
import { Global } from "@/global"
import type { LanguageModelV2Usage } from "@ai-sdk/provider"
import { Effect, Layer, ServiceMap } from "effect"
import { Effect, Layer, Scope, ServiceMap } from "effect"
import { makeRuntime } from "@/effect/run-service"
export namespace Session {
@@ -318,6 +322,8 @@ export namespace Session {
readonly fork: (input: { sessionID: SessionID; messageID?: MessageID }) => Effect.Effect<Info>
readonly touch: (sessionID: SessionID) => Effect.Effect<void>
readonly get: (id: SessionID) => Effect.Effect<Info>
readonly share: (id: SessionID) => Effect.Effect<{ url: string }>
readonly unshare: (id: SessionID) => Effect.Effect<void>
readonly setTitle: (input: { sessionID: SessionID; title: string }) => Effect.Effect<void>
readonly setArchived: (input: { sessionID: SessionID; time?: number }) => Effect.Effect<void>
readonly setPermission: (input: { sessionID: SessionID; permission: Permission.Ruleset }) => Effect.Effect<void>
@@ -352,6 +358,12 @@ export namespace Session {
field: string
delta: string
}) => Effect.Effect<void>
readonly initialize: (input: {
sessionID: SessionID
modelID: ModelID
providerID: ProviderID
messageID: MessageID
}) => Effect.Effect<void>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Session") {}
@@ -361,11 +373,12 @@ export namespace Session {
const db = <T>(fn: (d: Parameters<typeof Database.use>[0] extends (trx: infer D) => any ? D : never) => T) =>
Effect.sync(() => Database.use(fn))
export const layer: Layer.Layer<Service, never, Bus.Service | Storage.Service> = Layer.effect(
export const layer: Layer.Layer<Service, never, Bus.Service | Config.Service> = Layer.effect(
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
const storage = yield* Storage.Service
const config = yield* Config.Service
const scope = yield* Scope.Scope
const createNext = Effect.fn("Session.createNext")(function* (input: {
id?: SessionID
@@ -395,6 +408,11 @@ export namespace Session {
yield* Effect.sync(() => SyncEvent.run(Event.Created, { sessionID: result.id, info: result }))
const cfg = yield* config.get()
if (!result.parentID && (Flag.OPENCODE_AUTO_SHARE || cfg.share === "auto")) {
yield* share(result.id).pipe(Effect.ignore, Effect.forkIn(scope))
}
if (!Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
// This only exist for backwards compatibility. We should not be
// manually publishing this event; it is a sync event now
@@ -413,36 +431,47 @@ export namespace Session {
return fromRow(row)
})
const share = Effect.fn("Session.share")(function* (id: SessionID) {
const cfg = yield* config.get()
if (cfg.share === "disabled") throw new Error("Sharing is disabled in configuration")
const result = yield* Effect.promise(async () => {
const { ShareNext } = await import("@/share/share-next")
return ShareNext.create(id)
})
yield* Effect.sync(() => SyncEvent.run(Event.Updated, { sessionID: id, info: { share: { url: result.url } } }))
return result
})
const unshare = Effect.fn("Session.unshare")(function* (id: SessionID) {
yield* Effect.promise(async () => {
const { ShareNext } = await import("@/share/share-next")
await ShareNext.remove(id)
})
yield* Effect.sync(() => SyncEvent.run(Event.Updated, { sessionID: id, info: { share: { url: null } } }))
})
const children = Effect.fn("Session.children")(function* (parentID: SessionID) {
const ctx = yield* InstanceState.context
const rows = yield* db((d) =>
d
.select()
.from(SessionTable)
.where(and(eq(SessionTable.parent_id, parentID)))
.where(and(eq(SessionTable.project_id, ctx.project.id), eq(SessionTable.parent_id, parentID)))
.all(),
)
return rows.map(fromRow)
})
const remove: Interface["remove"] = Effect.fnUntraced(function* (sessionID: SessionID) {
const remove: (sessionID: SessionID) => Effect.Effect<void> = Effect.fnUntraced(function* (sessionID: SessionID) {
try {
const session = yield* get(sessionID)
const kids = yield* children(sessionID)
for (const child of kids) {
yield* remove(child.id)
}
// `remove` needs to work in all cases, such as a broken
// sessions that run cleanup. In certain cases these will
// run without any instance state, so we need to turn off
// publishing of events in that case
const hasInstance = yield* InstanceState.directory.pipe(
Effect.as(true),
Effect.catchCause(() => Effect.succeed(false)),
)
yield* unshare(sessionID).pipe(Effect.ignore)
yield* Effect.sync(() => {
SyncEvent.run(Event.Deleted, { sessionID, info: session }, { publish: hasInstance })
SyncEvent.run(Event.Deleted, { sessionID, info: session })
SyncEvent.remove(sessionID)
})
} catch (e) {
@@ -586,9 +615,9 @@ export namespace Session {
})
const diff = Effect.fn("Session.diff")(function* (sessionID: SessionID) {
return yield* storage
.read<Snapshot.FileDiff[]>(["session_diff", sessionID])
.pipe(Effect.orElseSucceed((): Snapshot.FileDiff[] => []))
return yield* Effect.tryPromise(() => Storage.read<Snapshot.FileDiff[]>(["session_diff", sessionID])).pipe(
Effect.orElseSucceed(() => [] as Snapshot.FileDiff[]),
)
})
const messages = Effect.fn("Session.messages")(function* (input: { sessionID: SessionID; limit?: number }) {
@@ -636,11 +665,30 @@ export namespace Session {
yield* bus.publish(MessageV2.Event.PartDelta, input)
})
const initialize = Effect.fn("Session.initialize")(function* (input: {
sessionID: SessionID
modelID: ModelID
providerID: ProviderID
messageID: MessageID
}) {
yield* Effect.promise(() =>
SessionPrompt.command({
sessionID: input.sessionID,
messageID: input.messageID,
model: input.providerID + "/" + input.modelID,
command: Command.Default.INIT,
arguments: "",
}),
)
})
return Service.of({
create,
fork,
touch,
get,
share,
unshare,
setTitle,
setArchived,
setPermission,
@@ -657,11 +705,12 @@ export namespace Session {
updatePart,
getPart,
updatePartDelta,
initialize,
})
}),
)
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(Storage.defaultLayer))
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(Config.defaultLayer))
const { runPromise } = makeRuntime(Service, defaultLayer)
@@ -681,7 +730,10 @@ export namespace Session {
runPromise((svc) => svc.fork(input)),
)
export const touch = fn(SessionID.zod, (id) => runPromise((svc) => svc.touch(id)))
export const get = fn(SessionID.zod, (id) => runPromise((svc) => svc.get(id)))
export const share = fn(SessionID.zod, (id) => runPromise((svc) => svc.share(id)))
export const unshare = fn(SessionID.zod, (id) => runPromise((svc) => svc.unshare(id)))
export const setTitle = fn(z.object({ sessionID: SessionID.zod, title: z.string() }), (input) =>
runPromise((svc) => svc.setTitle(input)),
@@ -691,12 +743,24 @@ export namespace Session {
runPromise((svc) => svc.setArchived(input)),
)
export const setPermission = fn(z.object({ sessionID: SessionID.zod, permission: Permission.Ruleset }), (input) =>
runPromise((svc) => svc.setPermission(input)),
)
export const setRevert = fn(
z.object({ sessionID: SessionID.zod, revert: Info.shape.revert, summary: Info.shape.summary }),
(input) =>
runPromise((svc) => svc.setRevert({ sessionID: input.sessionID, revert: input.revert, summary: input.summary })),
)
export const clearRevert = fn(SessionID.zod, (id) => runPromise((svc) => svc.clearRevert(id)))
export const setSummary = fn(z.object({ sessionID: SessionID.zod, summary: Info.shape.summary }), (input) =>
runPromise((svc) => svc.setSummary({ sessionID: input.sessionID, summary: input.summary })),
)
export const diff = fn(SessionID.zod, (id) => runPromise((svc) => svc.diff(id)))
export const messages = fn(z.object({ sessionID: SessionID.zod, limit: z.number().optional() }), (input) =>
runPromise((svc) => svc.messages(input)),
)
@@ -844,4 +908,9 @@ export namespace Session {
}),
(input) => runPromise((svc) => svc.updatePartDelta(input)),
)
export const initialize = fn(
z.object({ sessionID: SessionID.zod, modelID: ModelID.zod, providerID: ProviderID.zod, messageID: MessageID.zod }),
(input) => runPromise((svc) => svc.initialize(input)),
)
}
+69 -148
View File
@@ -1,4 +1,4 @@
import { Cause, Deferred, Effect, Layer, ServiceMap } from "effect"
import { Cause, Effect, Layer, ServiceMap } from "effect"
import * as Stream from "effect/Stream"
import { Agent } from "@/agent/agent"
import { Bus } from "@/bus"
@@ -6,7 +6,7 @@ import { Config } from "@/config/config"
import { Permission } from "@/permission"
import { Plugin } from "@/plugin"
import { Snapshot } from "@/snapshot"
import { EffectLogger } from "@/effect/logger"
import { Log } from "@/util/log"
import { Session } from "."
import { LLM } from "./llm"
import { MessageV2 } from "./message-v2"
@@ -18,12 +18,11 @@ import { SessionStatus } from "./status"
import { SessionSummary } from "./summary"
import type { Provider } from "@/provider/provider"
import { Question } from "@/question"
import { errorMessage } from "@/util/error"
import { isRecord } from "@/util/record"
export namespace SessionProcessor {
const DOOM_LOOP_THRESHOLD = 3
const log = EffectLogger.create({ service: "session.processor" })
const log = Log.create({ service: "session.processor" })
export type Result = "compact" | "stop" | "continue"
@@ -31,19 +30,7 @@ export namespace SessionProcessor {
export interface Handle {
readonly message: MessageV2.Assistant
readonly updateToolCall: (
toolCallID: string,
update: (part: MessageV2.ToolPart) => MessageV2.ToolPart,
) => Effect.Effect<MessageV2.ToolPart | undefined>
readonly completeToolCall: (
toolCallID: string,
output: {
title: string
metadata: Record<string, any>
output: string
attachments?: MessageV2.FilePart[]
},
) => Effect.Effect<void>
readonly partFromToolCall: (toolCallID: string) => MessageV2.ToolPart | undefined
readonly process: (streamInput: LLM.StreamInput) => Effect.Effect<Result>
}
@@ -57,15 +44,8 @@ export namespace SessionProcessor {
readonly create: (input: Input) => Effect.Effect<Handle>
}
type ToolCall = {
partID: MessageV2.ToolPart["id"]
messageID: MessageV2.ToolPart["messageID"]
sessionID: MessageV2.ToolPart["sessionID"]
done: Deferred.Deferred<void>
}
interface ProcessorContext extends Input {
toolcalls: Record<string, ToolCall>
toolcalls: Record<string, MessageV2.ToolPart>
shouldBreak: boolean
snapshot: string | undefined
blocked: boolean
@@ -121,7 +101,6 @@ export namespace SessionProcessor {
reasoningMap: {},
}
let aborted = false
const slog = log.with({ sessionID: input.sessionID, messageID: input.assistantMessage.id })
const parse = (e: unknown) =>
MessageV2.fromError(e, {
@@ -129,88 +108,6 @@ export namespace SessionProcessor {
aborted,
})
const settleToolCall = Effect.fn("SessionProcessor.settleToolCall")(function* (toolCallID: string) {
const done = ctx.toolcalls[toolCallID]?.done
delete ctx.toolcalls[toolCallID]
if (done) yield* Deferred.succeed(done, undefined).pipe(Effect.ignore)
})
const readToolCall = Effect.fn("SessionProcessor.readToolCall")(function* (toolCallID: string) {
const call = ctx.toolcalls[toolCallID]
if (!call) return
const part = yield* session.getPart({
partID: call.partID,
messageID: call.messageID,
sessionID: call.sessionID,
})
if (!part || part.type !== "tool") {
delete ctx.toolcalls[toolCallID]
return
}
return { call, part }
})
const updateToolCall = Effect.fn("SessionProcessor.updateToolCall")(function* (
toolCallID: string,
update: (part: MessageV2.ToolPart) => MessageV2.ToolPart,
) {
const match = yield* readToolCall(toolCallID)
if (!match) return
const part = yield* session.updatePart(update(match.part))
ctx.toolcalls[toolCallID] = {
...match.call,
partID: part.id,
messageID: part.messageID,
sessionID: part.sessionID,
}
return part
})
const completeToolCall = Effect.fn("SessionProcessor.completeToolCall")(function* (
toolCallID: string,
output: {
title: string
metadata: Record<string, any>
output: string
attachments?: MessageV2.FilePart[]
},
) {
const match = yield* readToolCall(toolCallID)
if (!match || match.part.state.status !== "running") return
yield* session.updatePart({
...match.part,
state: {
status: "completed",
input: match.part.state.input,
output: output.output,
metadata: output.metadata,
title: output.title,
time: { start: match.part.state.time.start, end: Date.now() },
attachments: output.attachments,
},
})
yield* settleToolCall(toolCallID)
})
const failToolCall = Effect.fn("SessionProcessor.failToolCall")(function* (toolCallID: string, error: unknown) {
const match = yield* readToolCall(toolCallID)
if (!match || match.part.state.status !== "running") return false
yield* session.updatePart({
...match.part,
state: {
status: "error",
input: match.part.state.input,
error: errorMessage(error),
time: { start: match.part.state.time.start, end: Date.now() },
},
})
if (error instanceof Permission.RejectedError || error instanceof Question.RejectedError) {
ctx.blocked = ctx.shouldBreak
}
yield* settleToolCall(toolCallID)
return true
})
const handleEvent = Effect.fn("SessionProcessor.handleEvent")(function* (value: StreamEvent) {
switch (value.type) {
case "start":
@@ -246,7 +143,7 @@ export namespace SessionProcessor {
case "reasoning-end":
if (!(value.id in ctx.reasoningMap)) return
ctx.reasoningMap[value.id].text = ctx.reasoningMap[value.id].text
ctx.reasoningMap[value.id].text = ctx.reasoningMap[value.id].text.trimEnd()
ctx.reasoningMap[value.id].time = { ...ctx.reasoningMap[value.id].time, end: Date.now() }
if (value.providerMetadata) ctx.reasoningMap[value.id].metadata = value.providerMetadata
yield* session.updatePart(ctx.reasoningMap[value.id])
@@ -257,8 +154,8 @@ export namespace SessionProcessor {
if (ctx.assistantMessage.summary) {
throw new Error(`Tool call not allowed while generating summary: ${value.toolName}`)
}
const part = yield* session.updatePart({
id: ctx.toolcalls[value.id]?.partID ?? PartID.ascending(),
ctx.toolcalls[value.id] = yield* session.updatePart({
id: ctx.toolcalls[value.id]?.id ?? PartID.ascending(),
messageID: ctx.assistantMessage.id,
sessionID: ctx.assistantMessage.sessionID,
type: "tool",
@@ -267,12 +164,6 @@ export namespace SessionProcessor {
state: { status: "pending", input: {}, raw: "" },
metadata: value.providerExecuted ? { providerExecuted: true } : undefined,
} satisfies MessageV2.ToolPart)
ctx.toolcalls[value.id] = {
done: yield* Deferred.make<void>(),
partID: part.id,
messageID: part.messageID,
sessionID: part.sessionID,
}
return
case "tool-input-delta":
@@ -285,7 +176,14 @@ export namespace SessionProcessor {
if (ctx.assistantMessage.summary) {
throw new Error(`Tool call not allowed while generating summary: ${value.toolName}`)
}
yield* updateToolCall(value.toolCallId, (match) => ({
const pointer = ctx.toolcalls[value.toolCallId]
const match = yield* session.getPart({
partID: pointer.id,
messageID: pointer.messageID,
sessionID: pointer.sessionID,
})
if (!match || match.type !== "tool") return
ctx.toolcalls[value.toolCallId] = yield* session.updatePart({
...match,
tool: value.toolName,
state: {
@@ -297,7 +195,7 @@ export namespace SessionProcessor {
metadata: match.metadata?.providerExecuted
? { ...value.providerMetadata, providerExecuted: true }
: value.providerMetadata,
}))
} satisfies MessageV2.ToolPart)
const parts = MessageV2.parts(ctx.assistantMessage.id)
const recentParts = parts.slice(-DOOM_LOOP_THRESHOLD)
@@ -328,12 +226,41 @@ export namespace SessionProcessor {
}
case "tool-result": {
yield* completeToolCall(value.toolCallId, value.output)
const match = ctx.toolcalls[value.toolCallId]
if (!match || match.state.status !== "running") return
yield* session.updatePart({
...match,
state: {
status: "completed",
input: value.input ?? match.state.input,
output: value.output.output,
metadata: value.output.metadata,
title: value.output.title,
time: { start: match.state.time.start, end: Date.now() },
attachments: value.output.attachments,
},
})
delete ctx.toolcalls[value.toolCallId]
return
}
case "tool-error": {
yield* failToolCall(value.toolCallId, value.error)
const match = ctx.toolcalls[value.toolCallId]
if (!match || match.state.status !== "running") return
yield* session.updatePart({
...match,
state: {
status: "error",
input: value.input ?? match.state.input,
error: value.error instanceof Error ? value.error.message : String(value.error),
time: { start: match.state.time.start, end: Date.now() },
},
})
if (value.error instanceof Permission.RejectedError || value.error instanceof Question.RejectedError) {
ctx.blocked = ctx.shouldBreak
}
delete ctx.toolcalls[value.toolCallId]
return
}
@@ -426,7 +353,7 @@ export namespace SessionProcessor {
case "text-end":
if (!ctx.currentText) return
ctx.currentText.text = ctx.currentText.text
ctx.currentText.text = ctx.currentText.text.trimEnd()
ctx.currentText.text = (yield* plugin.trigger(
"experimental.text.complete",
{
@@ -449,7 +376,7 @@ export namespace SessionProcessor {
return
default:
yield* slog.info("unhandled", { event: value.type, value })
log.info("unhandled", { ...value })
return
}
})
@@ -486,16 +413,7 @@ export namespace SessionProcessor {
}
ctx.reasoningMap = {}
yield* Effect.forEach(
Object.values(ctx.toolcalls),
(call) => Deferred.await(call.done).pipe(Effect.timeout("250 millis"), Effect.ignore),
{ concurrency: "unbounded" },
)
for (const toolCallID of Object.keys(ctx.toolcalls)) {
const match = yield* readToolCall(toolCallID)
if (!match) continue
const part = match.part
for (const part of Object.values(ctx.toolcalls)) {
const end = Date.now()
const metadata = "metadata" in part.state && isRecord(part.state.metadata) ? part.state.metadata : {}
yield* session.updatePart({
@@ -515,7 +433,7 @@ export namespace SessionProcessor {
})
const halt = Effect.fn("SessionProcessor.halt")(function* (e: unknown) {
yield* slog.error("process", { error: errorMessage(e), stack: e instanceof Error ? e.stack : undefined })
log.error("process", { error: e, stack: e instanceof Error ? e.stack : undefined })
const error = parse(e)
if (MessageV2.ContextOverflowError.isInstance(error)) {
ctx.needsCompaction = true
@@ -531,7 +449,7 @@ export namespace SessionProcessor {
})
const process = Effect.fn("SessionProcessor.process")(function* (streamInput: LLM.StreamInput) {
yield* slog.info("process")
log.info("process")
ctx.needsCompaction = false
ctx.shouldBreak = (yield* config.get()).experimental?.continue_loop_on_deny !== true
@@ -585,8 +503,9 @@ export namespace SessionProcessor {
get message() {
return ctx.assistantMessage
},
updateToolCall,
completeToolCall,
partFromToolCall(toolCallID: string) {
return ctx.toolcalls[toolCallID]
},
process,
} satisfies Handle
})
@@ -595,17 +514,19 @@ export namespace SessionProcessor {
}),
)
export const defaultLayer = Layer.suspend(() =>
layer.pipe(
Layer.provide(Session.defaultLayer),
Layer.provide(Snapshot.defaultLayer),
Layer.provide(Agent.defaultLayer),
Layer.provide(LLM.defaultLayer),
Layer.provide(Permission.defaultLayer),
Layer.provide(Plugin.defaultLayer),
Layer.provide(SessionStatus.defaultLayer),
Layer.provide(Bus.layer),
Layer.provide(Config.defaultLayer),
export const defaultLayer = Layer.unwrap(
Effect.sync(() =>
layer.pipe(
Layer.provide(Session.defaultLayer),
Layer.provide(Snapshot.defaultLayer),
Layer.provide(Agent.defaultLayer),
Layer.provide(LLM.defaultLayer),
Layer.provide(Permission.defaultLayer),
Layer.provide(Plugin.defaultLayer),
Layer.provide(SessionStatus.layer.pipe(Layer.provide(Bus.layer))),
Layer.provide(Bus.layer),
Layer.provide(Config.defaultLayer),
),
),
)
}
+172 -139
View File
@@ -20,6 +20,7 @@ import PROMPT_PLAN from "../session/prompt/plan.txt"
import BUILD_SWITCH from "../session/prompt/build-switch.txt"
import MAX_STEPS from "../session/prompt/max-steps.txt"
import { ToolRegistry } from "../tool/registry"
import { Runner } from "@/effect/runner"
import { MCP } from "../mcp"
import { LSP } from "../lsp"
import { FileTime } from "../file/time"
@@ -44,11 +45,9 @@ import { Truncate } from "@/tool/truncate"
import { decodeDataUrl } from "@/util/data-url"
import { Process } from "@/util/process"
import { Cause, Effect, Exit, Layer, Option, Scope, ServiceMap } from "effect"
import { EffectLogger } from "@/effect/logger"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { TaskTool, type TaskPromptOps } from "@/tool/task"
import { SessionRunState } from "./run-state"
import { TaskTool } from "@/tool/task"
// @ts-ignore
globalThis.AI_SDK_LOG_WARNINGS = false
@@ -65,9 +64,9 @@ const STRUCTURED_OUTPUT_SYSTEM_PROMPT = `IMPORTANT: The user has requested struc
export namespace SessionPrompt {
const log = Log.create({ service: "session.prompt" })
const elog = EffectLogger.create({ service: "session.prompt" })
export interface Interface {
readonly assertNotBusy: (sessionID: SessionID) => Effect.Effect<void, Session.BusyError>
readonly cancel: (sessionID: SessionID) => Effect.Effect<void>
readonly prompt: (input: PromptInput) => Effect.Effect<MessageV2.WithParts>
readonly loop: (input: z.infer<typeof LoopInput>) => Effect.Effect<MessageV2.WithParts>
@@ -100,18 +99,55 @@ export namespace SessionPrompt {
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
const scope = yield* Scope.Scope
const instruction = yield* Instruction.Service
const state = yield* SessionRunState.Service
const revert = yield* SessionRevert.Service
const run = {
promise: <A, E>(effect: Effect.Effect<A, E>) =>
Effect.runPromise(effect.pipe(Effect.provide(EffectLogger.layer))),
fork: <A, E>(effect: Effect.Effect<A, E>) => Effect.runFork(effect.pipe(Effect.provide(EffectLogger.layer))),
const state = yield* InstanceState.make(
Effect.fn("SessionPrompt.state")(function* () {
const runners = new Map<string, Runner<MessageV2.WithParts>>()
yield* Effect.addFinalizer(
Effect.fnUntraced(function* () {
yield* Effect.forEach(runners.values(), (r) => r.cancel, { concurrency: "unbounded", discard: true })
runners.clear()
}),
)
return { runners }
}),
)
const getRunner = (runners: Map<string, Runner<MessageV2.WithParts>>, sessionID: SessionID) => {
const existing = runners.get(sessionID)
if (existing) return existing
const runner = Runner.make<MessageV2.WithParts>(scope, {
onIdle: Effect.gen(function* () {
runners.delete(sessionID)
yield* status.set(sessionID, { type: "idle" })
}),
onBusy: status.set(sessionID, { type: "busy" }),
onInterrupt: lastAssistant(sessionID),
busy: () => {
throw new Session.BusyError(sessionID)
},
})
runners.set(sessionID, runner)
return runner
}
const assertNotBusy: (sessionID: SessionID) => Effect.Effect<void, Session.BusyError> = Effect.fn(
"SessionPrompt.assertNotBusy",
)(function* (sessionID: SessionID) {
const s = yield* InstanceState.get(state)
const runner = s.runners.get(sessionID)
if (runner?.busy) throw new Session.BusyError(sessionID)
})
const cancel = Effect.fn("SessionPrompt.cancel")(function* (sessionID: SessionID) {
yield* elog.info("cancel", { sessionID })
yield* state.cancel(sessionID)
log.info("cancel", { sessionID })
const s = yield* InstanceState.get(state)
const runner = s.runners.get(sessionID)
if (!runner || !runner.busy) {
yield* status.set(sessionID, { type: "idle" })
return
}
yield* runner.cancel
})
const resolvePromptParts = Effect.fn("SessionPrompt.resolvePromptParts")(function* (template: string) {
@@ -204,7 +240,11 @@ export namespace SessionPrompt {
const t = cleaned.length > 100 ? cleaned.substring(0, 97) + "..." : cleaned
yield* sessions
.setTitle({ sessionID: input.session.id, title: t })
.pipe(Effect.catchCause((cause) => elog.error("failed to generate title", { error: Cause.squash(cause) })))
.pipe(
Effect.catchCause((cause) =>
Effect.sync(() => log.error("failed to generate title", { error: Cause.squash(cause) })),
),
)
})
const insertReminders = Effect.fn("SessionPrompt.insertReminders")(function* (input: {
@@ -348,7 +388,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
model: Provider.Model
session: Session.Info
tools?: Record<string, boolean>
processor: Pick<SessionProcessor.Handle, "message" | "updateToolCall" | "completeToolCall">
processor: Pick<SessionProcessor.Handle, "message" | "partFromToolCall">
bypassAgentCheck: boolean
messages: MessageV2.WithParts[]
}) {
@@ -360,14 +400,15 @@ NOTE: At any point in time through this workflow you should feel free to ask the
abort: options.abortSignal!,
messageID: input.processor.message.id,
callID: options.toolCallId,
extra: { model: input.model, bypassAgentCheck: input.bypassAgentCheck, promptOps },
extra: { model: input.model, bypassAgentCheck: input.bypassAgentCheck },
agent: input.agent.name,
messages: input.messages,
metadata: (val) =>
run.promise(
input.processor.updateToolCall(options.toolCallId, (match) => {
if (!["running", "pending"].includes(match.state.status)) return match
return {
Effect.runPromise(
Effect.gen(function* () {
const match = input.processor.partFromToolCall(options.toolCallId)
if (!match || !["running", "pending"].includes(match.state.status)) return
yield* sessions.updatePart({
...match,
state: {
title: val.title,
@@ -376,18 +417,18 @@ NOTE: At any point in time through this workflow you should feel free to ask the
input: args,
time: { start: Date.now() },
},
}
})
}),
),
ask: (req) =>
permission
.ask({
Effect.runPromise(
permission.ask({
...req,
sessionID: input.session.id,
tool: { messageID: input.processor.message.id, callID: options.toolCallId },
ruleset: Permission.merge(input.agent.permission, input.session.permission ?? []),
})
.pipe(Effect.orDie),
}),
),
})
for (const item of yield* registry.tools({
@@ -401,7 +442,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
description: item.description,
inputSchema: jsonSchema(schema as any),
execute(args, options) {
return run.promise(
return Effect.runPromise(
Effect.gen(function* () {
const ctx = context(args, options)
yield* plugin.trigger(
@@ -409,7 +450,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
{ tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID },
{ args },
)
const result = yield* item.execute(args, ctx)
const result = yield* Effect.promise(() => item.execute(args, ctx))
const output = {
...result,
attachments: result.attachments?.map((attachment) => ({
@@ -424,9 +465,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the
{ tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID, args },
output,
)
if (options.abortSignal?.aborted) {
yield* input.processor.completeToolCall(options.toolCallId, output)
}
return output
}),
)
@@ -442,7 +480,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
const transformed = ProviderTransform.schema(input.model, schema)
item.inputSchema = jsonSchema(transformed)
item.execute = (args, opts) =>
run.promise(
Effect.runPromise(
Effect.gen(function* () {
const ctx = context(args, opts)
yield* plugin.trigger(
@@ -450,7 +488,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
{ tool: key, sessionID: ctx.sessionID, callID: opts.toolCallId },
{ args },
)
yield* ctx.ask({ permission: key, metadata: {}, patterns: ["*"], always: ["*"] })
yield* Effect.promise(() => ctx.ask({ permission: key, metadata: {}, patterns: ["*"], always: ["*"] }))
const result: Awaited<ReturnType<NonNullable<typeof execute>>> = yield* Effect.promise(() =>
execute(args, opts),
)
@@ -491,7 +529,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
...(truncated.truncated && { outputPath: truncated.outputPath }),
}
const output = {
return {
title: "",
metadata,
output: truncated.content,
@@ -503,10 +541,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the
})),
content: result.content,
}
if (opts.abortSignal?.aborted) {
yield* input.processor.completeToolCall(opts.toolCallId, output)
}
return output
}),
)
tools[key] = item
@@ -582,64 +616,63 @@ NOTE: At any point in time through this workflow you should feel free to ask the
}
let error: Error | undefined
const taskAbort = new AbortController()
const result = yield* taskTool
.execute(taskArgs, {
agent: task.agent,
messageID: assistantMessage.id,
sessionID,
abort: taskAbort.signal,
callID: part.callID,
extra: { bypassAgentCheck: true, promptOps },
messages: msgs,
metadata(val: { title?: string; metadata?: Record<string, any> }) {
return run.promise(
Effect.gen(function* () {
part = yield* sessions.updatePart({
...part,
type: "tool",
state: { ...part.state, ...val },
} satisfies MessageV2.ToolPart)
}),
)
},
ask: (req: any) =>
permission
.ask({
...req,
sessionID,
ruleset: Permission.merge(taskAgent.permission, session.permission ?? []),
})
.pipe(Effect.orDie),
})
.pipe(
Effect.catchCause((cause) => {
const defect = Cause.squash(cause)
error = defect instanceof Error ? defect : new Error(String(defect))
const result = yield* Effect.promise((signal) =>
taskTool
.execute(taskArgs, {
agent: task.agent,
messageID: assistantMessage.id,
sessionID,
abort: signal,
callID: part.callID,
extra: { bypassAgentCheck: true },
messages: msgs,
metadata(val: { title?: string; metadata?: Record<string, any> }) {
return Effect.runPromise(
Effect.gen(function* () {
part = yield* sessions.updatePart({
...part,
type: "tool",
state: { ...part.state, ...val },
} satisfies MessageV2.ToolPart)
}),
)
},
ask(req: any) {
return Effect.runPromise(
permission.ask({
...req,
sessionID,
ruleset: Permission.merge(taskAgent.permission, session.permission ?? []),
}),
)
},
})
.catch((e) => {
error = e instanceof Error ? e : new Error(String(e))
log.error("subtask execution failed", { error, agent: task.agent, description: task.description })
return Effect.void
return undefined
}),
Effect.onInterrupt(() =>
Effect.gen(function* () {
taskAbort.abort()
assistantMessage.finish = "tool-calls"
assistantMessage.time.completed = Date.now()
yield* sessions.updateMessage(assistantMessage)
if (part.state.status === "running") {
yield* sessions.updatePart({
...part,
state: {
status: "error",
error: "Cancelled",
time: { start: part.state.time.start, end: Date.now() },
metadata: part.state.metadata,
input: part.state.input,
},
} satisfies MessageV2.ToolPart)
}
}),
),
)
).pipe(
Effect.onInterrupt(() =>
Effect.gen(function* () {
assistantMessage.finish = "tool-calls"
assistantMessage.time.completed = Date.now()
yield* sessions.updateMessage(assistantMessage)
if (part.state.status === "running") {
yield* sessions.updatePart({
...part,
state: {
status: "error",
error: "Cancelled",
time: { start: part.state.time.start, end: Date.now() },
metadata: part.state.metadata,
input: part.state.input,
},
} satisfies MessageV2.ToolPart)
}
}),
),
)
const attachments = result?.attachments?.map((attachment) => ({
...attachment,
@@ -714,7 +747,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
const ctx = yield* InstanceState.context
const session = yield* sessions.get(input.sessionID)
if (session.revert) {
yield* revert.cleanup(session)
yield* Effect.promise(() => SessionRevert.cleanup(session))
}
const agent = yield* agents.get(input.agent)
if (!agent) {
@@ -862,7 +895,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
output += chunk
if (part.state.status === "running") {
part.state.metadata = { output, description: "" }
void run.fork(sessions.updatePart(part))
void Effect.runFork(sessions.updatePart(part))
}
}),
)
@@ -1044,21 +1077,19 @@ NOTE: At any point in time through this workflow you should feel free to ask the
if (yield* fsys.isDir(filepath)) part.mime = "application/x-directory"
const { read } = yield* registry.named()
const execRead = (args: Parameters<typeof read.execute>[0], extra?: Tool.Context["extra"]) => {
const controller = new AbortController()
return read
.execute(args, {
const execRead = (args: Parameters<typeof read.execute>[0], extra?: Tool.Context["extra"]) =>
Effect.promise((signal: AbortSignal) =>
read.execute(args, {
sessionID: input.sessionID,
abort: controller.signal,
abort: signal,
agent: input.agent!,
messageID: info.id,
extra: { bypassCwdCheck: true, ...extra },
messages: [],
metadata: () => {},
ask: () => Effect.void,
})
.pipe(Effect.onInterrupt(() => Effect.sync(() => controller.abort())))
}
metadata: async () => {},
ask: async () => {},
}),
)
if (part.mime === "text/plain") {
let offset: number | undefined
@@ -1277,7 +1308,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
const prompt: (input: PromptInput) => Effect.Effect<MessageV2.WithParts> = Effect.fn("SessionPrompt.prompt")(
function* (input: PromptInput) {
const session = yield* sessions.get(input.sessionID)
yield* revert.cleanup(session)
yield* Effect.promise(() => SessionRevert.cleanup(session))
const message = yield* createUserMessage(input)
yield* sessions.touch(input.sessionID)
@@ -1309,14 +1340,13 @@ NOTE: At any point in time through this workflow you should feel free to ask the
const runLoop: (sessionID: SessionID) => Effect.Effect<MessageV2.WithParts> = Effect.fn("SessionPrompt.run")(
function* (sessionID: SessionID) {
const ctx = yield* InstanceState.context
const slog = elog.with({ sessionID })
let structured: unknown | undefined
let step = 0
const session = yield* sessions.get(sessionID)
while (true) {
yield* status.set(sessionID, { type: "busy" })
yield* slog.info("loop", { step })
log.info("loop", { step, sessionID })
let msgs = yield* MessageV2.filterCompactedEffect(sessionID)
@@ -1352,7 +1382,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
!hasToolCalls &&
lastUser.id < lastAssistant.id
) {
yield* slog.info("exiting loop")
log.info("exiting loop", { sessionID })
break
}
@@ -1538,17 +1568,21 @@ NOTE: At any point in time through this workflow you should feel free to ask the
const loop: (input: z.infer<typeof LoopInput>) => Effect.Effect<MessageV2.WithParts> = Effect.fn(
"SessionPrompt.loop",
)(function* (input: z.infer<typeof LoopInput>) {
return yield* state.ensureRunning(input.sessionID, lastAssistant(input.sessionID), runLoop(input.sessionID))
const s = yield* InstanceState.get(state)
const runner = getRunner(s.runners, input.sessionID)
return yield* runner.ensureRunning(runLoop(input.sessionID))
})
const shell: (input: ShellInput) => Effect.Effect<MessageV2.WithParts> = Effect.fn("SessionPrompt.shell")(
function* (input: ShellInput) {
return yield* state.startShell(input.sessionID, lastAssistant(input.sessionID), shellImpl(input))
const s = yield* InstanceState.get(state)
const runner = getRunner(s.runners, input.sessionID)
return yield* runner.startShell(shellImpl(input))
},
)
const command = Effect.fn("SessionPrompt.command")(function* (input: CommandInput) {
yield* elog.info("command", { sessionID: input.sessionID, command: input.command, agent: input.agent })
log.info("command", input)
const cmd = yield* commands.get(input.command)
if (!cmd) {
const available = (yield* commands.list()).map((c) => c.name)
@@ -1663,13 +1697,8 @@ NOTE: At any point in time through this workflow you should feel free to ask the
return result
})
const promptOps: TaskPromptOps = {
cancel: (sessionID) => run.fork(cancel(sessionID)),
resolvePromptParts: (template) => run.promise(resolvePromptParts(template)),
prompt: (input) => run.promise(prompt(input)),
}
return Service.of({
assertNotBusy,
cancel,
prompt,
loop,
@@ -1680,32 +1709,36 @@ NOTE: At any point in time through this workflow you should feel free to ask the
}),
)
export const defaultLayer = Layer.suspend(() =>
layer.pipe(
Layer.provide(SessionRunState.defaultLayer),
Layer.provide(SessionStatus.defaultLayer),
Layer.provide(SessionCompaction.defaultLayer),
Layer.provide(SessionProcessor.defaultLayer),
Layer.provide(Command.defaultLayer),
Layer.provide(Permission.defaultLayer),
Layer.provide(MCP.defaultLayer),
Layer.provide(LSP.defaultLayer),
Layer.provide(FileTime.defaultLayer),
Layer.provide(ToolRegistry.defaultLayer),
Layer.provide(Truncate.defaultLayer),
Layer.provide(Provider.defaultLayer),
Layer.provide(Instruction.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Plugin.defaultLayer),
Layer.provide(Session.defaultLayer),
Layer.provide(SessionRevert.defaultLayer),
Layer.provide(Agent.defaultLayer),
Layer.provide(Bus.layer),
Layer.provide(CrossSpawnSpawner.defaultLayer),
const defaultLayer = Layer.unwrap(
Effect.sync(() =>
layer.pipe(
Layer.provide(SessionStatus.layer),
Layer.provide(SessionCompaction.defaultLayer),
Layer.provide(SessionProcessor.defaultLayer),
Layer.provide(Command.defaultLayer),
Layer.provide(Permission.defaultLayer),
Layer.provide(MCP.defaultLayer),
Layer.provide(LSP.defaultLayer),
Layer.provide(FileTime.defaultLayer),
Layer.provide(ToolRegistry.defaultLayer),
Layer.provide(Truncate.layer),
Layer.provide(Provider.defaultLayer),
Layer.provide(Instruction.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Plugin.defaultLayer),
Layer.provide(Session.defaultLayer),
Layer.provide(Agent.defaultLayer),
Layer.provide(Bus.layer),
Layer.provide(CrossSpawnSpawner.defaultLayer),
),
),
)
const { runPromise } = makeRuntime(Service, defaultLayer)
export async function assertNotBusy(sessionID: SessionID) {
return runPromise((svc) => svc.assertNotBusy(SessionID.zod.parse(sessionID)))
}
export const PromptInput = z.object({
sessionID: SessionID.zod,
messageID: MessageID.zod.optional(),
+12 -13
View File
@@ -9,9 +9,8 @@ import { Log } from "../util/log"
import { Session } from "."
import { MessageV2 } from "./message-v2"
import { SessionID, MessageID, PartID } from "./schema"
import { SessionRunState } from "./run-state"
import { SessionPrompt } from "./prompt"
import { SessionSummary } from "./summary"
import { SessionStatus } from "./status"
export namespace SessionRevert {
const log = Log.create({ service: "session.revert" })
@@ -39,10 +38,9 @@ export namespace SessionRevert {
const storage = yield* Storage.Service
const bus = yield* Bus.Service
const summary = yield* SessionSummary.Service
const state = yield* SessionRunState.Service
const revert = Effect.fn("SessionRevert.revert")(function* (input: RevertInput) {
yield* state.assertNotBusy(input.sessionID)
yield* Effect.promise(() => SessionPrompt.assertNotBusy(input.sessionID))
const all = yield* sessions.messages({ sessionID: input.sessionID })
let lastUser: MessageV2.User | undefined
const session = yield* sessions.get(input.sessionID)
@@ -95,7 +93,7 @@ export namespace SessionRevert {
const unrevert = Effect.fn("SessionRevert.unrevert")(function* (input: { sessionID: SessionID }) {
log.info("unreverting", input)
yield* state.assertNotBusy(input.sessionID)
yield* Effect.promise(() => SessionPrompt.assertNotBusy(input.sessionID))
const session = yield* sessions.get(input.sessionID)
if (!session.revert) return session
if (session.revert.snapshot) yield* snap.restore(session.revert!.snapshot!)
@@ -150,14 +148,15 @@ export namespace SessionRevert {
}),
)
export const defaultLayer = Layer.suspend(() =>
layer.pipe(
Layer.provide(SessionRunState.defaultLayer),
Layer.provide(Session.defaultLayer),
Layer.provide(Snapshot.defaultLayer),
Layer.provide(Storage.defaultLayer),
Layer.provide(Bus.layer),
Layer.provide(SessionSummary.defaultLayer),
export const defaultLayer = Layer.unwrap(
Effect.sync(() =>
layer.pipe(
Layer.provide(Session.defaultLayer),
Layer.provide(Snapshot.defaultLayer),
Layer.provide(Storage.defaultLayer),
Layer.provide(Bus.layer),
Layer.provide(SessionSummary.defaultLayer),
),
),
)
-114
View File
@@ -1,114 +0,0 @@
import { InstanceState } from "@/effect/instance-state"
import { Runner } from "@/effect/runner"
import { makeRuntime } from "@/effect/run-service"
import { Effect, Layer, Scope, ServiceMap } from "effect"
import { Session } from "."
import { MessageV2 } from "./message-v2"
import { SessionID } from "./schema"
import { SessionStatus } from "./status"
export namespace SessionRunState {
export interface Interface {
readonly assertNotBusy: (sessionID: SessionID) => Effect.Effect<void>
readonly cancel: (sessionID: SessionID) => Effect.Effect<void>
readonly ensureRunning: (
sessionID: SessionID,
onInterrupt: Effect.Effect<MessageV2.WithParts>,
work: Effect.Effect<MessageV2.WithParts>,
) => Effect.Effect<MessageV2.WithParts>
readonly startShell: (
sessionID: SessionID,
onInterrupt: Effect.Effect<MessageV2.WithParts>,
work: Effect.Effect<MessageV2.WithParts>,
) => Effect.Effect<MessageV2.WithParts>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/SessionRunState") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const status = yield* SessionStatus.Service
const state = yield* InstanceState.make(
Effect.fn("SessionRunState.state")(function* () {
const scope = yield* Scope.Scope
const runners = new Map<SessionID, Runner<MessageV2.WithParts>>()
yield* Effect.addFinalizer(
Effect.fnUntraced(function* () {
yield* Effect.forEach(runners.values(), (runner) => runner.cancel, {
concurrency: "unbounded",
discard: true,
})
runners.clear()
}),
)
return { runners, scope }
}),
)
const runner = Effect.fn("SessionRunState.runner")(function* (
sessionID: SessionID,
onInterrupt: Effect.Effect<MessageV2.WithParts>,
) {
const data = yield* InstanceState.get(state)
const existing = data.runners.get(sessionID)
if (existing) return existing
const next = Runner.make<MessageV2.WithParts>(data.scope, {
onIdle: Effect.gen(function* () {
data.runners.delete(sessionID)
yield* status.set(sessionID, { type: "idle" })
}),
onBusy: status.set(sessionID, { type: "busy" }),
onInterrupt,
busy: () => {
throw new Session.BusyError(sessionID)
},
})
data.runners.set(sessionID, next)
return next
})
const assertNotBusy = Effect.fn("SessionRunState.assertNotBusy")(function* (sessionID: SessionID) {
const data = yield* InstanceState.get(state)
const existing = data.runners.get(sessionID)
if (existing?.busy) throw new Session.BusyError(sessionID)
})
const cancel = Effect.fn("SessionRunState.cancel")(function* (sessionID: SessionID) {
const data = yield* InstanceState.get(state)
const existing = data.runners.get(sessionID)
if (!existing || !existing.busy) {
yield* status.set(sessionID, { type: "idle" })
return
}
yield* existing.cancel
})
const ensureRunning = Effect.fn("SessionRunState.ensureRunning")(function* (
sessionID: SessionID,
onInterrupt: Effect.Effect<MessageV2.WithParts>,
work: Effect.Effect<MessageV2.WithParts>,
) {
return yield* (yield* runner(sessionID, onInterrupt)).ensureRunning(work)
})
const startShell = Effect.fn("SessionRunState.startShell")(function* (
sessionID: SessionID,
onInterrupt: Effect.Effect<MessageV2.WithParts>,
work: Effect.Effect<MessageV2.WithParts>,
) {
return yield* (yield* runner(sessionID, onInterrupt)).startShell(work)
})
return Service.of({ assertNotBusy, cancel, ensureRunning, startShell })
}),
)
export const defaultLayer = layer.pipe(Layer.provide(SessionStatus.defaultLayer))
const { runPromise } = makeRuntime(Service, defaultLayer)
export async function assertNotBusy(sessionID: SessionID) {
return runPromise((svc) => svc.assertNotBusy(sessionID))
}
}
+15 -1
View File
@@ -1,6 +1,7 @@
import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { SessionID } from "./schema"
import { Effect, Layer, ServiceMap } from "effect"
import z from "zod"
@@ -84,5 +85,18 @@ export namespace SessionStatus {
}),
)
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer))
const defaultLayer = layer.pipe(Layer.provide(Bus.layer))
const { runPromise } = makeRuntime(Service, defaultLayer)
export async function get(sessionID: SessionID) {
return runPromise((svc) => svc.get(sessionID))
}
export async function list() {
return runPromise((svc) => svc.list())
}
export async function set(sessionID: SessionID, status: Info) {
return runPromise((svc) => svc.set(sessionID, status))
}
}
+8 -6
View File
@@ -150,12 +150,14 @@ export namespace SessionSummary {
}),
)
export const defaultLayer = Layer.suspend(() =>
layer.pipe(
Layer.provide(Session.defaultLayer),
Layer.provide(Snapshot.defaultLayer),
Layer.provide(Storage.defaultLayer),
Layer.provide(Bus.layer),
export const defaultLayer = Layer.unwrap(
Effect.sync(() =>
layer.pipe(
Layer.provide(Session.defaultLayer),
Layer.provide(Snapshot.defaultLayer),
Layer.provide(Storage.defaultLayer),
Layer.provide(Bus.layer),
),
),
)
+10
View File
@@ -1,5 +1,6 @@
import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus"
import { makeRuntime } from "@/effect/run-service"
import { SessionID } from "./schema"
import { Effect, Layer, ServiceMap } from "effect"
import z from "zod"
@@ -82,4 +83,13 @@ export namespace Todo {
)
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer))
const { runPromise } = makeRuntime(Service, defaultLayer)
export async function update(input: { sessionID: SessionID; todos: Info[] }) {
return runPromise((svc) => svc.update(input))
}
export async function get(sessionID: SessionID) {
return runPromise((svc) => svc.get(sessionID))
}
}
-67
View File
@@ -1,67 +0,0 @@
import { makeRuntime } from "@/effect/run-service"
import { Session } from "@/session"
import { SessionID } from "@/session/schema"
import { SyncEvent } from "@/sync"
import { fn } from "@/util/fn"
import { Effect, Layer, Scope, ServiceMap } from "effect"
import { Config } from "../config/config"
import { Flag } from "../flag/flag"
import { ShareNext } from "./share-next"
export namespace SessionShare {
export interface Interface {
readonly create: (input?: Parameters<typeof Session.create>[0]) => Effect.Effect<Session.Info>
readonly share: (sessionID: SessionID) => Effect.Effect<{ url: string }, unknown>
readonly unshare: (sessionID: SessionID) => Effect.Effect<void, unknown>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/SessionShare") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const cfg = yield* Config.Service
const session = yield* Session.Service
const shareNext = yield* ShareNext.Service
const scope = yield* Scope.Scope
const share = Effect.fn("SessionShare.share")(function* (sessionID: SessionID) {
const conf = yield* cfg.get()
if (conf.share === "disabled") throw new Error("Sharing is disabled in configuration")
const result = yield* shareNext.create(sessionID)
yield* Effect.sync(() =>
SyncEvent.run(Session.Event.Updated, { sessionID, info: { share: { url: result.url } } }),
)
return result
})
const unshare = Effect.fn("SessionShare.unshare")(function* (sessionID: SessionID) {
yield* shareNext.remove(sessionID)
yield* Effect.sync(() => SyncEvent.run(Session.Event.Updated, { sessionID, info: { share: { url: null } } }))
})
const create = Effect.fn("SessionShare.create")(function* (input?: Parameters<typeof Session.create>[0]) {
const result = yield* session.create(input)
if (result.parentID) return result
const conf = yield* cfg.get()
if (!(Flag.OPENCODE_AUTO_SHARE || conf.share === "auto")) return result
yield* share(result.id).pipe(Effect.ignore, Effect.forkIn(scope))
return result
})
return Service.of({ create, share, unshare })
}),
)
export const defaultLayer = layer.pipe(
Layer.provide(ShareNext.defaultLayer),
Layer.provide(Session.defaultLayer),
Layer.provide(Config.defaultLayer),
)
const { runPromise } = makeRuntime(Service, defaultLayer)
export const create = fn(Session.create.schema, (input) => runPromise((svc) => svc.create(input)))
export const share = fn(SessionID.zod, (sessionID) => runPromise((svc) => svc.share(sessionID)))
export const unshare = fn(SessionID.zod, (sessionID) => runPromise((svc) => svc.unshare(sessionID)))
}
+24 -5
View File
@@ -4,6 +4,7 @@ import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } fr
import { Account } from "@/account"
import { Bus } from "@/bus"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { Provider } from "@/provider/provider"
import { ModelID, ProviderID } from "@/provider/schema"
import { Session } from "@/session"
@@ -158,10 +159,7 @@ export namespace ShareNext {
if (disabled) return cache
const watch = <D extends { type: string }>(
def: D,
fn: (evt: { properties: any }) => Effect.Effect<void, unknown>,
) =>
const watch = <D extends { type: string }>(def: D, fn: (evt: { properties: any }) => Effect.Effect<void>) =>
bus.subscribe(def as never).pipe(
Stream.runForEach((evt) =>
fn(evt).pipe(
@@ -196,7 +194,6 @@ export namespace ShareNext {
yield* watch(Session.Event.Diff, (evt) =>
sync(evt.properties.sessionID, [{ type: "session_diff", data: evt.properties.diff }]),
)
yield* watch(Session.Event.Deleted, (evt) => remove(evt.properties.sessionID))
return cache
}),
@@ -347,4 +344,26 @@ export namespace ShareNext {
Layer.provide(Provider.defaultLayer),
Layer.provide(Session.defaultLayer),
)
const { runPromise } = makeRuntime(Service, defaultLayer)
export async function init() {
return runPromise((svc) => svc.init())
}
export async function url() {
return runPromise((svc) => svc.url())
}
export async function request(): Promise<Req> {
return runPromise((svc) => svc.request())
}
export async function create(sessionID: SessionID) {
return runPromise((svc) => svc.create(sessionID))
}
export async function remove(sessionID: SessionID) {
return runPromise((svc) => svc.remove(sessionID))
}
}
+1 -1
View File
@@ -29,7 +29,7 @@ const log = Log.create({ service: "db" })
export namespace Database {
export function getChannelPath() {
if (["latest", "beta", "prod"].includes(CHANNEL) || Flag.OPENCODE_DISABLE_CHANNEL_DB)
if (["latest", "beta"].includes(CHANNEL) || Flag.OPENCODE_DISABLE_CHANNEL_DB)
return path.join(Global.Path.data, "opencode.db")
const safe = CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-")
return path.join(Global.Path.data, `opencode-${safe}.db`)
+32 -12
View File
@@ -4,17 +4,14 @@ import { Global } from "../global"
import { NamedError } from "@opencode-ai/util/error"
import z from "zod"
import { AppFileSystem } from "@/filesystem"
import { makeRuntime } from "@/effect/run-service"
import { Effect, Exit, Layer, Option, RcMap, Schema, ServiceMap, TxReentrantLock } from "effect"
import { Git } from "@/git"
export namespace Storage {
const log = Log.create({ service: "storage" })
type Migration = (
dir: string,
fs: AppFileSystem.Interface,
git: Git.Interface,
) => Effect.Effect<void, AppFileSystem.Error>
type Migration = (dir: string, fs: AppFileSystem.Interface) => Effect.Effect<void, AppFileSystem.Error>
export const NotFoundError = NamedError.create(
"NotFoundError",
@@ -86,7 +83,7 @@ export namespace Storage {
}
const MIGRATIONS: Migration[] = [
Effect.fn("Storage.migration.1")(function* (dir: string, fs: AppFileSystem.Interface, git: Git.Interface) {
Effect.fn("Storage.migration.1")(function* (dir: string, fs: AppFileSystem.Interface) {
const project = path.resolve(dir, "../project")
if (!(yield* fs.isDir(project))) return
const projectDirs = yield* fs.glob("*", {
@@ -113,9 +110,11 @@ export namespace Storage {
}
if (!worktree) continue
if (!(yield* fs.isDir(worktree))) continue
const result = yield* git.run(["rev-list", "--max-parents=0", "--all"], {
cwd: worktree,
})
const result = yield* Effect.promise(() =>
Git.run(["rev-list", "--max-parents=0", "--all"], {
cwd: worktree,
}),
)
const [id] = result
.text()
.split("\n")
@@ -221,7 +220,6 @@ export namespace Storage {
Service,
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const git = yield* Git.Service
const locks = yield* RcMap.make({
lookup: () => TxReentrantLock.make(),
idleTimeToLive: 0,
@@ -238,7 +236,7 @@ export namespace Storage {
for (let i = migration; i < MIGRATIONS.length; i++) {
log.info("running migration", { index: i })
const step = MIGRATIONS[i]!
const exit = yield* Effect.exit(step(dir, fs, git))
const exit = yield* Effect.exit(step(dir, fs))
if (Exit.isFailure(exit)) {
log.error("failed to run migration", { index: i, cause: exit.cause })
break
@@ -329,5 +327,27 @@ export namespace Storage {
}),
)
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Git.defaultLayer))
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
const { runPromise } = makeRuntime(Service, defaultLayer)
export async function remove(key: string[]) {
return runPromise((svc) => svc.remove(key))
}
export async function read<T>(key: string[]) {
return runPromise((svc) => svc.read<T>(key))
}
export async function update<T>(key: string[], fn: (draft: T) => void) {
return runPromise((svc) => svc.update<T>(key, fn))
}
export async function write<T>(key: string[], content: T) {
return runPromise((svc) => svc.write(key, content))
}
export async function list(prefix: string[]) {
return runPromise((svc) => svc.list(prefix))
}
}
+4 -6
View File
@@ -165,7 +165,7 @@ export namespace SyncEvent {
// and it validets all the sequence ids
// * when loading events from db, apply zod validation to ensure shape
export function replay(event: SerializedEvent, options?: { publish: boolean }) {
export function replay(event: SerializedEvent, options?: { republish: boolean }) {
const def = registry.get(event.type)
if (!def) {
throw new Error(`Unknown event type: ${event.type}`)
@@ -189,10 +189,10 @@ export namespace SyncEvent {
throw new Error(`Sequence mismatch for aggregate "${event.aggregateID}": expected ${expected}, got ${event.seq}`)
}
process(def, event, { publish: !!options?.publish })
process(def, event, { publish: !!options?.republish })
}
export function run<Def extends Definition>(def: Def, data: Event<Def>["data"], options?: { publish?: boolean }) {
export function run<Def extends Definition>(def: Def, data: Event<Def>["data"]) {
const agg = (data as Record<string, string>)[def.aggregate]
// This should never happen: we've enforced it via typescript in
// the definition
@@ -204,8 +204,6 @@ export namespace SyncEvent {
throw new Error(`SyncEvent.run: running old versions of events is not allowed: ${def.type}`)
}
const { publish = true } = options || {}
// Note that this is an "immediate" transaction which is critical.
// We need to make sure we can safely read and write with nothing
// else changing the data from under us
@@ -220,7 +218,7 @@ export namespace SyncEvent {
const seq = row?.seq != null ? row.seq + 1 : 0
const event = { id, seq, aggregateID: agg, data }
process(def, event, { publish })
process(def, event, { publish: true })
},
{
behavior: "immediate",
+232 -239
View File
@@ -1,16 +1,16 @@
import z from "zod"
import * as path from "path"
import { Effect } from "effect"
import * as fs from "fs/promises"
import { Tool } from "./tool"
import { Bus } from "../bus"
import { FileWatcher } from "../file/watcher"
import { Instance } from "../project/instance"
import { Patch } from "../patch"
import { createTwoFilesPatch, diffLines } from "diff"
import { assertExternalDirectoryEffect } from "./external-directory"
import { assertExternalDirectory } from "./external-directory"
import { trimDiff } from "./edit"
import { LSP } from "../lsp"
import { AppFileSystem } from "../filesystem"
import { Filesystem } from "../util/filesystem"
import DESCRIPTION from "./apply_patch.txt"
import { File } from "../file"
import { Format } from "../format"
@@ -19,268 +19,261 @@ const PatchParams = z.object({
patchText: z.string().describe("The full patch text that describes all changes to be made"),
})
export const ApplyPatchTool = Tool.define(
"apply_patch",
Effect.gen(function* () {
const lsp = yield* LSP.Service
const afs = yield* AppFileSystem.Service
const format = yield* Format.Service
const bus = yield* Bus.Service
export const ApplyPatchTool = Tool.define("apply_patch", {
description: DESCRIPTION,
parameters: PatchParams,
async execute(params, ctx) {
if (!params.patchText) {
throw new Error("patchText is required")
}
const run = Effect.fn("ApplyPatchTool.execute")(function* (params: z.infer<typeof PatchParams>, ctx: Tool.Context) {
if (!params.patchText) {
return yield* Effect.fail(new Error("patchText is required"))
// Parse the patch to get hunks
let hunks: Patch.Hunk[]
try {
const parseResult = Patch.parsePatch(params.patchText)
hunks = parseResult.hunks
} catch (error) {
throw new Error(`apply_patch verification failed: ${error}`)
}
if (hunks.length === 0) {
const normalized = params.patchText.replace(/\r\n/g, "\n").replace(/\r/g, "\n").trim()
if (normalized === "*** Begin Patch\n*** End Patch") {
throw new Error("patch rejected: empty patch")
}
throw new Error("apply_patch verification failed: no hunks found")
}
// Parse the patch to get hunks
let hunks: Patch.Hunk[]
try {
const parseResult = Patch.parsePatch(params.patchText)
hunks = parseResult.hunks
} catch (error) {
return yield* Effect.fail(new Error(`apply_patch verification failed: ${error}`))
}
// Validate file paths and check permissions
const fileChanges: Array<{
filePath: string
oldContent: string
newContent: string
type: "add" | "update" | "delete" | "move"
movePath?: string
diff: string
additions: number
deletions: number
}> = []
if (hunks.length === 0) {
const normalized = params.patchText.replace(/\r\n/g, "\n").replace(/\r/g, "\n").trim()
if (normalized === "*** Begin Patch\n*** End Patch") {
return yield* Effect.fail(new Error("patch rejected: empty patch"))
}
return yield* Effect.fail(new Error("apply_patch verification failed: no hunks found"))
}
let totalDiff = ""
// Validate file paths and check permissions
const fileChanges: Array<{
filePath: string
oldContent: string
newContent: string
type: "add" | "update" | "delete" | "move"
movePath?: string
diff: string
additions: number
deletions: number
}> = []
for (const hunk of hunks) {
const filePath = path.resolve(Instance.directory, hunk.path)
await assertExternalDirectory(ctx, filePath)
let totalDiff = ""
switch (hunk.type) {
case "add": {
const oldContent = ""
const newContent =
hunk.contents.length === 0 || hunk.contents.endsWith("\n") ? hunk.contents : `${hunk.contents}\n`
const diff = trimDiff(createTwoFilesPatch(filePath, filePath, oldContent, newContent))
for (const hunk of hunks) {
const filePath = path.resolve(Instance.directory, hunk.path)
yield* assertExternalDirectoryEffect(ctx, filePath)
switch (hunk.type) {
case "add": {
const oldContent = ""
const newContent =
hunk.contents.length === 0 || hunk.contents.endsWith("\n") ? hunk.contents : `${hunk.contents}\n`
const diff = trimDiff(createTwoFilesPatch(filePath, filePath, oldContent, newContent))
let additions = 0
let deletions = 0
for (const change of diffLines(oldContent, newContent)) {
if (change.added) additions += change.count || 0
if (change.removed) deletions += change.count || 0
}
fileChanges.push({
filePath,
oldContent,
newContent,
type: "add",
diff,
additions,
deletions,
})
totalDiff += diff + "\n"
break
let additions = 0
let deletions = 0
for (const change of diffLines(oldContent, newContent)) {
if (change.added) additions += change.count || 0
if (change.removed) deletions += change.count || 0
}
case "update": {
// Check if file exists for update
const stats = yield* afs.stat(filePath).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (!stats || stats.type === "Directory") {
return yield* Effect.fail(
new Error(`apply_patch verification failed: Failed to read file to update: ${filePath}`),
)
}
fileChanges.push({
filePath,
oldContent,
newContent,
type: "add",
diff,
additions,
deletions,
})
const oldContent = yield* afs.readFileString(filePath)
let newContent = oldContent
totalDiff += diff + "\n"
break
}
// Apply the update chunks to get new content
try {
const fileUpdate = Patch.deriveNewContentsFromChunks(filePath, hunk.chunks)
newContent = fileUpdate.content
} catch (error) {
return yield* Effect.fail(new Error(`apply_patch verification failed: ${error}`))
}
const diff = trimDiff(createTwoFilesPatch(filePath, filePath, oldContent, newContent))
let additions = 0
let deletions = 0
for (const change of diffLines(oldContent, newContent)) {
if (change.added) additions += change.count || 0
if (change.removed) deletions += change.count || 0
}
const movePath = hunk.move_path ? path.resolve(Instance.directory, hunk.move_path) : undefined
yield* assertExternalDirectoryEffect(ctx, movePath)
fileChanges.push({
filePath,
oldContent,
newContent,
type: hunk.move_path ? "move" : "update",
movePath,
diff,
additions,
deletions,
})
totalDiff += diff + "\n"
break
case "update": {
// Check if file exists for update
const stats = await fs.stat(filePath).catch(() => null)
if (!stats || stats.isDirectory()) {
throw new Error(`apply_patch verification failed: Failed to read file to update: ${filePath}`)
}
case "delete": {
const contentToDelete = yield* afs
.readFileString(filePath)
.pipe(Effect.catch((error) => Effect.fail(new Error(`apply_patch verification failed: ${error}`))))
const deleteDiff = trimDiff(createTwoFilesPatch(filePath, filePath, contentToDelete, ""))
const oldContent = await fs.readFile(filePath, "utf-8")
let newContent = oldContent
const deletions = contentToDelete.split("\n").length
fileChanges.push({
filePath,
oldContent: contentToDelete,
newContent: "",
type: "delete",
diff: deleteDiff,
additions: 0,
deletions,
})
totalDiff += deleteDiff + "\n"
break
// Apply the update chunks to get new content
try {
const fileUpdate = Patch.deriveNewContentsFromChunks(filePath, hunk.chunks)
newContent = fileUpdate.content
} catch (error) {
throw new Error(`apply_patch verification failed: ${error}`)
}
}
}
// Build per-file metadata for UI rendering (used for both permission and result)
const files = fileChanges.map((change) => ({
filePath: change.filePath,
relativePath: path.relative(Instance.worktree, change.movePath ?? change.filePath).replaceAll("\\", "/"),
type: change.type,
patch: change.diff,
additions: change.additions,
deletions: change.deletions,
movePath: change.movePath,
}))
const diff = trimDiff(createTwoFilesPatch(filePath, filePath, oldContent, newContent))
// Check permissions if needed
const relativePaths = fileChanges.map((c) => path.relative(Instance.worktree, c.filePath).replaceAll("\\", "/"))
yield* ctx.ask({
permission: "edit",
patterns: relativePaths,
always: ["*"],
metadata: {
filepath: relativePaths.join(", "),
diff: totalDiff,
files,
},
})
let additions = 0
let deletions = 0
for (const change of diffLines(oldContent, newContent)) {
if (change.added) additions += change.count || 0
if (change.removed) deletions += change.count || 0
}
// Apply the changes
const updates: Array<{ file: string; event: "add" | "change" | "unlink" }> = []
const movePath = hunk.move_path ? path.resolve(Instance.directory, hunk.move_path) : undefined
await assertExternalDirectory(ctx, movePath)
for (const change of fileChanges) {
const edited = change.type === "delete" ? undefined : (change.movePath ?? change.filePath)
switch (change.type) {
case "add":
// Create parent directories (recursive: true is safe on existing/root dirs)
fileChanges.push({
filePath,
oldContent,
newContent,
type: hunk.move_path ? "move" : "update",
movePath,
diff,
additions,
deletions,
})
yield* afs.writeWithDirs(change.filePath, change.newContent)
updates.push({ file: change.filePath, event: "add" })
break
case "update":
yield* afs.writeWithDirs(change.filePath, change.newContent)
updates.push({ file: change.filePath, event: "change" })
break
case "move":
if (change.movePath) {
// Create parent directories (recursive: true is safe on existing/root dirs)
yield* afs.writeWithDirs(change.movePath!, change.newContent)
yield* afs.remove(change.filePath)
updates.push({ file: change.filePath, event: "unlink" })
updates.push({ file: change.movePath, event: "add" })
}
break
case "delete":
yield* afs.remove(change.filePath)
updates.push({ file: change.filePath, event: "unlink" })
break
totalDiff += diff + "\n"
break
}
if (edited) {
yield* format.file(edited)
yield* bus.publish(File.Event.Edited, { file: edited })
case "delete": {
const contentToDelete = await fs.readFile(filePath, "utf-8").catch((error) => {
throw new Error(`apply_patch verification failed: ${error}`)
})
const deleteDiff = trimDiff(createTwoFilesPatch(filePath, filePath, contentToDelete, ""))
const deletions = contentToDelete.split("\n").length
fileChanges.push({
filePath,
oldContent: contentToDelete,
newContent: "",
type: "delete",
diff: deleteDiff,
additions: 0,
deletions,
})
totalDiff += deleteDiff + "\n"
break
}
}
}
// Publish file change events
for (const update of updates) {
yield* bus.publish(FileWatcher.Event.Updated, update)
}
// Build per-file metadata for UI rendering (used for both permission and result)
const files = fileChanges.map((change) => ({
filePath: change.filePath,
relativePath: path.relative(Instance.worktree, change.movePath ?? change.filePath).replaceAll("\\", "/"),
type: change.type,
patch: change.diff,
additions: change.additions,
deletions: change.deletions,
movePath: change.movePath,
}))
// Notify LSP of file changes and collect diagnostics
for (const change of fileChanges) {
if (change.type === "delete") continue
const target = change.movePath ?? change.filePath
yield* lsp.touchFile(target, true)
}
const diagnostics = yield* lsp.diagnostics()
// Generate output summary
const summaryLines = fileChanges.map((change) => {
if (change.type === "add") {
return `A ${path.relative(Instance.worktree, change.filePath).replaceAll("\\", "/")}`
}
if (change.type === "delete") {
return `D ${path.relative(Instance.worktree, change.filePath).replaceAll("\\", "/")}`
}
const target = change.movePath ?? change.filePath
return `M ${path.relative(Instance.worktree, target).replaceAll("\\", "/")}`
})
let output = `Success. Updated the following files:\n${summaryLines.join("\n")}`
for (const change of fileChanges) {
if (change.type === "delete") continue
const target = change.movePath ?? change.filePath
const block = LSP.Diagnostic.report(target, diagnostics[AppFileSystem.normalizePath(target)] ?? [])
if (!block) continue
const rel = path.relative(Instance.worktree, target).replaceAll("\\", "/")
output += `\n\nLSP errors detected in ${rel}, please fix:\n${block}`
}
return {
title: output,
metadata: {
diff: totalDiff,
files,
diagnostics,
},
output,
}
// Check permissions if needed
const relativePaths = fileChanges.map((c) => path.relative(Instance.worktree, c.filePath).replaceAll("\\", "/"))
await ctx.ask({
permission: "edit",
patterns: relativePaths,
always: ["*"],
metadata: {
filepath: relativePaths.join(", "),
diff: totalDiff,
files,
},
})
return {
description: DESCRIPTION,
parameters: PatchParams,
execute: (params: z.infer<typeof PatchParams>, ctx: Tool.Context) => run(params, ctx).pipe(Effect.orDie),
// Apply the changes
const updates: Array<{ file: string; event: "add" | "change" | "unlink" }> = []
for (const change of fileChanges) {
const edited = change.type === "delete" ? undefined : (change.movePath ?? change.filePath)
switch (change.type) {
case "add":
// Create parent directories (recursive: true is safe on existing/root dirs)
await fs.mkdir(path.dirname(change.filePath), { recursive: true })
await fs.writeFile(change.filePath, change.newContent, "utf-8")
updates.push({ file: change.filePath, event: "add" })
break
case "update":
await fs.writeFile(change.filePath, change.newContent, "utf-8")
updates.push({ file: change.filePath, event: "change" })
break
case "move":
if (change.movePath) {
// Create parent directories (recursive: true is safe on existing/root dirs)
await fs.mkdir(path.dirname(change.movePath), { recursive: true })
await fs.writeFile(change.movePath, change.newContent, "utf-8")
await fs.unlink(change.filePath)
updates.push({ file: change.filePath, event: "unlink" })
updates.push({ file: change.movePath, event: "add" })
}
break
case "delete":
await fs.unlink(change.filePath)
updates.push({ file: change.filePath, event: "unlink" })
break
}
if (edited) {
await Format.file(edited)
Bus.publish(File.Event.Edited, { file: edited })
}
}
}),
)
// Publish file change events
for (const update of updates) {
await Bus.publish(FileWatcher.Event.Updated, update)
}
// Notify LSP of file changes and collect diagnostics
for (const change of fileChanges) {
if (change.type === "delete") continue
const target = change.movePath ?? change.filePath
await LSP.touchFile(target, true)
}
const diagnostics = await LSP.diagnostics()
// Generate output summary
const summaryLines = fileChanges.map((change) => {
if (change.type === "add") {
return `A ${path.relative(Instance.worktree, change.filePath).replaceAll("\\", "/")}`
}
if (change.type === "delete") {
return `D ${path.relative(Instance.worktree, change.filePath).replaceAll("\\", "/")}`
}
const target = change.movePath ?? change.filePath
return `M ${path.relative(Instance.worktree, target).replaceAll("\\", "/")}`
})
let output = `Success. Updated the following files:\n${summaryLines.join("\n")}`
// Report LSP errors for changed files
const MAX_DIAGNOSTICS_PER_FILE = 20
for (const change of fileChanges) {
if (change.type === "delete") continue
const target = change.movePath ?? change.filePath
const normalized = Filesystem.normalizePath(target)
const issues = diagnostics[normalized] ?? []
const errors = issues.filter((item) => item.severity === 1)
if (errors.length > 0) {
const limited = errors.slice(0, MAX_DIAGNOSTICS_PER_FILE)
const suffix =
errors.length > MAX_DIAGNOSTICS_PER_FILE ? `\n... and ${errors.length - MAX_DIAGNOSTICS_PER_FILE} more` : ""
output += `\n\nLSP errors detected in ${path.relative(Instance.worktree, target).replaceAll("\\", "/")}, please fix:\n<diagnostics file="${target}">\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n</diagnostics>`
}
}
return {
title: output,
metadata: {
diff: totalDiff,
files,
diagnostics,
},
output,
}
},
})
+214 -223
View File
@@ -8,7 +8,8 @@ import { Instance } from "../project/instance"
import { lazy } from "@/util/lazy"
import { Language, type Node } from "web-tree-sitter"
import { AppFileSystem } from "@/filesystem"
import { Filesystem } from "@/util/filesystem"
import { Process } from "@/util/process"
import { fileURLToPath } from "url"
import { Flag } from "@/flag/flag"
import { Shell } from "@/shell/shell"
@@ -16,9 +17,9 @@ import { Shell } from "@/shell/shell"
import { BashArity } from "@/permission/arity"
import { Truncate } from "./truncate"
import { Plugin } from "@/plugin"
import { Effect, Stream } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
import { Cause, Effect, Exit, Stream } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
const MAX_METADATA_LENGTH = 30_000
const DEFAULT_TIMEOUT = Flag.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2 * 60 * 1000
@@ -182,6 +183,34 @@ function prefix(text: string) {
return text.slice(0, match.index)
}
async function cygpath(shell: string, text: string) {
const out = await Process.text([shell, "-lc", 'cygpath -w -- "$1"', "_", text], { nothrow: true })
if (out.code !== 0) return
const file = out.text.trim()
if (!file) return
return Filesystem.normalizePath(file)
}
async function resolvePath(text: string, root: string, shell: string) {
if (process.platform === "win32") {
if (Shell.posix(shell) && text.startsWith("/") && Filesystem.windowsPath(text) === text) {
const file = await cygpath(shell, text)
if (file) return file
}
return Filesystem.normalizePath(path.resolve(root, Filesystem.windowsPath(text)))
}
return path.resolve(root, text)
}
async function argPath(arg: string, cwd: string, ps: boolean, shell: string) {
const text = ps ? expand(arg, cwd, shell) : home(unquote(arg))
const file = text && prefix(text)
if (!file || dynamic(file, ps)) return
const next = ps ? provider(file) : file
if (!next) return
return resolvePath(next, cwd, shell)
}
function pathArgs(list: Part[], ps: boolean) {
if (!ps) {
return list
@@ -209,24 +238,55 @@ function pathArgs(list: Part[], ps: boolean) {
return out
}
async function collect(root: Node, cwd: string, ps: boolean, shell: string): Promise<Scan> {
const scan: Scan = {
dirs: new Set<string>(),
patterns: new Set<string>(),
always: new Set<string>(),
}
for (const node of commands(root)) {
const command = parts(node)
const tokens = command.map((item) => item.text)
const cmd = ps ? tokens[0]?.toLowerCase() : tokens[0]
if (cmd && FILES.has(cmd)) {
for (const arg of pathArgs(command, ps)) {
const resolved = await argPath(arg, cwd, ps, shell)
log.info("resolved path", { arg, resolved })
if (!resolved || Instance.containsPath(resolved)) continue
const dir = (await Filesystem.isDir(resolved)) ? resolved : path.dirname(resolved)
scan.dirs.add(dir)
}
}
if (tokens.length && (!cmd || !CWD.has(cmd))) {
scan.patterns.add(source(node))
scan.always.add(BashArity.prefix(tokens).join(" ") + " *")
}
}
return scan
}
function preview(text: string) {
if (text.length <= MAX_METADATA_LENGTH) return text
return text.slice(0, MAX_METADATA_LENGTH) + "\n\n..."
}
const parse = Effect.fn("BashTool.parse")(function* (command: string, ps: boolean) {
const tree = yield* Effect.promise(() => parser().then((p) => (ps ? p.ps : p.bash).parse(command)))
async function parse(command: string, ps: boolean) {
const tree = await parser().then((p) => (ps ? p.ps : p.bash).parse(command))
if (!tree) throw new Error("Failed to parse command")
return tree.rootNode
})
}
const ask = Effect.fn("BashTool.ask")(function* (ctx: Tool.Context, scan: Scan) {
async function ask(ctx: Tool.Context, scan: Scan) {
if (scan.dirs.size > 0) {
const globs = Array.from(scan.dirs).map((dir) => {
if (process.platform === "win32") return AppFileSystem.normalizePathPattern(path.join(dir, "*"))
if (process.platform === "win32") return Filesystem.normalizePathPattern(path.join(dir, "*"))
return path.join(dir, "*")
})
yield* ctx.ask({
await ctx.ask({
permission: "external_directory",
patterns: globs,
always: globs,
@@ -235,13 +295,21 @@ const ask = Effect.fn("BashTool.ask")(function* (ctx: Tool.Context, scan: Scan)
}
if (scan.patterns.size === 0) return
yield* ctx.ask({
await ctx.ask({
permission: "bash",
patterns: Array.from(scan.patterns),
always: Array.from(scan.always),
metadata: {},
})
})
}
async function shellEnv(ctx: Tool.Context, cwd: string) {
const extra = await Plugin.trigger("shell.env", { cwd, sessionID: ctx.sessionID, callID: ctx.callID }, { env: {} })
return {
...process.env,
...extra.env,
}
}
function cmd(shell: string, name: string, command: string, cwd: string, env: NodeJS.ProcessEnv) {
if (process.platform === "win32" && PS.has(name)) {
@@ -262,6 +330,100 @@ function cmd(shell: string, name: string, command: string, cwd: string, env: Nod
})
}
async function run(
input: {
shell: string
name: string
command: string
cwd: string
env: NodeJS.ProcessEnv
timeout: number
description: string
},
ctx: Tool.Context,
) {
let output = ""
let expired = false
let aborted = false
ctx.metadata({
metadata: {
output: "",
description: input.description,
},
})
const exit = await CrossSpawnSpawner.runPromiseExit((spawner) =>
Effect.gen(function* () {
const handle = yield* spawner.spawn(cmd(input.shell, input.name, input.command, input.cwd, input.env))
yield* Effect.forkScoped(
Stream.runForEach(Stream.decodeText(handle.all), (chunk) =>
Effect.sync(() => {
output += chunk
ctx.metadata({
metadata: {
output: preview(output),
description: input.description,
},
})
}),
),
)
const abort = Effect.callback<void>((resume) => {
if (ctx.abort.aborted) return resume(Effect.void)
const handler = () => resume(Effect.void)
ctx.abort.addEventListener("abort", handler, { once: true })
return Effect.sync(() => ctx.abort.removeEventListener("abort", handler))
})
const timeout = Effect.sleep(`${input.timeout + 100} millis`)
const exit = yield* Effect.raceAll([
handle.exitCode.pipe(Effect.map((code) => ({ kind: "exit" as const, code }))),
abort.pipe(Effect.map(() => ({ kind: "abort" as const, code: null }))),
timeout.pipe(Effect.map(() => ({ kind: "timeout" as const, code: null }))),
])
if (exit.kind === "abort") {
aborted = true
yield* handle.kill({ forceKillAfter: "3 seconds" }).pipe(Effect.orDie)
}
if (exit.kind === "timeout") {
expired = true
yield* handle.kill({ forceKillAfter: "3 seconds" }).pipe(Effect.orDie)
}
return exit.kind === "exit" ? exit.code : null
}).pipe(Effect.scoped, Effect.orDie),
)
let code: number | null = null
if (Exit.isSuccess(exit)) {
code = exit.value
} else if (!Cause.hasInterruptsOnly(exit.cause)) {
throw Cause.squash(exit.cause)
}
const meta: string[] = []
if (expired) meta.push(`bash tool terminated command after exceeding timeout ${input.timeout} ms`)
if (aborted) meta.push("User aborted the command")
if (meta.length > 0) {
output += "\n\n<bash_metadata>\n" + meta.join("\n") + "\n</bash_metadata>"
}
return {
title: input.description,
metadata: {
output: preview(output),
exit: code,
description: input.description,
},
output,
}
}
const parser = lazy(async () => {
const { Parser } = await import("web-tree-sitter")
const { default: treeWasm } = await import("web-tree-sitter/tree-sitter.wasm" as string, {
@@ -290,218 +452,47 @@ const parser = lazy(async () => {
})
// TODO: we may wanna rename this tool so it works better on other shells
export const BashTool = Tool.define(
"bash",
Effect.gen(function* () {
const spawner = yield* ChildProcessSpawner
const fs = yield* AppFileSystem.Service
const plugin = yield* Plugin.Service
export const BashTool = Tool.define("bash", async () => {
const shell = Shell.acceptable()
const name = Shell.name(shell)
const chain =
name === "powershell"
? "If the commands depend on each other and must run sequentially, avoid '&&' in this shell because Windows PowerShell 5.1 does not support it. Use PowerShell conditionals such as `cmd1; if ($?) { cmd2 }` when later commands must depend on earlier success."
: "If the commands depend on each other and must run sequentially, use a single Bash call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`). For instance, if one operation must complete before another starts (like mkdir before cp, Write before Bash for git operations, or git add before git commit), run these operations sequentially instead."
log.info("bash tool using shell", { shell })
const cygpath = Effect.fn("BashTool.cygpath")(function* (shell: string, text: string) {
const lines = yield* spawner
.lines(ChildProcess.make(shell, ["-lc", 'cygpath -w -- "$1"', "_", text]))
.pipe(Effect.catch(() => Effect.succeed([] as string[])))
const file = lines[0]?.trim()
if (!file) return
return AppFileSystem.normalizePath(file)
})
const resolvePath = Effect.fn("BashTool.resolvePath")(function* (text: string, root: string, shell: string) {
if (process.platform === "win32") {
if (Shell.posix(shell) && text.startsWith("/") && AppFileSystem.windowsPath(text) === text) {
const file = yield* cygpath(shell, text)
if (file) return file
}
return AppFileSystem.normalizePath(path.resolve(root, AppFileSystem.windowsPath(text)))
return {
description: DESCRIPTION.replaceAll("${directory}", Instance.directory)
.replaceAll("${os}", process.platform)
.replaceAll("${shell}", name)
.replaceAll("${chaining}", chain)
.replaceAll("${maxLines}", String(Truncate.MAX_LINES))
.replaceAll("${maxBytes}", String(Truncate.MAX_BYTES)),
parameters: Parameters,
async execute(params, ctx) {
const cwd = params.workdir ? await resolvePath(params.workdir, Instance.directory, shell) : Instance.directory
if (params.timeout !== undefined && params.timeout < 0) {
throw new Error(`Invalid timeout value: ${params.timeout}. Timeout must be a positive number.`)
}
return path.resolve(root, text)
})
const timeout = params.timeout ?? DEFAULT_TIMEOUT
const ps = PS.has(name)
const root = await parse(params.command, ps)
const scan = await collect(root, cwd, ps, shell)
if (!Instance.containsPath(cwd)) scan.dirs.add(cwd)
await ask(ctx, scan)
const argPath = Effect.fn("BashTool.argPath")(function* (arg: string, cwd: string, ps: boolean, shell: string) {
const text = ps ? expand(arg, cwd, shell) : home(unquote(arg))
const file = text && prefix(text)
if (!file || dynamic(file, ps)) return
const next = ps ? provider(file) : file
if (!next) return
return yield* resolvePath(next, cwd, shell)
})
const collect = Effect.fn("BashTool.collect")(function* (root: Node, cwd: string, ps: boolean, shell: string) {
const scan: Scan = {
dirs: new Set<string>(),
patterns: new Set<string>(),
always: new Set<string>(),
}
for (const node of commands(root)) {
const command = parts(node)
const tokens = command.map((item) => item.text)
const cmd = ps ? tokens[0]?.toLowerCase() : tokens[0]
if (cmd && FILES.has(cmd)) {
for (const arg of pathArgs(command, ps)) {
const resolved = yield* argPath(arg, cwd, ps, shell)
log.info("resolved path", { arg, resolved })
if (!resolved || Instance.containsPath(resolved)) continue
const dir = (yield* fs.isDir(resolved)) ? resolved : path.dirname(resolved)
scan.dirs.add(dir)
}
}
if (tokens.length && (!cmd || !CWD.has(cmd))) {
scan.patterns.add(source(node))
scan.always.add(BashArity.prefix(tokens).join(" ") + " *")
}
}
return scan
})
const shellEnv = Effect.fn("BashTool.shellEnv")(function* (ctx: Tool.Context, cwd: string) {
const extra = yield* plugin.trigger(
"shell.env",
{ cwd, sessionID: ctx.sessionID, callID: ctx.callID },
{ env: {} },
return run(
{
shell,
name,
command: params.command,
cwd,
env: await shellEnv(ctx, cwd),
timeout,
description: params.description,
},
ctx,
)
return {
...process.env,
...extra.env,
}
})
const run = Effect.fn("BashTool.run")(function* (
input: {
shell: string
name: string
command: string
cwd: string
env: NodeJS.ProcessEnv
timeout: number
description: string
},
ctx: Tool.Context,
) {
let output = ""
let expired = false
let aborted = false
ctx.metadata({
metadata: {
output: "",
description: input.description,
},
})
const code: number | null = yield* Effect.scoped(
Effect.gen(function* () {
const handle = yield* spawner.spawn(cmd(input.shell, input.name, input.command, input.cwd, input.env))
yield* Effect.forkScoped(
Stream.runForEach(Stream.decodeText(handle.all), (chunk) =>
Effect.sync(() => {
output += chunk
ctx.metadata({
metadata: {
output: preview(output),
description: input.description,
},
})
}),
),
)
const abort = Effect.callback<void>((resume) => {
if (ctx.abort.aborted) return resume(Effect.void)
const handler = () => resume(Effect.void)
ctx.abort.addEventListener("abort", handler, { once: true })
return Effect.sync(() => ctx.abort.removeEventListener("abort", handler))
})
const timeout = Effect.sleep(`${input.timeout + 100} millis`)
const exit = yield* Effect.raceAll([
handle.exitCode.pipe(Effect.map((code) => ({ kind: "exit" as const, code }))),
abort.pipe(Effect.map(() => ({ kind: "abort" as const, code: null }))),
timeout.pipe(Effect.map(() => ({ kind: "timeout" as const, code: null }))),
])
if (exit.kind === "abort") {
aborted = true
yield* handle.kill({ forceKillAfter: "3 seconds" }).pipe(Effect.orDie)
}
if (exit.kind === "timeout") {
expired = true
yield* handle.kill({ forceKillAfter: "3 seconds" }).pipe(Effect.orDie)
}
return exit.kind === "exit" ? exit.code : null
}),
).pipe(Effect.orDie)
const meta: string[] = []
if (expired) meta.push(`bash tool terminated command after exceeding timeout ${input.timeout} ms`)
if (aborted) meta.push("User aborted the command")
if (meta.length > 0) {
output += "\n\n<bash_metadata>\n" + meta.join("\n") + "\n</bash_metadata>"
}
return {
title: input.description,
metadata: {
output: preview(output),
exit: code,
description: input.description,
},
output,
}
})
return async () => {
const shell = Shell.acceptable()
const name = Shell.name(shell)
const chain =
name === "powershell"
? "If the commands depend on each other and must run sequentially, avoid '&&' in this shell because Windows PowerShell 5.1 does not support it. Use PowerShell conditionals such as `cmd1; if ($?) { cmd2 }` when later commands must depend on earlier success."
: "If the commands depend on each other and must run sequentially, use a single Bash call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`). For instance, if one operation must complete before another starts (like mkdir before cp, Write before Bash for git operations, or git add before git commit), run these operations sequentially instead."
log.info("bash tool using shell", { shell })
return {
description: DESCRIPTION.replaceAll("${directory}", Instance.directory)
.replaceAll("${os}", process.platform)
.replaceAll("${shell}", name)
.replaceAll("${chaining}", chain)
.replaceAll("${maxLines}", String(Truncate.MAX_LINES))
.replaceAll("${maxBytes}", String(Truncate.MAX_BYTES)),
parameters: Parameters,
execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) =>
Effect.gen(function* () {
const cwd = params.workdir
? yield* resolvePath(params.workdir, Instance.directory, shell)
: Instance.directory
if (params.timeout !== undefined && params.timeout < 0) {
throw new Error(`Invalid timeout value: ${params.timeout}. Timeout must be a positive number.`)
}
const timeout = params.timeout ?? DEFAULT_TIMEOUT
const ps = PS.has(name)
const root = yield* parse(params.command, ps)
const scan = yield* collect(root, cwd, ps, shell)
if (!Instance.containsPath(cwd)) scan.dirs.add(cwd)
yield* ask(ctx, scan)
return yield* run(
{
shell,
name,
command: params.command,
cwd,
env: yield* shellEnv(ctx, cwd),
timeout,
description: params.description,
},
ctx,
)
}),
}
}
}),
)
},
}
})
+125 -56
View File
@@ -1,63 +1,132 @@
import z from "zod"
import { Effect } from "effect"
import { HttpClient } from "effect/unstable/http"
import { Tool } from "./tool"
import * as McpExa from "./mcp-exa"
import DESCRIPTION from "./codesearch.txt"
import { abortAfterAny } from "../util/abort"
export const CodeSearchTool = Tool.define(
"codesearch",
Effect.gen(function* () {
const http = yield* HttpClient.HttpClient
const API_CONFIG = {
BASE_URL: "https://mcp.exa.ai",
ENDPOINTS: {
CONTEXT: "/mcp",
},
} as const
return {
description: DESCRIPTION,
parameters: z.object({
query: z
.string()
.describe(
"Search query to find relevant context for APIs, Libraries, and SDKs. For example, 'React useState hook examples', 'Python pandas dataframe filtering', 'Express.js middleware', 'Next js partial prerendering configuration'",
),
tokensNum: z
.number()
.min(1000)
.max(50000)
.default(5000)
.describe(
"Number of tokens to return (1000-50000). Default is 5000 tokens. Adjust this value based on how much context you need - use lower values for focused queries and higher values for comprehensive documentation.",
),
}),
execute: (params: { query: string; tokensNum: number }, ctx: Tool.Context) =>
Effect.gen(function* () {
yield* ctx.ask({
permission: "codesearch",
patterns: [params.query],
always: ["*"],
metadata: {
query: params.query,
tokensNum: params.tokensNum,
},
})
const result = yield* McpExa.call(
http,
"get_code_context_exa",
McpExa.CodeArgs,
{
query: params.query,
tokensNum: params.tokensNum || 5000,
},
"30 seconds",
)
return {
output:
result ??
"No code snippets or documentation found. Please try a different query, be more specific about the library or programming concept, or check the spelling of framework names.",
title: `Code search: ${params.query}`,
metadata: {},
}
}).pipe(Effect.orDie),
interface McpCodeRequest {
jsonrpc: string
id: number
method: string
params: {
name: string
arguments: {
query: string
tokensNum: number
}
}
}
interface McpCodeResponse {
jsonrpc: string
result: {
content: Array<{
type: string
text: string
}>
}
}
export const CodeSearchTool = Tool.define("codesearch", {
description: DESCRIPTION,
parameters: z.object({
query: z
.string()
.describe(
"Search query to find relevant context for APIs, Libraries, and SDKs. For example, 'React useState hook examples', 'Python pandas dataframe filtering', 'Express.js middleware', 'Next js partial prerendering configuration'",
),
tokensNum: z
.number()
.min(1000)
.max(50000)
.default(5000)
.describe(
"Number of tokens to return (1000-50000). Default is 5000 tokens. Adjust this value based on how much context you need - use lower values for focused queries and higher values for comprehensive documentation.",
),
}),
)
async execute(params, ctx) {
await ctx.ask({
permission: "codesearch",
patterns: [params.query],
always: ["*"],
metadata: {
query: params.query,
tokensNum: params.tokensNum,
},
})
const codeRequest: McpCodeRequest = {
jsonrpc: "2.0",
id: 1,
method: "tools/call",
params: {
name: "get_code_context_exa",
arguments: {
query: params.query,
tokensNum: params.tokensNum || 5000,
},
},
}
const { signal, clearTimeout } = abortAfterAny(30000, ctx.abort)
try {
const headers: Record<string, string> = {
accept: "application/json, text/event-stream",
"content-type": "application/json",
}
const response = await fetch(`${API_CONFIG.BASE_URL}${API_CONFIG.ENDPOINTS.CONTEXT}`, {
method: "POST",
headers,
body: JSON.stringify(codeRequest),
signal,
})
clearTimeout()
if (!response.ok) {
const errorText = await response.text()
throw new Error(`Code search error (${response.status}): ${errorText}`)
}
const responseText = await response.text()
// Parse SSE response
const lines = responseText.split("\n")
for (const line of lines) {
if (line.startsWith("data: ")) {
const data: McpCodeResponse = JSON.parse(line.substring(6))
if (data.result && data.result.content && data.result.content.length > 0) {
return {
output: data.result.content[0].text,
title: `Code search: ${params.query}`,
metadata: {},
}
}
}
}
return {
output:
"No code snippets or documentation found. Please try a different query, be more specific about the library or programming concept, or check the spelling of framework names.",
title: `Code search: ${params.query}`,
metadata: {},
}
} catch (error) {
clearTimeout()
if (error instanceof Error && error.name === "AbortError") {
throw new Error("Code search request timed out")
}
throw error
}
},
})
+129 -151
View File
@@ -5,7 +5,6 @@
import z from "zod"
import * as path from "path"
import { Effect } from "effect"
import { Tool } from "./tool"
import { LSP } from "../lsp"
import { createTwoFilesPatch, diffLines } from "diff"
@@ -18,8 +17,9 @@ import { FileTime } from "../file/time"
import { Filesystem } from "../util/filesystem"
import { Instance } from "../project/instance"
import { Snapshot } from "@/snapshot"
import { assertExternalDirectoryEffect } from "./external-directory"
import { AppFileSystem } from "../filesystem"
import { assertExternalDirectory } from "./external-directory"
const MAX_DIAGNOSTICS_PER_FILE = 20
function normalizeLineEndings(text: string): string {
return text.replaceAll("\r\n", "\n")
@@ -34,158 +34,136 @@ function convertToLineEnding(text: string, ending: "\n" | "\r\n"): string {
return text.replaceAll("\n", "\r\n")
}
const Parameters = z.object({
filePath: z.string().describe("The absolute path to the file to modify"),
oldString: z.string().describe("The text to replace"),
newString: z.string().describe("The text to replace it with (must be different from oldString)"),
replaceAll: z.boolean().optional().describe("Replace all occurrences of oldString (default false)"),
})
export const EditTool = Tool.define("edit", {
description: DESCRIPTION,
parameters: z.object({
filePath: z.string().describe("The absolute path to the file to modify"),
oldString: z.string().describe("The text to replace"),
newString: z.string().describe("The text to replace it with (must be different from oldString)"),
replaceAll: z.boolean().optional().describe("Replace all occurrences of oldString (default false)"),
}),
async execute(params, ctx) {
if (!params.filePath) {
throw new Error("filePath is required")
}
export const EditTool = Tool.define(
"edit",
Effect.gen(function* () {
const lsp = yield* LSP.Service
const filetime = yield* FileTime.Service
const afs = yield* AppFileSystem.Service
const format = yield* Format.Service
const bus = yield* Bus.Service
if (params.oldString === params.newString) {
throw new Error("No changes to apply: oldString and newString are identical.")
}
const filePath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath)
await assertExternalDirectory(ctx, filePath)
let diff = ""
let contentOld = ""
let contentNew = ""
await FileTime.withLock(filePath, async () => {
if (params.oldString === "") {
const existed = await Filesystem.exists(filePath)
contentNew = params.newString
diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew))
await ctx.ask({
permission: "edit",
patterns: [path.relative(Instance.worktree, filePath)],
always: ["*"],
metadata: {
filepath: filePath,
diff,
},
})
await Filesystem.write(filePath, params.newString)
await Format.file(filePath)
Bus.publish(File.Event.Edited, { file: filePath })
await Bus.publish(FileWatcher.Event.Updated, {
file: filePath,
event: existed ? "change" : "add",
})
await FileTime.read(ctx.sessionID, filePath)
return
}
const stats = Filesystem.stat(filePath)
if (!stats) throw new Error(`File ${filePath} not found`)
if (stats.isDirectory()) throw new Error(`Path is a directory, not a file: ${filePath}`)
await FileTime.assert(ctx.sessionID, filePath)
contentOld = await Filesystem.readText(filePath)
const ending = detectLineEnding(contentOld)
const old = convertToLineEnding(normalizeLineEndings(params.oldString), ending)
const next = convertToLineEnding(normalizeLineEndings(params.newString), ending)
contentNew = replace(contentOld, old, next, params.replaceAll)
diff = trimDiff(
createTwoFilesPatch(filePath, filePath, normalizeLineEndings(contentOld), normalizeLineEndings(contentNew)),
)
await ctx.ask({
permission: "edit",
patterns: [path.relative(Instance.worktree, filePath)],
always: ["*"],
metadata: {
filepath: filePath,
diff,
},
})
await Filesystem.write(filePath, contentNew)
await Format.file(filePath)
Bus.publish(File.Event.Edited, { file: filePath })
await Bus.publish(FileWatcher.Event.Updated, {
file: filePath,
event: "change",
})
contentNew = await Filesystem.readText(filePath)
diff = trimDiff(
createTwoFilesPatch(filePath, filePath, normalizeLineEndings(contentOld), normalizeLineEndings(contentNew)),
)
await FileTime.read(ctx.sessionID, filePath)
})
const filediff: Snapshot.FileDiff = {
file: filePath,
patch: diff,
additions: 0,
deletions: 0,
}
for (const change of diffLines(contentOld, contentNew)) {
if (change.added) filediff.additions += change.count || 0
if (change.removed) filediff.deletions += change.count || 0
}
ctx.metadata({
metadata: {
diff,
filediff,
diagnostics: {},
},
})
let output = "Edit applied successfully."
await LSP.touchFile(filePath, true)
const diagnostics = await LSP.diagnostics()
const normalizedFilePath = Filesystem.normalizePath(filePath)
const issues = diagnostics[normalizedFilePath] ?? []
const errors = issues.filter((item) => item.severity === 1)
if (errors.length > 0) {
const limited = errors.slice(0, MAX_DIAGNOSTICS_PER_FILE)
const suffix =
errors.length > MAX_DIAGNOSTICS_PER_FILE ? `\n... and ${errors.length - MAX_DIAGNOSTICS_PER_FILE} more` : ""
output += `\n\nLSP errors detected in this file, please fix:\n<diagnostics file="${filePath}">\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n</diagnostics>`
}
return {
description: DESCRIPTION,
parameters: Parameters,
execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) =>
Effect.gen(function* () {
if (!params.filePath) {
throw new Error("filePath is required")
}
if (params.oldString === params.newString) {
throw new Error("No changes to apply: oldString and newString are identical.")
}
const filePath = path.isAbsolute(params.filePath)
? params.filePath
: path.join(Instance.directory, params.filePath)
yield* assertExternalDirectoryEffect(ctx, filePath)
let diff = ""
let contentOld = ""
let contentNew = ""
yield* filetime.withLock(filePath, () =>
Effect.gen(function* () {
if (params.oldString === "") {
const existed = yield* afs.existsSafe(filePath)
contentNew = params.newString
diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew))
yield* ctx.ask({
permission: "edit",
patterns: [path.relative(Instance.worktree, filePath)],
always: ["*"],
metadata: {
filepath: filePath,
diff,
},
})
yield* afs.writeWithDirs(filePath, params.newString)
yield* format.file(filePath)
yield* bus.publish(File.Event.Edited, { file: filePath })
yield* bus.publish(FileWatcher.Event.Updated, {
file: filePath,
event: existed ? "change" : "add",
})
yield* filetime.read(ctx.sessionID, filePath)
return
}
const info = yield* afs.stat(filePath).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (!info) throw new Error(`File ${filePath} not found`)
if (info.type === "Directory") throw new Error(`Path is a directory, not a file: ${filePath}`)
yield* filetime.assert(ctx.sessionID, filePath)
contentOld = yield* afs.readFileString(filePath)
const ending = detectLineEnding(contentOld)
const old = convertToLineEnding(normalizeLineEndings(params.oldString), ending)
const next = convertToLineEnding(normalizeLineEndings(params.newString), ending)
contentNew = replace(contentOld, old, next, params.replaceAll)
diff = trimDiff(
createTwoFilesPatch(
filePath,
filePath,
normalizeLineEndings(contentOld),
normalizeLineEndings(contentNew),
),
)
yield* ctx.ask({
permission: "edit",
patterns: [path.relative(Instance.worktree, filePath)],
always: ["*"],
metadata: {
filepath: filePath,
diff,
},
})
yield* afs.writeWithDirs(filePath, contentNew)
yield* format.file(filePath)
yield* bus.publish(File.Event.Edited, { file: filePath })
yield* bus.publish(FileWatcher.Event.Updated, {
file: filePath,
event: "change",
})
contentNew = yield* afs.readFileString(filePath)
diff = trimDiff(
createTwoFilesPatch(
filePath,
filePath,
normalizeLineEndings(contentOld),
normalizeLineEndings(contentNew),
),
)
yield* filetime.read(ctx.sessionID, filePath)
}).pipe(Effect.orDie),
)
const filediff: Snapshot.FileDiff = {
file: filePath,
patch: diff,
additions: 0,
deletions: 0,
}
for (const change of diffLines(contentOld, contentNew)) {
if (change.added) filediff.additions += change.count || 0
if (change.removed) filediff.deletions += change.count || 0
}
ctx.metadata({
metadata: {
diff,
filediff,
diagnostics: {},
},
})
let output = "Edit applied successfully."
yield* lsp.touchFile(filePath, true)
const diagnostics = yield* lsp.diagnostics()
const normalizedFilePath = Filesystem.normalizePath(filePath)
const block = LSP.Diagnostic.report(filePath, diagnostics[normalizedFilePath] ?? [])
if (block) output += `\n\nLSP errors detected in this file, please fix:\n${block}`
return {
metadata: {
diagnostics,
diff,
filediff,
},
title: `${path.relative(Instance.worktree, filePath)}`,
output,
}
}),
metadata: {
diagnostics,
diff,
filediff,
},
title: `${path.relative(Instance.worktree, filePath)}`,
output,
}
}),
)
},
})
export type Replacer = (content: string, find: string) => Generator<string, void, unknown>
@@ -11,11 +11,7 @@ type Options = {
kind?: Kind
}
export const assertExternalDirectoryEffect = Effect.fn("Tool.assertExternalDirectory")(function* (
ctx: Tool.Context,
target?: string,
options?: Options,
) {
export async function assertExternalDirectory(ctx: Tool.Context, target?: string, options?: Options) {
if (!target) return
if (options?.bypass) return
@@ -30,7 +26,7 @@ export const assertExternalDirectoryEffect = Effect.fn("Tool.assertExternalDirec
? AppFileSystem.normalizePathPattern(path.join(dir, "*"))
: path.join(dir, "*").replaceAll("\\", "/")
yield* ctx.ask({
await ctx.ask({
permission: "external_directory",
patterns: [glob],
always: [glob],
@@ -39,8 +35,12 @@ export const assertExternalDirectoryEffect = Effect.fn("Tool.assertExternalDirec
parentDir: dir,
},
})
})
export async function assertExternalDirectory(ctx: Tool.Context, target?: string, options?: Options) {
return Effect.runPromise(assertExternalDirectoryEffect(ctx, target, options))
}
export const assertExternalDirectoryEffect = Effect.fn("Tool.assertExternalDirectory")(function* (
ctx: Tool.Context,
target?: string,
options?: Options,
) {
yield* Effect.promise(() => assertExternalDirectory(ctx, target, options))
})
+68 -84
View File
@@ -1,94 +1,78 @@
import z from "zod"
import path from "path"
import { Effect, Option } from "effect"
import * as Stream from "effect/Stream"
import { Tool } from "./tool"
import { Filesystem } from "../util/filesystem"
import DESCRIPTION from "./glob.txt"
import { Ripgrep } from "../file/ripgrep"
import { Instance } from "../project/instance"
import { assertExternalDirectoryEffect } from "./external-directory"
import { AppFileSystem } from "../filesystem"
import { assertExternalDirectory } from "./external-directory"
export const GlobTool = Tool.define(
"glob",
Effect.gen(function* () {
const rg = yield* Ripgrep.Service
const fs = yield* AppFileSystem.Service
export const GlobTool = Tool.define("glob", {
description: DESCRIPTION,
parameters: z.object({
pattern: z.string().describe("The glob pattern to match files against"),
path: z
.string()
.optional()
.describe(
`The directory to search in. If not specified, the current working directory will be used. IMPORTANT: Omit this field to use the default directory. DO NOT enter "undefined" or "null" - simply omit it for the default behavior. Must be a valid directory path if provided.`,
),
}),
async execute(params, ctx) {
await ctx.ask({
permission: "glob",
patterns: [params.pattern],
always: ["*"],
metadata: {
pattern: params.pattern,
path: params.path,
},
})
let search = params.path ?? Instance.directory
search = path.isAbsolute(search) ? search : path.resolve(Instance.directory, search)
await assertExternalDirectory(ctx, search, { kind: "directory" })
const limit = 100
const files = []
let truncated = false
for await (const file of Ripgrep.files({
cwd: search,
glob: [params.pattern],
signal: ctx.abort,
})) {
if (files.length >= limit) {
truncated = true
break
}
const full = path.resolve(search, file)
const stats = Filesystem.stat(full)?.mtime.getTime() ?? 0
files.push({
path: full,
mtime: stats,
})
}
files.sort((a, b) => b.mtime - a.mtime)
const output = []
if (files.length === 0) output.push("No files found")
if (files.length > 0) {
output.push(...files.map((f) => f.path))
if (truncated) {
output.push("")
output.push(
`(Results are truncated: showing first ${limit} results. Consider using a more specific path or pattern.)`,
)
}
}
return {
description: DESCRIPTION,
parameters: z.object({
pattern: z.string().describe("The glob pattern to match files against"),
path: z
.string()
.optional()
.describe(
`The directory to search in. If not specified, the current working directory will be used. IMPORTANT: Omit this field to use the default directory. DO NOT enter "undefined" or "null" - simply omit it for the default behavior. Must be a valid directory path if provided.`,
),
}),
execute: (params: { pattern: string; path?: string }, ctx: Tool.Context) =>
Effect.gen(function* () {
yield* ctx.ask({
permission: "glob",
patterns: [params.pattern],
always: ["*"],
metadata: {
pattern: params.pattern,
path: params.path,
},
})
let search = params.path ?? Instance.directory
search = path.isAbsolute(search) ? search : path.resolve(Instance.directory, search)
yield* assertExternalDirectoryEffect(ctx, search, { kind: "directory" })
const limit = 100
let truncated = false
const files = yield* rg.files({ cwd: search, glob: [params.pattern] }).pipe(
Stream.mapEffect((file) =>
Effect.gen(function* () {
const full = path.resolve(search, file)
const info = yield* fs.stat(full).pipe(Effect.catch(() => Effect.succeed(undefined)))
const mtime =
info?.mtime.pipe(
Option.map((d) => d.getTime()),
Option.getOrElse(() => 0),
) ?? 0
return { path: full, mtime }
}),
),
Stream.take(limit + 1),
Stream.runCollect,
Effect.map((chunk) => [...chunk]),
)
if (files.length > limit) {
truncated = true
files.length = limit
}
files.sort((a, b) => b.mtime - a.mtime)
const output = []
if (files.length === 0) output.push("No files found")
if (files.length > 0) {
output.push(...files.map((f) => f.path))
if (truncated) {
output.push("")
output.push(
`(Results are truncated: showing first ${limit} results. Consider using a more specific path or pattern.)`,
)
}
}
return {
title: path.relative(Instance.worktree, search),
metadata: {
count: files.length,
truncated,
},
output: output.join("\n"),
}
}).pipe(Effect.orDie),
title: path.relative(Instance.worktree, search),
metadata: {
count: files.length,
truncated,
},
output: output.join("\n"),
}
}),
)
},
})
+142 -161
View File
@@ -1,175 +1,156 @@
import z from "zod"
import { Effect } from "effect"
import * as Stream from "effect/Stream"
import { text } from "node:stream/consumers"
import { Tool } from "./tool"
import { Filesystem } from "../util/filesystem"
import { Ripgrep } from "../file/ripgrep"
import { ChildProcess } from "effect/unstable/process"
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
import { Process } from "../util/process"
import DESCRIPTION from "./grep.txt"
import { Instance } from "../project/instance"
import path from "path"
import { assertExternalDirectoryEffect } from "./external-directory"
import { assertExternalDirectory } from "./external-directory"
const MAX_LINE_LENGTH = 2000
export const GrepTool = Tool.define(
"grep",
Effect.gen(function* () {
const spawner = yield* ChildProcessSpawner
export const GrepTool = Tool.define("grep", {
description: DESCRIPTION,
parameters: z.object({
pattern: z.string().describe("The regex pattern to search for in file contents"),
path: z.string().optional().describe("The directory to search in. Defaults to the current working directory."),
include: z.string().optional().describe('File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")'),
}),
async execute(params, ctx) {
if (!params.pattern) {
throw new Error("pattern is required")
}
await ctx.ask({
permission: "grep",
patterns: [params.pattern],
always: ["*"],
metadata: {
pattern: params.pattern,
path: params.path,
include: params.include,
},
})
let searchPath = params.path ?? Instance.directory
searchPath = path.isAbsolute(searchPath) ? searchPath : path.resolve(Instance.directory, searchPath)
await assertExternalDirectory(ctx, searchPath, { kind: "directory" })
const rgPath = await Ripgrep.filepath()
const args = ["-nH", "--hidden", "--no-messages", "--field-match-separator=|", "--regexp", params.pattern]
if (params.include) {
args.push("--glob", params.include)
}
args.push(searchPath)
const proc = Process.spawn([rgPath, ...args], {
stdout: "pipe",
stderr: "pipe",
abort: ctx.abort,
})
if (!proc.stdout || !proc.stderr) {
throw new Error("Process output not available")
}
const output = await text(proc.stdout)
const errorOutput = await text(proc.stderr)
const exitCode = await proc.exited
// Exit codes: 0 = matches found, 1 = no matches, 2 = errors (but may still have matches)
// With --no-messages, we suppress error output but still get exit code 2 for broken symlinks etc.
// Only fail if exit code is 2 AND no output was produced
if (exitCode === 1 || (exitCode === 2 && !output.trim())) {
return {
title: params.pattern,
metadata: { matches: 0, truncated: false },
output: "No files found",
}
}
if (exitCode !== 0 && exitCode !== 2) {
throw new Error(`ripgrep failed: ${errorOutput}`)
}
const hasErrors = exitCode === 2
// Handle both Unix (\n) and Windows (\r\n) line endings
const lines = output.trim().split(/\r?\n/)
const matches = []
for (const line of lines) {
if (!line) continue
const [filePath, lineNumStr, ...lineTextParts] = line.split("|")
if (!filePath || !lineNumStr || lineTextParts.length === 0) continue
const lineNum = parseInt(lineNumStr, 10)
const lineText = lineTextParts.join("|")
const stats = Filesystem.stat(filePath)
if (!stats) continue
matches.push({
path: filePath,
modTime: stats.mtime.getTime(),
lineNum,
lineText,
})
}
matches.sort((a, b) => b.modTime - a.modTime)
const limit = 100
const truncated = matches.length > limit
const finalMatches = truncated ? matches.slice(0, limit) : matches
if (finalMatches.length === 0) {
return {
title: params.pattern,
metadata: { matches: 0, truncated: false },
output: "No files found",
}
}
const totalMatches = matches.length
const outputLines = [`Found ${totalMatches} matches${truncated ? ` (showing first ${limit})` : ""}`]
let currentFile = ""
for (const match of finalMatches) {
if (currentFile !== match.path) {
if (currentFile !== "") {
outputLines.push("")
}
currentFile = match.path
outputLines.push(`${match.path}:`)
}
const truncatedLineText =
match.lineText.length > MAX_LINE_LENGTH ? match.lineText.substring(0, MAX_LINE_LENGTH) + "..." : match.lineText
outputLines.push(` Line ${match.lineNum}: ${truncatedLineText}`)
}
if (truncated) {
outputLines.push("")
outputLines.push(
`(Results truncated: showing ${limit} of ${totalMatches} matches (${totalMatches - limit} hidden). Consider using a more specific path or pattern.)`,
)
}
if (hasErrors) {
outputLines.push("")
outputLines.push("(Some paths were inaccessible and skipped)")
}
return {
description: DESCRIPTION,
parameters: z.object({
pattern: z.string().describe("The regex pattern to search for in file contents"),
path: z.string().optional().describe("The directory to search in. Defaults to the current working directory."),
include: z.string().optional().describe('File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")'),
}),
execute: (params: { pattern: string; path?: string; include?: string }, ctx: Tool.Context) =>
Effect.gen(function* () {
if (!params.pattern) {
throw new Error("pattern is required")
}
yield* ctx.ask({
permission: "grep",
patterns: [params.pattern],
always: ["*"],
metadata: {
pattern: params.pattern,
path: params.path,
include: params.include,
},
})
let searchPath = params.path ?? Instance.directory
searchPath = path.isAbsolute(searchPath) ? searchPath : path.resolve(Instance.directory, searchPath)
yield* assertExternalDirectoryEffect(ctx, searchPath, { kind: "directory" })
const rgPath = yield* Effect.promise(() => Ripgrep.filepath())
const args = ["-nH", "--hidden", "--no-messages", "--field-match-separator=|", "--regexp", params.pattern]
if (params.include) {
args.push("--glob", params.include)
}
args.push(searchPath)
const result = yield* Effect.scoped(
Effect.gen(function* () {
const handle = yield* spawner.spawn(
ChildProcess.make(rgPath, args, {
stdin: "ignore",
}),
)
const [output, errorOutput] = yield* Effect.all(
[Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))],
{ concurrency: 2 },
)
const exitCode = yield* handle.exitCode
return { output, errorOutput, exitCode }
}),
)
const { output, errorOutput, exitCode } = result
// Exit codes: 0 = matches found, 1 = no matches, 2 = errors (but may still have matches)
// With --no-messages, we suppress error output but still get exit code 2 for broken symlinks etc.
// Only fail if exit code is 2 AND no output was produced
if (exitCode === 1 || (exitCode === 2 && !output.trim())) {
return {
title: params.pattern,
metadata: { matches: 0, truncated: false },
output: "No files found",
}
}
if (exitCode !== 0 && exitCode !== 2) {
throw new Error(`ripgrep failed: ${errorOutput}`)
}
const hasErrors = exitCode === 2
// Handle both Unix (\n) and Windows (\r\n) line endings
const lines = output.trim().split(/\r?\n/)
const matches = []
for (const line of lines) {
if (!line) continue
const [filePath, lineNumStr, ...lineTextParts] = line.split("|")
if (!filePath || !lineNumStr || lineTextParts.length === 0) continue
const lineNum = parseInt(lineNumStr, 10)
const lineText = lineTextParts.join("|")
const stats = Filesystem.stat(filePath)
if (!stats) continue
matches.push({
path: filePath,
modTime: stats.mtime.getTime(),
lineNum,
lineText,
})
}
matches.sort((a, b) => b.modTime - a.modTime)
const limit = 100
const truncated = matches.length > limit
const finalMatches = truncated ? matches.slice(0, limit) : matches
if (finalMatches.length === 0) {
return {
title: params.pattern,
metadata: { matches: 0, truncated: false },
output: "No files found",
}
}
const totalMatches = matches.length
const outputLines = [`Found ${totalMatches} matches${truncated ? ` (showing first ${limit})` : ""}`]
let currentFile = ""
for (const match of finalMatches) {
if (currentFile !== match.path) {
if (currentFile !== "") {
outputLines.push("")
}
currentFile = match.path
outputLines.push(`${match.path}:`)
}
const truncatedLineText =
match.lineText.length > MAX_LINE_LENGTH
? match.lineText.substring(0, MAX_LINE_LENGTH) + "..."
: match.lineText
outputLines.push(` Line ${match.lineNum}: ${truncatedLineText}`)
}
if (truncated) {
outputLines.push("")
outputLines.push(
`(Results truncated: showing ${limit} of ${totalMatches} matches (${totalMatches - limit} hidden). Consider using a more specific path or pattern.)`,
)
}
if (hasErrors) {
outputLines.push("")
outputLines.push("(Some paths were inaccessible and skipped)")
}
return {
title: params.pattern,
metadata: {
matches: totalMatches,
truncated,
},
output: outputLines.join("\n"),
}
}).pipe(Effect.orDie),
title: params.pattern,
metadata: {
matches: totalMatches,
truncated,
},
output: outputLines.join("\n"),
}
}),
)
},
})
+13 -16
View File
@@ -1,20 +1,17 @@
import z from "zod"
import { Effect } from "effect"
import { Tool } from "./tool"
export const InvalidTool = Tool.define(
"invalid",
Effect.succeed({
description: "Do not use",
parameters: z.object({
tool: z.string(),
error: z.string(),
}),
execute: (params: { tool: string; error: string }) =>
Effect.succeed({
title: "Invalid Tool",
output: `The arguments provided to the tool are invalid: ${params.error}`,
metadata: {},
}),
export const InvalidTool = Tool.define("invalid", {
description: "Do not use",
parameters: z.object({
tool: z.string(),
error: z.string(),
}),
)
async execute(params) {
return {
title: "Invalid Tool",
output: `The arguments provided to the tool are invalid: ${params.error}`,
metadata: {},
}
},
})
+82 -95
View File
@@ -1,12 +1,10 @@
import z from "zod"
import { Effect } from "effect"
import * as Stream from "effect/Stream"
import { Tool } from "./tool"
import * as path from "path"
import DESCRIPTION from "./ls.txt"
import { Instance } from "../project/instance"
import { Ripgrep } from "../file/ripgrep"
import { assertExternalDirectoryEffect } from "./external-directory"
import { assertExternalDirectory } from "./external-directory"
export const IGNORE_PATTERNS = [
"node_modules/",
@@ -37,98 +35,87 @@ export const IGNORE_PATTERNS = [
const LIMIT = 100
export const ListTool = Tool.define(
"list",
Effect.gen(function* () {
const rg = yield* Ripgrep.Service
export const ListTool = Tool.define("list", {
description: DESCRIPTION,
parameters: z.object({
path: z.string().describe("The absolute path to the directory to list (must be absolute, not relative)").optional(),
ignore: z.array(z.string()).describe("List of glob patterns to ignore").optional(),
}),
async execute(params, ctx) {
const searchPath = path.resolve(Instance.directory, params.path || ".")
await assertExternalDirectory(ctx, searchPath, { kind: "directory" })
await ctx.ask({
permission: "list",
patterns: [searchPath],
always: ["*"],
metadata: {
path: searchPath,
},
})
const ignoreGlobs = IGNORE_PATTERNS.map((p) => `!${p}*`).concat(params.ignore?.map((p) => `!${p}`) || [])
const files = []
for await (const file of Ripgrep.files({ cwd: searchPath, glob: ignoreGlobs, signal: ctx.abort })) {
files.push(file)
if (files.length >= LIMIT) break
}
// Build directory structure
const dirs = new Set<string>()
const filesByDir = new Map<string, string[]>()
for (const file of files) {
const dir = path.dirname(file)
const parts = dir === "." ? [] : dir.split("/")
// Add all parent directories
for (let i = 0; i <= parts.length; i++) {
const dirPath = i === 0 ? "." : parts.slice(0, i).join("/")
dirs.add(dirPath)
}
// Add file to its directory
if (!filesByDir.has(dir)) filesByDir.set(dir, [])
filesByDir.get(dir)!.push(path.basename(file))
}
function renderDir(dirPath: string, depth: number): string {
const indent = " ".repeat(depth)
let output = ""
if (depth > 0) {
output += `${indent}${path.basename(dirPath)}/\n`
}
const childIndent = " ".repeat(depth + 1)
const children = Array.from(dirs)
.filter((d) => path.dirname(d) === dirPath && d !== dirPath)
.sort()
// Render subdirectories first
for (const child of children) {
output += renderDir(child, depth + 1)
}
// Render files
const files = filesByDir.get(dirPath) || []
for (const file of files.sort()) {
output += `${childIndent}${file}\n`
}
return output
}
const output = `${searchPath}/\n` + renderDir(".", 0)
return {
description: DESCRIPTION,
parameters: z.object({
path: z
.string()
.describe("The absolute path to the directory to list (must be absolute, not relative)")
.optional(),
ignore: z.array(z.string()).describe("List of glob patterns to ignore").optional(),
}),
execute: (params: { path?: string; ignore?: string[] }, ctx: Tool.Context) =>
Effect.gen(function* () {
const searchPath = path.resolve(Instance.directory, params.path || ".")
yield* assertExternalDirectoryEffect(ctx, searchPath, { kind: "directory" })
yield* ctx.ask({
permission: "list",
patterns: [searchPath],
always: ["*"],
metadata: {
path: searchPath,
},
})
const ignoreGlobs = IGNORE_PATTERNS.map((p) => `!${p}*`).concat(params.ignore?.map((p) => `!${p}`) || [])
const files = yield* rg.files({ cwd: searchPath, glob: ignoreGlobs }).pipe(
Stream.take(LIMIT),
Stream.runCollect,
Effect.map((chunk) => [...chunk]),
)
// Build directory structure
const dirs = new Set<string>()
const filesByDir = new Map<string, string[]>()
for (const file of files) {
const dir = path.dirname(file)
const parts = dir === "." ? [] : dir.split("/")
// Add all parent directories
for (let i = 0; i <= parts.length; i++) {
const dirPath = i === 0 ? "." : parts.slice(0, i).join("/")
dirs.add(dirPath)
}
// Add file to its directory
if (!filesByDir.has(dir)) filesByDir.set(dir, [])
filesByDir.get(dir)!.push(path.basename(file))
}
function renderDir(dirPath: string, depth: number): string {
const indent = " ".repeat(depth)
let output = ""
if (depth > 0) {
output += `${indent}${path.basename(dirPath)}/\n`
}
const childIndent = " ".repeat(depth + 1)
const children = Array.from(dirs)
.filter((d) => path.dirname(d) === dirPath && d !== dirPath)
.sort()
// Render subdirectories first
for (const child of children) {
output += renderDir(child, depth + 1)
}
// Render files
const files = filesByDir.get(dirPath) || []
for (const file of files.sort()) {
output += `${childIndent}${file}\n`
}
return output
}
const output = `${searchPath}/\n` + renderDir(".", 0)
return {
title: path.relative(Instance.worktree, searchPath),
metadata: {
count: files.length,
truncated: files.length >= LIMIT,
},
output,
}
}).pipe(Effect.orDie),
title: path.relative(Instance.worktree, searchPath),
metadata: {
count: files.length,
truncated: files.length >= LIMIT,
},
output,
}
}),
)
},
})
+74 -68
View File
@@ -1,13 +1,12 @@
import z from "zod"
import { Effect } from "effect"
import { Tool } from "./tool"
import path from "path"
import { LSP } from "../lsp"
import DESCRIPTION from "./lsp.txt"
import { Instance } from "../project/instance"
import { pathToFileURL } from "url"
import { assertExternalDirectoryEffect } from "./external-directory"
import { AppFileSystem } from "../filesystem"
import { assertExternalDirectory } from "./external-directory"
import { Filesystem } from "../util/filesystem"
const operations = [
"goToDefinition",
@@ -21,71 +20,78 @@ const operations = [
"outgoingCalls",
] as const
export const LspTool = Tool.define(
"lsp",
Effect.gen(function* () {
const lsp = yield* LSP.Service
const fs = yield* AppFileSystem.Service
export const LspTool = Tool.define("lsp", {
description: DESCRIPTION,
parameters: z.object({
operation: z.enum(operations).describe("The LSP operation to perform"),
filePath: z.string().describe("The absolute or relative path to the file"),
line: z.number().int().min(1).describe("The line number (1-based, as shown in editors)"),
character: z.number().int().min(1).describe("The character offset (1-based, as shown in editors)"),
}),
execute: async (args, ctx) => {
const file = path.isAbsolute(args.filePath) ? args.filePath : path.join(Instance.directory, args.filePath)
await assertExternalDirectory(ctx, file)
await ctx.ask({
permission: "lsp",
patterns: ["*"],
always: ["*"],
metadata: {},
})
const uri = pathToFileURL(file).href
const position = {
file,
line: args.line - 1,
character: args.character - 1,
}
const relPath = path.relative(Instance.worktree, file)
const title = `${args.operation} ${relPath}:${args.line}:${args.character}`
const exists = await Filesystem.exists(file)
if (!exists) {
throw new Error(`File not found: ${file}`)
}
const available = await LSP.hasClients(file)
if (!available) {
throw new Error("No LSP server available for this file type.")
}
await LSP.touchFile(file, true)
const result: unknown[] = await (async () => {
switch (args.operation) {
case "goToDefinition":
return LSP.definition(position)
case "findReferences":
return LSP.references(position)
case "hover":
return LSP.hover(position)
case "documentSymbol":
return LSP.documentSymbol(uri)
case "workspaceSymbol":
return LSP.workspaceSymbol("")
case "goToImplementation":
return LSP.implementation(position)
case "prepareCallHierarchy":
return LSP.prepareCallHierarchy(position)
case "incomingCalls":
return LSP.incomingCalls(position)
case "outgoingCalls":
return LSP.outgoingCalls(position)
}
})()
const output = (() => {
if (result.length === 0) return `No results found for ${args.operation}`
return JSON.stringify(result, null, 2)
})()
return {
description: DESCRIPTION,
parameters: z.object({
operation: z.enum(operations).describe("The LSP operation to perform"),
filePath: z.string().describe("The absolute or relative path to the file"),
line: z.number().int().min(1).describe("The line number (1-based, as shown in editors)"),
character: z.number().int().min(1).describe("The character offset (1-based, as shown in editors)"),
}),
execute: (
args: { operation: (typeof operations)[number]; filePath: string; line: number; character: number },
ctx: Tool.Context,
) =>
Effect.gen(function* () {
const file = path.isAbsolute(args.filePath) ? args.filePath : path.join(Instance.directory, args.filePath)
yield* assertExternalDirectoryEffect(ctx, file)
yield* ctx.ask({ permission: "lsp", patterns: ["*"], always: ["*"], metadata: {} })
const uri = pathToFileURL(file).href
const position = { file, line: args.line - 1, character: args.character - 1 }
const relPath = path.relative(Instance.worktree, file)
const title = `${args.operation} ${relPath}:${args.line}:${args.character}`
const exists = yield* fs.existsSafe(file)
if (!exists) throw new Error(`File not found: ${file}`)
const available = yield* lsp.hasClients(file)
if (!available) throw new Error("No LSP server available for this file type.")
yield* lsp.touchFile(file, true)
const result: unknown[] = yield* (() => {
switch (args.operation) {
case "goToDefinition":
return lsp.definition(position)
case "findReferences":
return lsp.references(position)
case "hover":
return lsp.hover(position)
case "documentSymbol":
return lsp.documentSymbol(uri)
case "workspaceSymbol":
return lsp.workspaceSymbol("")
case "goToImplementation":
return lsp.implementation(position)
case "prepareCallHierarchy":
return lsp.prepareCallHierarchy(position)
case "incomingCalls":
return lsp.incomingCalls(position)
case "outgoingCalls":
return lsp.outgoingCalls(position)
}
})()
return {
title,
metadata: { result },
output: result.length === 0 ? `No results found for ${args.operation}` : JSON.stringify(result, null, 2),
}
}),
title,
metadata: { result },
output,
}
}),
)
},
})
-76
View File
@@ -1,76 +0,0 @@
import { Duration, Effect, Schema } from "effect"
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
const URL = "https://mcp.exa.ai/mcp"
const McpResult = Schema.Struct({
result: Schema.Struct({
content: Schema.Array(
Schema.Struct({
type: Schema.String,
text: Schema.String,
}),
),
}),
})
const decode = Schema.decodeUnknownEffect(Schema.fromJsonString(McpResult))
const parseSse = Effect.fn("McpExa.parseSse")(function* (body: string) {
for (const line of body.split("\n")) {
if (!line.startsWith("data: ")) continue
const data = yield* decode(line.substring(6))
if (data.result.content[0]?.text) return data.result.content[0].text
}
return undefined
})
export const SearchArgs = Schema.Struct({
query: Schema.String,
type: Schema.String,
numResults: Schema.Number,
livecrawl: Schema.String,
contextMaxCharacters: Schema.optional(Schema.Number),
})
export const CodeArgs = Schema.Struct({
query: Schema.String,
tokensNum: Schema.Number,
})
const McpRequest = <F extends Schema.Struct.Fields>(args: Schema.Struct<F>) =>
Schema.Struct({
jsonrpc: Schema.Literal("2.0"),
id: Schema.Literal(1),
method: Schema.Literal("tools/call"),
params: Schema.Struct({
name: Schema.String,
arguments: args,
}),
})
export const call = <F extends Schema.Struct.Fields>(
http: HttpClient.HttpClient,
tool: string,
args: Schema.Struct<F>,
value: Schema.Struct.Type<F>,
timeout: Duration.Input,
) =>
Effect.gen(function* () {
const request = yield* HttpClientRequest.post(URL).pipe(
HttpClientRequest.accept("application/json, text/event-stream"),
HttpClientRequest.schemaBodyJson(McpRequest(args))({
jsonrpc: "2.0" as const,
id: 1 as const,
method: "tools/call" as const,
params: { name: tool, arguments: value },
}),
)
const response = yield* HttpClient.filterStatusOk(http)
.execute(request)
.pipe(
Effect.timeoutOrElse({ duration: timeout, orElse: () => Effect.die(new Error(`${tool} request timed out`)) }),
)
const body = yield* response.text
return yield* parseSse(body)
})
+37 -52
View File
@@ -1,61 +1,46 @@
import z from "zod"
import { Effect } from "effect"
import { Tool } from "./tool"
import { EditTool } from "./edit"
import DESCRIPTION from "./multiedit.txt"
import path from "path"
import { Instance } from "../project/instance"
export const MultiEditTool = Tool.define(
"multiedit",
Effect.gen(function* () {
const editInfo = yield* EditTool
const edit = yield* Effect.promise(() => editInfo.init())
return {
description: DESCRIPTION,
parameters: z.object({
filePath: z.string().describe("The absolute path to the file to modify"),
edits: z
.array(
z.object({
filePath: z.string().describe("The absolute path to the file to modify"),
oldString: z.string().describe("The text to replace"),
newString: z.string().describe("The text to replace it with (must be different from oldString)"),
replaceAll: z.boolean().optional().describe("Replace all occurrences of oldString (default false)"),
}),
)
.describe("Array of edit operations to perform sequentially on the file"),
}),
execute: (
params: {
filePath: string
edits: Array<{ filePath: string; oldString: string; newString: string; replaceAll?: boolean }>
},
ctx: Tool.Context,
) =>
Effect.gen(function* () {
const results = []
for (const [, entry] of params.edits.entries()) {
const result = yield* edit.execute(
{
filePath: params.filePath,
oldString: entry.oldString,
newString: entry.newString,
replaceAll: entry.replaceAll,
},
ctx,
)
results.push(result)
}
return {
title: path.relative(Instance.worktree, params.filePath),
metadata: {
results: results.map((r) => r.metadata),
},
output: results.at(-1)!.output,
}
export const MultiEditTool = Tool.define("multiedit", {
description: DESCRIPTION,
parameters: z.object({
filePath: z.string().describe("The absolute path to the file to modify"),
edits: z
.array(
z.object({
filePath: z.string().describe("The absolute path to the file to modify"),
oldString: z.string().describe("The text to replace"),
newString: z.string().describe("The text to replace it with (must be different from oldString)"),
replaceAll: z.boolean().optional().describe("Replace all occurrences of oldString (default false)"),
}),
}
)
.describe("Array of edit operations to perform sequentially on the file"),
}),
)
async execute(params, ctx) {
const tool = await EditTool.init()
const results = []
for (const [, edit] of params.edits.entries()) {
const result = await tool.execute(
{
filePath: params.filePath,
oldString: edit.oldString,
newString: edit.newString,
replaceAll: edit.replaceAll,
},
ctx,
)
results.push(result)
}
return {
title: path.relative(Instance.worktree, params.filePath),
metadata: {
results: results.map((r) => r.metadata),
},
output: results.at(-1)!.output,
}
},
})
+113 -62
View File
@@ -1,6 +1,5 @@
import z from "zod"
import path from "path"
import { Effect } from "effect"
import { Tool } from "./tool"
import { Question } from "../question"
import { Session } from "../session"
@@ -10,71 +9,123 @@ import { Instance } from "../project/instance"
import { type SessionID, MessageID, PartID } from "../session/schema"
import EXIT_DESCRIPTION from "./plan-exit.txt"
function getLastModel(sessionID: SessionID) {
for (const item of MessageV2.stream(sessionID)) {
async function getLastModel(sessionID: SessionID) {
for await (const item of MessageV2.stream(sessionID)) {
if (item.info.role === "user" && item.info.model) return item.info.model
}
return undefined
return Provider.defaultModel()
}
export const PlanExitTool = Tool.define(
"plan_exit",
Effect.gen(function* () {
const session = yield* Session.Service
const question = yield* Question.Service
const provider = yield* Provider.Service
export const PlanExitTool = Tool.define("plan_exit", {
description: EXIT_DESCRIPTION,
parameters: z.object({}),
async execute(_params, ctx) {
const session = await Session.get(ctx.sessionID)
const plan = path.relative(Instance.worktree, Session.plan(session))
const answers = await Question.ask({
sessionID: ctx.sessionID,
questions: [
{
question: `Plan at ${plan} is complete. Would you like to switch to the build agent and start implementing?`,
header: "Build Agent",
custom: false,
options: [
{ label: "Yes", description: "Switch to build agent and start implementing the plan" },
{ label: "No", description: "Stay with plan agent to continue refining the plan" },
],
},
],
tool: ctx.callID ? { messageID: ctx.messageID, callID: ctx.callID } : undefined,
})
const answer = answers[0]?.[0]
if (answer === "No") throw new Question.RejectedError()
const model = await getLastModel(ctx.sessionID)
const userMsg: MessageV2.User = {
id: MessageID.ascending(),
sessionID: ctx.sessionID,
role: "user",
time: {
created: Date.now(),
},
agent: "build",
model,
}
await Session.updateMessage(userMsg)
await Session.updatePart({
id: PartID.ascending(),
messageID: userMsg.id,
sessionID: ctx.sessionID,
type: "text",
text: `The plan at ${plan} has been approved, you can now edit files. Execute the plan`,
synthetic: true,
} satisfies MessageV2.TextPart)
return {
description: EXIT_DESCRIPTION,
parameters: z.object({}),
execute: (_params: {}, ctx: Tool.Context) =>
Effect.gen(function* () {
const info = yield* session.get(ctx.sessionID)
const plan = path.relative(Instance.worktree, Session.plan(info))
const answers = yield* question.ask({
sessionID: ctx.sessionID,
questions: [
{
question: `Plan at ${plan} is complete. Would you like to switch to the build agent and start implementing?`,
header: "Build Agent",
custom: false,
options: [
{ label: "Yes", description: "Switch to build agent and start implementing the plan" },
{ label: "No", description: "Stay with plan agent to continue refining the plan" },
],
},
],
tool: ctx.callID ? { messageID: ctx.messageID, callID: ctx.callID } : undefined,
})
if (answers[0]?.[0] === "No") yield* new Question.RejectedError()
const model = getLastModel(ctx.sessionID) ?? (yield* provider.defaultModel())
const msg: MessageV2.User = {
id: MessageID.ascending(),
sessionID: ctx.sessionID,
role: "user",
time: { created: Date.now() },
agent: "build",
model,
}
yield* session.updateMessage(msg)
yield* session.updatePart({
id: PartID.ascending(),
messageID: msg.id,
sessionID: ctx.sessionID,
type: "text",
text: `The plan at ${plan} has been approved, you can now edit files. Execute the plan`,
synthetic: true,
} satisfies MessageV2.TextPart)
return {
title: "Switching to build agent",
output: "User approved switching to build agent. Wait for further instructions.",
metadata: {},
}
}).pipe(Effect.orDie),
title: "Switching to build agent",
output: "User approved switching to build agent. Wait for further instructions.",
metadata: {},
}
}),
)
},
})
/*
export const PlanEnterTool = Tool.define("plan_enter", {
description: ENTER_DESCRIPTION,
parameters: z.object({}),
async execute(_params, ctx) {
const session = await Session.get(ctx.sessionID)
const plan = path.relative(Instance.worktree, Session.plan(session))
const answers = await Question.ask({
sessionID: ctx.sessionID,
questions: [
{
question: `Would you like to switch to the plan agent and create a plan saved to ${plan}?`,
header: "Plan Mode",
custom: false,
options: [
{ label: "Yes", description: "Switch to plan agent for research and planning" },
{ label: "No", description: "Stay with build agent to continue making changes" },
],
},
],
tool: ctx.callID ? { messageID: ctx.messageID, callID: ctx.callID } : undefined,
})
const answer = answers[0]?.[0]
if (answer === "No") throw new Question.RejectedError()
const model = await getLastModel(ctx.sessionID)
const userMsg: MessageV2.User = {
id: MessageID.ascending(),
sessionID: ctx.sessionID,
role: "user",
time: {
created: Date.now(),
},
agent: "plan",
model,
}
await Session.updateMessage(userMsg)
await Session.updatePart({
id: PartID.ascending(),
messageID: userMsg.id,
sessionID: ctx.sessionID,
type: "text",
text: "User has requested to enter plan mode. Switch to plan mode and begin planning.",
synthetic: true,
} satisfies MessageV2.TextPart)
return {
title: "Switching to plan agent",
output: `User confirmed to switch to plan mode. A new message has been created to switch you to plan mode. The plan file will be at ${plan}. Begin planning.`,
metadata: {},
}
},
})
*/
+16 -15
View File
@@ -12,7 +12,7 @@ type Metadata = {
answers: Question.Answer[]
}
export const QuestionTool = Tool.define<typeof parameters, Metadata, Question.Service>(
export const QuestionTool = Tool.defineEffect<typeof parameters, Metadata, Question.Service>(
"question",
Effect.gen(function* () {
const question = yield* Question.Service
@@ -20,26 +20,27 @@ export const QuestionTool = Tool.define<typeof parameters, Metadata, Question.Se
return {
description: DESCRIPTION,
parameters,
execute: (params: z.infer<typeof parameters>, ctx: Tool.Context<Metadata>) =>
Effect.gen(function* () {
const answers = yield* question.ask({
async execute(params: z.infer<typeof parameters>, ctx: Tool.Context<Metadata>) {
const answers = await question
.ask({
sessionID: ctx.sessionID,
questions: params.questions,
tool: ctx.callID ? { messageID: ctx.messageID, callID: ctx.callID } : undefined,
})
.pipe(Effect.runPromise)
const formatted = params.questions
.map((q, i) => `"${q.question}"="${answers[i]?.length ? answers[i].join(", ") : "Unanswered"}"`)
.join(", ")
const formatted = params.questions
.map((q, i) => `"${q.question}"="${answers[i]?.length ? answers[i].join(", ") : "Unanswered"}"`)
.join(", ")
return {
title: `Asked ${params.questions.length} question${params.questions.length > 1 ? "s" : ""}`,
output: `User has answered your questions: ${formatted}. You can now continue with the user's answers in mind.`,
metadata: {
answers,
},
}
}).pipe(Effect.orDie),
return {
title: `Asked ${params.questions.length} question${params.questions.length > 1 ? "s" : ""}`,
output: `User has answered your questions: ${formatted}. You can now continue with the user's answers in mind.`,
metadata: {
answers,
},
}
},
}
}),
)
+12 -8
View File
@@ -25,7 +25,7 @@ const parameters = z.object({
limit: z.coerce.number().describe("The maximum number of lines to read (defaults to 2000)").optional(),
})
export const ReadTool = Tool.define(
export const ReadTool = Tool.defineEffect(
"read",
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
@@ -106,12 +106,14 @@ export const ReadTool = Tool.define(
kind: stat?.type === "Directory" ? "directory" : "file",
})
yield* ctx.ask({
permission: "read",
patterns: [filepath],
always: ["*"],
metadata: {},
})
yield* Effect.promise(() =>
ctx.ask({
permission: "read",
patterns: [filepath],
always: ["*"],
metadata: {},
}),
)
if (!stat) return yield* miss(filepath)
@@ -216,7 +218,9 @@ export const ReadTool = Tool.define(
return {
description: DESCRIPTION,
parameters,
execute: (params: z.infer<typeof parameters>, ctx: Tool.Context) => run(params, ctx).pipe(Effect.orDie),
async execute(params: z.infer<typeof parameters>, ctx) {
return Effect.runPromise(run(params, ctx).pipe(Effect.orDie))
},
}
}),
)
+47 -126
View File
@@ -1,23 +1,21 @@
import { PlanExitTool } from "./plan"
import { Session } from "../session"
import { QuestionTool } from "./question"
import { BashTool } from "./bash"
import { EditTool } from "./edit"
import { GlobTool } from "./glob"
import { GrepTool } from "./grep"
import { ReadTool } from "./read"
import { TaskTool } from "./task"
import { TaskDescription, TaskTool } from "./task"
import { TodoWriteTool } from "./todo"
import { WebFetchTool } from "./webfetch"
import { WriteTool } from "./write"
import { InvalidTool } from "./invalid"
import { SkillTool } from "./skill"
import { SkillDescription, SkillTool } from "./skill"
import { Tool } from "./tool"
import { Config } from "../config/config"
import { type ToolContext as PluginToolContext, type ToolDefinition } from "@opencode-ai/plugin"
import z from "zod"
import { Plugin } from "../plugin"
import { Provider } from "../provider/provider"
import { ProviderID, type ModelID } from "../provider/schema"
import { WebSearchTool } from "./websearch"
import { CodeSearchTool } from "./codesearch"
@@ -30,11 +28,6 @@ import { Glob } from "../util/glob"
import path from "path"
import { pathToFileURL } from "url"
import { Effect, Layer, ServiceMap } from "effect"
import { FetchHttpClient, HttpClient } from "effect/unstable/http"
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
import { Ripgrep } from "../file/ripgrep"
import { Format } from "../format"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { Env } from "../env"
@@ -44,10 +37,7 @@ import { LSP } from "../lsp"
import { FileTime } from "../file/time"
import { Instruction } from "../session/instruction"
import { AppFileSystem } from "../filesystem"
import { Bus } from "../bus"
import { Agent } from "../agent/agent"
import { Skill } from "../skill"
import { Permission } from "@/permission"
export namespace ToolRegistry {
const log = Log.create({ service: "tool.registry" })
@@ -83,45 +73,20 @@ export namespace ToolRegistry {
| Question.Service
| Todo.Service
| Agent.Service
| Skill.Service
| Session.Service
| Provider.Service
| LSP.Service
| FileTime.Service
| Instruction.Service
| AppFileSystem.Service
| Bus.Service
| HttpClient.HttpClient
| ChildProcessSpawner
| Ripgrep.Service
| Format.Service
| Truncate.Service
> = Layer.effect(
Service,
Effect.gen(function* () {
const config = yield* Config.Service
const plugin = yield* Plugin.Service
const agents = yield* Agent.Service
const skill = yield* Skill.Service
const truncate = yield* Truncate.Service
const invalid = yield* InvalidTool
const task = yield* TaskTool
const read = yield* ReadTool
const question = yield* QuestionTool
const todo = yield* TodoWriteTool
const lsptool = yield* LspTool
const plan = yield* PlanExitTool
const webfetch = yield* WebFetchTool
const websearch = yield* WebSearchTool
const bash = yield* BashTool
const codesearch = yield* CodeSearchTool
const globtool = yield* GlobTool
const writetool = yield* WriteTool
const edit = yield* EditTool
const greptool = yield* GrepTool
const patchtool = yield* ApplyPatchTool
const skilltool = yield* SkillTool
const state = yield* InstanceState.make<State>(
Effect.fn("ToolRegistry.state")(function* (ctx) {
@@ -132,26 +97,23 @@ export namespace ToolRegistry {
id,
parameters: z.object(def.args),
description: def.description,
execute: (args, toolCtx) =>
Effect.gen(function* () {
const pluginCtx: PluginToolContext = {
...toolCtx,
ask: (req) => Effect.runPromise(toolCtx.ask(req)),
directory: ctx.directory,
worktree: ctx.worktree,
}
const result = yield* Effect.promise(() => def.execute(args as any, pluginCtx))
const agent = yield* Effect.promise(() => Agent.get(toolCtx.agent))
const out = yield* truncate.output(result, {}, agent)
return {
title: "",
output: out.truncated ? out.content : result,
metadata: {
truncated: out.truncated,
outputPath: out.truncated ? out.outputPath : undefined,
},
}
}),
execute: async (args, toolCtx) => {
const pluginCtx: PluginToolContext = {
...toolCtx,
directory: ctx.directory,
worktree: ctx.worktree,
}
const result = await def.execute(args as any, pluginCtx)
const out = await Truncate.output(result, {}, await Agent.get(toolCtx.agent))
return {
title: "",
output: out.truncated ? out.content : result,
metadata: {
truncated: out.truncated,
outputPath: out.truncated ? out.outputPath : undefined,
},
}
},
}
}
@@ -182,23 +144,23 @@ export namespace ToolRegistry {
["app", "cli", "desktop"].includes(Flag.OPENCODE_CLIENT) || Flag.OPENCODE_ENABLE_QUESTION_TOOL
const tool = yield* Effect.all({
invalid: Tool.init(invalid),
bash: Tool.init(bash),
invalid: Tool.init(InvalidTool),
bash: Tool.init(BashTool),
read: Tool.init(read),
glob: Tool.init(globtool),
grep: Tool.init(greptool),
edit: Tool.init(edit),
write: Tool.init(writetool),
glob: Tool.init(GlobTool),
grep: Tool.init(GrepTool),
edit: Tool.init(EditTool),
write: Tool.init(WriteTool),
task: Tool.init(task),
fetch: Tool.init(webfetch),
fetch: Tool.init(WebFetchTool),
todo: Tool.init(todo),
search: Tool.init(websearch),
code: Tool.init(codesearch),
skill: Tool.init(skilltool),
patch: Tool.init(patchtool),
search: Tool.init(WebSearchTool),
code: Tool.init(CodeSearchTool),
skill: Tool.init(SkillTool),
patch: Tool.init(ApplyPatchTool),
question: Tool.init(question),
lsp: Tool.init(lsptool),
plan: Tool.init(plan),
lsp: Tool.init(LspTool),
plan: Tool.init(PlanExitTool),
})
return {
@@ -237,40 +199,6 @@ export namespace ToolRegistry {
return (yield* all()).map((tool) => tool.id)
})
const describeSkill = Effect.fn("ToolRegistry.describeSkill")(function* (agent: Agent.Info) {
const list = yield* skill.available(agent)
if (list.length === 0) return "No skills are currently available."
return [
"Load a specialized skill that provides domain-specific instructions and workflows.",
"",
"When you recognize that a task matches one of the available skills listed below, use this tool to load the full skill instructions.",
"",
"The skill will inject detailed instructions, workflows, and access to bundled resources (scripts, references, templates) into the conversation context.",
"",
'Tool output includes a `<skill_content name="...">` block with the loaded content.',
"",
"The following skills provide specialized sets of instructions for particular tasks",
"Invoke this tool to load a skill when a task matches one of the available skills listed below:",
"",
Skill.fmt(list, { verbose: false }),
].join("\n")
})
const describeTask = Effect.fn("ToolRegistry.describeTask")(function* (agent: Agent.Info) {
const items = (yield* agents.list()).filter((item) => item.mode !== "primary")
const filtered = items.filter(
(item) => Permission.evaluate("task", item.name, agent.permission).action !== "deny",
)
const list = filtered.toSorted((a, b) => a.name.localeCompare(b.name))
const description = list
.map(
(item) =>
`- ${item.name}: ${item.description ?? "This subagent should only be called manually by the user."}`,
)
.join("\n")
return ["Available agent types and the tools they have access to:", description].join("\n")
})
const tools: Interface["tools"] = Effect.fn("ToolRegistry.tools")(function* (input) {
const filtered = (yield* all()).filter((tool) => {
if (tool.id === CodeSearchTool.id || tool.id === WebSearchTool.id) {
@@ -299,8 +227,8 @@ export namespace ToolRegistry {
id: tool.id,
description: [
output.description,
tool.id === TaskTool.id ? yield* describeTask(input.agent) : undefined,
tool.id === SkillTool.id ? yield* describeSkill(input.agent) : undefined,
tool.id === TaskTool.id ? yield* TaskDescription(input.agent) : undefined,
tool.id === SkillTool.id ? yield* SkillDescription(input.agent) : undefined,
]
.filter(Boolean)
.join("\n"),
@@ -322,26 +250,19 @@ export namespace ToolRegistry {
}),
)
export const defaultLayer = Layer.suspend(() =>
layer.pipe(
Layer.provide(Config.defaultLayer),
Layer.provide(Plugin.defaultLayer),
Layer.provide(Question.defaultLayer),
Layer.provide(Todo.defaultLayer),
Layer.provide(Skill.defaultLayer),
Layer.provide(Agent.defaultLayer),
Layer.provide(Session.defaultLayer),
Layer.provide(Provider.defaultLayer),
Layer.provide(LSP.defaultLayer),
Layer.provide(FileTime.defaultLayer),
Layer.provide(Instruction.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Bus.layer),
Layer.provide(FetchHttpClient.layer),
Layer.provide(Format.defaultLayer),
Layer.provide(CrossSpawnSpawner.defaultLayer),
Layer.provide(Ripgrep.defaultLayer),
Layer.provide(Truncate.defaultLayer),
export const defaultLayer = Layer.unwrap(
Effect.sync(() =>
layer.pipe(
Layer.provide(Config.defaultLayer),
Layer.provide(Plugin.defaultLayer),
Layer.provide(Question.defaultLayer),
Layer.provide(Todo.defaultLayer),
Layer.provide(Agent.defaultLayer),
Layer.provide(LSP.defaultLayer),
Layer.provide(FileTime.defaultLayer),
Layer.provide(Instruction.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer),
),
),
)
+104 -83
View File
@@ -1,99 +1,120 @@
import { Effect } from "effect"
import path from "path"
import { pathToFileURL } from "url"
import z from "zod"
import { Effect } from "effect"
import * as Stream from "effect/Stream"
import { Tool } from "./tool"
import { Skill } from "../skill"
import { Ripgrep } from "../file/ripgrep"
import { iife } from "@/util/iife"
const Parameters = z.object({
name: z.string().describe("The name of the skill from available_skills"),
})
export const SkillTool = Tool.define(
"skill",
Effect.gen(function* () {
const skill = yield* Skill.Service
const rg = yield* Ripgrep.Service
export const SkillTool = Tool.define("skill", async () => {
const list = await Skill.available()
return async () => {
const list = await Effect.runPromise(skill.available())
const description =
list.length === 0
? "Load a specialized skill that provides domain-specific instructions and workflows. No skills are currently available."
: [
"Load a specialized skill that provides domain-specific instructions and workflows.",
"",
"When you recognize that a task matches one of the available skills listed below, use this tool to load the full skill instructions.",
"",
"The skill will inject detailed instructions, workflows, and access to bundled resources (scripts, references, templates) into the conversation context.",
"",
'Tool output includes a `<skill_content name="...">` block with the loaded content.',
"",
"The following skills provide specialized sets of instructions for particular tasks",
"Invoke this tool to load a skill when a task matches one of the available skills listed below:",
"",
Skill.fmt(list, { verbose: false }),
].join("\n")
const description =
list.length === 0
? "Load a specialized skill that provides domain-specific instructions and workflows. No skills are currently available."
: [
"Load a specialized skill that provides domain-specific instructions and workflows.",
"",
"When you recognize that a task matches one of the available skills listed below, use this tool to load the full skill instructions.",
"",
"The skill will inject detailed instructions, workflows, and access to bundled resources (scripts, references, templates) into the conversation context.",
"",
'Tool output includes a `<skill_content name="...">` block with the loaded content.',
"",
"The following skills provide specialized sets of instructions for particular tasks",
"Invoke this tool to load a skill when a task matches one of the available skills listed below:",
"",
Skill.fmt(list, { verbose: false }),
].join("\n")
return {
description,
parameters: Parameters,
async execute(params: z.infer<typeof Parameters>, ctx) {
const skill = await Skill.get(params.name)
if (!skill) {
const available = await Skill.all().then((x) => x.map((skill) => skill.name).join(", "))
throw new Error(`Skill "${params.name}" not found. Available skills: ${available || "none"}`)
}
await ctx.ask({
permission: "skill",
patterns: [params.name],
always: [params.name],
metadata: {},
})
const dir = path.dirname(skill.location)
const base = pathToFileURL(dir).href
const limit = 10
const files = await iife(async () => {
const arr = []
for await (const file of Ripgrep.files({
cwd: dir,
follow: false,
hidden: true,
signal: ctx.abort,
})) {
if (file.includes("SKILL.md")) {
continue
}
arr.push(path.resolve(dir, file))
if (arr.length >= limit) {
break
}
}
return arr
}).then((f) => f.map((file) => `<file>${file}</file>`).join("\n"))
return {
description,
parameters: Parameters,
execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) =>
Effect.gen(function* () {
const info = yield* skill.get(params.name)
if (!info) {
const all = yield* skill.all()
const available = all.map((s) => s.name).join(", ")
throw new Error(`Skill "${params.name}" not found. Available skills: ${available || "none"}`)
}
yield* ctx.ask({
permission: "skill",
patterns: [params.name],
always: [params.name],
metadata: {},
})
const dir = path.dirname(info.location)
const base = pathToFileURL(dir).href
const limit = 10
const files = yield* rg.files({ cwd: dir, follow: false, hidden: true }).pipe(
Stream.filter((file) => !file.includes("SKILL.md")),
Stream.map((file) => path.resolve(dir, file)),
Stream.take(limit),
Stream.runCollect,
Effect.map((chunk) => [...chunk].map((file) => `<file>${file}</file>`).join("\n")),
)
return {
title: `Loaded skill: ${info.name}`,
output: [
`<skill_content name="${info.name}">`,
`# Skill: ${info.name}`,
"",
info.content.trim(),
"",
`Base directory for this skill: ${base}`,
"Relative paths in this skill (e.g., scripts/, reference/) are relative to this base directory.",
"Note: file list is sampled.",
"",
"<skill_files>",
files,
"</skill_files>",
"</skill_content>",
].join("\n"),
metadata: {
name: info.name,
dir,
},
}
}).pipe(Effect.orDie),
title: `Loaded skill: ${skill.name}`,
output: [
`<skill_content name="${skill.name}">`,
`# Skill: ${skill.name}`,
"",
skill.content.trim(),
"",
`Base directory for this skill: ${base}`,
"Relative paths in this skill (e.g., scripts/, reference/) are relative to this base directory.",
"Note: file list is sampled.",
"",
"<skill_files>",
files,
"</skill_files>",
"</skill_content>",
].join("\n"),
metadata: {
name: skill.name,
dir,
},
}
}
}),
)
},
}
})
export const SkillDescription: Tool.DynamicDescription = (agent) =>
Effect.gen(function* () {
const list = yield* Effect.promise(() => Skill.available(agent))
if (list.length === 0) return "No skills are currently available."
return [
"Load a specialized skill that provides domain-specific instructions and workflows.",
"",
"When you recognize that a task matches one of the available skills listed below, use this tool to load the full skill instructions.",
"",
"The skill will inject detailed instructions, workflows, and access to bundled resources (scripts, references, templates) into the conversation context.",
"",
'Tool output includes a `<skill_content name="...">` block with the loaded content.',
"",
"The following skills provide specialized sets of instructions for particular tasks",
"Invoke this tool to load a skill when a task matches one of the available skills listed below:",
"",
Skill.fmt(list, { verbose: false }),
].join("\n")
})
+35 -24
View File
@@ -5,17 +5,12 @@ import { Session } from "../session"
import { SessionID, MessageID } from "../session/schema"
import { MessageV2 } from "../session/message-v2"
import { Agent } from "../agent/agent"
import type { SessionPrompt } from "../session/prompt"
import { SessionPrompt } from "../session/prompt"
import { Config } from "../config/config"
import { Permission } from "@/permission"
import { Effect } from "effect"
import { Log } from "@/util/log"
export interface TaskPromptOps {
cancel(sessionID: SessionID): void
resolvePromptParts(template: string): Promise<SessionPrompt.PromptInput["parts"]>
prompt(input: SessionPrompt.PromptInput): Promise<MessageV2.WithParts>
}
const id = "task"
const parameters = z.object({
@@ -31,7 +26,7 @@ const parameters = z.object({
command: z.string().describe("The command that triggered this task").optional(),
})
export const TaskTool = Tool.define(
export const TaskTool = Tool.defineEffect(
id,
Effect.gen(function* () {
const agent = yield* Agent.Service
@@ -41,15 +36,17 @@ export const TaskTool = Tool.define(
const cfg = yield* config.get()
if (!ctx.extra?.bypassAgentCheck) {
yield* ctx.ask({
permission: id,
patterns: [params.subagent_type],
always: ["*"],
metadata: {
description: params.description,
subagent_type: params.subagent_type,
},
})
yield* Effect.promise(() =>
ctx.ask({
permission: id,
patterns: [params.subagent_type],
always: ["*"],
metadata: {
description: params.description,
subagent_type: params.subagent_type,
},
}),
)
}
const next = yield* agent.get(params.subagent_type)
@@ -117,13 +114,10 @@ export const TaskTool = Tool.define(
},
})
const ops = ctx.extra?.promptOps as TaskPromptOps
if (!ops) return yield* Effect.fail(new Error("TaskTool requires promptOps in ctx.extra"))
const messageID = MessageID.ascending()
function cancel() {
ops.cancel(nextSession.id)
SessionPrompt.cancel(nextSession.id)
}
return yield* Effect.acquireUseRelease(
@@ -132,9 +126,9 @@ export const TaskTool = Tool.define(
}),
() =>
Effect.gen(function* () {
const parts = yield* Effect.promise(() => ops.resolvePromptParts(params.prompt))
const parts = yield* Effect.promise(() => SessionPrompt.resolvePromptParts(params.prompt))
const result = yield* Effect.promise(() =>
ops.prompt({
SessionPrompt.prompt({
messageID,
sessionID: nextSession.id,
model: {
@@ -176,7 +170,24 @@ export const TaskTool = Tool.define(
return {
description: DESCRIPTION,
parameters,
execute: (params: z.infer<typeof parameters>, ctx: Tool.Context) => run(params, ctx).pipe(Effect.orDie),
async execute(params: z.infer<typeof parameters>, ctx) {
return Effect.runPromise(run(params, ctx))
},
}
}),
)
export const TaskDescription: Tool.DynamicDescription = (agent) =>
Effect.gen(function* () {
const items = yield* Effect.promise(() =>
Agent.list().then((items) => items.filter((item) => item.mode !== "primary")),
)
const filtered = items.filter((item) => Permission.evaluate(id, item.name, agent.permission).action !== "deny")
const list = filtered.toSorted((a, b) => a.name.localeCompare(b.name))
const description = list
.map(
(item) => `- ${item.name}: ${item.description ?? "This subagent should only be called manually by the user."}`,
)
.join("\n")
return ["Available agent types and the tools they have access to:", description].join("\n")
})
+19 -18
View File
@@ -12,7 +12,7 @@ type Metadata = {
todos: Todo.Info[]
}
export const TodoWriteTool = Tool.define<typeof parameters, Metadata, Todo.Service>(
export const TodoWriteTool = Tool.defineEffect<typeof parameters, Metadata, Todo.Service>(
"todowrite",
Effect.gen(function* () {
const todo = yield* Todo.Service
@@ -20,28 +20,29 @@ export const TodoWriteTool = Tool.define<typeof parameters, Metadata, Todo.Servi
return {
description: DESCRIPTION_WRITE,
parameters,
execute: (params: z.infer<typeof parameters>, ctx: Tool.Context<Metadata>) =>
Effect.gen(function* () {
yield* ctx.ask({
permission: "todowrite",
patterns: ["*"],
always: ["*"],
metadata: {},
})
async execute(params: z.infer<typeof parameters>, ctx: Tool.Context<Metadata>) {
await ctx.ask({
permission: "todowrite",
patterns: ["*"],
always: ["*"],
metadata: {},
})
yield* todo.update({
await todo
.update({
sessionID: ctx.sessionID,
todos: params.todos,
})
.pipe(Effect.runPromise)
return {
title: `${params.todos.filter((x) => x.status !== "completed").length} todos`,
output: JSON.stringify(params.todos, null, 2),
metadata: {
todos: params.todos,
},
}
}),
return {
title: `${params.todos.filter((x) => x.status !== "completed").length} todos`,
output: JSON.stringify(params.todos, null, 2),
metadata: {
todos: params.todos,
},
}
},
} satisfies Tool.DefWithoutID<typeof parameters, Metadata>
}),
)
+47 -39
View File
@@ -23,21 +23,22 @@ export namespace Tool {
extra?: { [key: string]: any }
messages: MessageV2.WithParts[]
metadata(input: { title?: string; metadata?: M }): void
ask(input: Omit<Permission.Request, "id" | "sessionID" | "tool">): Effect.Effect<void>
}
export interface ExecuteResult<M extends Metadata = Metadata> {
title: string
metadata: M
output: string
attachments?: Omit<MessageV2.FilePart, "id" | "sessionID" | "messageID">[]
ask(input: Omit<Permission.Request, "id" | "sessionID" | "tool">): Promise<void>
}
export interface Def<Parameters extends z.ZodType = z.ZodType, M extends Metadata = Metadata> {
id: string
description: string
parameters: Parameters
execute(args: z.infer<Parameters>, ctx: Context): Effect.Effect<ExecuteResult<M>>
execute(
args: z.infer<Parameters>,
ctx: Context,
): Promise<{
title: string
metadata: M
output: string
attachments?: Omit<MessageV2.FilePart, "id" | "sessionID" | "messageID">[]
}>
formatValidationError?(error: z.ZodError): string
}
export type DefWithoutID<Parameters extends z.ZodType = z.ZodType, M extends Metadata = Metadata> = Omit<
@@ -73,41 +74,48 @@ export namespace Tool {
return async () => {
const toolInfo = init instanceof Function ? await init() : { ...init }
const execute = toolInfo.execute
toolInfo.execute = (args, ctx) =>
Effect.gen(function* () {
yield* Effect.try({
try: () => toolInfo.parameters.parse(args),
catch: (error) => {
if (error instanceof z.ZodError && toolInfo.formatValidationError) {
return new Error(toolInfo.formatValidationError(error), { cause: error })
}
return new Error(
`The ${id} tool was called with invalid arguments: ${error}.\nPlease rewrite the input so it satisfies the expected schema.`,
{ cause: error },
)
},
})
const result = yield* execute(args, ctx)
if (result.metadata.truncated !== undefined) {
return result
toolInfo.execute = async (args, ctx) => {
try {
toolInfo.parameters.parse(args)
} catch (error) {
if (error instanceof z.ZodError && toolInfo.formatValidationError) {
throw new Error(toolInfo.formatValidationError(error), { cause: error })
}
const agent = yield* Effect.promise(() => Agent.get(ctx.agent))
const truncated = yield* Effect.promise(() => Truncate.output(result.output, {}, agent))
return {
...result,
output: truncated.content,
metadata: {
...result.metadata,
truncated: truncated.truncated,
...(truncated.truncated && { outputPath: truncated.outputPath }),
},
}
}).pipe(Effect.orDie)
throw new Error(
`The ${id} tool was called with invalid arguments: ${error}.\nPlease rewrite the input so it satisfies the expected schema.`,
{ cause: error },
)
}
const result = await execute(args, ctx)
if (result.metadata.truncated !== undefined) {
return result
}
const truncated = await Truncate.output(result.output, {}, await Agent.get(ctx.agent))
return {
...result,
output: truncated.content,
metadata: {
...result.metadata,
truncated: truncated.truncated,
...(truncated.truncated && { outputPath: truncated.outputPath }),
},
}
}
return toolInfo
}
}
export function define<Parameters extends z.ZodType, Result extends Metadata, R, ID extends string = string>(
export function define<Parameters extends z.ZodType, Result extends Metadata, ID extends string = string>(
id: ID,
init: (() => Promise<DefWithoutID<Parameters, Result>>) | DefWithoutID<Parameters, Result>,
): Info<Parameters, Result> & { id: ID } {
return {
id,
init: wrap(id, init),
}
}
export function defineEffect<Parameters extends z.ZodType, Result extends Metadata, R, ID extends string = string>(
id: ID,
init: Effect.Effect<(() => Promise<DefWithoutID<Parameters, Result>>) | DefWithoutID<Parameters, Result>, never, R>,
): Effect.Effect<Info<Parameters, Result>, never, R> & { id: ID } {
+156 -147
View File
@@ -1,160 +1,169 @@
import z from "zod"
import { Effect } from "effect"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { Tool } from "./tool"
import TurndownService from "turndown"
import DESCRIPTION from "./webfetch.txt"
import { abortAfterAny } from "../util/abort"
import { iife } from "@/util/iife"
const MAX_RESPONSE_SIZE = 5 * 1024 * 1024 // 5MB
const DEFAULT_TIMEOUT = 30 * 1000 // 30 seconds
const MAX_TIMEOUT = 120 * 1000 // 2 minutes
const parameters = z.object({
url: z.string().describe("The URL to fetch content from"),
format: z
.enum(["text", "markdown", "html"])
.default("markdown")
.describe("The format to return the content in (text, markdown, or html). Defaults to markdown."),
timeout: z.number().describe("Optional timeout in seconds (max 120)").optional(),
})
export const WebFetchTool = Tool.define(
"webfetch",
Effect.gen(function* () {
const http = yield* HttpClient.HttpClient
const httpOk = HttpClient.filterStatusOk(http)
return {
description: DESCRIPTION,
parameters,
execute: (params: z.infer<typeof parameters>, ctx: Tool.Context) =>
Effect.gen(function* () {
if (!params.url.startsWith("http://") && !params.url.startsWith("https://")) {
throw new Error("URL must start with http:// or https://")
}
yield* ctx.ask({
permission: "webfetch",
patterns: [params.url],
always: ["*"],
metadata: {
url: params.url,
format: params.format,
timeout: params.timeout,
},
})
const timeout = Math.min((params.timeout ?? DEFAULT_TIMEOUT / 1000) * 1000, MAX_TIMEOUT)
// Build Accept header based on requested format with q parameters for fallbacks
let acceptHeader = "*/*"
switch (params.format) {
case "markdown":
acceptHeader = "text/markdown;q=1.0, text/x-markdown;q=0.9, text/plain;q=0.8, text/html;q=0.7, */*;q=0.1"
break
case "text":
acceptHeader = "text/plain;q=1.0, text/markdown;q=0.9, text/html;q=0.8, */*;q=0.1"
break
case "html":
acceptHeader =
"text/html;q=1.0, application/xhtml+xml;q=0.9, text/plain;q=0.8, text/markdown;q=0.7, */*;q=0.1"
break
default:
acceptHeader =
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8"
}
const headers = {
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36",
Accept: acceptHeader,
"Accept-Language": "en-US,en;q=0.9",
}
const request = HttpClientRequest.get(params.url).pipe(HttpClientRequest.setHeaders(headers))
// Retry with honest UA if blocked by Cloudflare bot detection (TLS fingerprint mismatch)
const response = yield* httpOk.execute(request).pipe(
Effect.catchIf(
(err) =>
err.reason._tag === "StatusCodeError" &&
err.reason.response.status === 403 &&
err.reason.response.headers["cf-mitigated"] === "challenge",
() =>
httpOk.execute(
HttpClientRequest.get(params.url).pipe(
HttpClientRequest.setHeaders({ ...headers, "User-Agent": "opencode" }),
),
),
),
Effect.timeoutOrElse({ duration: timeout, orElse: () => Effect.die(new Error("Request timed out")) }),
)
// Check content length
const contentLength = response.headers["content-length"]
if (contentLength && parseInt(contentLength) > MAX_RESPONSE_SIZE) {
throw new Error("Response too large (exceeds 5MB limit)")
}
const arrayBuffer = yield* response.arrayBuffer
if (arrayBuffer.byteLength > MAX_RESPONSE_SIZE) {
throw new Error("Response too large (exceeds 5MB limit)")
}
const contentType = response.headers["content-type"] || ""
const mime = contentType.split(";")[0]?.trim().toLowerCase() || ""
const title = `${params.url} (${contentType})`
// Check if response is an image
const isImage = mime.startsWith("image/") && mime !== "image/svg+xml" && mime !== "image/vnd.fastbidsheet"
if (isImage) {
const base64Content = Buffer.from(arrayBuffer).toString("base64")
return {
title,
output: "Image fetched successfully",
metadata: {},
attachments: [
{
type: "file" as const,
mime,
url: `data:${mime};base64,${base64Content}`,
},
],
}
}
const content = new TextDecoder().decode(arrayBuffer)
// Handle content based on requested format and actual content type
switch (params.format) {
case "markdown":
if (contentType.includes("text/html")) {
const markdown = convertHTMLToMarkdown(content)
return {
output: markdown,
title,
metadata: {},
}
}
return { output: content, title, metadata: {} }
case "text":
if (contentType.includes("text/html")) {
const text = yield* Effect.promise(() => extractTextFromHTML(content))
return { output: text, title, metadata: {} }
}
return { output: content, title, metadata: {} }
case "html":
return { output: content, title, metadata: {} }
default:
return { output: content, title, metadata: {} }
}
}).pipe(Effect.orDie),
}
export const WebFetchTool = Tool.define("webfetch", {
description: DESCRIPTION,
parameters: z.object({
url: z.string().describe("The URL to fetch content from"),
format: z
.enum(["text", "markdown", "html"])
.default("markdown")
.describe("The format to return the content in (text, markdown, or html). Defaults to markdown."),
timeout: z.number().describe("Optional timeout in seconds (max 120)").optional(),
}),
)
async execute(params, ctx) {
// Validate URL
if (!params.url.startsWith("http://") && !params.url.startsWith("https://")) {
throw new Error("URL must start with http:// or https://")
}
await ctx.ask({
permission: "webfetch",
patterns: [params.url],
always: ["*"],
metadata: {
url: params.url,
format: params.format,
timeout: params.timeout,
},
})
const timeout = Math.min((params.timeout ?? DEFAULT_TIMEOUT / 1000) * 1000, MAX_TIMEOUT)
const { signal, clearTimeout } = abortAfterAny(timeout, ctx.abort)
// Build Accept header based on requested format with q parameters for fallbacks
let acceptHeader = "*/*"
switch (params.format) {
case "markdown":
acceptHeader = "text/markdown;q=1.0, text/x-markdown;q=0.9, text/plain;q=0.8, text/html;q=0.7, */*;q=0.1"
break
case "text":
acceptHeader = "text/plain;q=1.0, text/markdown;q=0.9, text/html;q=0.8, */*;q=0.1"
break
case "html":
acceptHeader = "text/html;q=1.0, application/xhtml+xml;q=0.9, text/plain;q=0.8, text/markdown;q=0.7, */*;q=0.1"
break
default:
acceptHeader =
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8"
}
const headers = {
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36",
Accept: acceptHeader,
"Accept-Language": "en-US,en;q=0.9",
}
const response = await iife(async () => {
try {
const initial = await fetch(params.url, { signal, headers })
// Retry with honest UA if blocked by Cloudflare bot detection (TLS fingerprint mismatch)
return initial.status === 403 && initial.headers.get("cf-mitigated") === "challenge"
? await fetch(params.url, { signal, headers: { ...headers, "User-Agent": "opencode" } })
: initial
} finally {
clearTimeout()
}
})
if (!response.ok) {
throw new Error(`Request failed with status code: ${response.status}`)
}
// Check content length
const contentLength = response.headers.get("content-length")
if (contentLength && parseInt(contentLength) > MAX_RESPONSE_SIZE) {
throw new Error("Response too large (exceeds 5MB limit)")
}
const arrayBuffer = await response.arrayBuffer()
if (arrayBuffer.byteLength > MAX_RESPONSE_SIZE) {
throw new Error("Response too large (exceeds 5MB limit)")
}
const contentType = response.headers.get("content-type") || ""
const mime = contentType.split(";")[0]?.trim().toLowerCase() || ""
const title = `${params.url} (${contentType})`
// Check if response is an image
const isImage = mime.startsWith("image/") && mime !== "image/svg+xml" && mime !== "image/vnd.fastbidsheet"
if (isImage) {
const base64Content = Buffer.from(arrayBuffer).toString("base64")
return {
title,
output: "Image fetched successfully",
metadata: {},
attachments: [
{
type: "file",
mime,
url: `data:${mime};base64,${base64Content}`,
},
],
}
}
const content = new TextDecoder().decode(arrayBuffer)
// Handle content based on requested format and actual content type
switch (params.format) {
case "markdown":
if (contentType.includes("text/html")) {
const markdown = convertHTMLToMarkdown(content)
return {
output: markdown,
title,
metadata: {},
}
}
return {
output: content,
title,
metadata: {},
}
case "text":
if (contentType.includes("text/html")) {
const text = await extractTextFromHTML(content)
return {
output: text,
title,
metadata: {},
}
}
return {
output: content,
title,
metadata: {},
}
case "html":
return {
output: content,
title,
metadata: {},
}
default:
return {
output: content,
title,
metadata: {},
}
}
},
})
async function extractTextFromHTML(html: string) {
let text = ""
+126 -50
View File
@@ -1,9 +1,15 @@
import z from "zod"
import { Effect } from "effect"
import { HttpClient } from "effect/unstable/http"
import { Tool } from "./tool"
import * as McpExa from "./mcp-exa"
import DESCRIPTION from "./websearch.txt"
import { abortAfterAny } from "../util/abort"
const API_CONFIG = {
BASE_URL: "https://mcp.exa.ai",
ENDPOINTS: {
SEARCH: "/mcp",
},
DEFAULT_NUM_RESULTS: 8,
} as const
const Parameters = z.object({
query: z.string().describe("Websearch query"),
@@ -24,51 +30,121 @@ const Parameters = z.object({
.describe("Maximum characters for context string optimized for LLMs (default: 10000)"),
})
export const WebSearchTool = Tool.define(
"websearch",
Effect.gen(function* () {
const http = yield* HttpClient.HttpClient
return {
get description() {
return DESCRIPTION.replace("{{year}}", new Date().getFullYear().toString())
},
parameters: Parameters,
execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) =>
Effect.gen(function* () {
yield* ctx.ask({
permission: "websearch",
patterns: [params.query],
always: ["*"],
metadata: {
query: params.query,
numResults: params.numResults,
livecrawl: params.livecrawl,
type: params.type,
contextMaxCharacters: params.contextMaxCharacters,
},
})
const result = yield* McpExa.call(
http,
"web_search_exa",
McpExa.SearchArgs,
{
query: params.query,
type: params.type || "auto",
numResults: params.numResults || 8,
livecrawl: params.livecrawl || "fallback",
contextMaxCharacters: params.contextMaxCharacters,
},
"25 seconds",
)
return {
output: result ?? "No search results found. Please try a different query.",
title: `Web search: ${params.query}`,
metadata: {},
}
}).pipe(Effect.orDie),
interface McpSearchRequest {
jsonrpc: string
id: number
method: string
params: {
name: string
arguments: {
query: string
numResults?: number
livecrawl?: "fallback" | "preferred"
type?: "auto" | "fast" | "deep"
contextMaxCharacters?: number
}
}),
)
}
}
interface McpSearchResponse {
jsonrpc: string
result: {
content: Array<{
type: string
text: string
}>
}
}
export const WebSearchTool = Tool.define("websearch", async () => {
return {
get description() {
return DESCRIPTION.replace("{{year}}", new Date().getFullYear().toString())
},
parameters: Parameters,
async execute(params, ctx) {
await ctx.ask({
permission: "websearch",
patterns: [params.query],
always: ["*"],
metadata: {
query: params.query,
numResults: params.numResults,
livecrawl: params.livecrawl,
type: params.type,
contextMaxCharacters: params.contextMaxCharacters,
},
})
const searchRequest: McpSearchRequest = {
jsonrpc: "2.0",
id: 1,
method: "tools/call",
params: {
name: "web_search_exa",
arguments: {
query: params.query,
type: params.type || "auto",
numResults: params.numResults || API_CONFIG.DEFAULT_NUM_RESULTS,
livecrawl: params.livecrawl || "fallback",
contextMaxCharacters: params.contextMaxCharacters,
},
},
}
const { signal, clearTimeout } = abortAfterAny(25000, ctx.abort)
try {
const headers: Record<string, string> = {
accept: "application/json, text/event-stream",
"content-type": "application/json",
}
const response = await fetch(`${API_CONFIG.BASE_URL}${API_CONFIG.ENDPOINTS.SEARCH}`, {
method: "POST",
headers,
body: JSON.stringify(searchRequest),
signal,
})
clearTimeout()
if (!response.ok) {
const errorText = await response.text()
throw new Error(`Search error (${response.status}): ${errorText}`)
}
const responseText = await response.text()
// Parse SSE response
const lines = responseText.split("\n")
for (const line of lines) {
if (line.startsWith("data: ")) {
const data: McpSearchResponse = JSON.parse(line.substring(6))
if (data.result && data.result.content && data.result.content.length > 0) {
return {
output: data.result.content[0].text,
title: `Web search: ${params.query}`,
metadata: {},
}
}
}
}
return {
output: "No search results found. Please try a different query.",
title: `Web search: ${params.query}`,
metadata: {},
}
} catch (error) {
clearTimeout()
if (error instanceof Error && error.name === "AbortError") {
throw new Error("Search request timed out")
}
throw error
}
},
}
})
+65 -77
View File
@@ -1,6 +1,5 @@
import z from "zod"
import * as path from "path"
import { Effect } from "effect"
import { Tool } from "./tool"
import { LSP } from "../lsp"
import { createTwoFilesPatch } from "diff"
@@ -10,87 +9,76 @@ import { File } from "../file"
import { FileWatcher } from "../file/watcher"
import { Format } from "../format"
import { FileTime } from "../file/time"
import { AppFileSystem } from "../filesystem"
import { Filesystem } from "../util/filesystem"
import { Instance } from "../project/instance"
import { trimDiff } from "./edit"
import { assertExternalDirectoryEffect } from "./external-directory"
import { assertExternalDirectory } from "./external-directory"
const MAX_DIAGNOSTICS_PER_FILE = 20
const MAX_PROJECT_DIAGNOSTICS_FILES = 5
export const WriteTool = Tool.define(
"write",
Effect.gen(function* () {
const lsp = yield* LSP.Service
const fs = yield* AppFileSystem.Service
const filetime = yield* FileTime.Service
const bus = yield* Bus.Service
const format = yield* Format.Service
export const WriteTool = Tool.define("write", {
description: DESCRIPTION,
parameters: z.object({
content: z.string().describe("The content to write to the file"),
filePath: z.string().describe("The absolute path to the file to write (must be absolute, not relative)"),
}),
async execute(params, ctx) {
const filepath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath)
await assertExternalDirectory(ctx, filepath)
const exists = await Filesystem.exists(filepath)
const contentOld = exists ? await Filesystem.readText(filepath) : ""
if (exists) await FileTime.assert(ctx.sessionID, filepath)
const diff = trimDiff(createTwoFilesPatch(filepath, filepath, contentOld, params.content))
await ctx.ask({
permission: "edit",
patterns: [path.relative(Instance.worktree, filepath)],
always: ["*"],
metadata: {
filepath,
diff,
},
})
await Filesystem.write(filepath, params.content)
await Format.file(filepath)
Bus.publish(File.Event.Edited, { file: filepath })
await Bus.publish(FileWatcher.Event.Updated, {
file: filepath,
event: exists ? "change" : "add",
})
await FileTime.read(ctx.sessionID, filepath)
let output = "Wrote file successfully."
await LSP.touchFile(filepath, true)
const diagnostics = await LSP.diagnostics()
const normalizedFilepath = Filesystem.normalizePath(filepath)
let projectDiagnosticsCount = 0
for (const [file, issues] of Object.entries(diagnostics)) {
const errors = issues.filter((item) => item.severity === 1)
if (errors.length === 0) continue
const limited = errors.slice(0, MAX_DIAGNOSTICS_PER_FILE)
const suffix =
errors.length > MAX_DIAGNOSTICS_PER_FILE ? `\n... and ${errors.length - MAX_DIAGNOSTICS_PER_FILE} more` : ""
if (file === normalizedFilepath) {
output += `\n\nLSP errors detected in this file, please fix:\n<diagnostics file="${filepath}">\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n</diagnostics>`
continue
}
if (projectDiagnosticsCount >= MAX_PROJECT_DIAGNOSTICS_FILES) continue
projectDiagnosticsCount++
output += `\n\nLSP errors detected in other files:\n<diagnostics file="${file}">\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n</diagnostics>`
}
return {
description: DESCRIPTION,
parameters: z.object({
content: z.string().describe("The content to write to the file"),
filePath: z.string().describe("The absolute path to the file to write (must be absolute, not relative)"),
}),
execute: (params: { content: string; filePath: string }, ctx: Tool.Context) =>
Effect.gen(function* () {
const filepath = path.isAbsolute(params.filePath)
? params.filePath
: path.join(Instance.directory, params.filePath)
yield* assertExternalDirectoryEffect(ctx, filepath)
const exists = yield* fs.existsSafe(filepath)
const contentOld = exists ? yield* fs.readFileString(filepath) : ""
if (exists) yield* filetime.assert(ctx.sessionID, filepath)
const diff = trimDiff(createTwoFilesPatch(filepath, filepath, contentOld, params.content))
yield* ctx.ask({
permission: "edit",
patterns: [path.relative(Instance.worktree, filepath)],
always: ["*"],
metadata: {
filepath,
diff,
},
})
yield* fs.writeWithDirs(filepath, params.content)
yield* format.file(filepath)
yield* bus.publish(File.Event.Edited, { file: filepath })
yield* bus.publish(FileWatcher.Event.Updated, {
file: filepath,
event: exists ? "change" : "add",
})
yield* filetime.read(ctx.sessionID, filepath)
let output = "Wrote file successfully."
yield* lsp.touchFile(filepath, true)
const diagnostics = yield* lsp.diagnostics()
const normalizedFilepath = AppFileSystem.normalizePath(filepath)
let projectDiagnosticsCount = 0
for (const [file, issues] of Object.entries(diagnostics)) {
const current = file === normalizedFilepath
if (!current && projectDiagnosticsCount >= MAX_PROJECT_DIAGNOSTICS_FILES) continue
const block = LSP.Diagnostic.report(current ? filepath : file, issues)
if (!block) continue
if (current) {
output += `\n\nLSP errors detected in this file, please fix:\n${block}`
continue
}
projectDiagnosticsCount++
output += `\n\nLSP errors detected in other files:\n${block}`
}
return {
title: path.relative(Instance.worktree, filepath),
metadata: {
diagnostics,
filepath,
exists: exists,
},
output,
}
}).pipe(Effect.orDie),
title: path.relative(Instance.worktree, filepath),
metadata: {
diagnostics,
filepath,
exists: exists,
},
output,
}
}),
)
},
})
+3 -5
View File
@@ -171,7 +171,7 @@ export namespace Worktree {
export const layer: Layer.Layer<
Service,
never,
AppFileSystem.Service | Path.Path | ChildProcessSpawner.ChildProcessSpawner | Git.Service | Project.Service
AppFileSystem.Service | Path.Path | ChildProcessSpawner.ChildProcessSpawner | Project.Service
> = Layer.effect(
Service,
Effect.gen(function* () {
@@ -179,7 +179,6 @@ export namespace Worktree {
const fs = yield* AppFileSystem.Service
const pathSvc = yield* Path.Path
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
const gitSvc = yield* Git.Service
const project = yield* Project.Service
const git = Effect.fnUntraced(
@@ -524,7 +523,7 @@ export namespace Worktree {
const worktreePath = entry.path
const base = yield* gitSvc.defaultBranch(Instance.worktree)
const base = yield* Effect.promise(() => Git.defaultBranch(Instance.worktree))
if (!base) {
throw new ResetFailedError({ message: "Default branch not found" })
}
@@ -590,8 +589,7 @@ export namespace Worktree {
}),
)
export const defaultLayer = layer.pipe(
Layer.provide(Git.defaultLayer),
const defaultLayer = layer.pipe(
Layer.provide(CrossSpawnSpawner.defaultLayer),
Layer.provide(Project.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer),
@@ -244,6 +244,7 @@ describe("SyncProvider", () => {
expect(log.some((item) => item.path === "/path" && item.workspace === "ws_a")).toBe(true)
expect(log.some((item) => item.path === "/config" && item.workspace === "ws_a")).toBe(true)
expect(log.some((item) => item.path === "/session" && item.workspace === "ws_a")).toBe(true)
expect(log.some((item) => item.path === "/command" && item.workspace === "ws_a")).toBe(true)
} finally {
app.renderer.destroy()
@@ -1,6 +1,4 @@
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import * as Stream from "effect/Stream"
import fs from "fs/promises"
import path from "path"
import { tmpdir } from "../fixture/fixture"
@@ -54,52 +52,3 @@ describe("file.ripgrep", () => {
expect(hits).toEqual([])
})
})
describe("Ripgrep.Service", () => {
test("files returns stream of filenames", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "a.txt"), "hello")
await Bun.write(path.join(dir, "b.txt"), "world")
},
})
const files = await Effect.gen(function* () {
const rg = yield* Ripgrep.Service
return yield* rg.files({ cwd: tmp.path }).pipe(
Stream.runCollect,
Effect.map((chunk) => [...chunk].sort()),
)
}).pipe(Effect.provide(Ripgrep.defaultLayer), Effect.runPromise)
expect(files).toEqual(["a.txt", "b.txt"])
})
test("files respects glob filter", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "keep.ts"), "yes")
await Bun.write(path.join(dir, "skip.txt"), "no")
},
})
const files = await Effect.gen(function* () {
const rg = yield* Ripgrep.Service
return yield* rg.files({ cwd: tmp.path, glob: ["*.ts"] }).pipe(
Stream.runCollect,
Effect.map((chunk) => [...chunk]),
)
}).pipe(Effect.provide(Ripgrep.defaultLayer), Effect.runPromise)
expect(files).toEqual(["keep.ts"])
})
test("files dies on nonexistent directory", async () => {
const exit = await Effect.gen(function* () {
const rg = yield* Ripgrep.Service
return yield* rg.files({ cwd: "/tmp/nonexistent-dir-12345" }).pipe(Stream.runCollect)
}).pipe(Effect.provide(Ripgrep.defaultLayer), Effect.runPromiseExit)
expect(exit._tag).toBe("Failure")
})
})
@@ -7,7 +7,6 @@ import { tmpdir } from "../fixture/fixture"
import { Bus } from "../../src/bus"
import { Config } from "../../src/config/config"
import { FileWatcher } from "../../src/file/watcher"
import { Git } from "../../src/git"
import { Instance } from "../../src/project/instance"
// Native @parcel/watcher bindings aren't reliably available in CI (missing on Linux, flaky on Windows)
@@ -33,7 +32,6 @@ function withWatcher<E>(directory: string, body: Effect.Effect<void, E>) {
fn: async () => {
const layer: Layer.Layer<FileWatcher.Service, never, never> = FileWatcher.layer.pipe(
Layer.provide(Config.defaultLayer),
Layer.provide(Git.defaultLayer),
Layer.provide(watcherConfigLayer),
)
const rt = ManagedRuntime.make(layer)
+6 -1
View File
@@ -93,6 +93,7 @@ type Opts = {
provider?: HostPluginApi["state"]["provider"]
path?: HostPluginApi["state"]["path"]
vcs?: HostPluginApi["state"]["vcs"]
workspace?: Partial<HostPluginApi["state"]["workspace"]>
session?: Partial<HostPluginApi["state"]["session"]>
part?: HostPluginApi["state"]["part"]
lsp?: HostPluginApi["state"]["lsp"]
@@ -276,11 +277,15 @@ export function createTuiPluginApi(opts: Opts = {}): HostPluginApi {
return opts.state?.provider ?? []
},
get path() {
return opts.state?.path ?? { home: "", state: "", config: "", worktree: "", directory: "" }
return opts.state?.path ?? { state: "", config: "", worktree: "", directory: "" }
},
get vcs() {
return opts.state?.vcs
},
workspace: {
list: opts.state?.workspace?.list ?? (() => []),
get: opts.state?.workspace?.get ?? (() => undefined),
},
session: {
count: opts.state?.session?.count ?? (() => 0),
diff: opts.state?.session?.diff ?? (() => []),
@@ -64,46 +64,6 @@ describe("Format", () => {
),
)
it.live("status() excludes uv when ruff is disabled", () =>
provideTmpdirInstance(
() =>
Format.Service.use((fmt) =>
Effect.gen(function* () {
const statuses = yield* fmt.status()
expect(statuses.find((item) => item.name === "ruff")).toBeUndefined()
expect(statuses.find((item) => item.name === "uv")).toBeUndefined()
}),
),
{
config: {
formatter: {
ruff: { disabled: true },
},
},
},
),
)
it.live("status() excludes ruff when uv is disabled", () =>
provideTmpdirInstance(
() =>
Format.Service.use((fmt) =>
Effect.gen(function* () {
const statuses = yield* fmt.status()
expect(statuses.find((item) => item.name === "ruff")).toBeUndefined()
expect(statuses.find((item) => item.name === "uv")).toBeUndefined()
}),
),
{
config: {
formatter: {
uv: { disabled: true },
},
},
},
),
)
it.live("service initializes without error", () => provideTmpdirInstance(() => Format.Service.use(() => Effect.void)))
it.live("status() initializes formatter state per directory", () =>
+78
View File
@@ -1,6 +1,8 @@
import { describe, expect, spyOn, test } from "bun:test"
import path from "path"
import fs from "fs/promises"
import * as Lsp from "../../src/lsp/index"
import * as launch from "../../src/lsp/launch"
import { LSPServer } from "../../src/lsp/server"
import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture"
@@ -52,4 +54,80 @@ describe("lsp.spawn", () => {
await Instance.disposeAll()
}
})
test("spawns builtin Typescript LSP with correct arguments", async () => {
await using tmp = await tmpdir()
// Create dummy tsserver to satisfy Module.resolve
const tsdk = path.join(tmp.path, "node_modules", "typescript", "lib")
await fs.mkdir(tsdk, { recursive: true })
await fs.writeFile(path.join(tsdk, "tsserver.js"), "")
const spawnSpy = spyOn(launch, "spawn").mockImplementation(
() =>
({
stdin: {},
stdout: {},
stderr: {},
on: () => {},
kill: () => {},
}) as any,
)
try {
await Instance.provide({
directory: tmp.path,
fn: async () => {
await LSPServer.Typescript.spawn(tmp.path)
},
})
expect(spawnSpy).toHaveBeenCalled()
const args = spawnSpy.mock.calls[0][1] as string[]
expect(args).toContain("--tsserver-path")
expect(args).toContain("--tsserver-log-verbosity")
expect(args).toContain("off")
} finally {
spawnSpy.mockRestore()
}
})
test("spawns builtin Typescript LSP with --ignore-node-modules if no config is found", async () => {
await using tmp = await tmpdir()
// Create dummy tsserver to satisfy Module.resolve
const tsdk = path.join(tmp.path, "node_modules", "typescript", "lib")
await fs.mkdir(tsdk, { recursive: true })
await fs.writeFile(path.join(tsdk, "tsserver.js"), "")
// NO tsconfig.json or jsconfig.json created here
const spawnSpy = spyOn(launch, "spawn").mockImplementation(
() =>
({
stdin: {},
stdout: {},
stderr: {},
on: () => {},
kill: () => {},
}) as any,
)
try {
await Instance.provide({
directory: tmp.path,
fn: async () => {
await LSPServer.Typescript.spawn(tmp.path)
},
})
expect(spawnSpy).toHaveBeenCalled()
const args = spawnSpy.mock.calls[0][1] as string[]
expect(args).toContain("--ignore-node-modules")
} finally {
spawnSpy.mockRestore()
}
})
})
@@ -0,0 +1,137 @@
import { describe, test, expect } from "bun:test"
import path from "path"
import { Instance } from "../../src/project/instance"
import { WebFetchTool } from "../../src/tool/webfetch"
import { SessionID, MessageID } from "../../src/session/schema"
const projectRoot = path.join(__dirname, "../..")
const ctx = {
sessionID: SessionID.make("ses_test"),
messageID: MessageID.make(""),
callID: "",
agent: "build",
abort: new AbortController().signal,
messages: [],
metadata: () => {},
ask: async () => {},
}
const MB = 1024 * 1024
const ITERATIONS = 50
const getHeapMB = () => {
Bun.gc(true)
return process.memoryUsage().heapUsed / MB
}
describe("memory: abort controller leak", () => {
test("webfetch does not leak memory over many invocations", async () => {
await Instance.provide({
directory: projectRoot,
fn: async () => {
const tool = await WebFetchTool.init()
// Warm up
await tool.execute({ url: "https://example.com", format: "text" }, ctx).catch(() => {})
Bun.gc(true)
const baseline = getHeapMB()
// Run many fetches
for (let i = 0; i < ITERATIONS; i++) {
await tool.execute({ url: "https://example.com", format: "text" }, ctx).catch(() => {})
}
Bun.gc(true)
const after = getHeapMB()
const growth = after - baseline
console.log(`Baseline: ${baseline.toFixed(2)} MB`)
console.log(`After ${ITERATIONS} fetches: ${after.toFixed(2)} MB`)
console.log(`Growth: ${growth.toFixed(2)} MB`)
// Memory growth should be minimal - less than 1MB per 10 requests
// With the old closure pattern, this would grow ~0.5MB per request
expect(growth).toBeLessThan(ITERATIONS / 10)
},
})
}, 60000)
test("compare closure vs bind pattern directly", async () => {
const ITERATIONS = 500
// Test OLD pattern: arrow function closure
// Store closures in a map keyed by content to force retention
const closureMap = new Map<string, () => void>()
const timers: Timer[] = []
const controllers: AbortController[] = []
Bun.gc(true)
Bun.sleepSync(100)
const baseline = getHeapMB()
for (let i = 0; i < ITERATIONS; i++) {
// Simulate large response body like webfetch would have
const content = `${i}:${"x".repeat(50 * 1024)}` // 50KB unique per iteration
const controller = new AbortController()
controllers.push(controller)
// OLD pattern - closure captures `content`
const handler = () => {
// Actually use content so it can't be optimized away
if (content.length > 1000000000) controller.abort()
}
closureMap.set(content, handler)
const timeoutId = setTimeout(handler, 30000)
timers.push(timeoutId)
}
Bun.gc(true)
Bun.sleepSync(100)
const after = getHeapMB()
const oldGrowth = after - baseline
console.log(`OLD pattern (closure): ${oldGrowth.toFixed(2)} MB growth (${closureMap.size} closures)`)
// Cleanup after measuring
timers.forEach(clearTimeout)
controllers.forEach((c) => c.abort())
closureMap.clear()
// Test NEW pattern: bind
Bun.gc(true)
Bun.sleepSync(100)
const baseline2 = getHeapMB()
const handlers2: (() => void)[] = []
const timers2: Timer[] = []
const controllers2: AbortController[] = []
for (let i = 0; i < ITERATIONS; i++) {
const _content = `${i}:${"x".repeat(50 * 1024)}` // 50KB - won't be captured
const controller = new AbortController()
controllers2.push(controller)
// NEW pattern - bind doesn't capture surrounding scope
const handler = controller.abort.bind(controller)
handlers2.push(handler)
const timeoutId = setTimeout(handler, 30000)
timers2.push(timeoutId)
}
Bun.gc(true)
Bun.sleepSync(100)
const after2 = getHeapMB()
const newGrowth = after2 - baseline2
// Cleanup after measuring
timers2.forEach(clearTimeout)
controllers2.forEach((c) => c.abort())
handlers2.length = 0
console.log(`NEW pattern (bind): ${newGrowth.toFixed(2)} MB growth`)
console.log(`Improvement: ${(oldGrowth - newGrowth).toFixed(2)} MB saved`)
expect(newGrowth).toBeLessThanOrEqual(oldGrowth)
})
})

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