diff --git a/packages/opencode/src/effect/runner.ts b/packages/opencode/src/effect/runner.ts
index 9fa55230c..89c3a844a 100644
--- a/packages/opencode/src/effect/runner.ts
+++ b/packages/opencode/src/effect/runner.ts
@@ -19,6 +19,7 @@ interface RunHandle {
interface ShellHandle {
id: number
ready: Deferred.Deferred
+ cancelled: Deferred.Deferred
fiber: Fiber.Fiber
}
@@ -99,7 +100,12 @@ export const make = (
}),
).pipe(Effect.flatten)
- const stopShell = (shell: ShellHandle) => Fiber.interrupt(shell.fiber)
+ const stopShell = (shell: ShellHandle) =>
+ Effect.gen(function* () {
+ yield* awaitShellReady(shell)
+ yield* Deferred.succeed(shell.cancelled, undefined).pipe(Effect.asVoid)
+ yield* Fiber.interrupt(shell.fiber)
+ })
const awaitShellReady = (shell: ShellHandle) =>
Deferred.await(shell.ready).pipe(Effect.raceFirst(Fiber.await(shell.fiber).pipe(Effect.asVoid)), Effect.ignore)
@@ -149,18 +155,28 @@ export const make = (
}
yield* busy
const id = next()
+ const cancelled = yield* Deferred.make()
const ready =
options?.ready ??
(yield* Deferred.make().pipe(
Effect.tap((ready) => Deferred.succeed(ready, undefined)),
))
const fiber = yield* work.pipe(Effect.ensuring(finishShell(id)), Effect.forkChild)
- const shell = { id, ready, fiber } satisfies ShellHandle
+ const shell = { id, ready, cancelled, fiber } satisfies ShellHandle
return [
Effect.gen(function* () {
const exit = yield* Fiber.await(fiber)
if (Exit.isSuccess(exit)) return exit.value
- if (Cause.hasInterruptsOnly(exit.cause) && onInterrupt) return yield* onInterrupt
+ if (
+ Cause.hasInterruptsOnly(exit.cause) ||
+ ((yield* Deferred.isDone(cancelled)) &&
+ Cause.hasInterrupts(exit.cause) &&
+ !Cause.hasFails(exit.cause) &&
+ !Cause.hasDies(exit.cause))
+ ) {
+ if (onInterrupt) return yield* onInterrupt
+ return yield* Effect.die(new Cancelled())
+ }
return yield* Effect.failCause(exit.cause)
}),
{ _tag: "Shell", shell },
@@ -184,7 +200,6 @@ export const make = (
case "Shell":
return [
Effect.gen(function* () {
- yield* awaitShellReady(st.shell)
yield* stopShell(st.shell)
yield* idleIfCurrent()
}),
@@ -193,9 +208,8 @@ export const make = (
case "ShellThenRun":
return [
Effect.gen(function* () {
- yield* Deferred.fail(st.run.done, new Cancelled()).pipe(Effect.asVoid)
- yield* awaitShellReady(st.shell)
yield* stopShell(st.shell)
+ yield* Deferred.fail(st.run.done, new Cancelled()).pipe(Effect.asVoid)
yield* idleIfCurrent()
}),
{ _tag: "Idle" } as const,
diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts
index 0e20d4f75..bb8e08f09 100644
--- a/packages/opencode/src/session/compaction.ts
+++ b/packages/opencode/src/session/compaction.ts
@@ -37,8 +37,8 @@ const PRUNE_PROTECTED_TOOLS = ["skill"]
const DEFAULT_TAIL_TURNS = 2
const MIN_PRESERVE_RECENT_TOKENS = 2_000
const MAX_PRESERVE_RECENT_TOKENS = 8_000
-const SUMMARY_TEMPLATE = `Output exactly this Markdown structure and keep the section order unchanged:
----
+const SUMMARY_TEMPLATE = `Output exactly the Markdown structure shown inside and keep the section order unchanged. Do not include the tags in your response.
+
## Goal
- [single-sentence task summary]
@@ -66,7 +66,7 @@ const SUMMARY_TEMPLATE = `Output exactly this Markdown structure and keep the se
## Relevant Files
- [file or directory path: why it matters, or "(none)"]
----
+
Rules:
- Keep every section, even when empty.
diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts
index ccfc912d9..30a918f0e 100644
--- a/packages/opencode/src/session/prompt.ts
+++ b/packages/opencode/src/session/prompt.ts
@@ -887,10 +887,9 @@ NOTE: At any point in time through this workflow you should feel free to ask the
const shellImpl = Effect.fn("SessionPrompt.shellImpl")(function* (input: ShellInput, ready: Deferred.Deferred) {
let output = ""
let aborted = false
- const { run, msg, part, cmd, finish } = yield* Effect.uninterruptibleMask((restore) =>
+ const { msg, part, cmd, finish } = yield* Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
const ctx = yield* InstanceState.context
- const run = yield* runner()
const session = yield* sessions.get(input.sessionID)
if (session.revert) {
yield* revert.cleanup(session)
@@ -958,6 +957,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
const shellName = (
process.platform === "win32" ? path.win32.basename(sh, ".exe") : path.basename(sh)
).toLowerCase()
+ const cwd = ctx.directory
const invocations: Record = {
nu: { args: ["-c", input.command] },
fish: { args: ["-c", input.command] },
@@ -966,12 +966,12 @@ NOTE: At any point in time through this workflow you should feel free to ask the
"-l",
"-c",
`
- __oc_cwd=$PWD
[[ -f ~/.zshenv ]] && source ~/.zshenv >/dev/null 2>&1 || true
[[ -f "\${ZDOTDIR:-$HOME}/.zshrc" ]] && source "\${ZDOTDIR:-$HOME}/.zshrc" >/dev/null 2>&1 || true
- cd "$__oc_cwd"
+ cd -- ${JSON.stringify(cwd)}
eval ${JSON.stringify(input.command)}
`,
+ "pawwork",
],
},
bash: {
@@ -979,12 +979,12 @@ NOTE: At any point in time through this workflow you should feel free to ask the
"-l",
"-c",
`
- __oc_cwd=$PWD
shopt -s expand_aliases
[[ -f ~/.bashrc ]] && source ~/.bashrc >/dev/null 2>&1 || true
- cd "$__oc_cwd"
+ cd -- ${JSON.stringify(cwd)}
eval ${JSON.stringify(input.command)}
`,
+ "pawwork",
],
},
cmd: { args: ["/c", input.command] },
@@ -994,7 +994,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the
}
const args = (invocations[shellName] ?? invocations[""]).args
- const cwd = ctx.directory
const shellEnv = yield* restore(
plugin.trigger(
"shell.env",
@@ -1034,35 +1033,34 @@ NOTE: At any point in time through this workflow you should feel free to ask the
}),
)
- return { run, msg, part, cmd, finish }
+ return { msg, part, cmd, finish }
}),
)
const exit = yield* Effect.gen(function* () {
const handle = yield* spawner.spawn(cmd)
yield* Stream.runForEach(Stream.decodeText(handle.all), (chunk) =>
- Effect.sync(() => {
+ Effect.gen(function* () {
output += chunk
if (part.state.status === "running") {
part.state.metadata = { ...part.state.metadata, output, description: "" }
- void run.fork(sessions.updatePart(part))
+ yield* sessions.updatePart(part)
}
}),
)
yield* handle.exitCode
}).pipe(
Effect.scoped,
- Effect.onInterrupt(() =>
- Effect.sync(() => {
- aborted = true
- }),
- ),
Effect.orDie,
- Effect.ensuring(finish),
Effect.exit,
)
- if (Exit.isFailure(exit) && !Cause.hasInterruptsOnly(exit.cause)) {
+ if (Exit.isFailure(exit) && Cause.hasInterrupts(exit.cause) && !Cause.hasDies(exit.cause)) {
+ aborted = true
+ }
+ yield* finish
+
+ if (Exit.isFailure(exit) && !aborted && !Cause.hasInterruptsOnly(exit.cause)) {
return yield* Effect.failCause(exit.cause)
}
diff --git a/packages/opencode/src/tool/lsp.ts b/packages/opencode/src/tool/lsp.ts
index ddc8f03a3..84a8f9c5c 100644
--- a/packages/opencode/src/tool/lsp.ts
+++ b/packages/opencode/src/tool/lsp.ts
@@ -29,6 +29,9 @@ export const Parameters = Schema.Struct({
character: Schema.Number.check(Schema.isInt())
.check(Schema.isGreaterThanOrEqualTo(1))
.annotate({ description: "The character offset (1-based, as shown in editors)" }),
+ query: Schema.optional(Schema.String).annotate({
+ description: "Search query for workspaceSymbol. Empty string requests all symbols.",
+ }),
})
export const LspTool = Tool.define(
@@ -39,10 +42,7 @@ export const LspTool = Tool.define(
return {
description: DESCRIPTION,
parameters: Parameters,
- execute: (
- args: { operation: (typeof operations)[number]; filePath: string; line: number; character: number },
- ctx: Tool.Context,
- ) =>
+ execute: (args: Schema.Schema.Type, ctx: Tool.Context) =>
Effect.gen(function* () {
const file = path.isAbsolute(args.filePath) ? args.filePath : path.join(Instance.directory, args.filePath)
yield* assertExternalDirectoryEffect(ctx, file)
@@ -89,7 +89,7 @@ export const LspTool = Tool.define(
case "documentSymbol":
return lsp.documentSymbol(uri)
case "workspaceSymbol":
- return lsp.workspaceSymbol("")
+ return lsp.workspaceSymbol(args.query ?? "")
case "goToImplementation":
return lsp.implementation(position)
case "prepareCallHierarchy":
diff --git a/packages/opencode/src/tool/lsp.txt b/packages/opencode/src/tool/lsp.txt
index 5a50a571b..5975118eb 100644
--- a/packages/opencode/src/tool/lsp.txt
+++ b/packages/opencode/src/tool/lsp.txt
@@ -5,7 +5,7 @@ Supported operations:
- findReferences: Find all references to a symbol
- hover: Get hover information (documentation, type info) for a symbol
- documentSymbol: Get all symbols (functions, classes, variables) in a document
-- workspaceSymbol: Search for symbols across the entire workspace
+- workspaceSymbol: List project-wide symbols matching a query string
- goToImplementation: Find implementations of an interface or abstract method
- prepareCallHierarchy: Get call hierarchy item at a position (functions/methods)
- incomingCalls: Find all functions/methods that call the function at a position
@@ -16,4 +16,9 @@ All operations require:
- line: The line number (1-based, as shown in editors)
- character: The character offset (1-based, as shown in editors)
+workspaceSymbol also accepts:
+- query: A query string to filter symbols by. Empty string requests all symbols.
+
+For workspaceSymbol, filePath is not sent in the LSP workspace/symbol request. It is used by PawWork to select and start the matching LSP server.
+
Note: LSP servers must be configured for the file type. If no server is available, an error will be returned.
diff --git a/packages/opencode/test/effect/runner.test.ts b/packages/opencode/test/effect/runner.test.ts
index deebdcaa3..7f2661ed4 100644
--- a/packages/opencode/test/effect/runner.test.ts
+++ b/packages/opencode/test/effect/runner.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
-import { Deferred, Effect, Exit, Fiber, Ref, Scope } from "effect"
+import { Cause, Deferred, Effect, Exit, Fiber, Ref, Scope } from "effect"
import { Runner } from "../../src/effect"
import { it } from "../lib/effect"
@@ -334,6 +334,42 @@ describe("Runner", () => {
}),
)
+ it.live(
+ "cancel does not mask shell defects",
+ Effect.gen(function* () {
+ const s = yield* Scope.Scope
+ const runner = Runner.make(s, { onInterrupt: Effect.succeed("interrupted") })
+
+ const sh = yield* runner
+ .startShell(Effect.never.pipe(Effect.ensuring(Effect.die("boom")), Effect.as("ignored")))
+ .pipe(Effect.forkChild)
+ yield* Effect.sleep("10 millis")
+
+ yield* runner.cancel
+ expect(Exit.isFailure(yield* Fiber.await(sh))).toBe(true)
+ }),
+ )
+
+ it.live(
+ "cancel does not mask shell typed failures",
+ Effect.gen(function* () {
+ const s = yield* Scope.Scope
+ const runner = Runner.make(s, { onInterrupt: Effect.succeed("interrupted") })
+
+ const sh = yield* runner
+ .startShell(Effect.never.pipe(Effect.onInterrupt(() => Effect.fail("boom")), Effect.as("ignored")))
+ .pipe(Effect.forkChild)
+ yield* Effect.sleep("10 millis")
+
+ yield* runner.cancel
+ const exit = yield* Fiber.await(sh)
+ expect(Exit.isFailure(exit)).toBe(true)
+ if (Exit.isFailure(exit)) {
+ expect(Cause.hasFails(exit.cause)).toBe(true)
+ }
+ }),
+ )
+
// --- shell→run handoff ---
it.live(
diff --git a/packages/opencode/test/server/global-session-list.test.ts b/packages/opencode/test/server/global-session-list.test.ts
index 7a8dba2d3..253fca982 100644
--- a/packages/opencode/test/server/global-session-list.test.ts
+++ b/packages/opencode/test/server/global-session-list.test.ts
@@ -221,6 +221,43 @@ describe("session.listGlobal", () => {
}),
)
+ it.live(
+ "session routes omit undefined optional fields",
+ Effect.promise(async () => {
+ await using tmp = await tmpdir({ git: true })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ await svc.create({ title: "route-optional-fields" })
+
+ const app = Server.Default().app
+ const response = await app.request(`/session?directory=${encodeURIComponent(tmp.path)}&roots=true&limit=1`)
+ expect(response.status).toBe(200)
+ const body = (await response.json()) as Array>
+ expect(body).toHaveLength(1)
+ const item = body[0]
+
+ expect(Object.hasOwn(item, "parentID")).toBe(false)
+ expect(Object.hasOwn(item, "workspaceID")).toBe(false)
+ expect(Object.hasOwn(item, "summary")).toBe(false)
+ expect(Object.hasOwn(item, "share")).toBe(false)
+ expect(Object.hasOwn(item, "permission")).toBe(false)
+ expect(Object.hasOwn(item, "revert")).toBe(false)
+ expect(Object.hasOwn(item.time, "compacting")).toBe(false)
+ expect(Object.hasOwn(item.time, "archived")).toBe(false)
+
+ const global = await app.request(
+ `/experimental/session?directory=${encodeURIComponent(tmp.path)}&roots=true&limit=1`,
+ )
+ expect(global.status).toBe(200)
+ const globalBody = (await global.json()) as Array>
+ expect(globalBody).toHaveLength(1)
+ expect(Object.hasOwn(globalBody[0].project, "name")).toBe(false)
+ },
+ })
+ }),
+ )
+
test("experimental route round-trips created-order cursor", async () => {
await using tmp = await tmpdir({ git: true })
const originalNow = Date.now
diff --git a/packages/opencode/test/session/prompt-effect.test.ts b/packages/opencode/test/session/prompt-effect.test.ts
index 537ac7a77..7a28f4e71 100644
--- a/packages/opencode/test/session/prompt-effect.test.ts
+++ b/packages/opencode/test/session/prompt-effect.test.ts
@@ -73,10 +73,14 @@ function defer() {
}
function withSh(fx: () => Effect.Effect) {
+ return withShell("/bin/sh", fx)
+}
+
+function withShell(shell: string, fx: () => Effect.Effect) {
return Effect.acquireUseRelease(
Effect.sync(() => {
const prev = process.env.SHELL
- process.env.SHELL = "/bin/sh"
+ process.env.SHELL = shell
Shell.preferred.reset()
return prev
}),
@@ -1269,6 +1273,34 @@ unix("shell completes a fast command on the preferred shell", () =>
),
)
+unix("shell commands can change directory after login startup", () =>
+ withShell("/bin/bash", () =>
+ provideTmpdirInstance(
+ (dir) =>
+ Effect.gen(function* () {
+ const { prompt, run, chat } = yield* boot()
+ const parent = path.dirname(dir)
+ const result = yield* prompt.shell({
+ sessionID: chat.id,
+ agent: "build",
+ command: 'printf "argc:%s\\n" "$#"; cd .. && pwd',
+ })
+
+ expect(result.info.role).toBe("assistant")
+ const tool = completedTool(result.parts)
+ if (!tool) return
+
+ expect(tool.state.output).toContain("argc:0")
+ expect(tool.state.output).toContain(parent)
+ expect(tool.state.metadata.output).toContain("argc:0")
+ expect(tool.state.metadata.output).toContain(parent)
+ yield* run.assertNotBusy(chat.id)
+ }),
+ { git: true, config: cfg },
+ ),
+ ),
+)
+
unix("shell lists files from the project directory", () =>
provideTmpdirInstance(
(dir) =>
diff --git a/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap b/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap
index eb3fe6cce..b20665b34 100644
--- a/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap
+++ b/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap
@@ -209,6 +209,10 @@ exports[`tool parameters JSON Schema (wire shape) lsp 1`] = `
],
"type": "string",
},
+ "query": {
+ "description": "Search query for workspaceSymbol. Empty string requests all symbols.",
+ "type": "string",
+ },
},
"required": [
"operation",
diff --git a/packages/opencode/test/tool/lsp.test.ts b/packages/opencode/test/tool/lsp.test.ts
new file mode 100644
index 000000000..55f95fef7
--- /dev/null
+++ b/packages/opencode/test/tool/lsp.test.ts
@@ -0,0 +1,92 @@
+import { afterEach, describe, expect } from "bun:test"
+import { Effect, Layer } from "effect"
+import { Agent } from "../../src/agent/agent"
+import * as CrossSpawnSpawner from "@opencode-ai/core/cross-spawn-spawner"
+import { LSP } from "../../src/lsp"
+import { Instance } from "../../src/project/instance"
+import { MessageID, SessionID } from "../../src/session/schema"
+import { LspTool } from "../../src/tool/lsp"
+import { Truncate } from "../../src/tool/truncate"
+import type * as Tool from "../../src/tool/tool"
+import { AppFileSystem } from "@opencode-ai/core/filesystem"
+import { provideInstance, tmpdirScoped } from "../fixture/fixture"
+import { testEffect } from "../lib/effect"
+
+afterEach(async () => {
+ await Instance.disposeAll()
+})
+
+const workspaceSymbolQueries: string[] = []
+
+const lsp = Layer.succeed(
+ LSP.Service,
+ LSP.Service.of({
+ init: () => Effect.void,
+ status: () => Effect.succeed([]),
+ hasClients: () => Effect.succeed(true),
+ touchFile: () => Effect.void,
+ diagnostics: () => Effect.succeed({}),
+ hover: () => Effect.succeed([]),
+ definition: () => Effect.succeed([]),
+ references: () => Effect.succeed([]),
+ implementation: () => Effect.succeed([]),
+ documentSymbol: () => Effect.succeed([]),
+ workspaceSymbol: (query) =>
+ Effect.sync(() => {
+ workspaceSymbolQueries.push(query)
+ return []
+ }),
+ prepareCallHierarchy: () => Effect.succeed([]),
+ incomingCalls: () => Effect.succeed([]),
+ outgoingCalls: () => Effect.succeed([]),
+ shutdownAll: () => Effect.void,
+ invalidate: () => Effect.void,
+ }),
+)
+
+const it = testEffect(
+ Layer.mergeAll(
+ Agent.defaultLayer,
+ AppFileSystem.defaultLayer,
+ CrossSpawnSpawner.defaultLayer,
+ lsp,
+ Truncate.defaultLayer,
+ ),
+)
+
+const ctx: Tool.Context = {
+ sessionID: SessionID.make("ses_test"),
+ messageID: MessageID.make("msg_test"),
+ agent: "build",
+ abort: AbortSignal.any([]),
+ messages: [],
+ metadata: () => Effect.void,
+ ask: () => Effect.void,
+}
+
+const run = Effect.fn("LspToolTest.run")(function* (args: Tool.InferParameters) {
+ const info = yield* LspTool
+ const tool = yield* info.init()
+ return yield* tool.execute(args, ctx)
+})
+
+describe("tool.lsp", () => {
+ it.live(
+ "passes workspaceSymbol query to LSP",
+ Effect.gen(function* () {
+ const dir = yield* tmpdirScoped({ git: true })
+ const file = `${dir}/test.ts`
+ yield* Effect.promise(() => Bun.write(file, "export function TestSymbol() {}\n"))
+ workspaceSymbolQueries.length = 0
+
+ yield* provideInstance(dir)(
+ Effect.gen(function* () {
+ yield* run({ operation: "workspaceSymbol", filePath: file, line: 1, character: 1, query: "TestSymbol" })
+ yield* run({ operation: "workspaceSymbol", filePath: file, line: 1, character: 1 })
+ }),
+ )
+
+ expect(workspaceSymbolQueries).toEqual(["TestSymbol", ""])
+ }),
+ )
+})