readme, cleanup logs, bash support

This commit is contained in:
Hunter Lovell
2026-01-14 20:37:15 -08:00
parent 676b8e678b
commit 92c59da18d
14 changed files with 839 additions and 317 deletions
+22 -110
View File
@@ -1,29 +1,23 @@
# openwork
[![CI](https://github.com/langchain-ai/openwork/actions/workflows/ci.yml/badge.svg)](https://github.com/langchain-ai/openwork/actions/workflows/ci.yml)
[![npm version](https://img.shields.io/npm/v/openwork.svg)](https://www.npmjs.com/package/openwork)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![npm][npm-badge]][npm-url] [![License: MIT][license-badge]][license-url]
A tactical agent interface for [deepagentsjs](https://github.com/langchain-ai/deepagentsjs) - an opinionated harness for building deep agents with filesystem capabilities, planning, and subagent delegation.
[npm-badge]: https://img.shields.io/npm/v/openwork.svg
[npm-url]: https://www.npmjs.com/package/openwork
[license-badge]: https://img.shields.io/badge/License-MIT-yellow.svg
[license-url]: https://opensource.org/licenses/MIT
A desktop interface for [deepagentsjs](https://github.com/langchain-ai/deepagentsjs) — an opinionated harness for building deep agents with filesystem capabilities planning, and subagent delegation.
![openwork screenshot](docs/screenshot.png)
## Features
> [!CAUTION]
> openwork gives AI agents direct access to your filesystem and the ability to execute shell commands. Always review tool calls before approving them, and only run in workspaces you trust.
- **Chat Interface** - Stream conversations with your AI agent in real-time
- **TODO Tracking** - Visual task list showing agent's planning progress
- **Filesystem Browser** - See files the agent reads, writes, and edits
- **Subagent Monitoring** - Track spawned subagents and their status
- **Human-in-the-Loop** - Approve, edit, or reject sensitive tool calls
- **Multi-Model Support** - Use Claude, GPT-4, Gemini, or local models
- **Thread Persistence** - SQLite-backed conversation history
## Installation
### npm (recommended)
## Get Started
```bash
# Run directly
# Run directly with npx
npx openwork
# Or install globally
@@ -31,7 +25,7 @@ npm install -g openwork
openwork
```
Requires Node.js 18+. Electron is installed automatically as a dependency.
Requires Node.js 18+.
### From Source
@@ -41,103 +35,21 @@ cd openwork
npm install
npm run dev
```
Or configure them in-app via the settings panel.
## Configuration
## Supported Models
### API Keys
openwork supports multiple LLM providers. Set your API keys via:
1. **Environment Variables** (recommended)
```bash
export ANTHROPIC_API_KEY="sk-ant-..."
export OPENAI_API_KEY="sk-..."
export GOOGLE_API_KEY="..."
```
2. **In-App Settings** - Click the settings icon and enter your API keys securely.
### Supported Models
| Provider | Models |
|----------|--------|
| Anthropic | Claude Sonnet 4, Claude 3.5 Sonnet, Claude 3.5 Haiku |
| OpenAI | GPT-4o, GPT-4o Mini |
| Google | Gemini 2.0 Flash |
## Architecture
openwork is built with:
- **Electron** - Cross-platform desktop framework
- **React** - UI components with tactical/SCADA-inspired design
- **deepagentsjs** - Agent harness with planning, filesystem, and subagents
- **LangGraph** - State machine for agent orchestration
- **SQLite** - Local persistence for threads and checkpoints
```
┌─────────────────────────────────────────────────────────────┐
│ Electron Main Process │
├─────────────────────────────────────────────────────────────┤
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
│ │ IPC Handlers│ │ SQLite │ │ DeepAgentsJS │ │
│ │ - agent │ │ - threads │ │ - createAgent │ │
│ │ - threads │ │ - runs │ │ - checkpointer │ │
│ │ - models │ │ - assists │ │ - middleware │ │
│ └─────────────┘ └─────────────┘ └─────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
IPC Bridge
┌─────────────────────────────────────────────────────────────┐
│ Electron Renderer Process │
├─────────────────────────────────────────────────────────────┤
│ ┌──────────┐ ┌─────────────────────┐ ┌───────────────┐ │
│ │ Sidebar │ │ Chat Interface │ │ Right Panel │ │
│ │ - Threads│ │ - Messages │ │ - TODOs │ │
│ │ - Model │ │ - Tool Renderers │ │ - Files │ │
│ │ - Config │ │ - Streaming │ │ - Subagents │ │
│ └──────────┘ └─────────────────────┘ └───────────────┘ │
└─────────────────────────────────────────────────────────────┘
```
## Development
```bash
# Install dependencies
npm install
# Start development server
npm run dev
# Build for production
npm run build
```
## Releases
To publish a new release:
1. Create a git tag: `git tag v0.2.0`
2. Push the tag: `git push origin v0.2.0`
3. GitHub Actions will:
- Build the application
- Publish to npm
- Create a GitHub release
## Design System
openwork uses a tactical/SCADA-inspired design system optimized for:
- **Information density** - Dense layouts for monitoring agent activity
- **Status at a glance** - Color-coded status indicators (nominal, warning, critical)
- **Dark mode only** - Reduced eye strain for extended sessions
- **Monospace typography** - JetBrains Mono for data and code
| Provider | Models |
| --------- | ----------------------------------------------------------------- |
| Anthropic | Claude Opus 4.5, Claude Sonnet 4.5, Claude Haiku 4.5, Claude Opus 4.1, Claude Sonnet 4 |
| OpenAI | GPT-5.2, GPT-5.1, o3, o3 Mini, o4 Mini, o1, GPT-4.1, GPT-4o |
## Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md) for development guidelines.
We welcome contributions! See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
Report bugs via [GitHub Issues](https://github.com/langchain-ai/openwork/issues).
## License
MIT License - see [LICENSE](LICENSE) for details.
MIT see [LICENSE](LICENSE) for details.
Binary file not shown.

After

Width:  |  Height:  |  Size: 650 KiB

+221
View File
@@ -0,0 +1,221 @@
/**
* LocalSandbox: Execute shell commands locally on the host machine.
*
* Extends FilesystemBackend with command execution capability.
* Commands run in the workspace directory with configurable timeout and output limits.
*
* Security note: This has NO built-in safeguards except for the human-in-the-loop
* middleware provided by the agent framework. All command approval should be
* handled via HITL configuration.
*/
import { spawn } from 'node:child_process'
import { randomUUID } from 'node:crypto'
import { FilesystemBackend, type ExecuteResponse, type SandboxBackendProtocol } from 'deepagents'
/**
* Options for LocalSandbox configuration.
*/
export interface LocalSandboxOptions {
/** Root directory for file operations and command execution (default: process.cwd()) */
rootDir?: string
/** Enable virtual path mode where "/" maps to rootDir (default: false) */
virtualMode?: boolean
/** Maximum file size in MB for file operations (default: 10) */
maxFileSizeMb?: number
/** Command timeout in milliseconds (default: 120000 = 2 minutes) */
timeout?: number
/** Maximum output bytes before truncation (default: 100000 = ~100KB) */
maxOutputBytes?: number
/** Environment variables to pass to commands (default: process.env) */
env?: Record<string, string>
}
/**
* LocalSandbox backend with shell command execution.
*
* Extends FilesystemBackend to inherit all file operations (ls, read, write,
* edit, glob, grep) and adds execute() for running shell commands locally.
*
* @example
* ```typescript
* const sandbox = new LocalSandbox({
* rootDir: '/path/to/workspace',
* virtualMode: true,
* timeout: 60_000,
* });
*
* const result = await sandbox.execute('npm test');
* console.log(result.output);
* console.log('Exit code:', result.exitCode);
* ```
*/
export class LocalSandbox extends FilesystemBackend implements SandboxBackendProtocol {
/** Unique identifier for this sandbox instance */
readonly id: string
private readonly timeout: number
private readonly maxOutputBytes: number
private readonly env: Record<string, string>
private readonly workingDir: string
constructor(options: LocalSandboxOptions = {}) {
super({
rootDir: options.rootDir,
virtualMode: options.virtualMode,
maxFileSizeMb: options.maxFileSizeMb
})
this.id = `local-sandbox-${randomUUID().slice(0, 8)}`
this.timeout = options.timeout ?? 120_000 // 2 minutes default
this.maxOutputBytes = options.maxOutputBytes ?? 100_000 // ~100KB default
this.env = options.env ?? ({ ...process.env } as Record<string, string>)
this.workingDir = options.rootDir ?? process.cwd()
}
/**
* Execute a shell command in the workspace directory.
*
* @param command - Shell command string to execute
* @returns ExecuteResponse with combined output, exit code, and truncation flag
*
* @example
* ```typescript
* const result = await sandbox.execute('echo "Hello World"');
* // result.output: "Hello World\n"
* // result.exitCode: 0
* // result.truncated: false
* ```
*/
async execute(command: string): Promise<ExecuteResponse> {
if (!command || typeof command !== 'string') {
return {
output: 'Error: Shell tool expects a non-empty command string.',
exitCode: 1,
truncated: false
}
}
return new Promise<ExecuteResponse>((resolve) => {
const outputParts: string[] = []
let totalBytes = 0
let truncated = false
let resolved = false
// Determine shell based on platform
const isWindows = process.platform === 'win32'
const shell = isWindows ? 'cmd.exe' : '/bin/sh'
const shellArgs = isWindows ? ['/c', command] : ['-c', command]
const proc = spawn(shell, shellArgs, {
cwd: this.workingDir,
env: this.env,
stdio: ['ignore', 'pipe', 'pipe']
})
// Handle timeout
const timeoutId = setTimeout(() => {
if (!resolved) {
resolved = true
proc.kill('SIGTERM')
// Give it a moment, then force kill
setTimeout(() => proc.kill('SIGKILL'), 1000)
resolve({
output: `Error: Command timed out after ${(this.timeout / 1000).toFixed(1)} seconds.`,
exitCode: null,
truncated: false
})
}
}, this.timeout)
// Collect stdout
proc.stdout.on('data', (data: Buffer) => {
if (truncated) return
const chunk = data.toString()
const newTotal = totalBytes + chunk.length
if (newTotal > this.maxOutputBytes) {
// Truncate to fit within limit
const remaining = this.maxOutputBytes - totalBytes
if (remaining > 0) {
outputParts.push(chunk.slice(0, remaining))
}
truncated = true
totalBytes = this.maxOutputBytes
} else {
outputParts.push(chunk)
totalBytes = newTotal
}
})
// Collect stderr with [stderr] prefix per line
proc.stderr.on('data', (data: Buffer) => {
if (truncated) return
const chunk = data.toString()
// Prefix each line with [stderr]
const prefixedLines = chunk
.split('\n')
.filter((line) => line.length > 0)
.map((line) => `[stderr] ${line}`)
.join('\n')
if (prefixedLines.length === 0) return
const withNewline = prefixedLines + (chunk.endsWith('\n') ? '\n' : '')
const newTotal = totalBytes + withNewline.length
if (newTotal > this.maxOutputBytes) {
const remaining = this.maxOutputBytes - totalBytes
if (remaining > 0) {
outputParts.push(withNewline.slice(0, remaining))
}
truncated = true
totalBytes = this.maxOutputBytes
} else {
outputParts.push(withNewline)
totalBytes = newTotal
}
})
// Handle process exit
proc.on('close', (code, signal) => {
if (resolved) return
resolved = true
clearTimeout(timeoutId)
let output = outputParts.join('')
// Add truncation notice if needed
if (truncated) {
output += `\n\n... Output truncated at ${this.maxOutputBytes} bytes.`
}
// If no output, show placeholder
if (!output.trim()) {
output = '<no output>'
}
resolve({
output,
exitCode: signal ? null : code,
truncated
})
})
// Handle spawn errors
proc.on('error', (err) => {
if (resolved) return
resolved = true
clearTimeout(timeoutId)
resolve({
output: `Error: Failed to execute command: ${err.message}`,
exitCode: 1,
truncated: false
})
})
})
}
}
+10 -5
View File
@@ -1,10 +1,11 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { createDeepAgent, FilesystemBackend } from 'deepagents'
import { createDeepAgent } from 'deepagents'
import { getDefaultModel } from '../ipc/models'
import { getApiKey, getCheckpointDbPath } from '../storage'
import { ChatAnthropic } from '@langchain/anthropic'
import { ChatOpenAI } from '@langchain/openai'
import { SqlJsSaver } from '../checkpointer/sqljs-saver'
import { LocalSandbox } from './local-sandbox'
import type * as _lcTypes from 'langchain'
import type * as _lcMessages from '@langchain/core/messages'
@@ -111,9 +112,11 @@ export async function createAgentRuntime(options: CreateAgentRuntimeOptions) {
const checkpointer = await getCheckpointer()
console.log('[Runtime] Checkpointer ready')
const backend = new FilesystemBackend({
const backend = new LocalSandbox({
rootDir: workspacePath,
virtualMode: true
virtualMode: true,
timeout: 120_000, // 2 minutes
maxOutputBytes: 100_000 // ~100KB
})
const systemPrompt = getSystemPrompt(workspacePath)
@@ -122,10 +125,12 @@ export async function createAgentRuntime(options: CreateAgentRuntimeOptions) {
model,
checkpointer,
backend,
systemPrompt
systemPrompt,
// Require human approval for all shell commands
interruptOn: { execute: true }
})
console.log('[Runtime] Deep agent created with FilesystemBackend at:', workspacePath)
console.log('[Runtime] Deep agent created with LocalSandbox at:', workspacePath)
return agent
}
+16
View File
@@ -63,6 +63,22 @@ When delegating to subagents:
All file paths are virtual paths relative to the workspace root, starting with /.
### Shell Tool
- execute: Run shell commands in the workspace directory
The execute tool runs commands directly on the user's machine. Use it for:
- Running scripts, tests, and builds (npm test, python script.py, make)
- Git operations (git status, git diff, git commit)
- Installing dependencies (npm install, pip install)
- System commands (which, env, pwd)
**Important:**
- All execute commands require user approval before running
- Commands run in the workspace root directory
- Avoid using shell for file reading (use read_file instead)
- Avoid using shell for file searching (use grep/glob instead)
- When running non-trivial commands, briefly explain what they do
## Code References
When referencing code, use format: \`file_path:line_number\`
+147 -9
View File
@@ -1,5 +1,6 @@
import { IpcMain, BrowserWindow } from 'electron'
import { HumanMessage } from '@langchain/core/messages'
import { Command } from '@langchain/langgraph'
import { createAgentRuntime } from '../agent/runtime'
import { getThread } from '../db'
import type { HITLDecision } from '../types'
@@ -107,26 +108,163 @@ export function registerAgentHandlers(ipcMain: IpcMain): void {
}
)
// Handle agent resume (after interrupt approval/rejection via useStream)
ipcMain.on(
'agent:resume',
async (
event,
{
threadId,
command
}: { threadId: string; command: { resume?: { decision?: string } } }
) => {
const channel = `agent:stream:${threadId}`
const window = BrowserWindow.fromWebContents(event.sender)
console.log('[Agent] Received resume request:', { threadId, command })
if (!window) {
console.error('[Agent] No window found for resume')
return
}
// Get workspace path from thread metadata
const thread = getThread(threadId)
const metadata = thread?.metadata ? JSON.parse(thread.metadata) : {}
const workspacePath = metadata.workspacePath as string | undefined
if (!workspacePath) {
window.webContents.send(channel, {
type: 'error',
error: 'Workspace path is required'
})
return
}
// Abort any existing stream before resuming
const existingController = activeRuns.get(threadId)
if (existingController) {
existingController.abort()
activeRuns.delete(threadId)
}
const abortController = new AbortController()
activeRuns.set(threadId, abortController)
try {
const agent = await createAgentRuntime({ workspacePath })
const config = {
configurable: { thread_id: threadId },
signal: abortController.signal,
streamMode: ['messages', 'values'] as const,
recursionLimit: 1000
}
// Resume from checkpoint by streaming with Command containing the decision
// The HITL middleware expects { decisions: [{ type: 'approve' | 'reject' | 'edit' }] }
const decisionType = command?.resume?.decision || 'approve'
const resumeValue = { decisions: [{ type: decisionType }] }
const stream = await agent.stream(new Command({ resume: resumeValue }), config)
for await (const chunk of stream) {
if (abortController.signal.aborted) break
const [mode, data] = chunk as unknown as [string, unknown]
window.webContents.send(channel, {
type: 'stream',
mode,
data: JSON.parse(JSON.stringify(data))
})
}
window.webContents.send(channel, { type: 'done' })
} catch (error) {
console.error('[Agent] Resume error:', error)
window.webContents.send(channel, {
type: 'error',
error: error instanceof Error ? error.message : 'Unknown error'
})
} finally {
activeRuns.delete(threadId)
}
}
)
// Handle HITL interrupt response
ipcMain.handle(
ipcMain.on(
'agent:interrupt',
async (_event, { threadId, decision }: { threadId: string; decision: HITLDecision }) => {
async (event, { threadId, decision }: { threadId: string; decision: HITLDecision }) => {
const channel = `agent:stream:${threadId}`
const window = BrowserWindow.fromWebContents(event.sender)
if (!window) {
console.error('[Agent] No window found for interrupt response')
return
}
// Get workspace path from thread metadata - REQUIRED
const thread = getThread(threadId)
const metadata = thread?.metadata ? JSON.parse(thread.metadata) : {}
const workspacePath = metadata.workspacePath as string | undefined
if (!workspacePath) {
throw new Error('Workspace path is required')
window.webContents.send(channel, {
type: 'error',
error: 'Workspace path is required'
})
return
}
const agent = await createAgentRuntime({ workspacePath })
const config = { configurable: { thread_id: threadId } }
if (decision.type === 'approve') {
await agent.invoke(null, config)
// Abort any existing stream before continuing
const existingController = activeRuns.get(threadId)
if (existingController) {
existingController.abort()
activeRuns.delete(threadId)
}
const abortController = new AbortController()
activeRuns.set(threadId, abortController)
try {
const agent = await createAgentRuntime({ workspacePath })
const config = {
configurable: { thread_id: threadId },
signal: abortController.signal,
streamMode: ['messages', 'values'] as const,
recursionLimit: 1000
}
if (decision.type === 'approve') {
// Resume execution by invoking with null (continues from checkpoint)
const stream = await agent.stream(null, config)
for await (const chunk of stream) {
if (abortController.signal.aborted) break
const [mode, data] = chunk as unknown as [string, unknown]
window.webContents.send(channel, {
type: 'stream',
mode,
data: JSON.parse(JSON.stringify(data))
})
}
window.webContents.send(channel, { type: 'done' })
} else if (decision.type === 'reject') {
// For reject, we need to send a Command with reject decision
// For now, just send done - the agent will see no resumption happened
window.webContents.send(channel, { type: 'done' })
}
// edit case handled similarly to approve with modified args
} catch (error) {
console.error('[Agent] Interrupt error:', error)
window.webContents.send(channel, {
type: 'error',
error: error instanceof Error ? error.message : 'Unknown error'
})
} finally {
activeRuns.delete(threadId)
}
// reject and edit handled by Command in future
}
)
+5 -1
View File
@@ -22,7 +22,11 @@ interface CustomAPI {
command: unknown,
onEvent: (event: StreamEvent) => void
) => () => void
interrupt: (threadId: string, decision: HITLDecision) => Promise<void>
interrupt: (
threadId: string,
decision: HITLDecision,
onEvent?: (event: StreamEvent) => void
) => () => void
cancel: (threadId: string) => Promise<void>
}
threads: {
+21 -15
View File
@@ -29,22 +29,16 @@ const api = {
message: string,
onEvent: (event: StreamEvent) => void
): (() => void) => {
console.log('[Preload] invoke() called', { threadId, message: message.substring(0, 50) })
const channel = `agent:stream:${threadId}`
const handler = (_: unknown, data: StreamEvent): void => {
console.log('[Preload] Received event:', data.type)
onEvent(data)
// Clean up listener on terminal events
if (data.type === 'done' || data.type === 'error') {
ipcRenderer.removeListener(channel, handler)
}
}
ipcRenderer.on(channel, handler)
console.log('[Preload] Sending agent:invoke IPC')
ipcRenderer.send('agent:invoke', { threadId, message })
// Return cleanup function
@@ -59,15 +53,10 @@ const api = {
command: unknown,
onEvent: (event: StreamEvent) => void
): (() => void) => {
console.log('[Preload] streamAgent() called', { threadId, message: message.substring(0, 50) })
const channel = `agent:stream:${threadId}`
const handler = (_: unknown, data: StreamEvent): void => {
console.log('[Preload] Received stream event:', data.type)
onEvent(data)
// Clean up listener on terminal events
if (data.type === 'done' || data.type === 'error') {
ipcRenderer.removeListener(channel, handler)
}
@@ -77,10 +66,8 @@ const api = {
// If we have a command, it might be a resume/retry
if (command) {
console.log('[Preload] Sending agent:resume IPC')
ipcRenderer.send('agent:resume', { threadId, command })
} else {
console.log('[Preload] Sending agent:invoke IPC')
ipcRenderer.send('agent:invoke', { threadId, message })
}
@@ -89,8 +76,27 @@ const api = {
ipcRenderer.removeListener(channel, handler)
}
},
interrupt: (threadId: string, decision: HITLDecision): Promise<void> => {
return ipcRenderer.invoke('agent:interrupt', { threadId, decision })
interrupt: (
threadId: string,
decision: HITLDecision,
onEvent?: (event: StreamEvent) => void
): (() => void) => {
const channel = `agent:stream:${threadId}`
const handler = (_: unknown, data: StreamEvent): void => {
onEvent?.(data)
if (data.type === 'done' || data.type === 'error') {
ipcRenderer.removeListener(channel, handler)
}
}
ipcRenderer.on(channel, handler)
ipcRenderer.send('agent:interrupt', { threadId, decision })
// Return cleanup function
return () => {
ipcRenderer.removeListener(channel, handler)
}
},
cancel: (threadId: string): Promise<void> => {
return ipcRenderer.invoke('agent:cancel', { threadId })
@@ -9,7 +9,6 @@ import { ModelSwitcher } from './ModelSwitcher'
import { Folder } from 'lucide-react'
import { WorkspacePicker, selectWorkspaceFolder } from './WorkspacePicker'
import { ChatTodos } from './ChatTodos'
import { ApprovalDialog } from '@/components/hitl/ApprovalDialog'
import { ElectronIPCTransport } from '@/lib/electron-transport'
import type { Message } from '@/types'
import type { DeepAgent } from '../../../../main/agent/types'
@@ -87,13 +86,14 @@ export function ChatContainer({ threadId }: ChatContainerProps): React.JSX.Eleme
// Get error for current thread
const threadError = errorByThread[threadId] || null
// Debug: log pendingApproval state (moved detailed log after displayMessages)
// Create transport instance (memoized to avoid recreating)
const transport = useMemo(() => new ElectronIPCTransport(), [])
// Handle custom events from the stream
const handleCustomEvent = useCallback(
(data: CustomEventData): void => {
console.log('[ChatContainer] Custom event:', data)
switch (data.type) {
case 'message':
if (data.message) {
@@ -116,7 +116,6 @@ export function ChatContainer({ threadId }: ChatContainerProps): React.JSX.Eleme
...(isTool && msg.name && { name: msg.name }),
created_at: msg.created_at ? new Date(msg.created_at) : new Date()
}
console.log('[ChatContainer] Adding message:', storeMsg)
appendMessage(storeMsg)
}
break
@@ -178,6 +177,24 @@ export function ChatContainer({ threadId }: ChatContainerProps): React.JSX.Eleme
}
})
// Handle approval decision - use stream.submit with resume command
const handleApprovalDecision = useCallback(async (decision: 'approve' | 'reject' | 'edit') => {
if (!pendingApproval) return
// Clear pending approval first
setPendingApproval(null)
// Submit with a resume command - the transport will send to agent:resume
try {
await stream.submit(
null, // No message needed for resume
{ command: { resume: { decision } } }
)
} catch (err) {
console.error('[ChatContainer] Resume command failed:', err)
}
}, [pendingApproval, setPendingApproval, stream])
// Sync todos from stream state
const agentValues = stream.values as AgentStreamValues | undefined
const streamTodos = agentValues?.todos
@@ -386,6 +403,11 @@ export function ChatContainer({ threadId }: ChatContainerProps): React.JSX.Eleme
clearThreadError(threadId)
}
// Clear any pending approval from previous turns
if (pendingApproval) {
setPendingApproval(null)
}
const message = input.trim()
setInput('')
@@ -480,7 +502,13 @@ export function ChatContainer({ threadId }: ChatContainerProps): React.JSX.Eleme
)}
{displayMessages.map((message) => (
<MessageBubble key={message.id} message={message} toolResults={toolResults} />
<MessageBubble
key={message.id}
message={message}
toolResults={toolResults}
pendingApproval={pendingApproval}
onApprovalDecision={handleApprovalDecision}
/>
))}
{/* Streaming indicator and inline TODOs */}
@@ -542,7 +570,7 @@ export function ChatContainer({ threadId }: ChatContainerProps): React.JSX.Eleme
<Square className="size-4" />
</Button>
) : (
<Button type="submit" variant="default" size="icon" disabled={!input.trim()}>
<Button type="submit" variant="default" size="icon" disabled={!input.trim()} className="rounded-md">
<Send className="size-4" />
</Button>
)}
@@ -557,8 +585,6 @@ export function ChatContainer({ threadId }: ChatContainerProps): React.JSX.Eleme
</form>
</div>
{/* HITL Approval Dialog */}
{pendingApproval && <ApprovalDialog request={pendingApproval} />}
</div>
)
}
@@ -1,6 +1,6 @@
import { User, Bot } from 'lucide-react'
import { cn } from '@/lib/utils'
import type { Message } from '@/types'
import type { Message, HITLRequest } from '@/types'
import { ToolCallRenderer } from './ToolCallRenderer'
import { StreamingMarkdown } from './StreamingMarkdown'
@@ -13,9 +13,11 @@ interface MessageBubbleProps {
message: Message
isStreaming?: boolean
toolResults?: Map<string, ToolResultInfo>
pendingApproval?: HITLRequest | null
onApprovalDecision?: (decision: 'approve' | 'reject' | 'edit') => void
}
export function MessageBubble({ message, isStreaming, toolResults }: MessageBubbleProps) {
export function MessageBubble({ message, isStreaming, toolResults, pendingApproval, onApprovalDecision }: MessageBubbleProps) {
const isUser = message.role === 'user'
const isTool = message.role === 'tool'
@@ -40,7 +42,7 @@ export function MessageBubble({ message, isStreaming, toolResults }: MessageBubb
if (!message.content.trim()) {
return null
}
// Use streaming markdown for assistant messages, plain text for user messages
if (isUser) {
return (
@@ -81,7 +83,7 @@ export function MessageBubble({ message, isStreaming, toolResults }: MessageBubb
const content = renderContent()
const hasToolCalls = message.tool_calls && message.tool_calls.length > 0
// Don't render if there's no content and no tool calls
if (!content && !hasToolCalls) {
return null
@@ -106,7 +108,7 @@ export function MessageBubble({ message, isStreaming, toolResults }: MessageBubb
)}>
{getLabel()}
</div>
{content && (
<div className={cn(
"rounded-sm p-3 overflow-hidden",
@@ -119,14 +121,18 @@ export function MessageBubble({ message, isStreaming, toolResults }: MessageBubb
{/* Tool calls */}
{hasToolCalls && (
<div className="space-y-2 overflow-hidden">
{message.tool_calls!.map((toolCall) => {
{message.tool_calls!.map((toolCall, index) => {
const result = toolResults?.get(toolCall.id)
const pendingId = pendingApproval?.tool_call?.id
const needsApproval = Boolean(pendingId && pendingId === toolCall.id)
return (
<ToolCallRenderer
key={toolCall.id}
<ToolCallRenderer
key={`${toolCall.id || `tc-${index}`}-${needsApproval ? 'pending' : 'done'}`}
toolCall={toolCall}
result={result?.content}
isError={result?.is_error}
needsApproval={needsApproval}
onApprovalDecision={needsApproval ? onApprovalDecision : undefined}
/>
)
})}
@@ -24,6 +24,8 @@ interface ToolCallRendererProps {
toolCall: ToolCall
result?: string | unknown
isError?: boolean
needsApproval?: boolean
onApprovalDecision?: (decision: 'approve' | 'reject' | 'edit') => void
}
const TOOL_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
@@ -261,16 +263,34 @@ function TaskDisplay({ args, isExpanded }: { args: Record<string, unknown>; isEx
)
}
export function ToolCallRenderer({ toolCall, result, isError }: ToolCallRendererProps) {
export function ToolCallRenderer({ toolCall, result, isError, needsApproval, onApprovalDecision }: ToolCallRendererProps) {
// Defensive: ensure args is always an object
const args = toolCall?.args || {}
const [isExpanded, setIsExpanded] = useState(false)
// Bail out if no toolCall
if (!toolCall) {
return null
}
const Icon = TOOL_ICONS[toolCall.name] || Terminal
const label = TOOL_LABELS[toolCall.name] || toolCall.name
const isPanelSynced = PANEL_SYNCED_TOOLS.has(toolCall.name)
const handleApprove = (e: React.MouseEvent) => {
e.stopPropagation()
onApprovalDecision?.('approve')
}
const handleReject = (e: React.MouseEvent) => {
e.stopPropagation()
onApprovalDecision?.('reject')
}
// Format the main argument for display
const getDisplayArg = () => {
const args = toolCall.args
if (!args) return null
if (args.path) return args.path as string
if (args.file_path) return args.file_path as string
if (args.command) return (args.command as string).slice(0, 50)
@@ -284,7 +304,7 @@ export function ToolCallRenderer({ toolCall, result, isError }: ToolCallRenderer
// Render formatted content based on tool type
const renderFormattedContent = () => {
const args = toolCall.args
if (!args) return null
switch (toolCall.name) {
case 'write_todos': {
@@ -393,7 +413,18 @@ export function ToolCallRenderer({ toolCall, result, isError }: ToolCallRenderer
}
case 'execute': {
// When expanded, output is shown in CommandDisplay - just show status
// When collapsed, show the output preview
const output = typeof result === 'string' ? result : JSON.stringify(result)
if (isExpanded) {
return (
<div className="text-xs text-status-nominal flex items-center gap-1.5">
<CheckCircle2 className="size-3" />
<span>Command completed</span>
</div>
)
}
// Collapsed view - show output preview
if (output.trim()) {
return (
<div className="space-y-2">
@@ -402,8 +433,8 @@ export function ToolCallRenderer({ toolCall, result, isError }: ToolCallRenderer
<span>Command completed</span>
</div>
<pre className="text-xs font-mono bg-background rounded-sm p-2 overflow-auto max-h-32 text-muted-foreground whitespace-pre-wrap break-all">
{output.slice(0, 1000)}
{output.length > 1000 && '...'}
{output.slice(0, 500)}
{output.length > 500 && '...'}
</pre>
</div>
)
@@ -488,7 +519,10 @@ export function ToolCallRenderer({ toolCall, result, isError }: ToolCallRenderer
const hasFormattedDisplay = formattedContent || formattedResult
return (
<div className="rounded-sm border border-border bg-background-elevated overflow-hidden">
<div className={cn(
"rounded-sm border overflow-hidden",
needsApproval ? "border-amber-500/50 bg-amber-500/5" : "border-border bg-background-elevated"
)}>
{/* Header */}
<button
onClick={() => setIsExpanded(!isExpanded)}
@@ -500,7 +534,7 @@ export function ToolCallRenderer({ toolCall, result, isError }: ToolCallRenderer
<ChevronRight className="size-4 text-muted-foreground shrink-0" />
)}
<Icon className="size-4 text-status-info shrink-0" />
<Icon className={cn("size-4 shrink-0", needsApproval ? "text-amber-500" : "text-status-info")} />
<span className="text-xs font-medium shrink-0">{label}</span>
@@ -510,21 +544,65 @@ export function ToolCallRenderer({ toolCall, result, isError }: ToolCallRenderer
</span>
)}
{result !== undefined && (
{needsApproval && (
<Badge variant="warning" className="ml-auto shrink-0">
APPROVAL
</Badge>
)}
{!needsApproval && result === undefined && (
<Badge variant="outline" className="ml-auto shrink-0 animate-pulse">
RUNNING
</Badge>
)}
{result !== undefined && !needsApproval && (
<Badge variant={isError ? 'critical' : 'nominal'} className="ml-auto shrink-0">
{isError ? 'ERROR' : 'OK'}
</Badge>
)}
{isPanelSynced && (
{isPanelSynced && !needsApproval && (
<Badge variant="outline" className="shrink-0 text-[9px]">
SYNCED
</Badge>
)}
</button>
{/* Formatted content (always visible if present) */}
{hasFormattedDisplay && !isExpanded && (
{/* Approval UI */}
{needsApproval ? (
<div className="border-t border-amber-500/20 px-3 py-3 space-y-3">
{/* Show formatted content (e.g., command preview) */}
{formattedContent}
{/* Arguments */}
<div>
<div className="text-section-header text-[10px] mb-1">ARGUMENTS</div>
<pre className="text-xs font-mono bg-background p-2 rounded-sm overflow-auto max-h-24">
{JSON.stringify(args, null, 2)}
</pre>
</div>
{/* Action buttons */}
<div className="flex items-center justify-end gap-2">
<button
className="px-3 py-1.5 text-xs border border-border rounded-sm hover:bg-background-interactive transition-colors"
onClick={handleReject}
>
Reject
</button>
<button
className="px-3 py-1.5 text-xs bg-status-nominal text-background rounded-sm hover:bg-status-nominal/90 transition-colors"
onClick={handleApprove}
>
Approve & Run
</button>
</div>
</div>
) : null}
{/* Formatted content (only visible when collapsed AND has result) */}
{hasFormattedDisplay && !isExpanded && !needsApproval && result !== undefined && (
<div className="border-t border-border px-3 py-2 space-y-2 overflow-hidden">
{formattedContent}
{formattedResult}
@@ -532,7 +610,7 @@ export function ToolCallRenderer({ toolCall, result, isError }: ToolCallRenderer
)}
{/* Expanded content - raw details */}
{isExpanded && (
{isExpanded && !needsApproval && (
<div className="border-t border-border px-3 py-2 space-y-2 overflow-hidden">
{/* Formatted display first */}
{formattedContent}
@@ -542,7 +620,7 @@ export function ToolCallRenderer({ toolCall, result, isError }: ToolCallRenderer
<div className="overflow-hidden w-full">
<div className="text-section-header mb-1">RAW ARGUMENTS</div>
<pre className="text-xs font-mono bg-background p-2 rounded-sm overflow-auto max-h-48 w-full whitespace-pre-wrap break-all">
{JSON.stringify(toolCall.args, null, 2)}
{JSON.stringify(args, null, 2)}
</pre>
</div>
@@ -1,5 +1,5 @@
import { useState } from 'react'
import { AlertTriangle, Check, X, Edit2 } from 'lucide-react'
import { Terminal, Check, X, Edit2, ChevronDown, ChevronUp } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { useAppStore } from '@/lib/store'
@@ -12,9 +12,13 @@ interface ApprovalDialogProps {
export function ApprovalDialog({ request }: ApprovalDialogProps) {
const { respondToApproval } = useAppStore()
const [isEditing, setIsEditing] = useState(false)
const [editedArgs, setEditedArgs] = useState(
JSON.stringify(request.tool_call.args, null, 2)
)
const [isExpanded, setIsExpanded] = useState(true)
// Defensive: ensure tool_call and args exist
const toolCall = request?.tool_call || { id: '', name: 'unknown', args: {} }
const args = toolCall.args || {}
const [editedArgs, setEditedArgs] = useState(JSON.stringify(args, null, 2))
const handleApprove = async () => {
if (isEditing) {
@@ -34,79 +38,121 @@ export function ApprovalDialog({ request }: ApprovalDialogProps) {
await respondToApproval('reject')
}
const getToolWarning = () => {
const name = request.tool_call.name
if (name === 'execute') return 'This will execute a shell command'
if (name === 'write_file') return 'This will create or overwrite a file'
if (name === 'edit_file') return 'This will modify an existing file'
// Get a preview of the command for execute tool
const getCommandPreview = () => {
if (toolCall.name === 'execute' && args.command) {
const cmd = String(args.command)
return cmd.length > 60 ? cmd.substring(0, 60) + '...' : cmd
}
return null
}
const warning = getToolWarning()
const commandPreview = getCommandPreview()
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm">
<div className="w-full max-w-lg rounded-sm border border-border bg-card p-6 shadow-lg">
{/* Header */}
<div className="flex items-start justify-between mb-4">
<div>
<div className="flex items-center gap-2 mb-1">
<AlertTriangle className="size-5 text-status-warning" />
<h2 className="text-lg font-medium">Tool Approval Required</h2>
</div>
<p className="text-sm text-muted-foreground">
The agent wants to execute the following action
</p>
</div>
<Badge variant="warning">{request.tool_call.name}</Badge>
<div className="rounded-md border border-amber-500/50 bg-amber-500/5 overflow-hidden">
{/* Header - always visible */}
<div
className="flex items-center gap-3 px-4 py-3 cursor-pointer hover:bg-amber-500/10 transition-colors"
onClick={() => setIsExpanded(!isExpanded)}
>
<div className="flex items-center justify-center size-8 rounded-md bg-amber-500/20 text-amber-500">
<Terminal className="size-4" />
</div>
{/* Warning */}
{warning && (
<div className="mb-4 rounded-sm border border-status-warning/30 bg-status-warning/10 p-3 text-sm text-status-warning">
{warning}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">Approval Required</span>
<Badge variant="warning" className="text-[10px] px-1.5 py-0">
{toolCall.name}
</Badge>
</div>
)}
{/* Arguments */}
<div className="mb-4">
<div className="flex items-center justify-between mb-2">
<span className="text-section-header">ARGUMENTS</span>
<Button
variant="ghost"
size="sm"
onClick={() => setIsEditing(!isEditing)}
>
<Edit2 className="size-3 mr-1" />
{isEditing ? 'Cancel Edit' : 'Edit'}
</Button>
</div>
{isEditing ? (
<textarea
value={editedArgs}
onChange={(e) => setEditedArgs(e.target.value)}
className="w-full h-48 rounded-sm border border-border bg-background p-3 font-mono text-xs focus:outline-none focus:ring-1 focus:ring-ring"
/>
) : (
<pre className="rounded-sm border border-border bg-background p-3 font-mono text-xs overflow-x-auto max-h-48">
{JSON.stringify(request.tool_call.args, null, 2)}
</pre>
{commandPreview && !isExpanded && (
<div className="text-xs text-muted-foreground font-mono truncate mt-0.5">
{commandPreview}
</div>
)}
</div>
{/* Actions */}
<div className="flex items-center justify-end gap-2">
<Button variant="outline" onClick={handleReject}>
<X className="size-4 mr-1" />
Reject
</Button>
<Button variant="nominal" onClick={handleApprove}>
<Check className="size-4 mr-1" />
{isEditing ? 'Apply & Approve' : 'Approve'}
</Button>
<div className="flex items-center gap-2">
{!isExpanded && (
<>
<Button
size="sm"
variant="ghost"
className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground"
onClick={(e) => {
e.stopPropagation()
handleReject()
}}
>
<X className="size-3 mr-1" />
Reject
</Button>
<Button
size="sm"
variant="nominal"
className="h-7 px-2 text-xs"
onClick={(e) => {
e.stopPropagation()
handleApprove()
}}
>
<Check className="size-3 mr-1" />
Run
</Button>
</>
)}
{isExpanded ? (
<ChevronUp className="size-4 text-muted-foreground" />
) : (
<ChevronDown className="size-4 text-muted-foreground" />
)}
</div>
</div>
{/* Expanded content */}
{isExpanded && (
<div className="px-4 pb-4 border-t border-amber-500/20">
{/* Arguments */}
<div className="mt-3">
<div className="flex items-center justify-between mb-2">
<span className="text-section-header text-[10px]">ARGUMENTS</span>
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={() => setIsEditing(!isEditing)}
>
<Edit2 className="size-3 mr-1" />
{isEditing ? 'Cancel' : 'Edit'}
</Button>
</div>
{isEditing ? (
<textarea
value={editedArgs}
onChange={(e) => setEditedArgs(e.target.value)}
className="w-full h-32 rounded-sm border border-border bg-background p-2 font-mono text-xs focus:outline-none focus:ring-1 focus:ring-ring"
/>
) : (
<pre className="rounded-sm border border-border bg-background p-2 font-mono text-xs overflow-x-auto max-h-32">
{JSON.stringify(args, null, 2)}
</pre>
)}
</div>
{/* Actions */}
<div className="flex items-center justify-end gap-2 mt-3">
<Button variant="outline" size="sm" className="h-8" onClick={handleReject}>
<X className="size-3.5 mr-1" />
Reject
</Button>
<Button variant="nominal" size="sm" className="h-8" onClick={handleApprove}>
<Check className="size-3.5 mr-1" />
{isEditing ? 'Apply & Run' : 'Run'}
</Button>
</div>
</div>
)}
</div>
)
}
+133 -76
View File
@@ -43,6 +43,13 @@ interface AccumulatedToolCall {
args: string // Accumulated JSON string
}
// Completed tool call with parsed args
interface CompletedToolCall {
id: string
name: string
args: Record<string, unknown>
}
/**
* Custom transport for useStream that uses Electron IPC instead of HTTP.
* This allows useStream to work seamlessly in an Electron app where the
@@ -58,17 +65,24 @@ export class ElectronIPCTransport implements UseStreamTransport {
// Track accumulated tool call chunks (for streaming tool calls)
private accumulatedToolCalls: Map<string, AccumulatedToolCall> = new Map()
// Track completed tool calls by name for HITL matching
private completedToolCallsByName: Map<string, CompletedToolCall[]> = new Map()
async stream(payload: StreamPayload): Promise<AsyncGenerator<StreamEvent>> {
// Reset state for new stream
this.currentMessageId = null
this.activeSubagents.clear()
this.accumulatedToolCalls.clear()
this.completedToolCallsByName.clear()
// Extract thread ID from config
const threadId = payload.config?.configurable?.thread_id
if (!threadId) {
return this.createErrorGenerator('MISSING_THREAD_ID', 'Thread ID is required')
}
// Check if this is a resume command (no message needed)
const hasResumeCommand = payload.command?.resume !== undefined
// Extract the message content from input
const input = payload.input as
| { messages?: Array<{ content: string; type: string }> }
@@ -78,7 +92,8 @@ export class ElectronIPCTransport implements UseStreamTransport {
const lastHumanMessage = messages.find((m) => m.type === 'human')
const messageContent = lastHumanMessage?.content ?? ''
if (!messageContent) {
// Only require message content if not resuming
if (!messageContent && !hasResumeCommand) {
return this.createErrorGenerator('MISSING_MESSAGE', 'Message content is required')
}
@@ -123,8 +138,6 @@ export class ElectronIPCTransport implements UseStreamTransport {
const sdkEvents = this.convertToSDKEvents(ipcEvent as IPCEvent, threadId)
for (const sdkEvent of sdkEvents) {
console.log('[Transport] Converted event:', sdkEvent)
if (sdkEvent.event === 'done' || sdkEvent.event === 'error') {
isDone = true
hasError = sdkEvent.event === 'error'
@@ -200,13 +213,10 @@ export class ElectronIPCTransport implements UseStreamTransport {
private convertToSDKEvents(event: IPCEvent, threadId: string): StreamEvent[] {
const events: StreamEvent[] = []
console.log('[Transport] convertToSDKEvents:', { type: event.type })
switch (event.type) {
// Raw stream events from LangGraph - parse and convert
case 'stream': {
const streamEvents = this.processStreamEvent(event)
console.log('[Transport] processStreamEvent returned:', streamEvents.length, 'events')
events.push(...streamEvents)
break
}
@@ -276,19 +286,50 @@ export class ElectronIPCTransport implements UseStreamTransport {
})
}
// Emit interrupt
// Emit interrupt - handle both legacy format and new langchain HITL format
if (interrupt) {
events.push({
event: 'custom',
data: {
type: 'interrupt',
request: {
id: interrupt.id || crypto.randomUUID(),
tool_call: interrupt.tool_call,
allowed_decisions: ['approve', 'reject', 'edit']
}
// Check if this is the new array format from langchain HITL
if (Array.isArray(interrupt) && interrupt.length > 0) {
const interruptValue = interrupt[0]?.value
const actionRequests = interruptValue?.actionRequests
const reviewConfigs = interruptValue?.reviewConfigs
if (actionRequests?.length) {
const firstAction = actionRequests[0]
const reviewConfig = reviewConfigs?.find(
(rc: { actionName: string }) => rc.actionName === firstAction.name
)
events.push({
event: 'custom',
data: {
type: 'interrupt',
request: {
id: firstAction.id || crypto.randomUUID(),
tool_call: {
id: firstAction.id,
name: firstAction.name,
args: firstAction.args || {}
},
allowed_decisions: reviewConfig?.allowedDecisions || ['approve', 'reject', 'edit']
}
}
})
}
})
} else if (interrupt.tool_call) {
// Legacy format with direct tool_call property
events.push({
event: 'custom',
data: {
type: 'interrupt',
request: {
id: interrupt.id || crypto.randomUUID(),
tool_call: interrupt.tool_call,
allowed_decisions: ['approve', 'reject', 'edit']
}
}
})
}
}
break
}
@@ -324,8 +365,6 @@ export class ElectronIPCTransport implements UseStreamTransport {
const events: StreamEvent[] = []
const { mode, data } = event
console.log('[Transport] processStreamEvent:', { mode, dataType: typeof data })
if (mode === 'messages') {
// Messages mode returns [message, metadata] tuples
const [msgChunk, metadata] = data as [SerializedMessageChunk, MessageMetadata]
@@ -335,26 +374,6 @@ export class ElectronIPCTransport implements UseStreamTransport {
const classId = Array.isArray(msgChunk?.id) ? msgChunk.id : []
const className = classId[classId.length - 1] || ''
console.log('[Transport] Messages mode chunk:', {
className,
hasContent: !!kwargs.content,
tool_calls_count: kwargs.tool_calls?.length,
tool_call_chunks_count: kwargs.tool_call_chunks?.length,
name: kwargs.name,
tool_call_id: kwargs.tool_call_id
})
// Debug logging
if (kwargs.tool_calls?.length || kwargs.tool_call_chunks?.length) {
console.log('[Transport] Message with tool calls:', {
className,
tool_calls: kwargs.tool_calls,
tool_call_chunks_count: kwargs.tool_call_chunks?.length,
name: kwargs.name,
tool_call_id: kwargs.tool_call_id
})
}
// Check if this is a ToolMessage (class name contains 'ToolMessage')
const isToolMessage = className.includes('ToolMessage') && !!kwargs.tool_call_id
@@ -367,7 +386,6 @@ export class ElectronIPCTransport implements UseStreamTransport {
this.currentMessageId = msgId
if (content || kwargs.tool_calls?.length) {
console.log('[Transport] Processing AI message:', content?.substring(0, 50) || '(tool calls)')
events.push({
event: 'messages',
data: [
@@ -402,6 +420,15 @@ export class ElectronIPCTransport implements UseStreamTransport {
if (kwargs.tool_calls?.length) {
const subagentEvents = this.processCompletedToolCalls(kwargs.tool_calls)
events.push(...subagentEvents)
// Track tool calls for HITL matching
for (const tc of kwargs.tool_calls) {
if (tc.id && tc.name) {
const existing = this.completedToolCallsByName.get(tc.name) || []
existing.push({ id: tc.id, name: tc.name, args: tc.args || {} })
this.completedToolCallsByName.set(tc.name, existing)
}
}
}
}
@@ -427,13 +454,11 @@ export class ElectronIPCTransport implements UseStreamTransport {
// Handle subagent task completion
if (kwargs.name === 'task') {
console.log('[Transport] ToolMessage detected for:', kwargs.tool_call_id)
const completionEvents = this.processToolMessage(kwargs.tool_call_id)
events.push(...completionEvents)
}
}
} else if (mode === 'values') {
console.log('[Transport] Values mode - processing state')
// Values mode returns full state with serialized LangChain messages
const state = data as {
@@ -441,26 +466,24 @@ export class ElectronIPCTransport implements UseStreamTransport {
todos?: { id?: string; content?: string; status?: string }[]
files?: Record<string, unknown> | Array<{ path: string; is_dir?: boolean; size?: number }>
workspacePath?: string
__interrupt__?: { id?: string; tool_call?: unknown }
// __interrupt__ is an array of interrupt objects from langchain HITL middleware
__interrupt__?: Array<{
value?: {
actionRequests?: Array<{ name: string; id: string; args: Record<string, unknown> }>
reviewConfigs?: Array<{ actionName: string; allowedDecisions: string[] }>
}
}>
}
// Process messages in values mode to extract subagents
if (state.messages) {
console.log('[Transport] Values mode has', state.messages.length, 'messages')
for (const msg of state.messages) {
const kwargs = msg.kwargs || {}
const classId = Array.isArray(msg.id) ? msg.id : []
const className = classId[classId.length - 1] || ''
console.log('[Transport] Values message:', {
className,
tool_calls_count: kwargs.tool_calls?.length,
tool_call_id: kwargs.tool_call_id
})
// Check for task tool calls in AI messages
if (kwargs.tool_calls?.length) {
console.log('[Transport] Found message with tool_calls:', kwargs.tool_calls)
for (const toolCall of kwargs.tool_calls) {
if (
toolCall.name === 'task' &&
@@ -471,7 +494,6 @@ export class ElectronIPCTransport implements UseStreamTransport {
if (args.subagent_type || args.description) {
const subagent = this.createSubagentFromTask(toolCall.id, args)
this.activeSubagents.set(toolCall.id, subagent)
console.log('[Transport] Detected subagent from values mode:', subagent)
}
}
}
@@ -483,7 +505,6 @@ export class ElectronIPCTransport implements UseStreamTransport {
if (subagent && subagent.status === 'running') {
subagent.status = 'completed'
subagent.completedAt = new Date()
console.log('[Transport] Subagent completed from values mode:', subagent)
}
}
}
@@ -524,14 +545,26 @@ export class ElectronIPCTransport implements UseStreamTransport {
}
})
events.push({
event: 'values',
data: {
messages: transformedMessages,
todos: state.todos,
workspacePath: state.workspacePath
}
})
// Only emit values event if we have actual data to update
// Don't emit messages: undefined as it would clear the UI
const valuesData: Record<string, unknown> = {}
if (transformedMessages && transformedMessages.length > 0) {
valuesData.messages = transformedMessages
}
if (state.todos !== undefined) {
valuesData.todos = state.todos
}
if (state.workspacePath) {
valuesData.workspacePath = state.workspacePath
}
// Only emit if we have something to update
if (Object.keys(valuesData).length > 0) {
events.push({
event: 'values',
data: valuesData
})
}
// Emit files/workspace
if (state.files) {
@@ -554,19 +587,46 @@ export class ElectronIPCTransport implements UseStreamTransport {
}
}
// Emit interrupt
if (state.__interrupt__) {
events.push({
event: 'custom',
data: {
type: 'interrupt',
request: {
id: state.__interrupt__.id || crypto.randomUUID(),
tool_call: state.__interrupt__.tool_call,
allowed_decisions: ['approve', 'reject', 'edit']
}
// Emit interrupt - langchain HITL returns __interrupt__ as array of { value: HITLRequest }
if (state.__interrupt__?.length) {
const interruptValue = state.__interrupt__[0]?.value
const actionRequests = interruptValue?.actionRequests
const reviewConfigs = interruptValue?.reviewConfigs
// For each action request (tool call) that needs approval
if (actionRequests?.length) {
// Get the first action request for now (can be extended for batch approvals)
const firstAction = actionRequests[0]
const reviewConfig = reviewConfigs?.find((rc) => rc.actionName === firstAction.name)
// The actionRequest doesn't include tool_call.id - look up from tracked tool calls
let toolCallId: string | undefined
// Find the tool call ID from our tracked completed tool calls
const trackedToolCalls = this.completedToolCallsByName.get(firstAction.name)
if (trackedToolCalls && trackedToolCalls.length > 0) {
// Get the most recent tool call with this name
const lastTracked = trackedToolCalls[trackedToolCalls.length - 1]
toolCallId = lastTracked.id
}
})
events.push({
event: 'custom',
data: {
type: 'interrupt',
request: {
id: toolCallId || crypto.randomUUID(),
tool_call: {
id: toolCallId,
name: firstAction.name,
args: firstAction.args || {}
},
allowed_decisions: reviewConfig?.allowedDecisions || ['approve', 'reject', 'edit']
}
}
})
}
}
}
@@ -629,7 +689,6 @@ export class ElectronIPCTransport implements UseStreamTransport {
const subagent = this.createSubagentFromTask(chunk.id, args)
this.activeSubagents.set(chunk.id, subagent)
events.push(this.createSubagentEvent())
console.log('[Transport] Detected subagent task:', subagent)
}
} catch {
// Args not complete yet, continue accumulating
@@ -658,7 +717,6 @@ export class ElectronIPCTransport implements UseStreamTransport {
const subagent = this.createSubagentFromTask(toolCall.id, args)
this.activeSubagents.set(toolCall.id, subagent)
events.push(this.createSubagentEvent())
console.log('[Transport] Detected subagent task (complete):', subagent)
}
}
}
@@ -678,7 +736,6 @@ export class ElectronIPCTransport implements UseStreamTransport {
subagent.status = 'completed'
subagent.completedAt = new Date()
events.push(this.createSubagentEvent())
console.log('[Transport] Subagent completed:', subagent)
}
return events
+13 -6
View File
@@ -435,13 +435,20 @@ export const useAppStore = create<AppState>((set, get) => ({
const { currentThreadId, pendingApproval } = get()
if (!currentThreadId || !pendingApproval) return
await window.api.agent.interrupt(currentThreadId, {
type: decision,
tool_call_id: pendingApproval.tool_call.id,
edited_args: editedArgs
})
// Clear pending approval immediately so UI updates
set({ pendingApproval: null })
// Send interrupt decision - streaming response handled by useStream hook
window.api.agent.interrupt(
currentThreadId,
{
type: decision,
tool_call_id: pendingApproval.tool_call.id,
edited_args: editedArgs
}
// Note: We don't pass onEvent here - the useStream hook in ChatContainer
// will pick up the stream events on the same channel
)
},
// Todo actions