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
44 changes: 44 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,50 @@ src/shared/ Shared terminal protocol types
server/pty-websocket.ts Optional WebSocket PTY bridge for web/mobile
```

## Electron preload API example: `window.baton.agentSession`

In the Electron renderer, the preload bridge exposes `window.baton.agentSession` for creating and controlling long-lived local agent-backed shell sessions.

```ts
const stopData = window.baton.agentSession.onData(({ sessionId, data }) => {
console.log(`[agent ${sessionId}]`, data)
})

const stopExit = window.baton.agentSession.onExit(({ sessionId, exitCode, signal }) => {
console.log(`agent exited`, { sessionId, exitCode, signal })
})

const session = await window.baton.agentSession.create({
cols: 120,
rows: 30,
cwd: '/tmp',
})

console.log('created session', session.sessionId, session.status, session.cwd)
console.log('buffered output so far', session.recentOutput)

window.baton.agentSession.write(session.sessionId, 'echo hello from Baton\r')
window.baton.agentSession.write(session.sessionId, 'pwd\r')

const current = await window.baton.agentSession.get(session.sessionId)
console.log('current summary', current)

const sessions = await window.baton.agentSession.list()
console.log('all sessions', sessions.map(({ sessionId, status, cwd }) => ({ sessionId, status, cwd })))

