Merge remote-tracking branch 'origin/v2' into optimistic-prompt

# Conflicts:
#	packages/tui/src/routes/session/index.tsx
This commit is contained in:
Aiden Cline 2026-07-01 12:03:11 -05:00
commit 1c88de1224
20 changed files with 645 additions and 106 deletions

View file

@ -0,0 +1,144 @@
---
name: debug-opencode
description: Use when interactively running, debugging, or verifying opencode's own V2 CLI/TUI or server during development in this repo — starting the dev TUI, driving it with termctrl, comparing V2 against the legacy TUI, hitting the V2 server/API directly, reading log files, or attaching Bun's inspector.
---
# Debugging opencode itself
Workflow for interactively exercising the V2 CLI/TUI and server while developing in this repo. All commands below run from `packages/cli` unless noted otherwise.
## Migration context
- The TUI is being ported from legacy APIs to the new V2 APIs. New and migrated TUI behavior should use `sdk.client.v2` and the location-scoped data in `packages/tui/src/context/data.tsx` instead of adding dependencies on legacy sync state.
- Preserve established TUI behavior unless the task intentionally changes it. When behavior, copy, keyboard interaction, or layout is unclear, compare the local V2 TUI with the latest released legacy TUI (see "Comparing V2 against the legacy TUI" below) rather than guessing.
## Server/client model
opencode V2 is a client/server system, not a single monolithic process:
- **Server process** runs the Effect HTTP API (`packages/server`) and owns all domain state: sessions, database, plugins, permissions, Location services. It's started by the `serve` command (`packages/cli/src/commands/handlers/serve.ts`).
- **TUI process** is a separate process that runs no application logic itself — it's an HTTP/SSE client of the server via the generated SDK (`createOpencodeClient` / `sdk.client.v2`).
- **Discovery**: CLI processes find the shared server through a JSON registration file at `~/.local/state/opencode/service.json` (or `service-local.json` for the local/dev channel) containing `{id, version, url, pid}`. A separate password file under `~/.config/opencode/service.json` provides HTTP Basic auth. Before reusing a registration, the client calls `GET /health` to confirm the server is alive, authenticated, and version-compatible.
- **Sharing**: because of this registration/health-check dance, many concurrent `opencode`/TUI invocations converge on one shared background daemon rather than each spawning their own. If no compatible healthy daemon is found, a new one is spawned detached (`serve --service`) and registers itself.
- **`bun dev service start|status|stop|restart`** manages this shared background daemon's lifecycle directly — useful when you need to force a fresh server, confirm one is running, or kill a stuck one.
- **Standalone mode** (`--standalone`) opts a single invocation out of the shared daemon: it spawns a private one-off `serve --stdio --port 0` child tied to that invocation's lifetime, with its own random password. Use this to isolate a debugging session from your other running opencode sessions.
- Every log line is tagged `role=server` or `role=cli` and a per-process `run=<id>`, so you can distinguish server-side and client-side activity in one shared log file (see "Logs" below) even when both roles are interleaved from concurrent processes.
## Starting the dev TUI
- This package is the V2 CLI adapter. Run its `dev` script when testing the TUI; do not use the repository-root `bun dev`, which launches the legacy `packages/opencode` CLI.
- Run commands from `packages/cli`. Use `bun dev` for most debugging so the TUI starts with a private V2 server.
## Interactive debugging with termctrl
- Use `termctrl` for interactive checks instead of starting the TUI as a blocking foreground process. It provides a real PTY, handles OpenTUI's host handshake, and can save reviewable screenshots.
- Use a dedicated session name and do not reuse or kill an unrelated session.
```bash
termctrl start opencode-v2-dev --host opentui --cols 112 --rows 34 -- bun dev
termctrl wait opencode-v2-dev "Ask anything" --timeout 20000
termctrl show opencode-v2-dev
```
- Wait for visible text before interacting instead of relying on fixed sleeps. Use the text expected from the screen under test, such as `Ask anything` or `Connect a provider`.
- Drive the running TUI with `termctrl send`. Prefix typed input with `text:` and send control keys separately so the interaction matches real terminal input.
```bash
termctrl send opencode-v2-dev 'text:example prompt' enter
termctrl send opencode-v2-dev ctrl-c
```
- Use `termctrl show` after each meaningful interaction and inspect the full visible screen for rendering errors, stale state, error toasts, and unexpected exits.
- Save PNG evidence for every user-visible bug and fix. Do not save text captures; inspect the rendered PNG. Write temporary captures outside the repository unless the artifact is intended to be committed.
```bash
termctrl save opencode-v2-dev --format png --out /tmp/opencode/v2-tui.png
```
- For resize-sensitive changes, resize the viewport, wait for the expected content, and capture the screen again:
```bash
termctrl resize opencode-v2-dev --cols 100 --rows 30
termctrl show opencode-v2-dev
```
- Source changes may require restarting the process. Use `termctrl restart opencode-v2-dev` rather than assuming the running TUI reloaded the change.
- To exercise background-service behavior, use `bun dev service start`, `bun dev service status`, and `bun dev service stop`.
- Always clean up the Terminal Control session when the check is complete:
```bash
termctrl stop opencode-v2-dev
```
## Comparing V2 against the legacy TUI
Run both versions in separate Terminal Control sessions and save PNG-only captures at equivalent states:
```bash
# From packages/cli: local V2 TUI
termctrl start opencode-v2-dev --host opentui --cols 112 --rows 34 -- bun dev
# Released legacy TUI behavior reference
termctrl start opencode-legacy --host opentui --cols 112 --rows 34 -- bunx opencode-ai@latest
termctrl save opencode-v2-dev --format png --out /tmp/opencode/v2.png
termctrl save opencode-legacy --format png --out /tmp/opencode/legacy.png
```
- Use the same viewport and send equivalent inputs to both sessions before comparing screenshots. The released CLI is a behavioral reference, not a source of V2 API design; keep the local implementation on V2 endpoints.
- Stop both sessions after comparison: `termctrl stop opencode-v2-dev` and `termctrl stop opencode-legacy`.
## Server/API debugging
- Use `bun dev api --help` from `packages/cli` to inspect the API debugging command. It sends one request to the V2 server using the same daemon discovery/auth path as the CLI.
- Use `bun dev api` to introspect the server-side data backing the TUI. This is useful when debugging UI bugs: compare what the screen renders with the raw session, message, event, agent, or health data returned by the API to determine whether the bug is in the server state, the client data layer, or the TUI rendering.
- `bun dev api` accepts either an OpenAPI operation ID or a raw HTTP method plus path:
```bash
bun dev api get /health
bun dev api get /openapi.json
bun dev api <operationId> --param key=value
```
- Pass JSON request bodies with `--data`/`-d`; the command sets `content-type: application/json` automatically unless you provide a header. Add extra headers with `--header`/`-H name:value`.
- If no compatible background server is registered, `bun dev api` starts one through the daemon service. Use `bun dev service status`, `bun dev service restart`, and `bun dev service stop` when you need explicit lifecycle control.
- Prefer raw method/path calls for quick server debugging and operation IDs when exercising documented OpenAPI routes with path or query parameters.
## Logs
- Log files live under `~/.local/share/opencode/log/`. In a local/dev checkout the active file is `opencode-local.log`; `opencode.log` is used for non-local (released) channel installs. Both are append-only, shared across every CLI and server process on the machine.
- Each line is structured `key=value` text: `timestamp`, `level`, `run=<id>` (per-process run ID), `message`, and a `role=cli` or `role=server` tag. Use `run=` to isolate one process's activity and `role=` to separate client-side from server-side log lines, since a shared daemon interleaves many processes' output in one file.
- Tail the live file while reproducing an issue instead of guessing from stale output:
```bash
tail -f ~/.local/share/opencode/log/opencode-local.log
```
- Filter to one run or role when the file is noisy:
```bash
grep 'run=8fc3b1d5' ~/.local/share/opencode/log/opencode-local.log
grep 'role=server' ~/.local/share/opencode/log/opencode-local.log
```
- `OPENCODE_LOG_LEVEL` controls verbosity (default `INFO`); set it before starting `bun dev` or `serve` to get `DEBUG` output for a specific repro.
- `OPENCODE_PRINT_LOGS=1` additionally tees log output to stderr of the process that emitted it, which is useful when a process fails before you'd think to check the shared log file.
- `termctrl logs <session>` surfaces stdout/stderr for a Terminal Control session specifically (e.g. inspector output or startup failures before the TUI renderer starts) — use the log file above for anything emitted by a separate server/daemon process instead.
## Debugger
- To debug the V2 CLI or TUI with Bun's inspector, launch the CLI entrypoint through Terminal Control with an inspector URL, then attach a debugger to that URL:
```bash
termctrl start opencode-v2-debug --host opentui --cols 112 --rows 34 -- \
bun run --inspect=ws://localhost:6499/ src/index.ts
```
- Use `--inspect-wait` or `--inspect-brk` when execution must pause until the debugger attaches.
- Use `termctrl logs opencode-v2-debug` for inspector output or startup failures emitted before the TUI renderer starts. Use `termctrl show` for the visible full-screen TUI.
## Verification
- Run `bun typecheck` from `packages/cli` after CLI adapter changes.
- Run `bun typecheck` and `bun test` from `packages/tui` after shared TUI changes. Do not run tests from the repository root.
- Treat automated checks and Terminal Control smoke tests as complementary. For user-visible changes, verify initial render, the changed interaction, Ctrl-C exit behavior, and save a screenshot of the corrected state.

