From a8d387de929d225cc96f6ec6f723a583bd5668a2 Mon Sep 17 00:00:00 2001 From: agentmish Date: Tue, 21 Apr 2026 18:03:49 +0100 Subject: [PATCH 1/3] Add agent session IPC manager --- src/main/agent-session-manager.test.ts | 142 +++++++++++++++++++ src/main/agent-session-manager.ts | 181 +++++++++++++++++++++++++ src/main/index.ts | 63 +++++++++ src/preload/index.test.ts | 81 +++++++++++ src/preload/index.ts | 43 ++++++ src/renderer/src/env.d.ts | 15 ++ src/shared/terminal-types.ts | 66 +++++++++ 7 files changed, 591 insertions(+) create mode 100644 src/main/agent-session-manager.test.ts create mode 100644 src/main/agent-session-manager.ts create mode 100644 src/preload/index.test.ts diff --git a/src/main/agent-session-manager.test.ts b/src/main/agent-session-manager.test.ts new file mode 100644 index 0000000..e6fb22e --- /dev/null +++ b/src/main/agent-session-manager.test.ts @@ -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([]) + }) +}) diff --git a/src/main/agent-session-manager.ts b/src/main/agent-session-manager.ts new file mode 100644 index 0000000..f8d8e14 --- /dev/null +++ b/src/main/agent-session-manager.ts @@ -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 + resolveWorkspaceCwd(requested?: string): string + shellRegistry: readonly ShellDescriptor[] + platform: NodeJS.Platform + env: Record + 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() + 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 { + 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() + 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))) +} diff --git a/src/main/index.ts b/src/main/index.ts index 2c362fa..e95d69e 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -5,6 +5,11 @@ import fs from 'node:fs' import crypto from 'node:crypto' import * as pty from 'node-pty' import type { + AgentSessionCloseRequest, + AgentSessionCreateRequest, + AgentSessionGetRequest, + AgentSessionResizeRequest, + AgentSessionWriteRequest, TerminalCloseRequest, TerminalCreateRequest, TerminalCreateResponse, @@ -17,6 +22,7 @@ import type { ShellDescriptor } from '../shared/shell-registry' import { detectPreferredShellId, resolveShell } from './shell-resolver' import { detectShells } from './shell-detection' import { createPreferencesStore, migratePreferences, type PreferencesStore } from './preferences' +import { AgentSessionManager } from './agent-session-manager' const terminals = new Map() let mainWindow: BrowserWindow | null = null @@ -24,6 +30,7 @@ let mainWindow: BrowserWindow | null = null let shellRegistry: ShellDescriptor[] = [] let preferencesStore: PreferencesStore | null = null let preferencesWereFreshlyCreated = false +let agentSessionManager: AgentSessionManager | null = null const isDevelopment = Boolean(process.env.ELECTRON_RENDERER_URL) @@ -200,6 +207,13 @@ async function createTerminal( } } +function getAgentSessionManager(): AgentSessionManager { + if (!agentSessionManager) { + throw new Error('Agent session manager not initialised') + } + return agentSessionManager +} + ipcMain.handle('terminal:create', (event, request: TerminalCreateRequest) => { return createTerminal(event, request) }) @@ -221,6 +235,19 @@ ipcMain.handle('terminal:list-shells', (): TerminalListShellsResponse => { } }) +ipcMain.handle('agentSession:create', (_event, request: AgentSessionCreateRequest) => { + return getAgentSessionManager().create(request) +}) + +ipcMain.handle('agentSession:list', () => { + return getAgentSessionManager().list() +}) + +ipcMain.handle('agentSession:get', (_event, request: AgentSessionGetRequest) => { + if (!request || typeof request.sessionId !== 'string') return null + return getAgentSessionManager().get(request) +}) + ipcMain.handle('preferences:get', async (): Promise => { if (!preferencesStore) throw new Error('Preferences not initialised') return preferencesStore.load() @@ -252,6 +279,16 @@ ipcMain.on('terminal:resize', (_event, request: TerminalResizeRequest) => { terminal.resize(cols, rows) }) +ipcMain.on('agentSession:write', (_event, request: AgentSessionWriteRequest) => { + if (!request || typeof request.sessionId !== 'string' || typeof request.data !== 'string') return + getAgentSessionManager().write(request.sessionId, request.data) +}) + +ipcMain.on('agentSession:resize', (_event, request: AgentSessionResizeRequest) => { + if (!request || typeof request.sessionId !== 'string') return + getAgentSessionManager().resize(request.sessionId, request.cols, request.rows) +}) + ipcMain.handle('workspace:pick-directory', async (event) => { const sender = BrowserWindow.fromWebContents(event.sender) const result = await (sender @@ -284,6 +321,11 @@ ipcMain.handle('terminal:close', (_event, request: TerminalCloseRequest) => { return true }) +ipcMain.handle('agentSession:close', (_event, request: AgentSessionCloseRequest) => { + if (!request || typeof request.sessionId !== 'string') return false + return getAgentSessionManager().close(request.sessionId) +}) + function killAllTerminals(): void { for (const [terminalId, terminal] of terminals) { try { @@ -294,6 +336,8 @@ function killAllTerminals(): void { terminals.delete(terminalId) } } + + agentSessionManager?.closeAll() } void app.whenReady().then(async () => { @@ -302,6 +346,25 @@ void app.whenReady().then(async () => { preferencesStore = createPreferencesStore(preferencesPath) preferencesWereFreshlyCreated = !(await preferencesStore.exists()) + agentSessionManager = new AgentSessionManager({ + spawn: (file, args, options) => pty.spawn(file, args, options), + resolveEffectiveShellId, + resolveWorkspaceCwd, + shellRegistry, + platform: process.platform, + env: process.env, + onData: ({ sessionId, data }) => { + if (mainWindow && !mainWindow.webContents.isDestroyed()) { + mainWindow.webContents.send('agentSession:data', { sessionId, data }) + } + }, + onExit: ({ sessionId, exitCode, signal }) => { + if (mainWindow && !mainWindow.webContents.isDestroyed()) { + mainWindow.webContents.send('agentSession:exit', { sessionId, exitCode, signal }) + } + }, + }) + createWindow() app.on('activate', () => { diff --git a/src/preload/index.test.ts b/src/preload/index.test.ts new file mode 100644 index 0000000..11ca772 --- /dev/null +++ b/src/preload/index.test.ts @@ -0,0 +1,81 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test' + +const invoke = mock(() => Promise.resolve(undefined)) +const send = mock(() => undefined) +const on = mock(() => undefined) +const removeListener = mock(() => undefined) +const exposeInMainWorld = mock(() => undefined) + +mock.module('electron', () => ({ + contextBridge: { exposeInMainWorld }, + ipcRenderer: { invoke, send, on, removeListener }, +})) + +let baton: Window['baton'] + +beforeEach(() => { + invoke.mockClear() + send.mockClear() + on.mockClear() + removeListener.mockClear() + exposeInMainWorld.mockClear() + delete require.cache[require.resolve('./index')] + require('./index') + baton = exposeInMainWorld.mock.calls[0]?.[1] as Window['baton'] +}) + +afterEach(() => { + delete (globalThis as { baton?: unknown }).baton +}) + +describe('preload agentSession bridge', () => { + test('exposes typed agentSession invoke/send APIs', async () => { + expect(exposeInMainWorld).toHaveBeenCalledTimes(1) + const [key, api] = exposeInMainWorld.mock.calls[0]! + expect(key).toBe('baton') + baton = api as Window['baton'] + + await baton?.agentSession.create({ cols: 80, rows: 24, cwd: '/tmp' }) + expect(invoke).toHaveBeenCalledWith('agentSession:create', { cols: 80, rows: 24, cwd: '/tmp' }) + + await baton?.agentSession.list() + expect(invoke).toHaveBeenCalledWith('agentSession:list') + + await baton?.agentSession.get('session-1') + expect(invoke).toHaveBeenCalledWith('agentSession:get', { sessionId: 'session-1' }) + + baton?.agentSession.write('session-1', 'ls\n') + expect(send).toHaveBeenCalledWith('agentSession:write', { sessionId: 'session-1', data: 'ls\n' }) + + baton?.agentSession.resize('session-1', 120, 40) + expect(send).toHaveBeenCalledWith('agentSession:resize', { sessionId: 'session-1', cols: 120, rows: 40 }) + + await baton?.agentSession.close('session-1') + expect(invoke).toHaveBeenCalledWith('agentSession:close', { sessionId: 'session-1' }) + }) + + test('registers and unregisters agentSession event listeners', () => { + const onCallsBefore = on.mock.calls.length + const onData = mock(() => undefined) + const disposeData = baton?.agentSession.onData(onData) + expect(on.mock.calls.length).toBe(onCallsBefore + 1) + const dataCall = on.mock.calls[onCallsBefore] + expect(dataCall?.[0]).toBe('agentSession:data') + const dataListener = dataCall?.[1] as ((event: unknown, payload: unknown) => void) + dataListener({}, { sessionId: 'session-1', data: 'hello' }) + expect(onData).toHaveBeenCalledWith({ sessionId: 'session-1', data: 'hello' }) + disposeData?.() + expect(removeListener).toHaveBeenCalledWith('agentSession:data', dataListener) + + const onExit = mock(() => undefined) + const disposeExit = baton?.agentSession.onExit(onExit) + expect(on.mock.calls.length).toBe(onCallsBefore + 2) + const exitCall = on.mock.calls[onCallsBefore + 1] + expect(exitCall?.[0]).toBe('agentSession:exit') + const exitListener = exitCall?.[1] as ((event: unknown, payload: unknown) => void) + exitListener({}, { sessionId: 'session-1', exitCode: 0, signal: null }) + expect(onExit).toHaveBeenCalledWith({ sessionId: 'session-1', exitCode: 0, signal: null }) + disposeExit?.() + expect(removeListener).toHaveBeenCalledWith('agentSession:exit', exitListener) + }) +}) diff --git a/src/preload/index.ts b/src/preload/index.ts index 0d38d53..d750d5f 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,5 +1,14 @@ import { contextBridge, ipcRenderer } from 'electron' import type { + AgentSessionCloseRequest, + AgentSessionCreateRequest, + AgentSessionCreateResponse, + AgentSessionDataEvent, + AgentSessionExitEvent, + AgentSessionGetRequest, + AgentSessionResizeRequest, + AgentSessionSummary, + AgentSessionWriteRequest, TerminalCloseRequest, TerminalCreateRequest, TerminalCreateResponse, @@ -66,6 +75,40 @@ const api = { return ipcRenderer.invoke('terminal:list-shells') as Promise } }, + agentSession: { + create(request: AgentSessionCreateRequest): Promise { + return ipcRenderer.invoke('agentSession:create', request) as Promise + }, + list(): Promise { + return ipcRenderer.invoke('agentSession:list') as Promise + }, + get(sessionId: string): Promise { + const payload: AgentSessionGetRequest = { sessionId } + return ipcRenderer.invoke('agentSession:get', payload) as Promise + }, + write(sessionId: string, data: string): void { + const payload: AgentSessionWriteRequest = { sessionId, data } + ipcRenderer.send('agentSession:write', payload) + }, + resize(sessionId: string, cols: number, rows: number): void { + const payload: AgentSessionResizeRequest = { sessionId, cols, rows } + ipcRenderer.send('agentSession:resize', payload) + }, + close(sessionId: string): Promise { + const payload: AgentSessionCloseRequest = { sessionId } + return ipcRenderer.invoke('agentSession:close', payload) as Promise + }, + onData(callback: (event: AgentSessionDataEvent) => void): ListenerCleanup { + const listener = (_event: Electron.IpcRendererEvent, payload: AgentSessionDataEvent): void => callback(payload) + ipcRenderer.on('agentSession:data', listener) + return () => ipcRenderer.removeListener('agentSession:data', listener) + }, + onExit(callback: (event: AgentSessionExitEvent) => void): ListenerCleanup { + const listener = (_event: Electron.IpcRendererEvent, payload: AgentSessionExitEvent): void => callback(payload) + ipcRenderer.on('agentSession:exit', listener) + return () => ipcRenderer.removeListener('agentSession:exit', listener) + }, + }, preferences: { get(): Promise { return ipcRenderer.invoke('preferences:get') as Promise diff --git a/src/renderer/src/env.d.ts b/src/renderer/src/env.d.ts index 4f699f6..41bcc5f 100644 --- a/src/renderer/src/env.d.ts +++ b/src/renderer/src/env.d.ts @@ -1,4 +1,9 @@ import type { + AgentSessionCreateRequest, + AgentSessionCreateResponse, + AgentSessionDataEvent, + AgentSessionExitEvent, + AgentSessionSummary, TerminalCreateRequest, TerminalCreateResponse, TerminalDataEvent, @@ -30,6 +35,16 @@ interface BatonBridge { onExit(callback: (event: TerminalExitEvent) => void): () => void listShells(): Promise } + agentSession: { + create(request: AgentSessionCreateRequest): Promise + list(): Promise + get(sessionId: string): Promise + write(sessionId: string, data: string): void + resize(sessionId: string, cols: number, rows: number): void + close(sessionId: string): Promise + onData(callback: (event: AgentSessionDataEvent) => void): () => void + onExit(callback: (event: AgentSessionExitEvent) => void): () => void + } preferences: { get(): Promise set(next: AppPreferences): Promise diff --git a/src/shared/terminal-types.ts b/src/shared/terminal-types.ts index 3f76b07..ca49ab7 100644 --- a/src/shared/terminal-types.ts +++ b/src/shared/terminal-types.ts @@ -51,3 +51,69 @@ export interface TerminalResizeRequest { export interface TerminalCloseRequest { terminalId: string } + +export type AgentSessionStatus = 'running' | 'exited' | 'closed' + +export interface AgentSessionCreateRequest { + cols: number + rows: number + cwd?: string + shellId?: string +} + +export interface AgentSessionCreateResponse { + sessionId: string + shell: string + shellId: string + pid?: number + cwd: string + status: AgentSessionStatus + createdAt: number + startedAt: number + recentOutput: string +} + +export interface AgentSessionSummary { + sessionId: string + shell: string + shellId: string + pid?: number + cwd: string + status: AgentSessionStatus + createdAt: number + startedAt: number + closedAt?: number + exitCode?: number | null + signal?: number | null + recentOutput: string +} + +export interface AgentSessionDataEvent { + sessionId: string + data: string +} + +export interface AgentSessionExitEvent { + sessionId: string + exitCode: number | null + signal?: number | null +} + +export interface AgentSessionWriteRequest { + sessionId: string + data: string +} + +export interface AgentSessionResizeRequest { + sessionId: string + cols: number + rows: number +} + +export interface AgentSessionCloseRequest { + sessionId: string +} + +export interface AgentSessionGetRequest { + sessionId: string +} From e25245e2f2fb82707da7828962d7362d90fe422f Mon Sep 17 00:00:00 2001 From: agentmish Date: Tue, 21 Apr 2026 18:04:47 +0100 Subject: [PATCH 2/3] Ignore TypeScript baseUrl deprecation --- tsconfig.json | 1 + 1 file changed, 1 insertion(+) diff --git a/tsconfig.json b/tsconfig.json index 7f52f3e..1320162 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -16,6 +16,7 @@ "noEmit": true, "jsx": "react-jsx", "types": ["vite/client", "node", "react", "react-dom"], + "ignoreDeprecations": "5.0", "baseUrl": ".", "paths": { "@renderer/*": ["src/renderer/src/*"], From 28c46225ba1952ade1f6a7a289d4625a9649be45 Mon Sep 17 00:00:00 2001 From: agentmish Date: Tue, 21 Apr 2026 18:28:03 +0100 Subject: [PATCH 3/3] docs: add agent session API examples --- README.md | 44 +++++++++++++++++++++++++++++++++++++++ src/renderer/src/env.d.ts | 18 ++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/README.md b/README.md index befd68b..b1688c9 100644 --- a/README.md +++ b/README.md @@ -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. + ## Notes for production hardening - Add application signing and notarization for macOS distribution. diff --git a/src/renderer/src/env.d.ts b/src/renderer/src/env.d.ts index 41bcc5f..bbf3cf0 100644 --- a/src/renderer/src/env.d.ts +++ b/src/renderer/src/env.d.ts @@ -36,13 +36,31 @@ interface BatonBridge { listShells(): Promise } agentSession: { + /** + * Create a local agent session in Electron preload code. + * + * Example: + * ```ts + * const session = await window.baton.agentSession.create({ cols: 120, rows: 30, cwd: '/tmp' }) + * const stopData = window.baton.agentSession.onData(({ sessionId, data }) => { + * if (sessionId === session.sessionId) console.log(data) + * }) + * window.baton.agentSession.write(session.sessionId, 'echo hello\r') + * ``` + */ create(request: AgentSessionCreateRequest): Promise + /** Returns all tracked sessions for inspection UIs or reconnection flows. */ list(): Promise + /** Returns the latest summary for one session, or null when it is unknown. */ get(sessionId: string): Promise + /** Writes raw PTY input to a session; send `\r` to submit a shell command. */ write(sessionId: string, data: string): void resize(sessionId: string, cols: number, rows: number): void + /** Closes a session and resolves true when a tracked session was closed. */ close(sessionId: string): Promise + /** Subscribes to streamed PTY output events shaped as `{ sessionId, data }`. */ onData(callback: (event: AgentSessionDataEvent) => void): () => void + /** Subscribes to exit events shaped as `{ sessionId, exitCode, signal }`. */ onExit(callback: (event: AgentSessionExitEvent) => void): () => void } preferences: {