await window.baton.agentSession.close(session.sessionId)
stopData()
stopExit()
```

How data flows in Electron:

- `create()` returns the initial session metadata, including `sessionId`, `status`, and `recentOutput`.
- `write()` sends input to the session PTY; use `\r` for Enter when sending shell commands.
- `onData()` streams incremental PTY output as `{ sessionId, data }` events.
- `onExit()` fires when the backing process exits as `{ sessionId, exitCode, signal }`.
- `get()` returns the latest known summary for one session, while `list()` returns all tracked sessions, including exited or closed state when available.
Comment on lines +177 to +178

## Notes for production hardening

- Add application signing and notarization for macOS distribution.
Expand Down
142 changes: 142 additions & 0 deletions src/main/agent-session-manager.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import { describe, expect, test } from 'bun:test'
import { AgentSessionManager, type PtyLike } from './agent-session-manager'
import type { ShellDescriptor } from '../shared/shell-registry'

const registry: ShellDescriptor[] = [
{ id: 'bash', kind: 'native', label: 'bash', file: '/bin/bash', args: [], platforms: ['darwin', 'linux'] },
]

function createMockPty() {
let onData: ((data: string) => void) | undefined
let onExit: ((event: { exitCode: number | null; signal?: number | null }) => void) | undefined
const writes: string[] = []
const resizes: Array<{ cols: number; rows: number }> = []
let killed = 0

const pty: PtyLike = {
pid: 1234,
write(data: string) {
writes.push(data)
},
resize(cols: number, rows: number) {
resizes.push({ cols, rows })
},
kill() {
killed += 1
},
onData(listener) {
onData = listener
},
onExit(listener) {
onExit = listener
},
}

return {
pty,
writes,
resizes,
get killed() {
return killed
},
emitData(data: string) {
onData?.(data)
},
emitExit(event: { exitCode: number | null; signal?: number | null }) {
onExit?.(event)
},
}
}

describe('AgentSessionManager', () => {
test('creates sessions, tracks metadata, and emits bounded recent output', async () => {
const created = createMockPty()
const dataEvents: Array<{ sessionId: string; data: string }> = []
const exitEvents: Array<{ sessionId: string; exitCode: number | null; signal?: number | null }> = []

const manager = new AgentSessionManager({
spawn: (_file, _args, options) => {
expect(options.cols).toBe(100)
expect(options.rows).toBe(30)
expect(options.cwd).toBe('/workspace')
return created.pty
},
resolveEffectiveShellId: async () => 'bash',
resolveWorkspaceCwd: () => '/workspace',
shellRegistry: registry,
platform: 'linux',
env: { HOME: '/home/test' },
now: () => 111,
createId: () => 'session-1',
recentOutputLimit: 5,
onData: (event) => dataEvents.push(event),
onExit: (event) => exitEvents.push(event),
})

const createdSession = await manager.create({ cols: Number.NaN, rows: Number.NaN, cwd: '/ignored' })
expect(createdSession).toMatchObject({
sessionId: 'session-1',
shellId: 'bash',
shell: 'bash',
pid: 1234,
cwd: '/workspace',
status: 'running',
createdAt: 111,
startedAt: 111,
recentOutput: '',
})

created.emitData('abc')
created.emitData('def')
expect(dataEvents).toEqual([
{ sessionId: 'session-1', data: 'abc' },
{ sessionId: 'session-1', data: 'def' },
])
expect(manager.getById('session-1')).toMatchObject({ recentOutput: 'bcdef', status: 'running' })

created.emitExit({ exitCode: 0, signal: null })
expect(exitEvents).toEqual([{ sessionId: 'session-1', exitCode: 0, signal: null }])
expect(manager.get({ sessionId: 'session-1' })).toMatchObject({
status: 'exited',
exitCode: 0,
signal: null,
closedAt: 111,
})
expect(manager.write('session-1', 'echo nope')).toBe(false)
})

test('writes, resizes, closes, and lists active sessions', async () => {
const first = createMockPty()
const second = createMockPty()
const spawned = [first, second]
let nextId = 0

const manager = new AgentSessionManager({
spawn: () => spawned[nextId++]!.pty,
resolveEffectiveShellId: async () => 'bash',
resolveWorkspaceCwd: (cwd) => cwd ?? '/workspace',
shellRegistry: registry,
platform: 'linux',
env: { HOME: '/home/test' },
createId: () => `session-${nextId + 1}`,
})

await manager.create({ cols: 80, rows: 24, cwd: '/one' })
await manager.create({ cols: 81, rows: 25, cwd: '/two' })

expect(manager.list().map((session) => session.sessionId)).toEqual(['session-1', 'session-2'])
expect(manager.write('session-1', 'pwd\n')).toBe(true)
expect(first.writes).toEqual(['pwd\n'])

expect(manager.resize('session-1', 1, 999)).toBe(true)
expect(first.resizes).toEqual([{ cols: 10, rows: 200 }])

expect(manager.close('session-1')).toBe(true)
expect(first.killed).toBe(1)
expect(manager.getById('session-1')).toBeNull()

manager.closeAll()
expect(second.killed).toBe(1)
expect(manager.list()).toEqual([])
})
})
181 changes: 181 additions & 0 deletions src/main/agent-session-manager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import crypto from 'node:crypto'
import os from 'node:os'
import type * as pty from 'node-pty'
import type {
AgentSessionCreateRequest,
AgentSessionCreateResponse,
AgentSessionExitEvent,
AgentSessionGetRequest,
AgentSessionSummary,
} from '../shared/terminal-types'
import type { ShellDescriptor } from '../shared/shell-registry'
import { resolveShell } from './shell-resolver'

export interface PtyLike {
pid?: number
write(data: string): void
resize(cols: number, rows: number): void
kill(): void
onData(listener: (data: string) => void): void
onExit(listener: (event: { exitCode: number | null; signal?: number | null }) => void): void
}

export interface AgentSessionManagerDeps {
spawn(file: string, args: string[], options: pty.IPtyForkOptions): PtyLike
resolveEffectiveShellId(request: AgentSessionCreateRequest): Promise<string>
resolveWorkspaceCwd(requested?: string): string
shellRegistry: readonly ShellDescriptor[]
platform: NodeJS.Platform
env: Record<string, string | undefined>
now?: () => number
createId?: () => string
recentOutputLimit?: number
onData?: (event: { sessionId: string; data: string }) => void
onExit?: (event: AgentSessionExitEvent) => void
}

interface ManagedSession {
pty: PtyLike
summary: AgentSessionSummary
}

const DEFAULT_RECENT_OUTPUT_LIMIT = 64 * 1024

export class AgentSessionManager {
private readonly sessions = new Map<string, ManagedSession>()
private readonly now: () => number
private readonly createId: () => string
private readonly recentOutputLimit: number

constructor(private readonly deps: AgentSessionManagerDeps) {
this.now = deps.now ?? Date.now
this.createId = deps.createId ?? crypto.randomUUID
this.recentOutputLimit = deps.recentOutputLimit ?? DEFAULT_RECENT_OUTPUT_LIMIT
}

async create(request: AgentSessionCreateRequest): Promise<AgentSessionCreateResponse> {
const sessionId = this.createId()
const cols = clampInteger(request.cols, 10, 500, 100)
const rows = clampInteger(request.rows, 4, 200, 30)
const cwd = this.deps.resolveWorkspaceCwd(request.cwd)
const effectiveId = await this.deps.resolveEffectiveShellId(request)
const resolved = resolveShell({
id: effectiveId,
registry: this.deps.shellRegistry,
cwd,
platform: this.deps.platform,
env: {
...this.deps.env,
HOME: this.deps.env.HOME || os.homedir(),
},
})

const createdAt = this.now()
const terminal = this.deps.spawn(resolved.file, resolved.args, {
name: 'xterm-256color',
cols,
rows,
cwd: resolved.cwd,
env: resolved.env,
})

const summary: AgentSessionSummary = {
sessionId,
shell: resolved.descriptor.label,
shellId: resolved.descriptor.id,
pid: terminal.pid,
cwd: resolved.cwd,
status: 'running',
createdAt,
startedAt: createdAt,
recentOutput: '',
}

this.sessions.set(sessionId, { pty: terminal, summary })

terminal.onData((data) => {
const session = this.sessions.get(sessionId)
if (!session) return
session.summary.recentOutput = appendBounded(session.summary.recentOutput, data, this.recentOutputLimit)
this.deps.onData?.({ sessionId, data })
})

terminal.onExit(({ exitCode, signal }) => {
const session = this.sessions.get(sessionId)
if (!session) return
session.summary.status = 'exited'
session.summary.exitCode = exitCode
session.summary.signal = signal
session.summary.closedAt = this.now()
this.deps.onExit?.({ sessionId, exitCode, signal })
})

return cloneSummary(summary)
}

list(): AgentSessionSummary[] {
return [...this.sessions.values()].map(({ summary }) => cloneSummary(summary))
}

get(request: AgentSessionGetRequest): AgentSessionSummary | null {
return this.getById(request.sessionId)
}

getById(sessionId: string): AgentSessionSummary | null {
const session = this.sessions.get(sessionId)
return session ? cloneSummary(session.summary) : null
}

write(sessionId: string, data: string): boolean {
if (typeof data !== 'string' || data.length > 65536) return false
const session = this.sessions.get(sessionId)
if (!session || session.summary.status !== 'running') return false
session.pty.write(data)
return true
}

resize(sessionId: string, cols: number, rows: number): boolean {
const session = this.sessions.get(sessionId)
if (!session || session.summary.status !== 'running') return false
session.pty.resize(clampInteger(cols, 10, 500, 100), clampInteger(rows, 4, 200, 30))
return true
}

close(sessionId: string): boolean {
const session = this.sessions.get(sessionId)
if (!session) return false

try {
if (session.summary.status === 'running') {
session.pty.kill()
}
} finally {
session.summary.status = 'closed'
session.summary.closedAt = session.summary.closedAt ?? this.now()
Comment on lines +148 to +154
this.sessions.delete(sessionId)
}

return true
}

closeAll(): void {
for (const sessionId of [...this.sessions.keys()]) {
this.close(sessionId)
}
}
}

function cloneSummary(summary: AgentSessionSummary): AgentSessionSummary {
return { ...summary }
}

function appendBounded(existing: string, chunk: string, limit: number): string {
const combined = existing + chunk
if (combined.length <= limit) return combined
return combined.slice(combined.length - limit)
}

function clampInteger(value: unknown, min: number, max: number, fallback: number): number {
if (typeof value !== 'number' || !Number.isFinite(value)) return fallback
return Math.max(min, Math.min(max, Math.floor(value)))
}
Loading
Loading