View file

@ -237,12 +237,17 @@ type Endpoint4_18Input = { readonly sessionID: Endpoint4_18Request["params"]["se
const Endpoint4_18 = (raw: RawClient["server.session"]) => (input: Endpoint4_18Input) =>
raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint4_19Request = Parameters<RawClient["server.session"]["session.message"]>[0]
type Endpoint4_19Input = {
readonly sessionID: Endpoint4_19Request["params"]["sessionID"]
readonly messageID: Endpoint4_19Request["params"]["messageID"]
}
type Endpoint4_19Request = Parameters<RawClient["server.session"]["session.background"]>[0]
type Endpoint4_19Input = { readonly sessionID: Endpoint4_19Request["params"]["sessionID"] }
const Endpoint4_19 = (raw: RawClient["server.session"]) => (input: Endpoint4_19Input) =>
raw["session.background"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint4_20Request = Parameters<RawClient["server.session"]["session.message"]>[0]
type Endpoint4_20Input = {
readonly sessionID: Endpoint4_20Request["params"]["sessionID"]
readonly messageID: Endpoint4_20Request["params"]["messageID"]
}
const Endpoint4_20 = (raw: RawClient["server.session"]) => (input: Endpoint4_20Input) =>
raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
@ -268,7 +273,8 @@ const adaptGroup4 = (raw: RawClient["server.session"]) => ({
history: Endpoint4_16(raw),
events: Endpoint4_17(raw),
interrupt: Endpoint4_18(raw),
message: Endpoint4_19(raw),
background: Endpoint4_19(raw),
message: Endpoint4_20(raw),
})
type Endpoint5_0Request = Parameters<RawClient["server.message"]["session.messages"]>[0]

View file

@ -43,6 +43,8 @@ import type {
SessionEventsOutput,
SessionInterruptInput,
SessionInterruptOutput,
SessionBackgroundInput,
SessionBackgroundOutput,
SessionMessageInput,
SessionMessageOutput,
MessageListInput,
@ -561,6 +563,17 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
background: (input: SessionBackgroundInput, requestOptions?: RequestOptions) =>
request<SessionBackgroundOutput>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/background`,
successStatus: 204,
declaredStatuses: [404, 400, 401],
empty: true,
},
requestOptions,
),
message: (input: SessionMessageInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionMessageOutput }>(
{

View file

@ -1775,6 +1775,10 @@ export type SessionInterruptInput = { readonly sessionID: { readonly sessionID:
export type SessionInterruptOutput = void
export type SessionBackgroundInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
export type SessionBackgroundOutput = void
export type SessionMessageInput = {
readonly sessionID: { readonly sessionID: string; readonly messageID: string }["sessionID"]
readonly messageID: { readonly sessionID: string; readonly messageID: string }["messageID"]

View file

@ -19,7 +19,7 @@ export const MAX_TIMEOUT_MS = 10 * 60 * 1_000
export const MAX_CAPTURE_BYTES = 1024 * 1024
const BACKGROUND_STARTED =
"The command is running in the background. You will be notified automatically when it completes. DO NOT sleep, poll, or proactively check on its progress."
"The command has not completed; it is now running in the background."
export const Input = Schema.Struct({
command: Schema.String.annotate({ description: "Shell command string to execute" }),
@ -185,75 +185,80 @@ export const Plugin = {
return yield* Effect.fail(new Error(`Working directory is not a directory: ${target.canonical}`))
const timeout = input.timeout ?? DEFAULT_TIMEOUT_MS
if (input.background === true) {
const background = yield* shell.create({
command: input.command,
cwd: target.canonical,
timeout,
metadata: { sessionID: context.sessionID },
})
const run = Effect.fn("ShellTool.run")(function* () {
return yield* Effect.gen(function* () {
const final = yield* shell.wait(background.id)
const page = yield* shell.output(background.id, { limit: MAX_CAPTURE_BYTES })
if (final.status === "timeout")
return `Command exceeded timeout of ${timeout} ms. Retry with a larger timeout if the command is expected to take longer.`
const truncated = page.size > page.cursor
const body = page.output || "(no output)"
const notice = truncated ? `\n\n[output truncated; full output saved to: ${final.file}]` : ""
return `${body}${notice}`
}).pipe(Effect.onInterrupt(() => shell.remove(background.id).pipe(Effect.ignore)))
})
const info = yield* runtime.job.start({
id: context.toolCallID,
type: name,
title: input.command,
metadata: { sessionID: context.sessionID },
run: run(),
})
yield* runtime.job.background(info.id)
yield* notifyWhenDone(context.sessionID, context.toolCallID, input.command)
return {
output: BACKGROUND_STARTED,
shellID: background.id,
truncated: false,
status: "running" as const,
...(warnings.length ? { warnings } : {}),
}
}
const info = yield* shell.create({
command: input.command,
cwd: target.canonical,
timeout,
metadata: { sessionID: context.sessionID },
})
const final = yield* shell.wait(info.id)
const page = yield* shell.output(info.id, { limit: MAX_CAPTURE_BYTES })
if (final.status === "timeout") {
const settleShell = Effect.fn("ShellTool.settleShell")(function* () {
const final = yield* shell.wait(info.id)
const page = yield* shell.output(info.id, { limit: MAX_CAPTURE_BYTES })
if (final.status === "timeout") {
return {
exit: final.exit,
output: `Command exceeded timeout of ${timeout} ms. Retry with a larger timeout if the command is expected to take longer.`,
truncated: false,
timeout: true,
status: "completed" as const,
}
}
const truncated = page.size > page.cursor
const body = page.output || "(no output)"
const notice = truncated ? `\n\n[output truncated; full output saved to: ${final.file}]` : ""
return {
exit: final.exit,
output: `Command exceeded timeout of ${timeout} ms. Retry with a larger timeout if the command is expected to take longer.`,
truncated: false,
timeout: true,
output: `${body}${notice}`,
truncated,
status: "completed" as const,
}
})
const run = settleShell().pipe(
Effect.map((output) => output.output),
Effect.onInterrupt(() => shell.remove(info.id).pipe(Effect.ignore)),
)
const job = yield* runtime.job.start({
id: context.toolCallID,
type: name,
title: input.command,
metadata: { sessionID: context.sessionID, shellID: info.id },
run,
})
if (input.background === true) {
yield* runtime.job.background(job.id)
yield* notifyWhenDone(context.sessionID, context.toolCallID, input.command)
return {
output: BACKGROUND_STARTED,
shellID: info.id,
truncated: false,
status: "running" as const,
...(warnings.length ? { warnings } : {}),
}
}
const truncated = page.size > page.cursor
const body = page.output || "(no output)"
const notice = truncated ? `\n\n[output truncated; full output saved to: ${final.file}]` : ""
const result = yield* runtime.job.block({ id: job.id, sessionID: context.sessionID }).pipe(
Effect.onInterrupt(() => runtime.job.cancel(job.id).pipe(Effect.ignore)),
)
if (result?.type === "backgrounded") {
yield* notifyWhenDone(context.sessionID, context.toolCallID, input.command)
return {
output: BACKGROUND_STARTED,
shellID: info.id,
truncated: false,
status: "running" as const,
...(warnings.length ? { warnings } : {}),
}
}
if (result?.info.status === "error") return yield* Effect.fail(new Error(result.info.error ?? "Command failed"))
if (result?.info.status === "cancelled") return yield* Effect.fail(new Error("Command cancelled"))
return {
exit: final.exit,
output: `${body}${notice}`,
truncated,
status: "completed" as const,
...(yield* settleShell()),
...(warnings.length ? { warnings } : {}),
}
}).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to execute command: ${input.command}` }))),

