Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 41 additions & 1 deletion src/plugin/pty/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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 {
Expand Down Expand Up @@ -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)
}
}
}

Expand Down
4 changes: 3 additions & 1 deletion src/plugin/pty/tools/kill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
16 changes: 9 additions & 7 deletions src/plugin/pty/tools/kill.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
86 changes: 82 additions & 4 deletions src/web/client/components/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -63,6 +68,7 @@ export function App() {
}
})
}, []),
onSessionRemoved: handleSessionRemoved,
})

// Update connected from wsConnected
Expand All @@ -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,
Expand All @@ -94,22 +107,87 @@ 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 (
<div className="container" data-active-session={activeSession?.id}>
<Sidebar
sessions={sessions}
activeSession={activeSession}
onSessionClick={handleSessionClick}
onKillSession={handleKillSessionById}
onRemoveSession={handleRemoveSessionClick}
onClearFinished={handleClearFinishedClick}
connected={connected}
/>
<div className="main">
{activeSession ? (
<>
<div className="output-header">
<div className="output-title">{activeSession.description ?? activeSession.title}</div>
<button type="button" className="kill-btn" onClick={handleKillSession}>
Kill Session
</button>
<div className="output-actions">
<button
type="button"
className="download-btn"
onClick={handleDownloadSession}
disabled={rawOutput.length === 0}
>
Download
</button>
{activeSession.status === 'running' ? (
<button type="button" className="kill-btn" onClick={handleKillSession}>
Kill Session
</button>
) : (
<button
type="button"
className="remove-btn"
onClick={() => handleRemoveSessionClick(activeSession)}
>
Remove
</button>
)}
</div>
</div>
<div className="output-container">
<RawTerminal
Expand Down
Loading