From b5823d10778f0f18834f9881be83c2a533c706a1 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Wed, 1 Jul 2026 21:27:56 +0530 Subject: [PATCH 1/6] fix(tui): show queued prompt admissions (#34771) --- packages/tui/src/context/data.tsx | 36 ++++++++++++++++++-- packages/tui/src/routes/session/index.tsx | 23 ++++++++++--- packages/tui/src/routes/session/rows.ts | 1 + packages/tui/test/cli/tui/data.test.tsx | 40 +++++++++++++++-------- 4 files changed, 79 insertions(+), 21 deletions(-) diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index bd444ea1ef..1589e93f92 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -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) diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index f1f3e18283..04b28465e7 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -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 { @@ -1319,11 +1319,15 @@ function RevertMessage(props: { function UserMessage(props: { message: SessionMessageUser }) { 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() @@ -1356,7 +1360,7 @@ function UserMessage(props: { message: SessionMessageUser }) { - + + + {Locale.todayTimeOrDateTime(props.message.time.created)} + + + } + > - {Locale.todayTimeOrDateTime(props.message.time.created)} + QUEUED diff --git a/packages/tui/src/routes/session/rows.ts b/packages/tui/src/routes/session/rows.ts index 3a15d6bc14..3b21eec0cf 100644 --- a/packages/tui/src/routes/session/rows.ts +++ b/packages/tui/src/routes/session/rows.ts @@ -116,6 +116,7 @@ export function createSessionRows(sessionID: Accessor) { 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), diff --git a/packages/tui/test/cli/tui/data.test.tsx b/packages/tui/test/cli/tui/data.test.tsx index b7893d8abd..efeab233f3 100644 --- a/packages/tui/test/cli/tui/data.test.tsx +++ b/packages/tui/test/cli/tui/data.test.tsx @@ -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 let ready!: () => void const mounted = new Promise((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() From a10733dbf4f3592b317c4dcc28daf3d8d1630fd7 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Wed, 1 Jul 2026 11:45:52 -0400 Subject: [PATCH 2/6] docs: add debug opencode skill --- .opencode/skills/debug-opencode/SKILL.md | 144 +++++++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 .opencode/skills/debug-opencode/SKILL.md diff --git a/.opencode/skills/debug-opencode/SKILL.md b/.opencode/skills/debug-opencode/SKILL.md new file mode 100644 index 0000000000..fd7cae09d2 --- /dev/null +++ b/.opencode/skills/debug-opencode/SKILL.md @@ -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=`, 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 --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=` (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 ` 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. From e2bca216a24b1063db136c29e7d3fdf27da1e505 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Wed, 1 Jul 2026 12:17:30 -0400 Subject: [PATCH 3/6] feat: background blocking tools --- .../client/src/generated-effect/client.ts | 18 ++- packages/client/src/generated/client.ts | 13 ++ packages/client/src/generated/types.ts | 4 + packages/core/src/tool/shell.ts | 115 +++++++++--------- packages/core/test/tool-shell.test.ts | 47 ++++++- .../plugin/src/v2/effect/generated/api.ts | 18 ++- packages/protocol/src/groups/session.ts | 16 +++ packages/schema/src/tui-event.ts | 1 + packages/sdk/js/src/v2/gen/sdk.gen.ts | 54 ++++++++ packages/sdk/js/src/v2/gen/types.gen.ts | 80 ++++++++++++ packages/server/src/handlers/session.ts | 44 +++++++ packages/server/src/routes.ts | 2 + packages/tui/src/component/prompt/index.tsx | 18 +++ packages/tui/src/config/keybind.ts | 2 +- packages/tui/src/routes/session/index.tsx | 36 +++++- 15 files changed, 397 insertions(+), 71 deletions(-) diff --git a/packages/client/src/generated-effect/client.ts b/packages/client/src/generated-effect/client.ts index 9747762001..d05a82cb20 100644 --- a/packages/client/src/generated-effect/client.ts +++ b/packages/client/src/generated-effect/client.ts @@ -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[0] -type Endpoint4_19Input = { - readonly sessionID: Endpoint4_19Request["params"]["sessionID"] - readonly messageID: Endpoint4_19Request["params"]["messageID"] -} +type Endpoint4_19Request = Parameters[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[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[0] diff --git a/packages/client/src/generated/client.ts b/packages/client/src/generated/client.ts index 716fec0cd8..9f03e0be4f 100644 --- a/packages/client/src/generated/client.ts +++ b/packages/client/src/generated/client.ts @@ -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( + { + 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 }>( { diff --git a/packages/client/src/generated/types.ts b/packages/client/src/generated/types.ts index 4ce3845cba..21ee2cffdd 100644 --- a/packages/client/src/generated/types.ts +++ b/packages/client/src/generated/types.ts @@ -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"] diff --git a/packages/core/src/tool/shell.ts b/packages/core/src/tool/shell.ts index 5b439f9abe..e86f960ba5 100644 --- a/packages/core/src/tool/shell.ts +++ b/packages/core/src/tool/shell.ts @@ -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}` }))), diff --git a/packages/core/test/tool-shell.test.ts b/packages/core/test/tool-shell.test.ts index 83c94a5c7b..f95d8377ea 100644 --- a/packages/core/test/tool-shell.test.ts +++ b/packages/core/test/tool-shell.test.ts @@ -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 => + 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 | 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 () => { diff --git a/packages/plugin/src/v2/effect/generated/api.ts b/packages/plugin/src/v2/effect/generated/api.ts index 5a2bb465b0..86a7eb60d8 100644 --- a/packages/plugin/src/v2/effect/generated/api.ts +++ b/packages/plugin/src/v2/effect/generated/api.ts @@ -182,13 +182,18 @@ export type Endpoint4_18Input = { readonly sessionID: Endpoint4_18Request["param export type Endpoint4_18Output = EffectValue> export type SessionInterruptOperation = (input: Endpoint4_18Input) => Effect.Effect -type Endpoint4_19Request = Parameters[0] -export type Endpoint4_19Input = { - readonly sessionID: Endpoint4_19Request["params"]["sessionID"] - readonly messageID: Endpoint4_19Request["params"]["messageID"] +type Endpoint4_19Request = Parameters[0] +export type Endpoint4_19Input = { readonly sessionID: Endpoint4_19Request["params"]["sessionID"] } +export type Endpoint4_19Output = EffectValue> +export type SessionBackgroundOperation = (input: Endpoint4_19Input) => Effect.Effect + +type Endpoint4_20Request = Parameters[0] +export type Endpoint4_20Input = { + readonly sessionID: Endpoint4_20Request["params"]["sessionID"] + readonly messageID: Endpoint4_20Request["params"]["messageID"] } -export type Endpoint4_19Output = EffectValue>["data"] -export type SessionMessageOperation = (input: Endpoint4_19Input) => Effect.Effect +export type Endpoint4_20Output = EffectValue>["data"] +export type SessionMessageOperation = (input: Endpoint4_20Input) => Effect.Effect export interface SessionApi { readonly list: SessionListOperation @@ -210,6 +215,7 @@ export interface SessionApi { readonly history: SessionHistoryOperation readonly events: SessionEventsOperation readonly interrupt: SessionInterruptOperation + readonly background: SessionBackgroundOperation readonly message: SessionMessageOperation } diff --git a/packages/protocol/src/groups/session.ts b/packages/protocol/src/groups/session.ts index 291a75f7f0..fb812973de 100644 --- a/packages/protocol/src/groups/session.ts +++ b/packages/protocol/src/groups/session.ts @@ -411,6 +411,22 @@ export const makeSessionGroup = (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 }, diff --git a/packages/schema/src/tui-event.ts b/packages/schema/src/tui-event.ts index 800094e61e..d468050346 100644 --- a/packages/schema/src/tui-event.ts +++ b/packages/schema/src/tui-event.ts @@ -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", diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 3039b1f82d..145ae005b3 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -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( + parameters?: { + location?: { + directory?: string + workspace?: string + } + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "location" }] }]) + return (options?.client ?? this.client).get({ + 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( + parameters: { + sessionID: string + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "path", key: "sessionID" }] }]) + return (options?.client ?? this.client).post( + { + 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 })) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index dbc173c829..938f916ca2 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -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 + } +} + +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 = { + /** + * + */ + 204: void +} + +export type V2SessionBackgroundResponse = V2SessionBackgroundResponses[keyof V2SessionBackgroundResponses] + export type V2SessionMessageData = { body?: never path: { diff --git a/packages/server/src/handlers/session.ts b/packages/server/src/handlers/session.ts index d026ca8d16..c41374522b 100644 --- a/packages/server/src/handlers/session.ts +++ b/packages/server/src/handlers/session.ts @@ -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) { diff --git a/packages/server/src/routes.ts b/packages/server/src/routes.ts index 625c5567a8..db096396df 100644 --- a/packages/server/src/routes.ts +++ b/packages/server/src/routes.ts @@ -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, diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index 53c6a520cb..46324e06d6 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -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", ]), diff --git a/packages/tui/src/config/keybind.ts b/packages/tui/src/config/keybind.ts index 878ed34093..af1ff0f5a7 100644 --- a/packages/tui/src/config/keybind.ts +++ b/packages/tui/src/config/keybind.ts @@ -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("c", "Compact the session"), session_toggle_timestamps: keybind("none", "Toggle message timestamps"), session_toggle_generic_tool_output: keybind("none", "Toggle generic tool output"), diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 04b28465e7..61f0328875 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -782,11 +782,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", @@ -920,6 +923,7 @@ export function Session() { /> )} + { + 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 ( + + {(value) => ( + + + Press {value()} to move running work to the background + + + )} + + ) +} + function SessionMessageView(props: { message: SessionMessage }) { return ( From 72ec09cf7450e42344b5f0dd55c8ac58ce799d33 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Wed, 1 Jul 2026 12:33:01 -0400 Subject: [PATCH 4/6] fix(tui): keep backgrounded subagents spinning --- packages/tui/src/routes/session/index.tsx | 40 ++++++++++++++--------- 1 file changed, 25 insertions(+), 15 deletions(-) diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 61f0328875..72103a027d 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -1719,18 +1719,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 @@ -1755,9 +1760,6 @@ function ToolPart(props: { part: SessionMessageAssistantTool }) { get part() { return props.part }, - get runningShell() { - return runningShell() - }, } return ( @@ -1788,7 +1790,7 @@ function ToolPart(props: { part: SessionMessageAssistantTool }) { - + @@ -1816,7 +1818,6 @@ type ToolProps = { tool: string output?: string part: SessionMessageAssistantTool - runningShell?: boolean } function GenericTool(props: ToolProps) { const { theme } = useTheme() @@ -2062,7 +2063,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 "" @@ -2225,15 +2230,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 ( Date: Wed, 1 Jul 2026 12:39:06 -0400 Subject: [PATCH 5/6] fix(tui): indent synthetic session notices --- packages/tui/src/routes/session/index.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 72103a027d..459c48553b 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -1253,7 +1253,11 @@ function SessionNoticeMessageV2(props: { message: SessionMessage }) { if (props.message.type === "system" || props.message.type === "synthetic") return props.message.text return "" } - return {text()} + return ( + + {text()} + + ) } function SessionSkillMessage(props: { message: Extract }) { From a6983b65fc387de11e0a5761a558d750d88f594f Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Wed, 1 Jul 2026 12:57:48 -0400 Subject: [PATCH 6/6] fix: skip plugin publish --- script/publish.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/script/publish.ts b/script/publish.ts index 6251637d4c..0dad7a099b 100755 --- a/script/publish.ts +++ b/script/publish.ts @@ -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`