View file

@ -2,7 +2,7 @@ import fs from "fs/promises"
import { realpathSync } from "node:fs"
import path from "path"
import { describe, expect, test } from "bun:test"
import { DateTime, Effect, Layer } from "effect"
import { DateTime, Effect, Fiber, Layer, Scope } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { makeGlobalNode } from "@opencode-ai/core/effect/app-node"
@ -454,6 +454,51 @@ describe("ShellTool", () => {
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
),
)
it.live("backgrounds a foreground command when the session is signaled", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
return withSession(tmp.path, (registry) =>
Effect.gen(function* () {
const jobs = yield* Job.Service
const scope = yield* Scope.Scope
const waiting = yield* settleTool(registry, call({ command: idleCommand }, "call-background-signal")).pipe(
Effect.forkIn(scope, { startImmediately: true }),
)
const backgroundWhenReady = (remaining = 1000): Effect.Effect<Job.Info[], Error> =>
Effect.gen(function* () {
const backgrounded = yield* jobs.backgroundAll({ sessionID })
if (backgrounded.length > 0) return backgrounded
if (remaining <= 0) return yield* Effect.fail(new Error("Timed out waiting for foreground shell job"))
yield* Effect.promise(() => Bun.sleep(1))
return yield* backgroundWhenReady(remaining - 1)
})
expect(yield* backgroundWhenReady()).toMatchObject([{ id: "call-background-signal", type: "shell" }])
const settled = yield* Fiber.join(waiting)
const structured = settled.output?.structured as Record<string, unknown> | undefined
const shellID = typeof structured?.shellID === "string" ? structured.shellID : undefined
expect(settled.output?.structured).toMatchObject({ truncated: false })
expect(settled.output?.content[0]).toMatchObject({
type: "text",
text: expect.stringContaining("running in the background"),
})
expect(shellID).toStartWith("sh_")
const shell = yield* Shell.Service
if (!shellID) return
const id = ShellSchema.ID.make(shellID)
expect((yield* shell.list()).map((info) => info.id)).toContain(id)
yield* shell.remove(id)
}),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
),
)
})
test("keeps locked deferred parity TODOs visible", async () => {

View file

@ -182,13 +182,18 @@ export type Endpoint4_18Input = { readonly sessionID: Endpoint4_18Request["param
export type Endpoint4_18Output = EffectValue<ReturnType<RawClient["server.session"]["session.interrupt"]>>
export type SessionInterruptOperation<E = never> = (input: Endpoint4_18Input) => Effect.Effect<Endpoint4_18Output, E>
type Endpoint4_19Request = Parameters<RawClient["server.session"]["session.message"]>[0]
export type Endpoint4_19Input = {
readonly sessionID: Endpoint4_19Request["params"]["sessionID"]
readonly messageID: Endpoint4_19Request["params"]["messageID"]
type Endpoint4_19Request = Parameters<RawClient["server.session"]["session.background"]>[0]
export type Endpoint4_19Input = { readonly sessionID: Endpoint4_19Request["params"]["sessionID"] }
export type Endpoint4_19Output = EffectValue<ReturnType<RawClient["server.session"]["session.background"]>>
export type SessionBackgroundOperation<E = never> = (input: Endpoint4_19Input) => Effect.Effect<Endpoint4_19Output, E>
type Endpoint4_20Request = Parameters<RawClient["server.session"]["session.message"]>[0]
export type Endpoint4_20Input = {
readonly sessionID: Endpoint4_20Request["params"]["sessionID"]
readonly messageID: Endpoint4_20Request["params"]["messageID"]
}
export type Endpoint4_19Output = EffectValue<ReturnType<RawClient["server.session"]["session.message"]>>["data"]
export type SessionMessageOperation<E = never> = (input: Endpoint4_19Input) => Effect.Effect<Endpoint4_19Output, E>
export type Endpoint4_20Output = EffectValue<ReturnType<RawClient["server.session"]["session.message"]>>["data"]
export type SessionMessageOperation<E = never> = (input: Endpoint4_20Input) => Effect.Effect<Endpoint4_20Output, E>
export interface SessionApi<E = never> {
readonly list: SessionListOperation<E>
@ -210,6 +215,7 @@ export interface SessionApi<E = never> {
readonly history: SessionHistoryOperation<E>
readonly events: SessionEventsOperation<E>
readonly interrupt: SessionInterruptOperation<E>
readonly background: SessionBackgroundOperation<E>
readonly message: SessionMessageOperation<E>
}

View file

@ -411,6 +411,22 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
}),
),
)
.add(
HttpApiEndpoint.post("session.background", "/api/session/:sessionID/background", {
params: { sessionID: Session.ID },
success: HttpApiSchema.NoContent,
error: SessionNotFoundError,
})
.middleware(sessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.background",
summary: "Background blocking session tools",
description:
"Move active foreground backgroundable tools for this session into background observation. Idle requests are a no-op.",
}),
),
)
.add(
HttpApiEndpoint.get("session.message", "/api/session/:sessionID/message/:messageID", {
params: { sessionID: Session.ID, messageID: SessionMessage.ID },

View file

@ -19,6 +19,7 @@ export const CommandExecute = Event.define({
"session.new",
"session.share",
"session.interrupt",
"session.background",
"session.compact",
"session.page.up",
"session.page.down",

View file

@ -309,6 +309,8 @@ import type {
V2PermissionSavedListResponses,
V2PermissionSavedRemoveErrors,
V2PermissionSavedRemoveResponses,
V2PluginListErrors,
V2PluginListResponses,
V2ProjectCopyCreateErrors,
V2ProjectCopyCreateResponses,
V2ProjectCopyRefreshErrors,
@ -343,6 +345,8 @@ import type {
V2ReferenceListResponses,
V2SessionActiveErrors,
V2SessionActiveResponses,
V2SessionBackgroundErrors,
V2SessionBackgroundResponses,
V2SessionCompactErrors,
V2SessionCompactResponses,
V2SessionContextErrors,
@ -5107,6 +5111,30 @@ export class Agent extends HeyApiClient {
}
}
export class Plugin extends HeyApiClient {
/**
* List plugins
*
* Retrieve currently loaded plugins.
*/
public list<ThrowOnError extends boolean = false>(
parameters?: {
location?: {
directory?: string
workspace?: string
}
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "location" }] }])
return (options?.client ?? this.client).get<V2PluginListResponses, V2PluginListErrors, ThrowOnError>({
url: "/api/plugin",
...options,
...params,
})
}
}
export class Revert extends HeyApiClient {
/**
* Stage session revert
@ -5926,6 +5954,27 @@ export class Session3 extends HeyApiClient {
})
}
/**
* Background blocking session tools
*
* Move active foreground backgroundable tools for this session into background observation. Idle requests are a no-op.
*/
public background<ThrowOnError extends boolean = false>(
parameters: {
sessionID: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams([parameters], [{ args: [{ in: "path", key: "sessionID" }] }])
return (options?.client ?? this.client).post<V2SessionBackgroundResponses, V2SessionBackgroundErrors, ThrowOnError>(
{
url: "/api/session/{sessionID}/background",
...options,
...params,
},
)
}
/**
* Get session message
*
@ -7436,6 +7485,11 @@ export class V2 extends HeyApiClient {
return (this._agent ??= new Agent({ client: this.client }))
}
private _plugin?: Plugin
get plugin(): Plugin {
return (this._plugin ??= new Plugin({ client: this.client }))
}
private _session?: Session3
get session(): Session3 {
return (this._session ??= new Session3({ client: this.client }))

View file

@ -1502,6 +1502,7 @@ export type GlobalEvent = {
| "session.new"
| "session.share"
| "session.interrupt"
| "session.background"
| "session.compact"
| "session.page.up"
| "session.page.down"
@ -2707,6 +2708,7 @@ export type EventTuiCommandExecute = {
| "session.new"
| "session.share"
| "session.interrupt"
| "session.background"
| "session.compact"
| "session.page.up"
| "session.page.down"
@ -3134,6 +3136,7 @@ export type EventTuiCommandExecute2 = {
| "session.new"
| "session.share"
| "session.interrupt"
| "session.background"
| "session.compact"
| "session.page.up"
| "session.page.down"
@ -4111,6 +4114,10 @@ export type AgentV2Info = {
permissions: PermissionV2Ruleset
}
export type PluginInfo = {
id: string
}
export type SessionV2Info = {
id: string
parentID?: string
@ -6171,6 +6178,7 @@ export type TuiCommandExecute = {
| "session.new"
| "session.share"
| "session.interrupt"
| "session.background"
| "session.compact"
| "session.page.up"
| "session.page.down"
@ -11817,6 +11825,43 @@ export type V2AgentListResponses = {
export type V2AgentListResponse = V2AgentListResponses[keyof V2AgentListResponses]
export type V2PluginListData = {
body?: never
path?: never
query?: {
location?: {
directory?: string
workspace?: string
}
}
url: "/api/plugin"
}
export type V2PluginListErrors = {
/**
* InvalidRequestError
*/
400: InvalidRequestError
/**
* UnauthorizedError
*/
401: UnauthorizedError
}
export type V2PluginListError = V2PluginListErrors[keyof V2PluginListErrors]
export type V2PluginListResponses = {
/**
* Success
*/
200: {
location: LocationInfo
data: Array<PluginInfo>
}
}
export type V2PluginListResponse = V2PluginListResponses[keyof V2PluginListResponses]
export type V2SessionListData = {
body?: never
path?: never
@ -12570,6 +12615,41 @@ export type V2SessionInterruptResponses = {
export type V2SessionInterruptResponse = V2SessionInterruptResponses[keyof V2SessionInterruptResponses]
export type V2SessionBackgroundData = {
body?: never
path: {
sessionID: string
}
query?: never
url: "/api/session/{sessionID}/background"
}
export type V2SessionBackgroundErrors = {
/**
* InvalidRequestError
*/
400: InvalidRequestError
/**
* UnauthorizedError
*/
401: UnauthorizedError
/**
* SessionNotFoundError
*/
404: SessionNotFoundError
}
export type V2SessionBackgroundError = V2SessionBackgroundErrors[keyof V2SessionBackgroundErrors]
export type V2SessionBackgroundResponses = {
/**
* <No Content>
*/
204: void
}
export type V2SessionBackgroundResponse = V2SessionBackgroundResponses[keyof V2SessionBackgroundResponses]
export type V2SessionMessageData = {
body?: never
path: {

View file

@ -3,6 +3,7 @@ import { DateTime, Effect, Stream } from "effect"
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import { Api } from "../api"
import { SessionsCursor } from "@opencode-ai/protocol/groups/session"
import { Job } from "@opencode-ai/core/job"
import {
ConflictError,
InvalidCursorError,
@ -21,6 +22,7 @@ const DefaultSessionHistoryLimit = 50
export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handlers) =>
Effect.gen(function* () {
const session = yield* SessionV2.Service
const jobs = yield* Job.Service
return handlers
.handle(
@ -481,6 +483,48 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
return HttpApiSchema.NoContent.make()
}),
)
.handle(
"session.background",
Effect.fn(function* (ctx) {
yield* session.get(ctx.params.sessionID).pipe(
Effect.catchTag("Session.NotFoundError", (error) =>
Effect.fail(
new SessionNotFoundError({
sessionID: error.sessionID,
message: `Session not found: ${error.sessionID}`,
}),
),
),
)
const backgrounded = yield* jobs.backgroundAll({ sessionID: ctx.params.sessionID })
if (backgrounded.length > 0)
yield* session
.synthetic({
sessionID: ctx.params.sessionID,
text: [
"User requested that active blocking work be moved to the background.",
"",
"Backgrounded work:",
...backgrounded.map(
(job) => `- ${job.type}: ${job.title && job.title.length > 0 ? job.title : job.id}`,
),
"",
"The backgrounded work is still unfinished. Move on to other work if you can. If there is nothing else useful to do, finish your response. Do not wait, sleep, poll, or report the backgrounded work as complete until a later completion notification is added to the conversation.",
].join("\n"),
})
.pipe(
Effect.catchTag("Session.NotFoundError", (error) =>
Effect.fail(
new SessionNotFoundError({
sessionID: error.sessionID,
message: `Session not found: ${error.sessionID}`,
}),
),
),
)
return HttpApiSchema.NoContent.make()
}),
)
.handle(
"session.message",
Effect.fn(function* (ctx) {

View file

@ -8,6 +8,7 @@ import { PermissionSaved } from "@opencode-ai/core/permission/saved"
import { PtyTicket } from "@opencode-ai/core/pty/ticket"
import { SessionV2 } from "@opencode-ai/core/session"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { Job } from "@opencode-ai/core/job"
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
import { SessionExecutionLocal } from "@opencode-ai/core/session/execution/local"
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
@ -30,6 +31,7 @@ const applicationServices = LayerNode.group([
EventV2.node,
httpClient,
ToolOutputStore.cleanupNode,
Job.node,
SessionV2.node,
PluginRuntime.providerNode,
PermissionSaved.node,

View file

@ -435,6 +435,23 @@ export function Prompt(props: PromptProps) {
dialog.clear()
},
},
{
title: "Background blocking tools",
name: "session.background",
category: "Session",
hidden: true,
enabled: status() === "running",
run: () => {
if (auto()?.visible) return
if (!input.focused) return
if (!props.sessionID) return
void sdk.api.session.background({
sessionID: props.sessionID,
})
dialog.clear()
},
},
{
title: "Open editor",
category: "Session",
@ -590,6 +607,7 @@ export function Prompt(props: PromptProps) {
"prompt.stash.list",
"prompt.skills",
"session.interrupt",
"session.background",
"workspace.set",
"session.move",
]),

View file

@ -94,7 +94,7 @@ export const Definitions = {
session_share: keybind("none", "Share current session"),
session_unshare: keybind("none", "Unshare current session"),
session_interrupt: keybind("escape", "Interrupt current session"),
session_background: keybind("ctrl+b", "Background synchronous subagents"),
session_background: keybind("ctrl+b", "Background blocking session tools"),
session_compact: keybind("<leader>c", "Compact the session"),
session_toggle_timestamps: keybind("none", "Toggle message timestamps"),
session_toggle_generic_tool_output: keybind("none", "Toggle generic tool output"),

View file

@ -194,6 +194,19 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
case "session.next.prompted": {
setStore("session", "status", event.data.sessionID, "running")
message.update(event.data.sessionID, (draft, index) => {
const position = index.get(event.data.messageID)
const existing = position === undefined ? undefined : draft[position]
if (existing?.type === "user") {
existing.text = event.data.prompt.text
existing.files = event.data.prompt.files
existing.agents = event.data.prompt.agents
existing.time.created = event.data.timestamp
if (existing.metadata?.queued === true) {
delete existing.metadata.queued
if (Object.keys(existing.metadata).length === 0) existing.metadata = undefined
}
return
}
message.append(draft, index, {
id: event.data.messageID,
type: "user",
@ -206,6 +219,17 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
break
}
case "session.next.prompt.admitted":
message.update(event.data.sessionID, (draft, index) => {
message.append(draft, index, {
id: event.data.messageID,
type: "user",
text: event.data.prompt.text,
files: event.data.prompt.files,
agents: event.data.prompt.agents,
metadata: { queued: true },
time: { created: event.data.timestamp },
})
})
break
case "session.next.context.updated":
message.update(event.data.sessionID, (draft, index) => {
@ -590,15 +614,21 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
return position === undefined ? undefined : messages?.[position]
},
async refresh(sessionID: string) {
const live = [...(store.session.message[sessionID] ?? [])]
setStore("session", "message", sessionID, [])
messageIndex.set(sessionID, new Map())
const loaded = mutable(
(await sdk.api.message.list({ sessionID, limit: 200, order: "desc" })).data,
).toReversed()
const live = store.session.message[sessionID] ?? []
const loadedIDs = new Set(loaded.map((message) => message.id))
const liveByID = new Map(live.map((message) => [message.id, message]))
const messages = [...loaded.map((message) => liveByID.get(message.id) ?? message), ...live]
.filter((message, index, messages) => messages.findIndex((item) => item.id === message.id) === index)
const messages = [
...loaded.map((message) => {
if (message.type === "user") return message
return liveByID.get(message.id) ?? message
}),
...live.filter((message) => !loadedIDs.has(message.id)),
]
.toSorted((a, b) => a.time.created - b.time.created)
messageIndex.set(sessionID, new Map(messages.map((message, index) => [message.id, index])))
setStore("session", "message", sessionID, messages)

View file

@ -22,7 +22,7 @@ import { useData } from "../../context/data"
import { SplitBorder } from "../../ui/border"
import { useTuiPaths, useTuiTerminalEnvironment } from "../../context/runtime"
import { Spinner } from "../../component/spinner"
import { createSyntaxStyleMemo, generateSubtleSyntax, useTheme } from "../../context/theme"
import { createSyntaxStyleMemo, generateSubtleSyntax, selectedForeground, useTheme } from "../../context/theme"
import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA } from "@opentui/core"
import { Prompt, type PromptRef } from "../../component/prompt"
import type {
@ -789,11 +789,14 @@ export function Session() {
},
},
{
title: "Background subagents",
title: "Background blocking tools",
value: "session.background",
category: "Session",
hidden: true,
run: () => unavailable("Backgrounding subagents"),
run: () => {
void sdk.api.session.background({ sessionID: route.sessionID })
dialog.clear()
},
},
{
title: "Toggle subagent picker",
@ -934,6 +937,7 @@ export function Session() {
</box>
)}
</For>
<BackgroundToolHint messages={messages()} />
<Show when={session()?.revert?.messageID}>
<RevertMessage
count={
@ -1056,6 +1060,34 @@ function SessionRowView(props: { row: SessionRow; message: (messageID: string) =
)
}
function BackgroundToolHint(props: { messages: SessionMessage[] }) {
const { theme } = useTheme()
const shortcut = useCommandShortcut("session.background")
const visible = createMemo(() => {
const current = props.messages.findLast(
(message): message is SessionMessageAssistant => message.type === "assistant" && !message.time.completed,
)
return (
current?.content.some((part) => {
if (part.type !== "tool" || part.state.status !== "running") return false
const display = toolDisplay(part.name)
return display === "shell" || display === "subagent"
}) ?? false
)
})
return (
<Show when={visible() && shortcut()}>
{(value) => (
<box marginTop={1} paddingLeft={3} flexShrink={0}>
<text fg={theme.textMuted}>
Press <span style={{ fg: theme.text }}>{value()}</span> to move running work to the background
</text>
</box>
)}
</Show>
)
}
function SessionMessageView(props: { message: SessionMessage }) {
return (
<Switch>
@ -1240,7 +1272,11 @@ function SessionNoticeMessageV2(props: { message: SessionMessage }) {
if (props.message.type === "system" || props.message.type === "synthetic") return props.message.text
return ""
}
return <text fg={theme.textMuted}>{text()}</text>
return (
<box paddingLeft={3}>
<text fg={theme.textMuted}>{text()}</text>
</box>
)
}
function SessionSkillMessage(props: { message: Extract<SessionMessage, { type: "skill" }> }) {
@ -1338,11 +1374,15 @@ function RevertMessage(props: {
function UserMessage(props: { message: SessionMessageUser; optimistic?: boolean }) {
const ctx = use()
const data = useData()
const local = useLocal()
const files = createMemo(() => props.message.files ?? [])
const { theme } = useTheme()
const [hover, setHover] = createSignal(false)
const color = createMemo(() => local.agent.color(useData().session.get(ctx.sessionID)?.agent ?? "build"))
const color = createMemo(() => local.agent.color(data.session.get(ctx.sessionID)?.agent ?? "build"))
const queued = createMemo(() => props.message.metadata?.queued === true)
const queuedFg = createMemo(() => selectedForeground(theme, color()))
const metadataVisible = createMemo(() => queued() || ctx.showTimestamps())
const dialog = useDialog()
const renderer = useRenderer()
@ -1379,7 +1419,7 @@ function UserMessage(props: { message: SessionMessageUser; optimistic?: boolean
<Show when={files().length}>
<box
flexDirection="row"
paddingBottom={ctx.showTimestamps() ? 1 : 0}
paddingBottom={metadataVisible() ? 1 : 0}
paddingTop={1}
gap={1}
flexWrap="wrap"
@ -1402,9 +1442,18 @@ function UserMessage(props: { message: SessionMessageUser; optimistic?: boolean
</For>
</box>
</Show>
<Show when={ctx.showTimestamps()}>
<Show
when={queued()}
fallback={
<Show when={ctx.showTimestamps()}>
<text fg={theme.textMuted}>
<span style={{ fg: theme.textMuted }}>{Locale.todayTimeOrDateTime(props.message.time.created)}</span>
</text>
</Show>
}
>
<text fg={theme.textMuted}>
<span style={{ fg: theme.textMuted }}>{Locale.todayTimeOrDateTime(props.message.time.created)}</span>
<span style={{ bg: color(), fg: queuedFg(), bold: true }}> QUEUED </span>
</text>
</Show>
</box>
@ -1697,18 +1746,23 @@ function ToolPart(props: { part: SessionMessageAssistantTool }) {
const ctx = use()
const data = useData()
const display = createMemo(() => toolDisplay(props.part.name))
const runningShell = createMemo(
() => {
if (display() !== "shell" || props.part.state.status === "pending") return false
const activeBackgroundWork = createMemo(() => {
if (props.part.state.status === "pending") return false
if (display() === "shell") {
const shellID = stringValue(props.part.state.structured.shellID)
return Boolean(shellID && data.shell.get(shellID))
},
)
}
if (display() === "subagent") {
const sessionID = stringValue(props.part.state.structured.sessionID) ?? stringValue(props.part.state.structured.sessionId)
return Boolean(sessionID && data.session.status(sessionID) === "running")
}
return false
})
// Hide tool if showDetails is false and tool completed successfully
const shouldHide = createMemo(() => {
if (ctx.showDetails()) return false
if (runningShell()) return false
if (activeBackgroundWork()) return false
if (props.part.state.status !== "completed") return false
if (display() === "shell") return false
return true
@ -1733,9 +1787,6 @@ function ToolPart(props: { part: SessionMessageAssistantTool }) {
get part() {
return props.part
},
get runningShell() {
return runningShell()
},
}
return (
@ -1766,7 +1817,7 @@ function ToolPart(props: { part: SessionMessageAssistantTool }) {
<Edit {...toolprops} />
</Match>
<Match when={display() === "subagent"}>
<Task {...toolprops} />
<Subagent {...toolprops} />
</Match>
<Match when={display() === "apply_patch"}>
<ApplyPatch {...toolprops} />
@ -1794,7 +1845,6 @@ type ToolProps = {
tool: string
output?: string
part: SessionMessageAssistantTool
runningShell?: boolean
}
function GenericTool(props: ToolProps) {
const { theme } = useTheme()
@ -2040,7 +2090,11 @@ function Shell(props: ToolProps) {
return request?.source?.type === "tool" && request.source.callID === props.part.id
})
const color = createMemo(() => (permission() ? theme.warning : theme.text))
const isRunning = createMemo(() => props.part.state.status === "running" || props.runningShell === true)
const isRunning = createMemo(() => {
if (props.part.state.status === "running") return true
const shellID = stringValue(props.metadata.shellID)
return Boolean(shellID && data.shell.get(shellID))
})
const command = createMemo(() => stringValue(props.input.command))
const output = createMemo(() => {
if (props.part.state.status === "pending") return ""
@ -2203,15 +2257,20 @@ function WebSearch(props: ToolProps) {
)
}
function Task(props: ToolProps) {
function Subagent(props: ToolProps) {
const { navigate } = useRoute()
const data = useData()
const sessionID = createMemo(() => stringValue(props.metadata.sessionID) ?? stringValue(props.metadata.sessionId))
const description = createMemo(() => stringValue(props.input.description))
const isRunning = createMemo(() => {
const id = sessionID()
return props.part.state.status === "running" || Boolean(id && data.session.status(id) === "running")
})
return (
<InlineTool
icon={props.part.state.status === "completed" ? "✓" : "│"}
spinner={props.part.state.status === "running"}
icon={isRunning() ? "│" : props.part.state.status === "completed" ? "✓" : "│"}
spinner={isRunning()}
complete={description()}
pending="Delegating..."
part={props.part}

View file

@ -116,6 +116,7 @@ export function createSessionRows(sessionID: Accessor<string>) {
if (event.data.sessionID === sessionID()) appendMessage(event.data.messageID)
}
const subscriptions = [
data.on("session.next.prompt.admitted", message),
data.on("session.next.prompted", message),
data.on("session.next.context.updated", message),
data.on("session.next.synthetic", message),

View file

@ -778,9 +778,17 @@ test("settles pending tools when a live failure arrives", async () => {
}
})
test("renders admitted prompts only after they become model-visible", async () => {
test("renders admitted prompts immediately with queued marker and clears when promoted", async () => {
const events = createEventStream()
const calls = createFetch(undefined, events)
const sessionID = "session-1"
const messageID = "msg_user_1"
const calls = createFetch((url) => {
if (url.pathname === `/api/session/${sessionID}/message`)
return json({
data: [{ id: messageID, type: "user", text: "hello", time: { created: 0 } }],
cursor: {},
})
}, events)
let sync!: ReturnType<typeof useData>
let ready!: () => void
const mounted = new Promise<void>((resolve) => {
@ -813,38 +821,44 @@ test("renders admitted prompts only after they become model-visible", async () =
id: "evt_admitted_1",
type: "session.next.prompt.admitted",
data: {
sessionID: "session-1",
messageID: "msg_user_1",
sessionID,
messageID,
timestamp: 0,
prompt: { text: "hello" },
delivery: "steer",
},
})
expect(sync.session.message.list("session-1") ?? []).toEqual([])
await wait(() => sync.session.message.list(sessionID)?.length === 1)
const admitted = sync.session.message.list(sessionID)?.[0]
expect(admitted).toMatchObject({ id: messageID, type: "user", text: "hello", metadata: { queued: true } })
await sync.session.message.refresh(sessionID)
expect(sync.session.message.list(sessionID)?.[0]?.metadata?.queued).toBeUndefined()
emitEvent(events, {
id: "evt_prompted_1",
type: "session.next.prompted",
data: {
sessionID: "session-1",
messageID: "msg_user_1",
sessionID,
messageID,
timestamp: 0,
prompt: { text: "hello" },
delivery: "steer",
},
})
await wait(() => sync.session.message.list("session-1")?.length === 1)
await wait(() => received.at(-1) === "session.next.prompted")
expect(received.slice(-2)).toEqual(["session.next.prompt.admitted", "session.next.prompted"])
unsubscribe()
const message = sync.session.message.list("session-1")?.[0]
const message = sync.session.message.list(sessionID)?.[0]
expect(message?.type).toBe("user")
if (message?.type !== "user") return
expect(message).toMatchObject({ id: "msg_user_1", text: "hello" })
expect(sync.session.message.ids("session-1")).toEqual(["msg_user_1"])
expect(message).toMatchObject({ id: messageID, text: "hello" })
expect(message.metadata?.queued).toBeUndefined()
expect(sync.session.message.ids(sessionID)).toEqual([messageID])
expect(sync.session.message.ids("missing")).toEqual([])
expect(sync.session.message.get("session-1", "msg_user_1")).toBe(message)
expect(sync.session.message.get("session-1", "missing")).toBeUndefined()
expect(sync.session.message.get(sessionID, messageID)).toBe(message)
expect(sync.session.message.get(sessionID, "missing")).toBeUndefined()
expect(received).toHaveLength(3)
} finally {
app.renderer.destroy()

View file

@ -41,9 +41,6 @@ await $`bun ./packages/cli/script/publish.ts`
console.log("\n=== sdk ===\n")
await $`bun ./packages/sdk/js/script/publish.ts`
console.log("\n=== plugin ===\n")
await $`bun ./packages/plugin/script/publish.ts`
console.log("\n=== ui ===\n")
await $`bun ./packages/ui/script/publish.ts`