diff --git a/packages/app/e2e/terminal/terminal-init.spec.ts b/packages/app/e2e/terminal/terminal-init.spec.ts
index 87934b66e..18991bf76 100644
--- a/packages/app/e2e/terminal/terminal-init.spec.ts
+++ b/packages/app/e2e/terminal/terminal-init.spec.ts
@@ -6,6 +6,7 @@ test("smoke terminal mounts and can create a second tab", async ({ page, gotoSes
await gotoSession()
const terminals = page.locator(terminalSelector)
+ const tabs = page.locator('#terminal-panel [data-slot="tabs-trigger"]')
const opened = await terminals.first().isVisible()
if (!opened) {
@@ -21,6 +22,7 @@ test("smoke terminal mounts and can create a second tab", async ({ page, gotoSes
await page.locator(promptSelector).click()
await page.keyboard.press("Control+Alt+T")
- await expect(terminals).toHaveCount(2)
- await expect(terminals.nth(1).locator("textarea")).toHaveCount(1)
+ await expect(tabs).toHaveCount(2)
+ await expect(terminals).toHaveCount(1)
+ await expect(terminals.first().locator("textarea")).toHaveCount(1)
})
diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx
index bd7ab2447..ce811463f 100644
--- a/packages/app/src/components/terminal.tsx
+++ b/packages/app/src/components/terminal.tsx
@@ -540,7 +540,7 @@ export const Terminal = (props: TerminalProps) => {
disposed = true
if (fitFrame !== undefined) cancelAnimationFrame(fitFrame)
if (sizeTimer !== undefined) clearTimeout(sizeTimer)
- if (ws && ws.readyState !== WebSocket.CLOSED && ws.readyState !== WebSocket.CLOSING) ws.close()
+ if (ws && ws.readyState !== WebSocket.CLOSED && ws.readyState !== WebSocket.CLOSING) ws.close(1000)
const finalize = () => {
persistTerminal({ term, addon: serializeAddon, cursor, pty: local.pty, onCleanup: props.onCleanup })
diff --git a/packages/app/src/pages/session/terminal-panel.tsx b/packages/app/src/pages/session/terminal-panel.tsx
index 33421c386..27ea4e6f3 100644
--- a/packages/app/src/pages/session/terminal-panel.tsx
+++ b/packages/app/src/pages/session/terminal-panel.tsx
@@ -67,11 +67,11 @@ export function TerminalPanel() {
on(
() => terminal.active(),
(activeId) => {
- if (!activeId || !opened()) return
+ if (!activeId || !open()) return
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur()
}
- focusTerminalById(activeId)
+ setTimeout(() => focusTerminalById(activeId), 0)
},
),
)
@@ -209,21 +209,17 @@ export function TerminalPanel() {
-
- {(pty) => (
-
-
- terminal.clone(pty.id)} />
-
-
+
+ {(id) => (
+
+ {(pty) => (
+
+ terminal.clone(id)} />
+
+ )}
+
)}
-
+
diff --git a/packages/opencode/src/pty/index.ts b/packages/opencode/src/pty/index.ts
index 2dda403e1..33083485b 100644
--- a/packages/opencode/src/pty/index.ts
+++ b/packages/opencode/src/pty/index.ts
@@ -41,13 +41,38 @@ export namespace Pty {
const token = (ws: Socket) => {
const data = ws.data
- if (!data || typeof data !== "object") return
+ if (data === undefined) return
+ if (data === null) return
+ if (typeof data !== "object") return data
- const events = (data as { events?: unknown }).events
- if (events && typeof events === "object") return events
+ const id = (data as { connId?: unknown }).connId
+ if (typeof id === "number" || typeof id === "string") return id
+
+ const href = (data as { href?: unknown }).href
+ if (typeof href === "string") return href
const url = (data as { url?: unknown }).url
- if (url && typeof url === "object") return url
+ if (typeof url === "string") return url
+ if (url && typeof url === "object") {
+ const href = (url as { href?: unknown }).href
+ if (typeof href === "string") return href
+ return url
+ }
+
+ const events = (data as { events?: unknown }).events
+ if (typeof events === "number" || typeof events === "string") return events
+ if (events && typeof events === "object") {
+ const id = (events as { connId?: unknown }).connId
+ if (typeof id === "number" || typeof id === "string") return id
+
+ const id2 = (events as { connection?: unknown }).connection
+ if (typeof id2 === "number" || typeof id2 === "string") return id2
+
+ const id3 = (events as { id?: unknown }).id
+ if (typeof id3 === "number" || typeof id3 === "string") return id3
+
+ return events
+ }
return data
}
@@ -210,7 +235,7 @@ export namespace Pty {
continue
}
- if (sub.token !== undefined && token(ws) !== sub.token) {
+ if (token(ws) !== sub.token) {
session.subscribers.delete(ws)
continue
}
diff --git a/packages/opencode/test/pty/pty-output-isolation.test.ts b/packages/opencode/test/pty/pty-output-isolation.test.ts
index 1b89a6374..07e86ea97 100644
--- a/packages/opencode/test/pty/pty-output-isolation.test.ts
+++ b/packages/opencode/test/pty/pty-output-isolation.test.ts
@@ -97,4 +97,48 @@ describe("pty", () => {
},
})
})
+
+ test("does not leak output when socket data mutates in-place", async () => {
+ await using dir = await tmpdir({ git: true })
+
+ await Instance.provide({
+ directory: dir.path,
+ fn: async () => {
+ const a = await Pty.create({ command: "cat", title: "a" })
+ try {
+ const outA: string[] = []
+ const outB: string[] = []
+
+ const ctx = { connId: 1 }
+ const ws = {
+ readyState: 1,
+ data: ctx,
+ send: (data: unknown) => {
+ outA.push(typeof data === "string" ? data : Buffer.from(data as Uint8Array).toString("utf8"))
+ },
+ close: () => {
+ // no-op
+ },
+ }
+
+ Pty.connect(a.id, ws as any)
+ outA.length = 0
+
+ // Simulate the runtime mutating per-connection data without
+ // swapping the reference (ws.data stays the same object).
+ ctx.connId = 2
+ ws.send = (data: unknown) => {
+ outB.push(typeof data === "string" ? data : Buffer.from(data as Uint8Array).toString("utf8"))
+ }
+
+ Pty.write(a.id, "AAA\n")
+ await Bun.sleep(100)
+
+ expect(outB.join("")).not.toContain("AAA")
+ } finally {
+ await Pty.remove(a.id)
+ }
+ },
+ })
+ })
})