diff --git a/src/plugin/pty/manager.ts b/src/plugin/pty/manager.ts
index e0550cf7..89a64f18 100644
--- a/src/plugin/pty/manager.ts
+++ b/src/plugin/pty/manager.ts
@@ -68,6 +68,31 @@ function notifyRawOutput(session: PTYSessionInfo, rawData: string): void {
}
}
+type SessionRemovedCallback = (sessionId: string) => void
+
+export const sessionRemovedCallbacks: SessionRemovedCallback[] = []
+
+export function registerSessionRemovedCallback(callback: SessionRemovedCallback): void {
+ sessionRemovedCallbacks.push(callback)
+}
+
+export function removeSessionRemovedCallback(callback: SessionRemovedCallback): void {
+ const index = sessionRemovedCallbacks.indexOf(callback)
+ if (index !== -1) {
+ sessionRemovedCallbacks.splice(index, 1)
+ }
+}
+
+function notifySessionRemoved(sessionId: string): void {
+ for (const callback of sessionRemovedCallbacks) {
+ try {
+ callback(sessionId)
+ } catch {
+ // Ignore callback errors
+ }
+ }
+}
+
class PTYManager {
private lifecycleManager = new SessionLifecycleManager()
private outputManager = new OutputManager()
@@ -88,7 +113,11 @@ class PTYManager {
}
clearAllSessions(): void {
+ const removedIds = this.lifecycleManager.listSessions().map((session) => session.id)
this.lifecycleManager.clearAllSessions()
+ for (const id of removedIds) {
+ notifySessionRemoved(id)
+ }
}
spawn(opts: SpawnOptions): PTYSessionInfo {
@@ -162,11 +191,22 @@ class PTYManager {
}
kill(id: string, cleanup: boolean = false): boolean {
- return this.lifecycleManager.kill(id, cleanup)
+ const success = this.lifecycleManager.kill(id, cleanup)
+ if (success && cleanup) {
+ notifySessionRemoved(id)
+ }
+ return success
}
cleanupBySession(parentSessionId: string): void {
+ const removedIds = this.lifecycleManager
+ .listSessions()
+ .filter((session) => session.parentSessionId === parentSessionId)
+ .map((session) => session.id)
this.lifecycleManager.cleanupBySession(parentSessionId)
+ for (const id of removedIds) {
+ notifySessionRemoved(id)
+ }
}
}
diff --git a/src/plugin/pty/tools/kill.ts b/src/plugin/pty/tools/kill.ts
index 36930cd1..50093423 100644
--- a/src/plugin/pty/tools/kill.ts
+++ b/src/plugin/pty/tools/kill.ts
@@ -10,7 +10,9 @@ export const ptyKill = tool({
cleanup: tool.schema
.boolean()
.optional()
- .describe('If true, removes the session and frees the buffer (default: false)'),
+ .describe(
+ 'Deprecated: removing sessions is intended for humans via the web UI. If true, removes the session and frees the buffer (default: false)'
+ ),
},
async execute(args) {
const session = manager.get(args.id)
diff --git a/src/plugin/pty/tools/kill.txt b/src/plugin/pty/tools/kill.txt
index 6c928da0..18b7ddb2 100644
--- a/src/plugin/pty/tools/kill.txt
+++ b/src/plugin/pty/tools/kill.txt
@@ -2,24 +2,26 @@ Terminates a PTY session and optionally cleans up its buffer.
Use this tool to:
- Stop a running process (sends SIGTERM)
-- Clean up an exited session to free memory
-- Remove a session from the list
Usage:
- `id`: The PTY session ID (from pty_spawn or pty_list)
-- `cleanup`: If true, removes the session and frees the buffer (default: false)
+- `cleanup`: Deprecated — remove sessions from the human web UI instead (default: false)
Behavior:
- If the session is running, it will be killed (status becomes "killed")
- If cleanup=false (default), the session remains in the list with its output buffer intact
- If cleanup=true, the session is removed entirely and the buffer is freed
-- Keeping sessions without cleanup allows you to compare logs between runs
+
+Deprecation:
+- Removing sessions is intended for humans via the web UI. The `cleanup` flag
+ still works for backwards compatibility but will be removed in a future
+ release, so prefer killing the session and leaving it for the human to
+ discard. Finished sessions are pruned from the list by the human, not by tools.
Tips:
-- Use cleanup=false if you might want to read the output later
-- Use cleanup=true when you're done with the session entirely
+- Use cleanup=false and let the human remove finished sessions from the web UI
+- Keeping sessions without cleanup allows you to compare logs between runs
- To send Ctrl+C instead of killing, use pty_write with data="\x03"
Examples:
- Kill but keep logs: cleanup=false (or omit)
-- Kill and remove: cleanup=true
diff --git a/src/web/client/components/app.tsx b/src/web/client/components/app.tsx
index a672d385..ad5b1158 100644
--- a/src/web/client/components/app.tsx
+++ b/src/web/client/components/app.tsx
@@ -17,6 +17,11 @@ export function App() {
const [wsMessageCount, setWsMessageCount] = useState(0)
const [sessionUpdateCount, setSessionUpdateCount] = useState(0)
+ const handleSessionRemoved = useCallback((sessionId: string) => {
+ setSessions((prevSessions) => prevSessions.filter((session) => session.id !== sessionId))
+ setActiveSession((current) => (current?.id === sessionId ? null : current))
+ }, [])
+
const {
connected: wsConnected,
subscribeWithRetry,
@@ -63,6 +68,7 @@ export function App() {
}
})
}, []),
+ onSessionRemoved: handleSessionRemoved,
})
// Update connected from wsConnected
@@ -83,7 +89,14 @@ export function App() {
return () => clearInterval(syncInterval)
}, [])
- const { handleSessionClick, handleSendInput, handleKillSession } = useSessionManager({
+ const {
+ handleSessionClick,
+ handleSendInput,
+ handleKillSession,
+ handleKillSessionById,
+ handleRemoveSession,
+ handleClearFinished,
+ } = useSessionManager({
activeSession,
setActiveSession,
subscribeWithRetry,
@@ -94,12 +107,57 @@ export function App() {
}, []),
})
+ const removeSessionFromList = handleSessionRemoved
+
+ const handleRemoveSessionClick = useCallback(
+ async (session: PTYSessionInfo) => {
+ const removed = await handleRemoveSession(session)
+ if (removed) {
+ removeSessionFromList(session.id)
+ }
+ },
+ [handleRemoveSession, removeSessionFromList]
+ )
+
+ const handleClearFinishedClick = useCallback(async () => {
+ const finishedSessions = sessions.filter(
+ (session) => session.status !== 'running' && session.status !== 'killing'
+ )
+ const cleared = await handleClearFinished(finishedSessions)
+ if (cleared) {
+ setSessions((prevSessions) =>
+ prevSessions.filter(
+ (session) => session.status === 'running' || session.status === 'killing'
+ )
+ )
+ setActiveSession((current) =>
+ current && (current.status === 'running' || current.status === 'killing') ? current : null
+ )
+ }
+ }, [sessions, handleClearFinished])
+
+ const handleDownloadSession = useCallback(() => {
+ if (!activeSession) {
+ return
+ }
+ const blob = new Blob([rawOutput], { type: 'text/plain' })
+ const url = URL.createObjectURL(blob)
+ const anchor = document.createElement('a')
+ anchor.href = url
+ anchor.download = `${activeSession.id}.log`
+ anchor.click()
+ URL.revokeObjectURL(url)
+ }, [activeSession, rawOutput])
+
return (
@@ -107,9 +165,29 @@ export function App() {
<>
{activeSession.description ?? activeSession.title}
-
+
+
+ {activeSession.status === 'running' ? (
+
+ ) : (
+
+ )}
+
void
+ onKillSession: (session: PTYSessionInfo) => void
+ onRemoveSession: (session: PTYSessionInfo) => void
+ onClearFinished: () => void
connected: boolean
}
-export function Sidebar({ sessions, activeSession, onSessionClick, connected }: SidebarProps) {
+interface SessionSectionProps {
+ title: string
+ sessions: PTYSessionInfo[]
+ emptyText: string
+ activeSession: PTYSessionInfo | null
+ onSessionClick: (session: PTYSessionInfo) => void
+ onKillSession: (session: PTYSessionInfo) => void
+ onRemoveSession: (session: PTYSessionInfo) => void
+ action?: React.ReactNode
+}
+
+/** A session is "live" until its process has actually exited. */
+function isLive(session: PTYSessionInfo): boolean {
+ return session.status === 'running' || session.status === 'killing'
+}
+
+function sessionLabel(session: PTYSessionInfo): string {
+ return session.description ?? session.title
+}
+
+function SessionItem({
+ session,
+ activeSession,
+ onSessionClick,
+ onKillSession,
+ onRemoveSession,
+}: {
+ session: PTYSessionInfo
+ activeSession: PTYSessionInfo | null
+ onSessionClick: (session: PTYSessionInfo) => void
+ onKillSession: (session: PTYSessionInfo) => void
+ onRemoveSession: (session: PTYSessionInfo) => void
+}) {
+ const label = sessionLabel(session)
+ const canKill = session.status === 'running'
+ const isFinished = session.status === 'exited' || session.status === 'killed'
+
+ return (
+
+
+
+ {canKill ? (
+
+ ) : null}
+ {isFinished ? (
+
+ ) : null}
+
+
+ )
+}
+
+function SessionSection({
+ title,
+ sessions,
+ emptyText,
+ activeSession,
+ onSessionClick,
+ onKillSession,
+ onRemoveSession,
+ action,
+}: SessionSectionProps) {
+ return (
+
+
+ {title}
+ {sessions.length}
+ {action}
+
+ {sessions.length === 0 ? (
+ {emptyText}
+ ) : (
+ sessions.map((session) => (
+
+ ))
+ )}
+
+ )
+}
+
+export function Sidebar({
+ sessions,
+ activeSession,
+ onSessionClick,
+ onKillSession,
+ onRemoveSession,
+ onClearFinished,
+ connected,
+}: SidebarProps) {
+ const liveSessions = sessions.filter(isLive)
+ const finishedSessions = sessions.filter((session) => !isLive(session))
+
return (
@@ -18,28 +149,35 @@ export function Sidebar({ sessions, activeSession, onSessionClick, connected }:
{sessions.length === 0 ? (
-
- No active sessions
-
+
No active sessions
) : (
- sessions.map((session) => (
-
- ))
+ <>
+
+
0 ? (
+
+ ) : null
+ }
+ />
+ >
)}
diff --git a/src/web/client/hooks/use-session-manager.ts b/src/web/client/hooks/use-session-manager.ts
index 9411f9e7..c50cb222 100644
--- a/src/web/client/hooks/use-session-manager.ts
+++ b/src/web/client/hooks/use-session-manager.ts
@@ -12,6 +12,10 @@ interface UseSessionManagerOptions {
onRawOutputUpdate?: (rawOutput: string) => void
}
+function sessionLabel(session: PTYSessionInfo): string {
+ return session.description ?? session.title
+}
+
export function useSessionManager({
activeSession,
setActiveSession,
@@ -76,29 +80,89 @@ export function useSessionManager({
[activeSession, wsConnected, sendInput]
)
+ /**
+ * Kills a running session. The session is retained (with its buffer) so the
+ * transcript stays available; removing it is a human action in the web UI.
+ */
+ const handleKillSessionById = useCallback(async (session: PTYSessionInfo): Promise => {
+ if (!confirm(`Are you sure you want to kill session "${sessionLabel(session)}"?`)) {
+ return false
+ }
+
+ try {
+ await api.session.kill({ id: session.id })
+ return true
+ } catch (error) {
+ console.error('Failed to kill session', error)
+ return false
+ }
+ }, [])
+
const handleKillSession = useCallback(async () => {
if (!activeSession) {
return
}
+ await handleKillSessionById(activeSession)
+ }, [activeSession, handleKillSessionById])
+ /**
+ * Human-only: discards a finished session entirely (buffer freed, dropped
+ * from the session list).
+ */
+ const handleRemoveSession = useCallback(async (session: PTYSessionInfo): Promise => {
if (
!confirm(
- `Are you sure you want to kill session "${activeSession.description ?? activeSession.title}"?`
+ `Remove finished session "${sessionLabel(session)}"? Its output buffer will be discarded.`
)
) {
- return
+ return false
}
try {
- await api.session.kill({ id: activeSession.id })
+ await api.session.cleanup({ id: session.id })
+ return true
+ } catch (error) {
+ console.error('Failed to remove session', error)
+ return false
+ }
+ }, [])
- // eslint-disable-next-line no-empty
- } catch {}
- }, [activeSession])
+ /**
+ * Human-only: discards every finished session in one go.
+ */
+ const handleClearFinished = useCallback(
+ async (finishedSessions: PTYSessionInfo[]): Promise => {
+ if (finishedSessions.length === 0) {
+ return false
+ }
+
+ if (
+ !confirm(
+ `Remove ${finishedSessions.length} finished session(s)? Their output buffers will be discarded.`
+ )
+ ) {
+ return false
+ }
+
+ const results = await Promise.allSettled(
+ finishedSessions.map((session) => api.session.cleanup({ id: session.id }))
+ )
+ const failed = results.filter((result) => result.status === 'rejected')
+ if (failed.length > 0) {
+ console.error(`Failed to remove ${failed.length} finished session(s)`)
+ return false
+ }
+ return true
+ },
+ []
+ )
return {
handleSessionClick,
handleSendInput,
handleKillSession,
+ handleKillSessionById,
+ handleRemoveSession,
+ handleClearFinished,
}
}
diff --git a/src/web/client/hooks/use-web-socket.ts b/src/web/client/hooks/use-web-socket.ts
index a8201eed..ec081b5f 100644
--- a/src/web/client/hooks/use-web-socket.ts
+++ b/src/web/client/hooks/use-web-socket.ts
@@ -4,6 +4,7 @@ import type {
WSMessageServer,
WSMessageServerRawData,
WSMessageServerSessionList,
+ WSMessageServerSessionRemoved,
WSMessageServerSessionUpdate,
} from 'opencode-pty/web/shared/types'
import { RETRY_DELAY, SKIP_AUTOSELECT_KEY } from 'opencode-pty/web/shared/constants'
@@ -15,6 +16,7 @@ interface UseWebSocketOptions {
onRawData?: (rawData: string) => void
onSessionList: (sessions: PTYSessionInfo[], autoSelected: PTYSessionInfo | null) => void
onSessionUpdate?: (updatedSession: PTYSessionInfo) => void
+ onSessionRemoved?: (sessionId: string) => void
}
export function useWebSocket({
@@ -22,6 +24,7 @@ export function useWebSocket({
onRawData,
onSessionList,
onSessionUpdate,
+ onSessionRemoved,
}: UseWebSocketOptions) {
const [connected, setConnected] = useState(false)
@@ -89,6 +92,9 @@ export function useWebSocket({
} else if (data.type === 'session_update') {
const sessionUpdateMsg = data as WSMessageServerSessionUpdate
onSessionUpdate?.(sessionUpdateMsg.session)
+ } else if (data.type === 'session_removed') {
+ const sessionRemovedMsg = data as WSMessageServerSessionRemoved
+ onSessionRemoved?.(sessionRemovedMsg.sessionId)
} else if (data.type === 'raw_data') {
const rawDataMsg = data as WSMessageServerRawData
const isForActiveSession = rawDataMsg.session.id === activeSessionRef.current?.id
@@ -107,7 +113,7 @@ export function useWebSocket({
return () => {
ws.close()
}
- }, [activeSession, onRawData, onSessionList, onSessionUpdate])
+ }, [activeSession, onRawData, onSessionList, onSessionUpdate, onSessionRemoved])
const subscribe = (sessionId: string) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
diff --git a/src/web/client/index.css b/src/web/client/index.css
index f102cda5..3c20984f 100644
--- a/src/web/client/index.css
+++ b/src/web/client/index.css
@@ -42,14 +42,65 @@ body {
overflow-y: auto;
padding: 8px;
}
-.session-item {
- padding: 12px;
+.session-empty {
+ padding: 16px;
+ color: #8b949e;
+ text-align: center;
+}
+.session-section {
+ margin-bottom: 16px;
+}
+.session-section-header {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 4px 4px 8px;
+ font-size: 11px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: #8b949e;
+}
+.session-section-count {
+ color: #6e7681;
+}
+.session-section-empty {
+ padding: 4px;
+ font-size: 12px;
+ color: #6e7681;
+}
+.clear-finished-btn {
+ margin-left: auto;
+ padding: 2px 8px;
+ background: transparent;
+ border: 1px solid #30363d;
+ border-radius: 6px;
+ color: #f85149;
+ font-size: 11px;
+ font-weight: 600;
+ text-transform: none;
+ letter-spacing: normal;
+ cursor: pointer;
+}
+.clear-finished-btn:hover {
+ background: #da363322;
+ border-color: #da3633;
+}
+.session-row {
+ position: relative;
margin-bottom: 8px;
+}
+.session-item {
+ width: 100%;
+ padding: 12px 76px 12px 12px;
background: #21262d;
border: 1px solid #30363d;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
+ font: inherit;
+ color: inherit;
+ text-align: left;
}
.session-item:hover {
background: #30363d;
@@ -58,6 +109,34 @@ body {
border-color: #58a6ff;
background: #1f6feb1a;
}
+.session-row.finished .session-item {
+ opacity: 0.75;
+}
+.session-actions {
+ position: absolute;
+ top: 8px;
+ right: 8px;
+ display: flex;
+ gap: 4px;
+}
+.session-action {
+ padding: 2px 8px;
+ border-radius: 6px;
+ border: 1px solid #30363d;
+ background: #0d1117;
+ color: #c9d1d9;
+ font-size: 11px;
+ font-weight: 600;
+ cursor: pointer;
+}
+.session-action-kill:hover {
+ border-color: #da3633;
+ color: #f85149;
+}
+.session-action-remove:hover {
+ border-color: #f85149;
+ color: #f85149;
+}
.session-title {
font-weight: 600;
margin-bottom: 4px;
@@ -105,6 +184,41 @@ body {
font-size: 16px;
font-weight: 600;
}
+.output-actions {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+.download-btn {
+ padding: 8px 16px;
+ background: #21262d;
+ color: #c9d1d9;
+ border: 1px solid #30363d;
+ border-radius: 6px;
+ cursor: pointer;
+ font-weight: 600;
+ font-size: 14px;
+}
+.download-btn:hover:not(:disabled) {
+ background: #30363d;
+}
+.download-btn:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+.remove-btn {
+ padding: 8px 16px;
+ background: #8957e5;
+ color: #fff;
+ border: none;
+ border-radius: 6px;
+ cursor: pointer;
+ font-weight: 600;
+ font-size: 14px;
+}
+.remove-btn:hover {
+ background: #a371f7;
+}
.kill-btn {
padding: 8px 16px;
background: #da3633;
diff --git a/src/web/client/index.html b/src/web/client/index.html
index bf9bfb1c..4b1f0544 100644
--- a/src/web/client/index.html
+++ b/src/web/client/index.html
@@ -4,197 +4,6 @@
PTY Sessions Monitor
-
diff --git a/src/web/client/main.tsx b/src/web/client/main.tsx
index 49ea2735..f5a3f7b1 100644
--- a/src/web/client/main.tsx
+++ b/src/web/client/main.tsx
@@ -1,5 +1,6 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
+import './index.css'
import { App } from './components/app.tsx'
import { ErrorBoundary } from './components/error-boundary.tsx'
diff --git a/src/web/server/callback-manager.ts b/src/web/server/callback-manager.ts
index e9a8040b..0994a1d5 100644
--- a/src/web/server/callback-manager.ts
+++ b/src/web/server/callback-manager.ts
@@ -1,17 +1,24 @@
import {
registerRawOutputCallback,
+ registerSessionRemovedCallback,
registerSessionUpdateCallback,
removeRawOutputCallback,
+ removeSessionRemovedCallback,
removeSessionUpdateCallback,
} from '../../plugin/pty/manager'
import type { PTYSessionInfo } from '../../plugin/pty/types'
-import type { WSMessageServerSessionUpdate, WSMessageServerRawData } from '../shared/types'
+import type {
+ WSMessageServerSessionRemoved,
+ WSMessageServerSessionUpdate,
+ WSMessageServerRawData,
+} from '../shared/types'
export class CallbackManager implements Disposable {
constructor(private server: Bun.Server) {
this.server = server
registerSessionUpdateCallback(this.sessionUpdateCallback)
registerRawOutputCallback(this.rawOutputCallback)
+ registerSessionRemovedCallback(this.sessionRemovedCallback)
}
private sessionUpdateCallback = (session: PTYSessionInfo): void => {
@@ -19,6 +26,11 @@ export class CallbackManager implements Disposable {
this.server.publish('sessions:update', JSON.stringify(message))
}
+ private sessionRemovedCallback = (sessionId: string): void => {
+ const message: WSMessageServerSessionRemoved = { type: 'session_removed', sessionId }
+ this.server.publish('sessions:update', JSON.stringify(message))
+ }
+
private rawOutputCallback = (session: PTYSessionInfo, rawData: string): void => {
const message: WSMessageServerRawData = { type: 'raw_data', session, rawData }
this.server.publish(`session:${session.id}`, JSON.stringify(message))
@@ -27,5 +39,6 @@ export class CallbackManager implements Disposable {
[Symbol.dispose]() {
removeSessionUpdateCallback(this.sessionUpdateCallback)
removeRawOutputCallback(this.rawOutputCallback)
+ removeSessionRemovedCallback(this.sessionRemovedCallback)
}
}
diff --git a/src/web/shared/types.ts b/src/web/shared/types.ts
index aaa795b5..2759834e 100644
--- a/src/web/shared/types.ts
+++ b/src/web/shared/types.ts
@@ -60,6 +60,7 @@ export interface WSMessageServer {
| 'readRawResponse'
| 'session_list'
| 'session_update'
+ | 'session_removed'
| 'error'
}
@@ -95,6 +96,15 @@ export interface WSMessageServerSessionUpdate extends WSMessageServer {
session: PTYSessionInfo
}
+/**
+ * Emitted when a session is permanently removed (buffer freed and dropped from
+ * the manager), e.g. when a human discards a finished session in the web UI.
+ */
+export interface WSMessageServerSessionRemoved extends WSMessageServer {
+ type: 'session_removed'
+ sessionId: string
+}
+
export interface WSMessageServerError extends WSMessageServer {
type: 'error'
error: CustomError
diff --git a/test/e2e/ui/app.pw.ts b/test/e2e/ui/app.pw.ts
index 2518fbaf..b28aa7ca 100644
--- a/test/e2e/ui/app.pw.ts
+++ b/test/e2e/ui/app.pw.ts
@@ -269,4 +269,48 @@ extendedTest.describe('App Component', () => {
expect(count).toBeGreaterThan(0)
})
})
+
+ extendedTest.describe('Session Discarding', () => {
+ extendedTest(
+ 'removes a finished session from the sidebar (human-only)',
+ async ({ page, api }) => {
+ // Prevent autoselect so the sidebar state stays predictable
+ await page.evaluate(() => {
+ localStorage.setItem('skip-autoselect', 'true')
+ })
+
+ await api.sessions.create({
+ command: 'echo',
+ args: ['finished'],
+ description: 'Finished session to discard',
+ })
+
+ // Wait until the session has actually exited
+ const deadline = Date.now() + 5000
+ while (Date.now() < deadline) {
+ const sessions = await api.sessions.list()
+ const target = sessions.find((s) => s.description === 'Finished session to discard')
+ if (target && target.status !== 'running') {
+ break
+ }
+ await new Promise((resolve) => setTimeout(resolve, 100))
+ }
+
+ await page.reload()
+
+ const sessionRow = page.locator('.session-row:has-text("Finished session to discard")')
+ await expect(sessionRow).toBeVisible({ timeout: 5000 })
+
+ // The confirmation dialog must be accepted for the removal to proceed
+ page.on('dialog', (dialog) => dialog.accept())
+ await sessionRow.locator('.session-action-remove').click()
+
+ // The row disappears immediately via the session_removed broadcast
+ await expect(sessionRow).toHaveCount(0, { timeout: 5000 })
+
+ const remaining = await api.sessions.list()
+ expect(remaining.some((s) => s.description === 'Finished session to discard')).toBe(false)
+ }
+ )
+ })
})
diff --git a/test/utils.ts b/test/utils.ts
index 4da56491..c89cb483 100644
--- a/test/utils.ts
+++ b/test/utils.ts
@@ -2,6 +2,7 @@ import { OpencodeClient } from '@opencode-ai/sdk'
import {
initManager,
manager,
+ sessionRemovedCallbacks,
sessionUpdateCallbacks,
rawOutputCallbacks,
} from '../src/plugin/pty/manager'
@@ -11,6 +12,7 @@ import type {
WSMessageServerSubscribedSession,
WSMessageServerUnsubscribedSession,
WSMessageServerSessionUpdate,
+ WSMessageServerSessionRemoved,
WSMessageServerRawData,
WSMessageServerReadRawResponse,
WSMessageServerSessionList,
@@ -34,6 +36,8 @@ export class ManagedTestClient implements Disposable {
> = []
public readonly sessionUpdateCallbacks: Array<(message: WSMessageServerSessionUpdate) => void> =
[]
+ public readonly sessionRemovedCallbacks: Array<(message: WSMessageServerSessionRemoved) => void> =
+ []
public readonly rawDataCallbacks: Array<(message: WSMessageServerRawData) => void> = []
public readonly readRawResponseCallbacks: Array<
(message: WSMessageServerReadRawResponse) => void
@@ -65,6 +69,11 @@ export class ManagedTestClient implements Disposable {
callback(message as WSMessageServerSessionUpdate)
})
break
+ case 'session_removed':
+ this.sessionRemovedCallbacks.forEach((callback) => {
+ callback(message as WSMessageServerSessionRemoved)
+ })
+ break
case 'raw_data':
this.rawDataCallbacks.forEach((callback) => {
callback(message as WSMessageServerRawData)
@@ -171,5 +180,6 @@ export class ManagedTestServer implements Disposable {
manager.clearAllSessions()
sessionUpdateCallbacks.length = 0
rawOutputCallbacks.length = 0
+ sessionRemovedCallbacks.length = 0
}
}
diff --git a/test/web-server.test.ts b/test/web-server.test.ts
index 991bed31..4238fd66 100644
--- a/test/web-server.test.ts
+++ b/test/web-server.test.ts
@@ -238,6 +238,50 @@ describe('Web Server', () => {
await sessionExitedPromise
}, 1000)
+ it('should retain a killed session when no cleanup is requested', async () => {
+ const session = manager.spawn({
+ command: 'cat',
+ args: [],
+ description: 'Retained after kill',
+ parentSessionId: 'test',
+ })
+
+ const response = await fetch(
+ `${managedTestServer.server.server.url}/api/sessions/${session.id}`,
+ { method: 'DELETE' }
+ )
+ expect(response.status).toBe(200)
+
+ const listResponse = await fetch(`${managedTestServer.server.server.url}/api/sessions`)
+ const sessions = (await listResponse.json()) as PTYSessionInfo[]
+ expect(sessions.some((s) => s.id === session.id)).toBe(true)
+ }, 1000)
+
+ it('should discard a session via the cleanup endpoint', async () => {
+ const session = manager.spawn({
+ command: 'cat',
+ args: [],
+ description: 'Discarded session',
+ parentSessionId: 'test',
+ })
+
+ const response = await fetch(
+ `${managedTestServer.server.server.url}/api/sessions/${session.id}/cleanup`,
+ { method: 'DELETE' }
+ )
+ expect(response.status).toBe(200)
+ expect((await response.json()).success).toBe(true)
+
+ const listResponse = await fetch(`${managedTestServer.server.server.url}/api/sessions`)
+ const sessions = (await listResponse.json()) as PTYSessionInfo[]
+ expect(sessions.some((s) => s.id === session.id)).toBe(false)
+
+ const getResponse = await fetch(
+ `${managedTestServer.server.server.url}/api/sessions/${session.id}`
+ )
+ expect(getResponse.status).toBe(404)
+ }, 1000)
+
it('should return session output', async () => {
const title = crypto.randomUUID()
const sessionExitedPromise = new Promise((resolve) => {
diff --git a/test/websocket.test.ts b/test/websocket.test.ts
index 19fd713e..dee34877 100644
--- a/test/websocket.test.ts
+++ b/test/websocket.test.ts
@@ -4,6 +4,7 @@ import type {
CustomError,
WSMessageServerError,
WSMessageServerSessionList,
+ WSMessageServerSessionRemoved,
WSMessageServerSessionUpdate,
WSMessageServerSubscribedSession,
WSMessageServerUnsubscribedSession,
@@ -170,6 +171,31 @@ describe('WebSocket Functionality', () => {
await sessionListPromise
}, 1000)
+ it('should broadcast session_removed when a session is discarded', async () => {
+ await using managedTestClient = await ManagedTestClient.create(
+ managedTestServer.server.getWsUrl()
+ )
+ const session = manager.spawn({
+ command: 'echo',
+ args: ['done'],
+ description: 'Session to discard',
+ parentSessionId: managedTestServer.sessionId,
+ })
+
+ const removedPromise = new Promise((res) => {
+ managedTestClient.sessionRemovedCallbacks.push((message) => {
+ if (message.sessionId === session.id) {
+ res(message)
+ }
+ })
+ })
+
+ manager.kill(session.id, true)
+
+ const removed = await removedPromise
+ expect(removed.sessionId).toBe(session.id)
+ }, 1000)
+
it('should handle invalid message format', async () => {
await using managedTestClient = await ManagedTestClient.create(
managedTestServer.server.getWsUrl()