chore: recover wellington workspace state

This commit is contained in:
Nathan Flurry 2026-03-09 19:59:55 -07:00
parent 5d65013aa5
commit c294ca85be
366 changed files with 1265 additions and 53395 deletions

View file

@ -1,24 +1,265 @@
--- ---
name: agent-browser name: agent-browser
description: Browser automation CLI for AI agents. Use when the user needs to interact with websites, including navigating pages, filling forms, clicking buttons, taking screenshots, extracting data, testing web apps, or automating any browser task. Triggers include requests to "open a website", "fill out a form", "click a button", "take a screenshot", "scrape data from a page", "test this web app", "login to a site", "automate browser actions", or any task requiring programmatic web interaction. description: Automates browser interactions for web testing, form filling, screenshots, and data extraction. Use when the user needs to navigate websites, interact with web pages, fill forms, take screenshots, test web applications, or extract information from web pages.
allowed-tools: Bash(npx agent-browser:*), Bash(agent-browser:*) allowed-tools: Bash(agent-browser:*)
--- ---
# Browser Automation with agent-browser # Browser Automation with agent-browser
## Core Workflow ## Quick start
Every browser automation follows this pattern: ```bash
agent-browser open <url> # Navigate to page
agent-browser snapshot -i # Get interactive elements with refs
agent-browser click @e1 # Click element by ref
agent-browser fill @e2 "text" # Fill input by ref
agent-browser close # Close browser
```
1. **Navigate**: `agent-browser open <url>` ## Core workflow
2. **Snapshot**: `agent-browser snapshot -i` (get element refs like `@e1`, `@e2`)
3. **Interact**: Use refs to click, fill, select 1. Navigate: `agent-browser open <url>`
4. **Re-snapshot**: After navigation or DOM changes, get fresh refs 2. Snapshot: `agent-browser snapshot -i` (returns elements with refs like `@e1`, `@e2`)
3. Interact using refs from the snapshot
4. Re-snapshot after navigation or significant DOM changes
## Commands
### Navigation
```bash
agent-browser open <url> # Navigate to URL (aliases: goto, navigate)
# Supports: https://, http://, file://, about:, data://
# Auto-prepends https:// if no protocol given
agent-browser back # Go back
agent-browser forward # Go forward
agent-browser reload # Reload page
agent-browser close # Close browser (aliases: quit, exit)
agent-browser connect 9222 # Connect to browser via CDP port
```
### Snapshot (page analysis)
```bash
agent-browser snapshot # Full accessibility tree
agent-browser snapshot -i # Interactive elements only (recommended)
agent-browser snapshot -c # Compact output
agent-browser snapshot -d 3 # Limit depth to 3
agent-browser snapshot -s "#main" # Scope to CSS selector
```
### Interactions (use @refs from snapshot)
```bash
agent-browser click @e1 # Click
agent-browser dblclick @e1 # Double-click
agent-browser focus @e1 # Focus element
agent-browser fill @e2 "text" # Clear and type
agent-browser type @e2 "text" # Type without clearing
agent-browser press Enter # Press key (alias: key)
agent-browser press Control+a # Key combination
agent-browser keydown Shift # Hold key down
agent-browser keyup Shift # Release key
agent-browser hover @e1 # Hover
agent-browser check @e1 # Check checkbox
agent-browser uncheck @e1 # Uncheck checkbox
agent-browser select @e1 "value" # Select dropdown option
agent-browser select @e1 "a" "b" # Select multiple options
agent-browser scroll down 500 # Scroll page (default: down 300px)
agent-browser scrollintoview @e1 # Scroll element into view (alias: scrollinto)
agent-browser drag @e1 @e2 # Drag and drop
agent-browser upload @e1 file.pdf # Upload files
```
### Get information
```bash
agent-browser get text @e1 # Get element text
agent-browser get html @e1 # Get innerHTML
agent-browser get value @e1 # Get input value
agent-browser get attr @e1 href # Get attribute
agent-browser get title # Get page title
agent-browser get url # Get current URL
agent-browser get count ".item" # Count matching elements
agent-browser get box @e1 # Get bounding box
agent-browser get styles @e1 # Get computed styles (font, color, bg, etc.)
```
### Check state
```bash
agent-browser is visible @e1 # Check if visible
agent-browser is enabled @e1 # Check if enabled
agent-browser is checked @e1 # Check if checked
```
### Screenshots & PDF
```bash
agent-browser screenshot # Save to a temporary directory
agent-browser screenshot path.png # Save to a specific path
agent-browser screenshot --full # Full page
agent-browser pdf output.pdf # Save as PDF
```
### Video recording
```bash
agent-browser record start ./demo.webm # Start recording (uses current URL + state)
agent-browser click @e1 # Perform actions
agent-browser record stop # Stop and save video
agent-browser record restart ./take2.webm # Stop current + start new recording
```
Recording creates a fresh context but preserves cookies/storage from your session. If no URL is provided, it
automatically returns to your current page. For smooth demos, explore first, then start recording.
### Wait
```bash
agent-browser wait @e1 # Wait for element
agent-browser wait 2000 # Wait milliseconds
agent-browser wait --text "Success" # Wait for text (or -t)
agent-browser wait --url "**/dashboard" # Wait for URL pattern (or -u)
agent-browser wait --load networkidle # Wait for network idle (or -l)
agent-browser wait --fn "window.ready" # Wait for JS condition (or -f)
```
### Mouse control
```bash
agent-browser mouse move 100 200 # Move mouse
agent-browser mouse down left # Press button
agent-browser mouse up left # Release button
agent-browser mouse wheel 100 # Scroll wheel
```
### Semantic locators (alternative to refs)
```bash
agent-browser find role button click --name "Submit"
agent-browser find text "Sign In" click
agent-browser find text "Sign In" click --exact # Exact match only
agent-browser find label "Email" fill "user@test.com"
agent-browser find placeholder "Search" type "query"
agent-browser find alt "Logo" click
agent-browser find title "Close" click
agent-browser find testid "submit-btn" click
agent-browser find first ".item" click
agent-browser find last ".item" click
agent-browser find nth 2 "a" hover
```
### Browser settings
```bash
agent-browser set viewport 1920 1080 # Set viewport size
agent-browser set device "iPhone 14" # Emulate device
agent-browser set geo 37.7749 -122.4194 # Set geolocation (alias: geolocation)
agent-browser set offline on # Toggle offline mode
agent-browser set headers '{"X-Key":"v"}' # Extra HTTP headers
agent-browser set credentials user pass # HTTP basic auth (alias: auth)
agent-browser set media dark # Emulate color scheme
agent-browser set media light reduced-motion # Light mode + reduced motion
```
### Cookies & Storage
```bash
agent-browser cookies # Get all cookies
agent-browser cookies set name value # Set cookie
agent-browser cookies clear # Clear cookies
agent-browser storage local # Get all localStorage
agent-browser storage local key # Get specific key
agent-browser storage local set k v # Set value
agent-browser storage local clear # Clear all
```
### Network
```bash
agent-browser network route <url> # Intercept requests
agent-browser network route <url> --abort # Block requests
agent-browser network route <url> --body '{}' # Mock response
agent-browser network unroute [url] # Remove routes
agent-browser network requests # View tracked requests
agent-browser network requests --filter api # Filter requests
```
### Tabs & Windows
```bash
agent-browser tab # List tabs
agent-browser tab new [url] # New tab
agent-browser tab 2 # Switch to tab by index
agent-browser tab close # Close current tab
agent-browser tab close 2 # Close tab by index
agent-browser window new # New window
```
### Frames
```bash
agent-browser frame "#iframe" # Switch to iframe
agent-browser frame main # Back to main frame
```
### Dialogs
```bash
agent-browser dialog accept [text] # Accept dialog
agent-browser dialog dismiss # Dismiss dialog
```
### JavaScript
```bash
agent-browser eval "document.title" # Run JavaScript
```
## Global options
```bash
agent-browser --session <name> ... # Isolated browser session
agent-browser --json ... # JSON output for parsing
agent-browser --headed ... # Show browser window (not headless)
agent-browser --full ... # Full page screenshot (-f)
agent-browser --cdp <port> ... # Connect via Chrome DevTools Protocol
agent-browser -p <provider> ... # Cloud browser provider (--provider)
agent-browser --proxy <url> ... # Use proxy server
agent-browser --headers <json> ... # HTTP headers scoped to URL's origin
agent-browser --executable-path <p> # Custom browser executable
agent-browser --extension <path> ... # Load browser extension (repeatable)
agent-browser --help # Show help (-h)
agent-browser --version # Show version (-V)
agent-browser <command> --help # Show detailed help for a command
```
### Proxy support
```bash
agent-browser --proxy http://proxy.com:8080 open example.com
agent-browser --proxy http://user:pass@proxy.com:8080 open example.com
agent-browser --proxy socks5://proxy.com:1080 open example.com
```
## Environment variables
```bash
AGENT_BROWSER_SESSION="mysession" # Default session name
AGENT_BROWSER_EXECUTABLE_PATH="/path/chrome" # Custom browser path
AGENT_BROWSER_EXTENSIONS="/ext1,/ext2" # Comma-separated extension paths
AGENT_BROWSER_PROVIDER="your-cloud-browser-provider" # Cloud browser provider (select browseruse or browserbase)
AGENT_BROWSER_STREAM_PORT="9223" # WebSocket streaming port
AGENT_BROWSER_HOME="/path/to/agent-browser" # Custom install location (for daemon.js)
```
## Example: Form submission
```bash ```bash
agent-browser open https://example.com/form agent-browser open https://example.com/form
agent-browser snapshot -i agent-browser snapshot -i
# Output: @e1 [input type="email"], @e2 [input type="password"], @e3 [button] "Submit" # Output shows: textbox "Email" [ref=e1], textbox "Password" [ref=e2], button "Submit" [ref=e3]
agent-browser fill @e1 "user@example.com" agent-browser fill @e1 "user@example.com"
agent-browser fill @e2 "password123" agent-browser fill @e2 "password123"
@ -27,504 +268,72 @@ agent-browser wait --load networkidle
agent-browser snapshot -i # Check result agent-browser snapshot -i # Check result
``` ```
## Command Chaining ## Example: Authentication with saved state
Commands can be chained with `&&` in a single shell invocation. The browser persists between commands via a background daemon, so chaining is safe and more efficient than separate calls.
```bash ```bash
# Chain open + wait + snapshot in one call # Login once
agent-browser open https://example.com && agent-browser wait --load networkidle && agent-browser snapshot -i
# Chain multiple interactions
agent-browser fill @e1 "user@example.com" && agent-browser fill @e2 "password123" && agent-browser click @e3
# Navigate and capture
agent-browser open https://example.com && agent-browser wait --load networkidle && agent-browser screenshot page.png
```
**When to chain:** Use `&&` when you don't need to read the output of an intermediate command before proceeding (e.g., open + wait + screenshot). Run commands separately when you need to parse the output first (e.g., snapshot to discover refs, then interact using those refs).
## Essential Commands
```bash
# Navigation
agent-browser open <url> # Navigate (aliases: goto, navigate)
agent-browser close # Close browser
# Snapshot
agent-browser snapshot -i # Interactive elements with refs (recommended)
agent-browser snapshot -i -C # Include cursor-interactive elements (divs with onclick, cursor:pointer)
agent-browser snapshot -s "#selector" # Scope to CSS selector
# Interaction (use @refs from snapshot)
agent-browser click @e1 # Click element
agent-browser click @e1 --new-tab # Click and open in new tab
agent-browser fill @e2 "text" # Clear and type text
agent-browser type @e2 "text" # Type without clearing
agent-browser select @e1 "option" # Select dropdown option
agent-browser check @e1 # Check checkbox
agent-browser press Enter # Press key
agent-browser keyboard type "text" # Type at current focus (no selector)
agent-browser keyboard inserttext "text" # Insert without key events
agent-browser scroll down 500 # Scroll page
agent-browser scroll down 500 --selector "div.content" # Scroll within a specific container
# Get information
agent-browser get text @e1 # Get element text
agent-browser get url # Get current URL
agent-browser get title # Get page title
# Wait
agent-browser wait @e1 # Wait for element
agent-browser wait --load networkidle # Wait for network idle
agent-browser wait --url "**/page" # Wait for URL pattern
agent-browser wait 2000 # Wait milliseconds
# Downloads
agent-browser download @e1 ./file.pdf # Click element to trigger download
agent-browser wait --download ./output.zip # Wait for any download to complete
agent-browser --download-path ./downloads open <url> # Set default download directory
# Capture
agent-browser screenshot # Screenshot to temp dir
agent-browser screenshot --full # Full page screenshot
agent-browser screenshot --annotate # Annotated screenshot with numbered element labels
agent-browser pdf output.pdf # Save as PDF
# Diff (compare page states)
agent-browser diff snapshot # Compare current vs last snapshot
agent-browser diff snapshot --baseline before.txt # Compare current vs saved file
agent-browser diff screenshot --baseline before.png # Visual pixel diff
agent-browser diff url <url1> <url2> # Compare two pages
agent-browser diff url <url1> <url2> --wait-until networkidle # Custom wait strategy
agent-browser diff url <url1> <url2> --selector "#main" # Scope to element
```
## Common Patterns
### Form Submission
```bash
agent-browser open https://example.com/signup
agent-browser snapshot -i
agent-browser fill @e1 "Jane Doe"
agent-browser fill @e2 "jane@example.com"
agent-browser select @e3 "California"
agent-browser check @e4
agent-browser click @e5
agent-browser wait --load networkidle
```
### Authentication with Auth Vault (Recommended)
```bash
# Save credentials once (encrypted with AGENT_BROWSER_ENCRYPTION_KEY)
# Recommended: pipe password via stdin to avoid shell history exposure
echo "pass" | agent-browser auth save github --url https://github.com/login --username user --password-stdin
# Login using saved profile (LLM never sees password)
agent-browser auth login github
# List/show/delete profiles
agent-browser auth list
agent-browser auth show github
agent-browser auth delete github
```
### Authentication with State Persistence
```bash
# Login once and save state
agent-browser open https://app.example.com/login agent-browser open https://app.example.com/login
agent-browser snapshot -i agent-browser snapshot -i
agent-browser fill @e1 "$USERNAME" agent-browser fill @e1 "username"
agent-browser fill @e2 "$PASSWORD" agent-browser fill @e2 "password"
agent-browser click @e3 agent-browser click @e3
agent-browser wait --url "**/dashboard" agent-browser wait --url "**/dashboard"
agent-browser state save auth.json agent-browser state save auth.json
# Reuse in future sessions # Later sessions: load saved state
agent-browser state load auth.json agent-browser state load auth.json
agent-browser open https://app.example.com/dashboard agent-browser open https://app.example.com/dashboard
``` ```
### Session Persistence ## Sessions (parallel browsers)
```bash ```bash
# Auto-save/restore cookies and localStorage across browser restarts agent-browser --session test1 open site-a.com
agent-browser --session-name myapp open https://app.example.com/login agent-browser --session test2 open site-b.com
# ... login flow ... agent-browser session list
agent-browser close # State auto-saved to ~/.agent-browser/sessions/
# Next time, state is auto-loaded
agent-browser --session-name myapp open https://app.example.com/dashboard
# Encrypt state at rest
export AGENT_BROWSER_ENCRYPTION_KEY=$(openssl rand -hex 32)
agent-browser --session-name secure open https://app.example.com
# Manage saved states
agent-browser state list
agent-browser state show myapp-default.json
agent-browser state clear myapp
agent-browser state clean --older-than 7
``` ```
### Data Extraction ## JSON output (for parsing)
Add `--json` for machine-readable output:
```bash ```bash
agent-browser open https://example.com/products
agent-browser snapshot -i
agent-browser get text @e5 # Get specific element text
agent-browser get text body > page.txt # Get all page text
# JSON output for parsing
agent-browser snapshot -i --json agent-browser snapshot -i --json
agent-browser get text @e1 --json agent-browser get text @e1 --json
``` ```
### Parallel Sessions ## Debugging
```bash ```bash
agent-browser --session site1 open https://site-a.com agent-browser --headed open example.com # Show browser window
agent-browser --session site2 open https://site-b.com agent-browser --cdp 9222 snapshot # Connect via CDP port
agent-browser connect 9222 # Alternative: connect command
agent-browser --session site1 snapshot -i agent-browser console # View console messages
agent-browser --session site2 snapshot -i agent-browser console --clear # Clear console
agent-browser errors # View page errors
agent-browser session list agent-browser errors --clear # Clear errors
agent-browser highlight @e1 # Highlight element
agent-browser trace start # Start recording trace
agent-browser trace stop trace.zip # Stop and save trace
agent-browser record start ./debug.webm # Record video from current page
agent-browser record stop # Save recording
``` ```
### Connect to Existing Chrome ## Deep-dive documentation
```bash For detailed patterns and best practices, see:
# Auto-discover running Chrome with remote debugging enabled
agent-browser --auto-connect open https://example.com
agent-browser --auto-connect snapshot
# Or with explicit CDP port | Reference | Description |
agent-browser --cdp 9222 snapshot
```
### Color Scheme (Dark Mode)
```bash
# Persistent dark mode via flag (applies to all pages and new tabs)
agent-browser --color-scheme dark open https://example.com
# Or via environment variable
AGENT_BROWSER_COLOR_SCHEME=dark agent-browser open https://example.com
# Or set during session (persists for subsequent commands)
agent-browser set media dark
```
### Visual Browser (Debugging)
```bash
agent-browser --headed open https://example.com
agent-browser highlight @e1 # Highlight element
agent-browser record start demo.webm # Record session
agent-browser profiler start # Start Chrome DevTools profiling
agent-browser profiler stop trace.json # Stop and save profile (path optional)
```
Use `AGENT_BROWSER_HEADED=1` to enable headed mode via environment variable. Browser extensions work in both headed and headless mode.
### Local Files (PDFs, HTML)
```bash
# Open local files with file:// URLs
agent-browser --allow-file-access open file:///path/to/document.pdf
agent-browser --allow-file-access open file:///path/to/page.html
agent-browser screenshot output.png
```
### iOS Simulator (Mobile Safari)
```bash
# List available iOS simulators
agent-browser device list
# Launch Safari on a specific device
agent-browser -p ios --device "iPhone 16 Pro" open https://example.com
# Same workflow as desktop - snapshot, interact, re-snapshot
agent-browser -p ios snapshot -i
agent-browser -p ios tap @e1 # Tap (alias for click)
agent-browser -p ios fill @e2 "text"
agent-browser -p ios swipe up # Mobile-specific gesture
# Take screenshot
agent-browser -p ios screenshot mobile.png
# Close session (shuts down simulator)
agent-browser -p ios close
```
**Requirements:** macOS with Xcode, Appium (`npm install -g appium && appium driver install xcuitest`)
**Real devices:** Works with physical iOS devices if pre-configured. Use `--device "<UDID>"` where UDID is from `xcrun xctrace list devices`.
## Security
All security features are opt-in. By default, agent-browser imposes no restrictions on navigation, actions, or output.
### Content Boundaries (Recommended for AI Agents)
Enable `--content-boundaries` to wrap page-sourced output in markers that help LLMs distinguish tool output from untrusted page content:
```bash
export AGENT_BROWSER_CONTENT_BOUNDARIES=1
agent-browser snapshot
# Output:
# --- AGENT_BROWSER_PAGE_CONTENT nonce=<hex> origin=https://example.com ---
# [accessibility tree]
# --- END_AGENT_BROWSER_PAGE_CONTENT nonce=<hex> ---
```
### Domain Allowlist
Restrict navigation to trusted domains. Wildcards like `*.example.com` also match the bare domain `example.com`. Sub-resource requests, WebSocket, and EventSource connections to non-allowed domains are also blocked. Include CDN domains your target pages depend on:
```bash
export AGENT_BROWSER_ALLOWED_DOMAINS="example.com,*.example.com"
agent-browser open https://example.com # OK
agent-browser open https://malicious.com # Blocked
```
### Action Policy
Use a policy file to gate destructive actions:
```bash
export AGENT_BROWSER_ACTION_POLICY=./policy.json
```
Example `policy.json`:
```json
{"default": "deny", "allow": ["navigate", "snapshot", "click", "scroll", "wait", "get"]}
```
Auth vault operations (`auth login`, etc.) bypass action policy but domain allowlist still applies.
### Output Limits
Prevent context flooding from large pages:
```bash
export AGENT_BROWSER_MAX_OUTPUT=50000
```
## Diffing (Verifying Changes)
Use `diff snapshot` after performing an action to verify it had the intended effect. This compares the current accessibility tree against the last snapshot taken in the session.
```bash
# Typical workflow: snapshot -> action -> diff
agent-browser snapshot -i # Take baseline snapshot
agent-browser click @e2 # Perform action
agent-browser diff snapshot # See what changed (auto-compares to last snapshot)
```
For visual regression testing or monitoring:
```bash
# Save a baseline screenshot, then compare later
agent-browser screenshot baseline.png
# ... time passes or changes are made ...
agent-browser diff screenshot --baseline baseline.png
# Compare staging vs production
agent-browser diff url https://staging.example.com https://prod.example.com --screenshot
```
`diff snapshot` output uses `+` for additions and `-` for removals, similar to git diff. `diff screenshot` produces a diff image with changed pixels highlighted in red, plus a mismatch percentage.
## Timeouts and Slow Pages
The default Playwright timeout is 25 seconds for local browsers. This can be overridden with the `AGENT_BROWSER_DEFAULT_TIMEOUT` environment variable (value in milliseconds). For slow websites or large pages, use explicit waits instead of relying on the default timeout:
```bash
# Wait for network activity to settle (best for slow pages)
agent-browser wait --load networkidle
# Wait for a specific element to appear
agent-browser wait "#content"
agent-browser wait @e1
# Wait for a specific URL pattern (useful after redirects)
agent-browser wait --url "**/dashboard"
# Wait for a JavaScript condition
agent-browser wait --fn "document.readyState === 'complete'"
# Wait a fixed duration (milliseconds) as a last resort
agent-browser wait 5000
```
When dealing with consistently slow websites, use `wait --load networkidle` after `open` to ensure the page is fully loaded before taking a snapshot. If a specific element is slow to render, wait for it directly with `wait <selector>` or `wait @ref`.
## Session Management and Cleanup
When running multiple agents or automations concurrently, always use named sessions to avoid conflicts:
```bash
# Each agent gets its own isolated session
agent-browser --session agent1 open site-a.com
agent-browser --session agent2 open site-b.com
# Check active sessions
agent-browser session list
```
Always close your browser session when done to avoid leaked processes:
```bash
agent-browser close # Close default session
agent-browser --session agent1 close # Close specific session
```
If a previous session was not closed properly, the daemon may still be running. Use `agent-browser close` to clean it up before starting new work.
## Ref Lifecycle (Important)
Refs (`@e1`, `@e2`, etc.) are invalidated when the page changes. Always re-snapshot after:
- Clicking links or buttons that navigate
- Form submissions
- Dynamic content loading (dropdowns, modals)
```bash
agent-browser click @e5 # Navigates to new page
agent-browser snapshot -i # MUST re-snapshot
agent-browser click @e1 # Use new refs
```
## Annotated Screenshots (Vision Mode)
Use `--annotate` to take a screenshot with numbered labels overlaid on interactive elements. Each label `[N]` maps to ref `@eN`. This also caches refs, so you can interact with elements immediately without a separate snapshot.
```bash
agent-browser screenshot --annotate
# Output includes the image path and a legend:
# [1] @e1 button "Submit"
# [2] @e2 link "Home"
# [3] @e3 textbox "Email"
agent-browser click @e2 # Click using ref from annotated screenshot
```
Use annotated screenshots when:
- The page has unlabeled icon buttons or visual-only elements
- You need to verify visual layout or styling
- Canvas or chart elements are present (invisible to text snapshots)
- You need spatial reasoning about element positions
## Semantic Locators (Alternative to Refs)
When refs are unavailable or unreliable, use semantic locators:
```bash
agent-browser find text "Sign In" click
agent-browser find label "Email" fill "user@test.com"
agent-browser find role button click --name "Submit"
agent-browser find placeholder "Search" type "query"
agent-browser find testid "submit-btn" click
```
## JavaScript Evaluation (eval)
Use `eval` to run JavaScript in the browser context. **Shell quoting can corrupt complex expressions** -- use `--stdin` or `-b` to avoid issues.
```bash
# Simple expressions work with regular quoting
agent-browser eval 'document.title'
agent-browser eval 'document.querySelectorAll("img").length'
# Complex JS: use --stdin with heredoc (RECOMMENDED)
agent-browser eval --stdin <<'EVALEOF'
JSON.stringify(
Array.from(document.querySelectorAll("img"))
.filter(i => !i.alt)
.map(i => ({ src: i.src.split("/").pop(), width: i.width }))
)
EVALEOF
# Alternative: base64 encoding (avoids all shell escaping issues)
agent-browser eval -b "$(echo -n 'Array.from(document.querySelectorAll("a")).map(a => a.href)' | base64)"
```
**Why this matters:** When the shell processes your command, inner double quotes, `!` characters (history expansion), backticks, and `$()` can all corrupt the JavaScript before it reaches agent-browser. The `--stdin` and `-b` flags bypass shell interpretation entirely.
**Rules of thumb:**
- Single-line, no nested quotes -> regular `eval 'expression'` with single quotes is fine
- Nested quotes, arrow functions, template literals, or multiline -> use `eval --stdin <<'EVALEOF'`
- Programmatic/generated scripts -> use `eval -b` with base64
## Configuration File
Create `agent-browser.json` in the project root for persistent settings:
```json
{
"headed": true,
"proxy": "http://localhost:8080",
"profile": "./browser-data"
}
```
Priority (lowest to highest): `~/.agent-browser/config.json` < `./agent-browser.json` < env vars < CLI flags. Use `--config <path>` or `AGENT_BROWSER_CONFIG` env var for a custom config file (exits with error if missing/invalid). All CLI options map to camelCase keys (e.g., `--executable-path` -> `"executablePath"`). Boolean flags accept `true`/`false` values (e.g., `--headed false` overrides config). Extensions from user and project configs are merged, not replaced.
## Deep-Dive Documentation
| Reference | When to Use |
|-----------|-------------| |-----------|-------------|
| [references/commands.md](references/commands.md) | Full command reference with all options |
| [references/snapshot-refs.md](references/snapshot-refs.md) | Ref lifecycle, invalidation rules, troubleshooting | | [references/snapshot-refs.md](references/snapshot-refs.md) | Ref lifecycle, invalidation rules, troubleshooting |
| [references/session-management.md](references/session-management.md) | Parallel sessions, state persistence, concurrent scraping | | [references/session-management.md](references/session-management.md) | Parallel sessions, state persistence, concurrent scraping |
| [references/authentication.md](references/authentication.md) | Login flows, OAuth, 2FA handling, state reuse | | [references/authentication.md](references/authentication.md) | Login flows, OAuth, 2FA handling, state reuse |
| [references/video-recording.md](references/video-recording.md) | Recording workflows for debugging and documentation | | [references/video-recording.md](references/video-recording.md) | Recording workflows for debugging and documentation |
| [references/profiling.md](references/profiling.md) | Chrome DevTools profiling for performance analysis |
| [references/proxy-support.md](references/proxy-support.md) | Proxy configuration, geo-testing, rotating proxies | | [references/proxy-support.md](references/proxy-support.md) | Proxy configuration, geo-testing, rotating proxies |
## Experimental: Native Mode ## Ready-to-use templates
agent-browser has an experimental native Rust daemon that communicates with Chrome directly via CDP, bypassing Node.js and Playwright entirely. It is opt-in and not recommended for production use yet. Executable workflow scripts for common patterns:
```bash
# Enable via flag
agent-browser --native open example.com
# Enable via environment variable (avoids passing --native every time)
export AGENT_BROWSER_NATIVE=1
agent-browser open example.com
```
The native daemon supports Chromium and Safari (via WebDriver). Firefox and WebKit are not yet supported. All core commands (navigate, snapshot, click, fill, screenshot, cookies, storage, tabs, eval, etc.) work identically in native mode. Use `agent-browser close` before switching between native and default mode within the same session.
## Browser Engine Selection
Use `--engine` to choose a local browser engine. The default is `chrome`.
```bash
# Use Lightpanda (fast headless browser, requires separate install)
agent-browser --engine lightpanda open example.com
# Via environment variable
export AGENT_BROWSER_ENGINE=lightpanda
agent-browser open example.com
# With custom binary path
agent-browser --engine lightpanda --executable-path /path/to/lightpanda open example.com
```
Supported engines:
- `chrome` (default) -- Chrome/Chromium via CDP
- `lightpanda` -- Lightpanda headless browser via CDP (10x faster, 10x less memory than Chrome)
Lightpanda does not support `--extension`, `--profile`, `--state`, or `--allow-file-access`. Install Lightpanda from https://lightpanda.io/docs/open-source/installation.
## Ready-to-Use Templates
| Template | Description | | Template | Description |
|----------|-------------| |----------|-------------|
@ -532,8 +341,16 @@ Lightpanda does not support `--extension`, `--profile`, `--state`, or `--allow-f
| [templates/authenticated-session.sh](templates/authenticated-session.sh) | Login once, reuse state | | [templates/authenticated-session.sh](templates/authenticated-session.sh) | Login once, reuse state |
| [templates/capture-workflow.sh](templates/capture-workflow.sh) | Content extraction with screenshots | | [templates/capture-workflow.sh](templates/capture-workflow.sh) | Content extraction with screenshots |
Usage:
```bash ```bash
./templates/form-automation.sh https://example.com/form ./templates/form-automation.sh https://example.com/form
./templates/authenticated-session.sh https://app.example.com/login ./templates/authenticated-session.sh https://app.example.com/login
./templates/capture-workflow.sh https://example.com ./output ./templates/capture-workflow.sh https://example.com ./output
``` ```
## HTTPS Certificate Errors
For sites with self-signed or invalid certificates:
```bash
agent-browser open https://localhost:8443 --ignore-https-errors
```

View file

@ -1,20 +1,6 @@
# Authentication Patterns # Authentication Patterns
Login flows, session persistence, OAuth, 2FA, and authenticated browsing. Patterns for handling login flows, session persistence, and authenticated browsing.
**Related**: [session-management.md](session-management.md) for state persistence details, [SKILL.md](../SKILL.md) for quick start.
## Contents
- [Basic Login Flow](#basic-login-flow)
- [Saving Authentication State](#saving-authentication-state)
- [Restoring Authentication](#restoring-authentication)
- [OAuth / SSO Flows](#oauth--sso-flows)
- [Two-Factor Authentication](#two-factor-authentication)
- [HTTP Basic Auth](#http-basic-auth)
- [Cookie-Based Auth](#cookie-based-auth)
- [Token Refresh Handling](#token-refresh-handling)
- [Security Best Practices](#security-best-practices)
## Basic Login Flow ## Basic Login Flow

View file

@ -1,29 +1,13 @@
# Proxy Support # Proxy Support
Proxy configuration for geo-testing, rate limiting avoidance, and corporate environments. Configure proxy servers for browser automation, useful for geo-testing, rate limiting avoidance, and corporate environments.
**Related**: [commands.md](commands.md) for global options, [SKILL.md](../SKILL.md) for quick start.
## Contents
- [Basic Proxy Configuration](#basic-proxy-configuration)
- [Authenticated Proxy](#authenticated-proxy)
- [SOCKS Proxy](#socks-proxy)
- [Proxy Bypass](#proxy-bypass)
- [Common Use Cases](#common-use-cases)
- [Verifying Proxy Connection](#verifying-proxy-connection)
- [Troubleshooting](#troubleshooting)
- [Best Practices](#best-practices)
## Basic Proxy Configuration ## Basic Proxy Configuration
Use the `--proxy` flag or set proxy via environment variable: Set proxy via environment variable before starting:
```bash ```bash
# Via CLI flag # HTTP proxy
agent-browser --proxy "http://proxy.example.com:8080" open https://example.com
# Via environment variable
export HTTP_PROXY="http://proxy.example.com:8080" export HTTP_PROXY="http://proxy.example.com:8080"
agent-browser open https://example.com agent-browser open https://example.com
@ -61,13 +45,10 @@ agent-browser open https://example.com
## Proxy Bypass ## Proxy Bypass
Skip proxy for specific domains using `--proxy-bypass` or `NO_PROXY`: Skip proxy for specific domains:
```bash ```bash
# Via CLI flag # Bypass proxy for local addresses
agent-browser --proxy "http://proxy.example.com:8080" --proxy-bypass "localhost,*.internal.com" open https://example.com
# Via environment variable
export NO_PROXY="localhost,127.0.0.1,.internal.company.com" export NO_PROXY="localhost,127.0.0.1,.internal.company.com"
agent-browser open https://internal.company.com # Direct connection agent-browser open https://internal.company.com # Direct connection
agent-browser open https://external.com # Via proxy agent-browser open https://external.com # Via proxy

View file

@ -1,18 +1,6 @@
# Session Management # Session Management
Multiple isolated browser sessions with state persistence and concurrent browsing. Run multiple isolated browser sessions concurrently with state persistence.
**Related**: [authentication.md](authentication.md) for login patterns, [SKILL.md](../SKILL.md) for quick start.
## Contents
- [Named Sessions](#named-sessions)
- [Session Isolation Properties](#session-isolation-properties)
- [Session State Persistence](#session-state-persistence)
- [Common Patterns](#common-patterns)
- [Default Session](#default-session)
- [Session Cleanup](#session-cleanup)
- [Best Practices](#best-practices)
## Named Sessions ## Named Sessions

View file

@ -1,29 +1,21 @@
# Snapshot and Refs # Snapshot + Refs Workflow
Compact element references that reduce context usage dramatically for AI agents. The core innovation of agent-browser: compact element references that reduce context usage dramatically for AI agents.
**Related**: [commands.md](commands.md) for full command reference, [SKILL.md](../SKILL.md) for quick start. ## How It Works
## Contents ### The Problem
Traditional browser automation sends full DOM to AI agents:
- [How Refs Work](#how-refs-work)
- [Snapshot Command](#the-snapshot-command)
- [Using Refs](#using-refs)
- [Ref Lifecycle](#ref-lifecycle)
- [Best Practices](#best-practices)
- [Ref Notation Details](#ref-notation-details)
- [Troubleshooting](#troubleshooting)
## How Refs Work
Traditional approach:
``` ```
Full DOM/HTML → AI parses → CSS selector → Action (~3000-5000 tokens) Full DOM/HTML sent → AI parses → Generates CSS selector → Executes action
~3000-5000 tokens per interaction
``` ```
agent-browser approach: ### The Solution
agent-browser uses compact snapshots with refs:
``` ```
Compact snapshot → @refs assigned → Direct interaction (~200-400 tokens) Compact snapshot → @refs assigned → Direct ref interaction
~200-400 tokens per interaction
``` ```
## The Snapshot Command ## The Snapshot Command
@ -174,8 +166,8 @@ agent-browser snapshot -i
### Element Not Visible in Snapshot ### Element Not Visible in Snapshot
```bash ```bash
# Scroll down to reveal element # Scroll to reveal element
agent-browser scroll down 1000 agent-browser scroll --bottom
agent-browser snapshot -i agent-browser snapshot -i
# Or wait for dynamic content # Or wait for dynamic content

View file

@ -1,17 +1,6 @@
# Video Recording # Video Recording
Capture browser automation as video for debugging, documentation, or verification. Capture browser automation sessions as video for debugging, documentation, or verification.
**Related**: [commands.md](commands.md) for full command reference, [SKILL.md](../SKILL.md) for quick start.
## Contents
- [Basic Recording](#basic-recording)
- [Recording Commands](#recording-commands)
- [Use Cases](#use-cases)
- [Best Practices](#best-practices)
- [Output Format](#output-format)
- [Limitations](#limitations)
## Basic Recording ## Basic Recording

View file

@ -1,81 +1,67 @@
#!/bin/bash #!/bin/bash
# Template: Authenticated Session Workflow # Template: Authenticated Session Workflow
# Purpose: Login once, save state, reuse for subsequent runs # Login once, save state, reuse for subsequent runs
# Usage: ./authenticated-session.sh <login-url> [state-file]
# #
# RECOMMENDED: Use the auth vault instead of this template: # Usage:
# echo "<pass>" | agent-browser auth save myapp --url <login-url> --username <user> --password-stdin # ./authenticated-session.sh <login-url> [state-file]
# agent-browser auth login myapp
# The auth vault stores credentials securely and the LLM never sees passwords.
# #
# Environment variables: # Setup:
# APP_USERNAME - Login username/email # 1. Run once to see your form structure
# APP_PASSWORD - Login password # 2. Note the @refs for your fields
# # 3. Uncomment LOGIN FLOW section and update refs
# Two modes:
# 1. Discovery mode (default): Shows form structure so you can identify refs
# 2. Login mode: Performs actual login after you update the refs
#
# Setup steps:
# 1. Run once to see form structure (discovery mode)
# 2. Update refs in LOGIN FLOW section below
# 3. Set APP_USERNAME and APP_PASSWORD
# 4. Delete the DISCOVERY section
set -euo pipefail set -euo pipefail
LOGIN_URL="${1:?Usage: $0 <login-url> [state-file]}" LOGIN_URL="${1:?Usage: $0 <login-url> [state-file]}"
STATE_FILE="${2:-./auth-state.json}" STATE_FILE="${2:-./auth-state.json}"
echo "Authentication workflow: $LOGIN_URL" echo "Authentication workflow for: $LOGIN_URL"
# ================================================================ # ══════════════════════════════════════════════════════════════
# SAVED STATE: Skip login if valid saved state exists # SAVED STATE: Skip login if we have valid saved state
# ================================================================ # ══════════════════════════════════════════════════════════════
if [[ -f "$STATE_FILE" ]]; then if [[ -f "$STATE_FILE" ]]; then
echo "Loading saved state from $STATE_FILE..." echo "Loading saved authentication state..."
if agent-browser --state "$STATE_FILE" open "$LOGIN_URL" 2>/dev/null; then agent-browser state load "$STATE_FILE"
agent-browser wait --load networkidle agent-browser open "$LOGIN_URL"
agent-browser wait --load networkidle
CURRENT_URL=$(agent-browser get url) CURRENT_URL=$(agent-browser get url)
if [[ "$CURRENT_URL" != *"login"* ]] && [[ "$CURRENT_URL" != *"signin"* ]]; then if [[ "$CURRENT_URL" != *"login"* ]] && [[ "$CURRENT_URL" != *"signin"* ]]; then
echo "Session restored successfully" echo "Session restored successfully!"
agent-browser snapshot -i agent-browser snapshot -i
exit 0 exit 0
fi
echo "Session expired, performing fresh login..."
agent-browser close 2>/dev/null || true
else
echo "Failed to load state, re-authenticating..."
fi fi
echo "Session expired, performing fresh login..."
rm -f "$STATE_FILE" rm -f "$STATE_FILE"
fi fi
# ================================================================ # ══════════════════════════════════════════════════════════════
# DISCOVERY MODE: Shows form structure (delete after setup) # DISCOVERY MODE: Show form structure (remove after setup)
# ================================================================ # ══════════════════════════════════════════════════════════════
echo "Opening login page..." echo "Opening login page..."
agent-browser open "$LOGIN_URL" agent-browser open "$LOGIN_URL"
agent-browser wait --load networkidle agent-browser wait --load networkidle
echo "" echo ""
echo "Login form structure:" echo "┌─────────────────────────────────────────────────────────┐"
echo "---" echo "│ LOGIN FORM STRUCTURE │"
echo "├─────────────────────────────────────────────────────────┤"
agent-browser snapshot -i agent-browser snapshot -i
echo "---" echo "└─────────────────────────────────────────────────────────┘"
echo "" echo ""
echo "Next steps:" echo "Next steps:"
echo " 1. Note the refs: username=@e?, password=@e?, submit=@e?" echo " 1. Note refs: @e? = username, @e? = password, @e? = submit"
echo " 2. Update the LOGIN FLOW section below with your refs" echo " 2. Uncomment LOGIN FLOW section below"
echo " 3. Set: export APP_USERNAME='...' APP_PASSWORD='...'" echo " 3. Replace @e1, @e2, @e3 with your refs"
echo " 4. Delete this DISCOVERY MODE section" echo " 4. Delete this DISCOVERY MODE section"
echo "" echo ""
agent-browser close agent-browser close
exit 0 exit 0
# ================================================================ # ══════════════════════════════════════════════════════════════
# LOGIN FLOW: Uncomment and customize after discovery # LOGIN FLOW: Uncomment and customize after discovery
# ================================================================ # ══════════════════════════════════════════════════════════════
# : "${APP_USERNAME:?Set APP_USERNAME environment variable}" # : "${APP_USERNAME:?Set APP_USERNAME environment variable}"
# : "${APP_PASSWORD:?Set APP_PASSWORD environment variable}" # : "${APP_PASSWORD:?Set APP_PASSWORD environment variable}"
# #
@ -92,14 +78,14 @@ exit 0
# # Verify login succeeded # # Verify login succeeded
# FINAL_URL=$(agent-browser get url) # FINAL_URL=$(agent-browser get url)
# if [[ "$FINAL_URL" == *"login"* ]] || [[ "$FINAL_URL" == *"signin"* ]]; then # if [[ "$FINAL_URL" == *"login"* ]] || [[ "$FINAL_URL" == *"signin"* ]]; then
# echo "Login failed - still on login page" # echo "ERROR: Login failed - still on login page"
# agent-browser screenshot /tmp/login-failed.png # agent-browser screenshot /tmp/login-failed.png
# agent-browser close # agent-browser close
# exit 1 # exit 1
# fi # fi
# #
# # Save state for future runs # # Save state for future runs
# echo "Saving state to $STATE_FILE" # echo "Saving authentication state to: $STATE_FILE"
# agent-browser state save "$STATE_FILE" # agent-browser state save "$STATE_FILE"
# echo "Login successful" # echo "Login successful!"
# agent-browser snapshot -i # agent-browser snapshot -i

View file

@ -1,69 +1,68 @@
#!/bin/bash #!/bin/bash
# Template: Content Capture Workflow # Template: Content Capture Workflow
# Purpose: Extract content from web pages (text, screenshots, PDF) # Extract content from web pages with optional authentication
# Usage: ./capture-workflow.sh <url> [output-dir]
#
# Outputs:
# - page-full.png: Full page screenshot
# - page-structure.txt: Page element structure with refs
# - page-text.txt: All text content
# - page.pdf: PDF version
#
# Optional: Load auth state for protected pages
set -euo pipefail set -euo pipefail
TARGET_URL="${1:?Usage: $0 <url> [output-dir]}" TARGET_URL="${1:?Usage: $0 <url> [output-dir]}"
OUTPUT_DIR="${2:-.}" OUTPUT_DIR="${2:-.}"
echo "Capturing: $TARGET_URL" echo "Capturing content from: $TARGET_URL"
mkdir -p "$OUTPUT_DIR" mkdir -p "$OUTPUT_DIR"
# Optional: Load authentication state # Optional: Load authentication state if needed
# if [[ -f "./auth-state.json" ]]; then # if [[ -f "./auth-state.json" ]]; then
# echo "Loading authentication state..."
# agent-browser state load "./auth-state.json" # agent-browser state load "./auth-state.json"
# fi # fi
# Navigate to target # Navigate to target page
agent-browser open "$TARGET_URL" agent-browser open "$TARGET_URL"
agent-browser wait --load networkidle agent-browser wait --load networkidle
# Get metadata # Get page metadata
TITLE=$(agent-browser get title) echo "Page title: $(agent-browser get title)"
URL=$(agent-browser get url) echo "Page URL: $(agent-browser get url)"
echo "Title: $TITLE"
echo "URL: $URL"
# Capture full page screenshot # Capture full page screenshot
agent-browser screenshot --full "$OUTPUT_DIR/page-full.png" agent-browser screenshot --full "$OUTPUT_DIR/page-full.png"
echo "Saved: $OUTPUT_DIR/page-full.png" echo "Screenshot saved: $OUTPUT_DIR/page-full.png"
# Get page structure with refs # Get page structure
agent-browser snapshot -i > "$OUTPUT_DIR/page-structure.txt" agent-browser snapshot -i > "$OUTPUT_DIR/page-structure.txt"
echo "Saved: $OUTPUT_DIR/page-structure.txt" echo "Structure saved: $OUTPUT_DIR/page-structure.txt"
# Extract all text content # Extract main content
# Adjust selector based on target site structure
# agent-browser get text @e1 > "$OUTPUT_DIR/main-content.txt"
# Extract specific elements (uncomment as needed)
# agent-browser get text "article" > "$OUTPUT_DIR/article.txt"
# agent-browser get text "main" > "$OUTPUT_DIR/main.txt"
# agent-browser get text ".content" > "$OUTPUT_DIR/content.txt"
# Get full page text
agent-browser get text body > "$OUTPUT_DIR/page-text.txt" agent-browser get text body > "$OUTPUT_DIR/page-text.txt"
echo "Saved: $OUTPUT_DIR/page-text.txt" echo "Text content saved: $OUTPUT_DIR/page-text.txt"
# Save as PDF # Optional: Save as PDF
agent-browser pdf "$OUTPUT_DIR/page.pdf" agent-browser pdf "$OUTPUT_DIR/page.pdf"
echo "Saved: $OUTPUT_DIR/page.pdf" echo "PDF saved: $OUTPUT_DIR/page.pdf"
# Optional: Extract specific elements using refs from structure # Optional: Capture with scrolling for infinite scroll pages
# agent-browser get text @e5 > "$OUTPUT_DIR/main-content.txt" # scroll_and_capture() {
# local count=0
# Optional: Handle infinite scroll pages # while [[ $count -lt 5 ]]; do
# for i in {1..5}; do # agent-browser scroll down 1000
# agent-browser scroll down 1000 # agent-browser wait 1000
# agent-browser wait 1000 # ((count++))
# done # done
# agent-browser screenshot --full "$OUTPUT_DIR/page-scrolled.png" # agent-browser screenshot --full "$OUTPUT_DIR/page-scrolled.png"
# }
# scroll_and_capture
# Cleanup # Cleanup
agent-browser close agent-browser close
echo "" echo ""
echo "Capture complete:" echo "Capture complete! Files saved to: $OUTPUT_DIR"
ls -la "$OUTPUT_DIR" ls -la "$OUTPUT_DIR"

View file

@ -1,62 +1,64 @@
#!/bin/bash #!/bin/bash
# Template: Form Automation Workflow # Template: Form Automation Workflow
# Purpose: Fill and submit web forms with validation # Fills and submits web forms with validation
# Usage: ./form-automation.sh <form-url>
#
# This template demonstrates the snapshot-interact-verify pattern:
# 1. Navigate to form
# 2. Snapshot to get element refs
# 3. Fill fields using refs
# 4. Submit and verify result
#
# Customize: Update the refs (@e1, @e2, etc.) based on your form's snapshot output
set -euo pipefail set -euo pipefail
FORM_URL="${1:?Usage: $0 <form-url>}" FORM_URL="${1:?Usage: $0 <form-url>}"
echo "Form automation: $FORM_URL" echo "Automating form at: $FORM_URL"
# Step 1: Navigate to form # Navigate to form page
agent-browser open "$FORM_URL" agent-browser open "$FORM_URL"
agent-browser wait --load networkidle agent-browser wait --load networkidle
# Step 2: Snapshot to discover form elements # Get interactive snapshot to identify form fields
echo "" echo "Analyzing form structure..."
echo "Form structure:"
agent-browser snapshot -i agent-browser snapshot -i
# Step 3: Fill form fields (customize these refs based on snapshot output) # Example: Fill common form fields
# # Uncomment and modify refs based on snapshot output
# Common field types:
# agent-browser fill @e1 "John Doe" # Text input
# agent-browser fill @e2 "user@example.com" # Email input
# agent-browser fill @e3 "SecureP@ss123" # Password input
# agent-browser select @e4 "Option Value" # Dropdown
# agent-browser check @e5 # Checkbox
# agent-browser click @e6 # Radio button
# agent-browser fill @e7 "Multi-line text" # Textarea
# agent-browser upload @e8 /path/to/file.pdf # File upload
#
# Uncomment and modify:
# agent-browser fill @e1 "Test User"
# agent-browser fill @e2 "test@example.com"
# agent-browser click @e3 # Submit button
# Step 4: Wait for submission # Text inputs
# agent-browser fill @e1 "John Doe" # Name field
# agent-browser fill @e2 "user@example.com" # Email field
# agent-browser fill @e3 "+1-555-123-4567" # Phone field
# Password fields
# agent-browser fill @e4 "SecureP@ssw0rd!"
# Dropdowns
# agent-browser select @e5 "Option Value"
# Checkboxes
# agent-browser check @e6 # Check
# agent-browser uncheck @e7 # Uncheck
# Radio buttons
# agent-browser click @e8 # Select radio option
# Text areas
# agent-browser fill @e9 "Multi-line text content here"
# File uploads
# agent-browser upload @e10 /path/to/file.pdf
# Submit form
# agent-browser click @e11 # Submit button
# Wait for response
# agent-browser wait --load networkidle # agent-browser wait --load networkidle
# agent-browser wait --url "**/success" # Or wait for redirect # agent-browser wait --url "**/success" # Or wait for redirect
# Step 5: Verify result # Verify submission
echo "" echo "Form submission result:"
echo "Result:"
agent-browser get url agent-browser get url
agent-browser snapshot -i agent-browser snapshot -i
# Optional: Capture evidence # Take screenshot of result
agent-browser screenshot /tmp/form-result.png agent-browser screenshot /tmp/form-result.png
echo "Screenshot saved: /tmp/form-result.png"
# Cleanup # Cleanup
agent-browser close agent-browser close
echo "Done"
echo "Form automation complete"

View file

@ -43,7 +43,7 @@ Manually verify the install script works in a fresh environment:
```bash ```bash
docker run --rm alpine:latest sh -c " docker run --rm alpine:latest sh -c "
apk add --no-cache curl ca-certificates libstdc++ libgcc bash && apk add --no-cache curl ca-certificates libstdc++ libgcc bash &&
curl -fsSL https://releases.rivet.dev/sandbox-agent/0.3.x/install.sh | sh && curl -fsSL https://releases.rivet.dev/sandbox-agent/0.2.x/install.sh | sh &&
sandbox-agent --version sandbox-agent --version
" "
``` ```

0
.context/notes.md Normal file
View file

0
.context/todos.md Normal file
View file

View file

@ -9,15 +9,11 @@ build/
# Cache # Cache
.cache/ .cache/
.turbo/ .turbo/
**/.turbo/
*.tsbuildinfo *.tsbuildinfo
.pnpm-store/
coverage/
# Environment # Environment
.env .env
.env.* .env.*
.openhandoff/
# IDE # IDE
.idea/ .idea/
@ -28,7 +24,3 @@ coverage/
# Git # Git
.git/ .git/
# Tests
**/test/
**/tests/

View file

@ -14,16 +14,15 @@ jobs:
- uses: dtolnay/rust-toolchain@stable - uses: dtolnay/rust-toolchain@stable
with: with:
components: rustfmt, clippy components: rustfmt, clippy
- uses: Swatinem/rust-cache@v2 - uses: Swatinem/rust-cache@main
- uses: pnpm/action-setup@v4 - uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 20 node-version: 20
cache: pnpm cache: pnpm
- run: pnpm install - run: pnpm install
- run: npm install -g tsx
- name: Run checks - name: Run checks
run: ./scripts/release/main.ts --version 0.0.0 --only-steps run-ci-checks run: ./scripts/release/main.ts --version 0.0.0 --check
- name: Run ACP v1 server tests - name: Run ACP v1 server tests
run: | run: |
cargo test -p sandbox-agent-agent-management cargo test -p sandbox-agent-agent-management
@ -32,3 +31,5 @@ jobs:
cargo test -p sandbox-agent --lib cargo test -p sandbox-agent --lib
- name: Run SDK tests - name: Run SDK tests
run: pnpm --dir sdks/typescript test run: pnpm --dir sdks/typescript test
- name: Run Inspector browser E2E
run: pnpm --filter @sandbox-agent/inspector test:agent-browser

4
.gitignore vendored
View file

@ -15,9 +15,6 @@ yarn.lock
.astro/ .astro/
*.tsbuildinfo *.tsbuildinfo
.turbo/ .turbo/
**/.turbo/
.pnpm-store/
coverage/
# Environment # Environment
.env .env
@ -51,7 +48,6 @@ Cargo.lock
# Example temp files # Example temp files
.tmp-upload/ .tmp-upload/
*.db *.db
.openhandoff/
# CLI binaries (downloaded during npm publish) # CLI binaries (downloaded during npm publish)
sdks/cli/platforms/*/bin/ sdks/cli/platforms/*/bin/

1
.npmrc
View file

@ -1 +0,0 @@
auto-install-peers=false

View file

@ -54,8 +54,8 @@
- `acp-http-client`: protocol-pure ACP-over-HTTP (`/v1/acp`) with no Sandbox-specific HTTP helpers. - `acp-http-client`: protocol-pure ACP-over-HTTP (`/v1/acp`) with no Sandbox-specific HTTP helpers.
- `sandbox-agent`: `SandboxAgent` SDK wrapper that combines ACP session operations with Sandbox control-plane and filesystem helpers. - `sandbox-agent`: `SandboxAgent` SDK wrapper that combines ACP session operations with Sandbox control-plane and filesystem helpers.
- `SandboxAgent` entry points are `SandboxAgent.connect(...)` and `SandboxAgent.start(...)`. - `SandboxAgent` entry points are `SandboxAgent.connect(...)` and `SandboxAgent.start(...)`.
- Stable Sandbox session methods are `createSession`, `resumeSession`, `resumeOrCreateSession`, `destroySession`, `sendSessionMethod`, `onSessionEvent`, `setSessionMode`, `setSessionModel`, `setSessionThoughtLevel`, `setSessionConfigOption`, `getSessionConfigOptions`, and `getSessionModes`. - Stable Sandbox session methods are `createSession`, `resumeSession`, `resumeOrCreateSession`, `destroySession`, `sendSessionMethod`, and `onSessionEvent`.
- `Session` helpers are `prompt(...)`, `send(...)`, `onEvent(...)`, `setMode(...)`, `setModel(...)`, `setThoughtLevel(...)`, `setConfigOption(...)`, `getConfigOptions()`, and `getModes()`. - `Session` helpers are `prompt(...)`, `send(...)`, and `onEvent(...)`.
- Cleanup is `sdk.dispose()`. - Cleanup is `sdk.dispose()`.
### Docs Source Of Truth ### Docs Source Of Truth
@ -86,8 +86,6 @@
- Regenerate `docs/openapi.json` when HTTP contracts change. - Regenerate `docs/openapi.json` when HTTP contracts change.
- Keep `docs/inspector.mdx` and `docs/sdks/typescript.mdx` aligned with implementation. - Keep `docs/inspector.mdx` and `docs/sdks/typescript.mdx` aligned with implementation.
- Append blockers/decisions to `research/acp/friction.md` during ACP work. - Append blockers/decisions to `research/acp/friction.md` during ACP work.
- `docs/agent-capabilities.mdx` lists models/modes/thought levels per agent. Update it when adding a new agent or changing `fallback_config_options`. If its "Last updated" date is >2 weeks old, re-run `cd scripts/agent-configs && npx tsx dump.ts` and update the doc to match. Source data: `scripts/agent-configs/resources/*.json` and hardcoded entries in `server/packages/sandbox-agent/src/router/support.rs` (`fallback_config_options`).
- Some agent models are gated by subscription (e.g. Claude `opus`). The live report only shows models available to the current credentials. The static doc and JSON resource files should list all known models regardless of subscription tier.
- TypeScript SDK tests should run against a real running server/runtime over real `/v1` HTTP APIs, typically using the real `mock` agent for deterministic behavior. - TypeScript SDK tests should run against a real running server/runtime over real `/v1` HTTP APIs, typically using the real `mock` agent for deterministic behavior.
- Do not use Vitest fetch/transport mocks to simulate server functionality in TypeScript SDK tests. - Do not use Vitest fetch/transport mocks to simulate server functionality in TypeScript SDK tests.
@ -109,7 +107,6 @@
- `docs/cli.mdx` - `docs/cli.mdx`
- `docs/quickstart.mdx` - `docs/quickstart.mdx`
- `docs/sdk-overview.mdx` - `docs/sdk-overview.mdx`
- `docs/react-components.mdx`
- `docs/session-persistence.mdx` - `docs/session-persistence.mdx`
- `docs/deploy/local.mdx` - `docs/deploy/local.mdx`
- `docs/deploy/cloudflare.mdx` - `docs/deploy/cloudflare.mdx`

View file

@ -3,7 +3,7 @@ resolver = "2"
members = ["server/packages/*", "gigacode"] members = ["server/packages/*", "gigacode"]
[workspace.package] [workspace.package]
version = "0.3.0" version = "0.2.1"
edition = "2021" edition = "2021"
authors = [ "Rivet Gaming, LLC <developer@rivet.gg>" ] authors = [ "Rivet Gaming, LLC <developer@rivet.gg>" ]
license = "Apache-2.0" license = "Apache-2.0"
@ -12,13 +12,13 @@ description = "Universal API for automatic coding agents in sandboxes. Supports
[workspace.dependencies] [workspace.dependencies]
# Internal crates # Internal crates
sandbox-agent = { version = "0.3.0", path = "server/packages/sandbox-agent" } sandbox-agent = { version = "0.2.1", path = "server/packages/sandbox-agent" }
sandbox-agent-error = { version = "0.3.0", path = "server/packages/error" } sandbox-agent-error = { version = "0.2.1", path = "server/packages/error" }
sandbox-agent-agent-management = { version = "0.3.0", path = "server/packages/agent-management" } sandbox-agent-agent-management = { version = "0.2.1", path = "server/packages/agent-management" }
sandbox-agent-agent-credentials = { version = "0.3.0", path = "server/packages/agent-credentials" } sandbox-agent-agent-credentials = { version = "0.2.1", path = "server/packages/agent-credentials" }
sandbox-agent-opencode-adapter = { version = "0.3.0", path = "server/packages/opencode-adapter" } sandbox-agent-opencode-adapter = { version = "0.2.1", path = "server/packages/opencode-adapter" }
sandbox-agent-opencode-server-manager = { version = "0.3.0", path = "server/packages/opencode-server-manager" } sandbox-agent-opencode-server-manager = { version = "0.2.1", path = "server/packages/opencode-server-manager" }
acp-http-adapter = { version = "0.3.0", path = "server/packages/acp-http-adapter" } acp-http-adapter = { version = "0.2.1", path = "server/packages/acp-http-adapter" }
# Serialization # Serialization
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
@ -32,7 +32,7 @@ schemars = "0.8"
utoipa = { version = "4.2", features = ["axum_extras"] } utoipa = { version = "4.2", features = ["axum_extras"] }
# Web framework # Web framework
axum = { version = "0.7", features = ["ws"] } axum = "0.7"
tower = { version = "0.5", features = ["util"] } tower = { version = "0.5", features = ["util"] }
tower-http = { version = "0.5", features = ["cors", "trace"] } tower-http = { version = "0.5", features = ["cors", "trace"] }

View file

@ -80,11 +80,11 @@ Import the SDK directly into your Node or browser application. Full type safety
**Install** **Install**
```bash ```bash
npm install sandbox-agent@0.3.x npm install sandbox-agent@0.2.x
``` ```
```bash ```bash
bun add sandbox-agent@0.3.x bun add sandbox-agent@0.2.x
# Optional: allow Bun to run postinstall scripts for native binaries (required for SandboxAgent.start()). # Optional: allow Bun to run postinstall scripts for native binaries (required for SandboxAgent.start()).
bun pm trust @sandbox-agent/cli-linux-x64 @sandbox-agent/cli-linux-arm64 @sandbox-agent/cli-darwin-arm64 @sandbox-agent/cli-darwin-x64 @sandbox-agent/cli-win32-x64 bun pm trust @sandbox-agent/cli-linux-x64 @sandbox-agent/cli-linux-arm64 @sandbox-agent/cli-darwin-arm64 @sandbox-agent/cli-darwin-x64 @sandbox-agent/cli-win32-x64
``` ```
@ -138,7 +138,7 @@ Run as an HTTP server and connect from any language. Deploy to E2B, Daytona, Ver
```bash ```bash
# Install it # Install it
curl -fsSL https://releases.rivet.dev/sandbox-agent/0.3.x/install.sh | sh curl -fsSL https://releases.rivet.dev/sandbox-agent/0.2.x/install.sh | sh
# Run it # Run it
sandbox-agent server --token "$SANDBOX_TOKEN" --host 127.0.0.1 --port 2468 sandbox-agent server --token "$SANDBOX_TOKEN" --host 127.0.0.1 --port 2468
``` ```
@ -165,12 +165,12 @@ sandbox-agent server --no-token --host 127.0.0.1 --port 2468
Install the CLI wrapper (optional but convenient): Install the CLI wrapper (optional but convenient):
```bash ```bash
npm install -g @sandbox-agent/cli@0.3.x npm install -g @sandbox-agent/cli@0.2.x
``` ```
```bash ```bash
# Allow Bun to run postinstall scripts for native binaries. # Allow Bun to run postinstall scripts for native binaries.
bun add -g @sandbox-agent/cli@0.3.x bun add -g @sandbox-agent/cli@0.2.x
bun pm -g trust @sandbox-agent/cli-linux-x64 @sandbox-agent/cli-linux-arm64 @sandbox-agent/cli-darwin-arm64 @sandbox-agent/cli-darwin-x64 @sandbox-agent/cli-win32-x64 bun pm -g trust @sandbox-agent/cli-linux-x64 @sandbox-agent/cli-linux-arm64 @sandbox-agent/cli-darwin-arm64 @sandbox-agent/cli-darwin-x64 @sandbox-agent/cli-win32-x64
``` ```
@ -185,11 +185,11 @@ sandbox-agent api sessions send-message-stream my-session --message "Hello" --en
You can also use npx like: You can also use npx like:
```bash ```bash
npx @sandbox-agent/cli@0.3.x --help npx @sandbox-agent/cli@0.2.x --help
``` ```
```bash ```bash
bunx @sandbox-agent/cli@0.3.x --help bunx @sandbox-agent/cli@0.2.x --help
``` ```
[CLI documentation](https://sandboxagent.dev/docs/cli) [CLI documentation](https://sandboxagent.dev/docs/cli)

View file

@ -11,7 +11,6 @@ COPY frontend/packages/inspector/package.json ./frontend/packages/inspector/
COPY sdks/cli-shared/package.json ./sdks/cli-shared/ COPY sdks/cli-shared/package.json ./sdks/cli-shared/
COPY sdks/acp-http-client/package.json ./sdks/acp-http-client/ COPY sdks/acp-http-client/package.json ./sdks/acp-http-client/
COPY sdks/persist-indexeddb/package.json ./sdks/persist-indexeddb/ COPY sdks/persist-indexeddb/package.json ./sdks/persist-indexeddb/
COPY sdks/react/package.json ./sdks/react/
COPY sdks/typescript/package.json ./sdks/typescript/ COPY sdks/typescript/package.json ./sdks/typescript/
# Install dependencies # Install dependencies
@ -22,15 +21,13 @@ COPY docs/openapi.json ./docs/
COPY sdks/cli-shared ./sdks/cli-shared COPY sdks/cli-shared ./sdks/cli-shared
COPY sdks/acp-http-client ./sdks/acp-http-client COPY sdks/acp-http-client ./sdks/acp-http-client
COPY sdks/persist-indexeddb ./sdks/persist-indexeddb COPY sdks/persist-indexeddb ./sdks/persist-indexeddb
COPY sdks/react ./sdks/react
COPY sdks/typescript ./sdks/typescript COPY sdks/typescript ./sdks/typescript
# Build cli-shared, acp-http-client, SDK, then persist-indexeddb and react (depends on SDK) # Build cli-shared, acp-http-client, SDK, then persist-indexeddb (depends on SDK)
RUN cd sdks/cli-shared && pnpm exec tsup RUN cd sdks/cli-shared && pnpm exec tsup
RUN cd sdks/acp-http-client && pnpm exec tsup RUN cd sdks/acp-http-client && pnpm exec tsup
RUN cd sdks/typescript && SKIP_OPENAPI_GEN=1 pnpm exec tsup RUN cd sdks/typescript && SKIP_OPENAPI_GEN=1 pnpm exec tsup
RUN cd sdks/persist-indexeddb && pnpm exec tsup RUN cd sdks/persist-indexeddb && pnpm exec tsup
RUN cd sdks/react && pnpm exec tsup
# Copy inspector source and build # Copy inspector source and build
COPY frontend/packages/inspector ./frontend/packages/inspector COPY frontend/packages/inspector ./frontend/packages/inspector

View file

@ -11,7 +11,6 @@ COPY frontend/packages/inspector/package.json ./frontend/packages/inspector/
COPY sdks/cli-shared/package.json ./sdks/cli-shared/ COPY sdks/cli-shared/package.json ./sdks/cli-shared/
COPY sdks/acp-http-client/package.json ./sdks/acp-http-client/ COPY sdks/acp-http-client/package.json ./sdks/acp-http-client/
COPY sdks/persist-indexeddb/package.json ./sdks/persist-indexeddb/ COPY sdks/persist-indexeddb/package.json ./sdks/persist-indexeddb/
COPY sdks/react/package.json ./sdks/react/
COPY sdks/typescript/package.json ./sdks/typescript/ COPY sdks/typescript/package.json ./sdks/typescript/
# Install dependencies # Install dependencies
@ -22,15 +21,13 @@ COPY docs/openapi.json ./docs/
COPY sdks/cli-shared ./sdks/cli-shared COPY sdks/cli-shared ./sdks/cli-shared
COPY sdks/acp-http-client ./sdks/acp-http-client COPY sdks/acp-http-client ./sdks/acp-http-client
COPY sdks/persist-indexeddb ./sdks/persist-indexeddb COPY sdks/persist-indexeddb ./sdks/persist-indexeddb
COPY sdks/react ./sdks/react
COPY sdks/typescript ./sdks/typescript COPY sdks/typescript ./sdks/typescript
# Build cli-shared, acp-http-client, SDK, then persist-indexeddb and react (depends on SDK) # Build cli-shared, acp-http-client, SDK, then persist-indexeddb (depends on SDK)
RUN cd sdks/cli-shared && pnpm exec tsup RUN cd sdks/cli-shared && pnpm exec tsup
RUN cd sdks/acp-http-client && pnpm exec tsup RUN cd sdks/acp-http-client && pnpm exec tsup
RUN cd sdks/typescript && SKIP_OPENAPI_GEN=1 pnpm exec tsup RUN cd sdks/typescript && SKIP_OPENAPI_GEN=1 pnpm exec tsup
RUN cd sdks/persist-indexeddb && pnpm exec tsup RUN cd sdks/persist-indexeddb && pnpm exec tsup
RUN cd sdks/react && pnpm exec tsup
# Copy inspector source and build # Copy inspector source and build
COPY frontend/packages/inspector ./frontend/packages/inspector COPY frontend/packages/inspector ./frontend/packages/inspector

View file

@ -11,7 +11,6 @@ COPY frontend/packages/inspector/package.json ./frontend/packages/inspector/
COPY sdks/cli-shared/package.json ./sdks/cli-shared/ COPY sdks/cli-shared/package.json ./sdks/cli-shared/
COPY sdks/acp-http-client/package.json ./sdks/acp-http-client/ COPY sdks/acp-http-client/package.json ./sdks/acp-http-client/
COPY sdks/persist-indexeddb/package.json ./sdks/persist-indexeddb/ COPY sdks/persist-indexeddb/package.json ./sdks/persist-indexeddb/
COPY sdks/react/package.json ./sdks/react/
COPY sdks/typescript/package.json ./sdks/typescript/ COPY sdks/typescript/package.json ./sdks/typescript/
# Install dependencies # Install dependencies
@ -22,15 +21,13 @@ COPY docs/openapi.json ./docs/
COPY sdks/cli-shared ./sdks/cli-shared COPY sdks/cli-shared ./sdks/cli-shared
COPY sdks/acp-http-client ./sdks/acp-http-client COPY sdks/acp-http-client ./sdks/acp-http-client
COPY sdks/persist-indexeddb ./sdks/persist-indexeddb COPY sdks/persist-indexeddb ./sdks/persist-indexeddb
COPY sdks/react ./sdks/react
COPY sdks/typescript ./sdks/typescript COPY sdks/typescript ./sdks/typescript
# Build cli-shared, acp-http-client, SDK, then persist-indexeddb and react (depends on SDK) # Build cli-shared, acp-http-client, SDK, then persist-indexeddb (depends on SDK)
RUN cd sdks/cli-shared && pnpm exec tsup RUN cd sdks/cli-shared && pnpm exec tsup
RUN cd sdks/acp-http-client && pnpm exec tsup RUN cd sdks/acp-http-client && pnpm exec tsup
RUN cd sdks/typescript && SKIP_OPENAPI_GEN=1 pnpm exec tsup RUN cd sdks/typescript && SKIP_OPENAPI_GEN=1 pnpm exec tsup
RUN cd sdks/persist-indexeddb && pnpm exec tsup RUN cd sdks/persist-indexeddb && pnpm exec tsup
RUN cd sdks/react && pnpm exec tsup
# Copy inspector source and build # Copy inspector source and build
COPY frontend/packages/inspector ./frontend/packages/inspector COPY frontend/packages/inspector ./frontend/packages/inspector

View file

@ -11,7 +11,6 @@ COPY frontend/packages/inspector/package.json ./frontend/packages/inspector/
COPY sdks/cli-shared/package.json ./sdks/cli-shared/ COPY sdks/cli-shared/package.json ./sdks/cli-shared/
COPY sdks/acp-http-client/package.json ./sdks/acp-http-client/ COPY sdks/acp-http-client/package.json ./sdks/acp-http-client/
COPY sdks/persist-indexeddb/package.json ./sdks/persist-indexeddb/ COPY sdks/persist-indexeddb/package.json ./sdks/persist-indexeddb/
COPY sdks/react/package.json ./sdks/react/
COPY sdks/typescript/package.json ./sdks/typescript/ COPY sdks/typescript/package.json ./sdks/typescript/
# Install dependencies # Install dependencies
@ -22,15 +21,13 @@ COPY docs/openapi.json ./docs/
COPY sdks/cli-shared ./sdks/cli-shared COPY sdks/cli-shared ./sdks/cli-shared
COPY sdks/acp-http-client ./sdks/acp-http-client COPY sdks/acp-http-client ./sdks/acp-http-client
COPY sdks/persist-indexeddb ./sdks/persist-indexeddb COPY sdks/persist-indexeddb ./sdks/persist-indexeddb
COPY sdks/react ./sdks/react
COPY sdks/typescript ./sdks/typescript COPY sdks/typescript ./sdks/typescript
# Build cli-shared, acp-http-client, SDK, then persist-indexeddb and react (depends on SDK) # Build cli-shared, acp-http-client, SDK, then persist-indexeddb (depends on SDK)
RUN cd sdks/cli-shared && pnpm exec tsup RUN cd sdks/cli-shared && pnpm exec tsup
RUN cd sdks/acp-http-client && pnpm exec tsup RUN cd sdks/acp-http-client && pnpm exec tsup
RUN cd sdks/typescript && SKIP_OPENAPI_GEN=1 pnpm exec tsup RUN cd sdks/typescript && SKIP_OPENAPI_GEN=1 pnpm exec tsup
RUN cd sdks/persist-indexeddb && pnpm exec tsup RUN cd sdks/persist-indexeddb && pnpm exec tsup
RUN cd sdks/react && pnpm exec tsup
# Copy inspector source and build # Copy inspector source and build
COPY frontend/packages/inspector ./frontend/packages/inspector COPY frontend/packages/inspector ./frontend/packages/inspector

View file

@ -11,7 +11,6 @@ COPY frontend/packages/inspector/package.json ./frontend/packages/inspector/
COPY sdks/cli-shared/package.json ./sdks/cli-shared/ COPY sdks/cli-shared/package.json ./sdks/cli-shared/
COPY sdks/acp-http-client/package.json ./sdks/acp-http-client/ COPY sdks/acp-http-client/package.json ./sdks/acp-http-client/
COPY sdks/persist-indexeddb/package.json ./sdks/persist-indexeddb/ COPY sdks/persist-indexeddb/package.json ./sdks/persist-indexeddb/
COPY sdks/react/package.json ./sdks/react/
COPY sdks/typescript/package.json ./sdks/typescript/ COPY sdks/typescript/package.json ./sdks/typescript/
# Install dependencies # Install dependencies
@ -22,15 +21,13 @@ COPY docs/openapi.json ./docs/
COPY sdks/cli-shared ./sdks/cli-shared COPY sdks/cli-shared ./sdks/cli-shared
COPY sdks/acp-http-client ./sdks/acp-http-client COPY sdks/acp-http-client ./sdks/acp-http-client
COPY sdks/persist-indexeddb ./sdks/persist-indexeddb COPY sdks/persist-indexeddb ./sdks/persist-indexeddb
COPY sdks/react ./sdks/react
COPY sdks/typescript ./sdks/typescript COPY sdks/typescript ./sdks/typescript
# Build cli-shared, acp-http-client, SDK, then persist-indexeddb and react (depends on SDK) # Build cli-shared, acp-http-client, SDK, then persist-indexeddb (depends on SDK)
RUN cd sdks/cli-shared && pnpm exec tsup RUN cd sdks/cli-shared && pnpm exec tsup
RUN cd sdks/acp-http-client && pnpm exec tsup RUN cd sdks/acp-http-client && pnpm exec tsup
RUN cd sdks/typescript && SKIP_OPENAPI_GEN=1 pnpm exec tsup RUN cd sdks/typescript && SKIP_OPENAPI_GEN=1 pnpm exec tsup
RUN cd sdks/persist-indexeddb && pnpm exec tsup RUN cd sdks/persist-indexeddb && pnpm exec tsup
RUN cd sdks/react && pnpm exec tsup
# Copy inspector source and build # Copy inspector source and build
COPY frontend/packages/inspector ./frontend/packages/inspector COPY frontend/packages/inspector ./frontend/packages/inspector

View file

@ -13,7 +13,6 @@ COPY frontend/packages/inspector/package.json ./frontend/packages/inspector/
COPY sdks/cli-shared/package.json ./sdks/cli-shared/ COPY sdks/cli-shared/package.json ./sdks/cli-shared/
COPY sdks/acp-http-client/package.json ./sdks/acp-http-client/ COPY sdks/acp-http-client/package.json ./sdks/acp-http-client/
COPY sdks/persist-indexeddb/package.json ./sdks/persist-indexeddb/ COPY sdks/persist-indexeddb/package.json ./sdks/persist-indexeddb/
COPY sdks/react/package.json ./sdks/react/
COPY sdks/typescript/package.json ./sdks/typescript/ COPY sdks/typescript/package.json ./sdks/typescript/
# Install dependencies # Install dependencies
@ -24,15 +23,13 @@ COPY docs/openapi.json ./docs/
COPY sdks/cli-shared ./sdks/cli-shared COPY sdks/cli-shared ./sdks/cli-shared
COPY sdks/acp-http-client ./sdks/acp-http-client COPY sdks/acp-http-client ./sdks/acp-http-client
COPY sdks/persist-indexeddb ./sdks/persist-indexeddb COPY sdks/persist-indexeddb ./sdks/persist-indexeddb
COPY sdks/react ./sdks/react
COPY sdks/typescript ./sdks/typescript COPY sdks/typescript ./sdks/typescript
# Build cli-shared, acp-http-client, SDK, then persist-indexeddb and react (depends on SDK) # Build cli-shared, acp-http-client, SDK, then persist-indexeddb (depends on SDK)
RUN cd sdks/cli-shared && pnpm exec tsup RUN cd sdks/cli-shared && pnpm exec tsup
RUN cd sdks/acp-http-client && pnpm exec tsup RUN cd sdks/acp-http-client && pnpm exec tsup
RUN cd sdks/typescript && SKIP_OPENAPI_GEN=1 pnpm exec tsup RUN cd sdks/typescript && SKIP_OPENAPI_GEN=1 pnpm exec tsup
RUN cd sdks/persist-indexeddb && pnpm exec tsup RUN cd sdks/persist-indexeddb && pnpm exec tsup
RUN cd sdks/react && pnpm exec tsup
# Copy inspector source and build # Copy inspector source and build
COPY frontend/packages/inspector ./frontend/packages/inspector COPY frontend/packages/inspector ./frontend/packages/inspector

View file

@ -1,127 +0,0 @@
---
title: "Agent Capabilities"
description: "Models, modes, and thought levels supported by each agent."
---
Capabilities are subject to change as the agents are updated. See [Agent Sessions](/agent-sessions) for full session configuration API details.
<Info>
_Last updated: March 5th, 2026. See [Generating a live report](#generating-a-live-report) for up-to-date reference._
</Info>
## Claude
| Category | Values |
|----------|--------|
| **Models** | `default`, `sonnet`, `opus`, `haiku` |
| **Modes** | `default`, `acceptEdits`, `plan`, `dontAsk`, `bypassPermissions` |
| **Thought levels** | Unsupported |
### Configuring Effort Level For Claude
Claude does not natively support changing effort level after a session starts, so configure it in the filesystem before creating the session.
```ts
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { SandboxAgent } from "sandbox-agent";
const cwd = "/path/to/workspace";
await mkdir(path.join(cwd, ".claude"), { recursive: true });
await writeFile(
path.join(cwd, ".claude", "settings.json"),
JSON.stringify({ effortLevel: "high" }, null, 2),
);
const sdk = await SandboxAgent.connect({ baseUrl: "http://127.0.0.1:2468" });
await sdk.createSession({
agent: "claude",
sessionInit: { cwd, mcpServers: [] },
});
```
<Accordion title="Supported file locations (highest precedence last)">
1. `~/.claude/settings.json`
2. `<session cwd>/.claude/settings.json`
3. `<session cwd>/.claude/settings.local.json`
</Accordion>
## Codex
| Category | Values |
|----------|--------|
| **Models** | `gpt-5.3-codex` (default), `gpt-5.3-codex-spark`, `gpt-5.2-codex`, `gpt-5.1-codex-max`, `gpt-5.2`, `gpt-5.1-codex-mini` |
| **Modes** | `read-only` (default), `auto`, `full-access` |
| **Thought levels** | `low`, `medium`, `high` (default), `xhigh` |
## OpenCode
| Category | Values |
|----------|--------|
| **Models** | See below |
| **Modes** | `build` (default), `plan` |
| **Thought levels** | Unsupported |
<Accordion title="See all models">
| Provider | Models |
|----------|--------|
| **Anthropic** | `anthropic/claude-3-5-haiku-20241022`, `anthropic/claude-3-5-haiku-latest`, `anthropic/claude-3-5-sonnet-20240620`, `anthropic/claude-3-5-sonnet-20241022`, `anthropic/claude-3-7-sonnet-20250219`, `anthropic/claude-3-7-sonnet-latest`, `anthropic/claude-3-haiku-20240307`, `anthropic/claude-3-opus-20240229`, `anthropic/claude-3-sonnet-20240229`, `anthropic/claude-haiku-4-5`, `anthropic/claude-haiku-4-5-20251001`, `anthropic/claude-opus-4-0`, `anthropic/claude-opus-4-1`, `anthropic/claude-opus-4-1-20250805`, `anthropic/claude-opus-4-20250514`, `anthropic/claude-opus-4-5`, `anthropic/claude-opus-4-5-20251101`, `anthropic/claude-opus-4-6`, `anthropic/claude-sonnet-4-0`, `anthropic/claude-sonnet-4-20250514`, `anthropic/claude-sonnet-4-5`, `anthropic/claude-sonnet-4-5-20250929` |
| **OpenAI** | `openai/gpt-5.1-codex`, `openai/gpt-5.1-codex-max`, `openai/gpt-5.1-codex-mini`, `openai/gpt-5.2`, `openai/gpt-5.2-codex`, `openai/gpt-5.3-codex` |
| **Cerebras** | `cerebras/gpt-oss-120b`, `cerebras/qwen-3-235b-a22b-instruct-2507`, `cerebras/zai-glm-4.7` |
| **OpenCode Zen** | `opencode/big-pickle`, `opencode/claude-3-5-haiku`, `opencode/claude-haiku-4-5`, `opencode/claude-opus-4-1`, `opencode/claude-opus-4-5`, `opencode/claude-opus-4-6`, `opencode/claude-sonnet-4`, `opencode/claude-sonnet-4-5`, `opencode/gemini-3-flash`, `opencode/gemini-3-pro` (default), `opencode/glm-4.6`, `opencode/glm-4.7`, `opencode/gpt-5`, `opencode/gpt-5-codex`, `opencode/gpt-5-nano`, `opencode/gpt-5.1`, `opencode/gpt-5.1-codex`, `opencode/gpt-5.1-codex-max`, `opencode/gpt-5.1-codex-mini`, `opencode/gpt-5.2`, `opencode/gpt-5.2-codex`, `opencode/kimi-k2`, `opencode/kimi-k2-thinking`, `opencode/kimi-k2.5`, `opencode/kimi-k2.5-free`, `opencode/minimax-m2.1`, `opencode/minimax-m2.1-free`, `opencode/trinity-large-preview-free` |
</Accordion>
## Cursor
| Category | Values |
|----------|--------|
| **Models** | See below |
| **Modes** | Unsupported |
| **Thought levels** | Unsupported |
<Accordion title="See all models">
| Group | Models |
|-------|--------|
| **Auto** | `auto` |
| **Composer** | `composer-1.5`, `composer-1` |
| **GPT-5.3 Codex** | `gpt-5.3-codex`, `gpt-5.3-codex-low`, `gpt-5.3-codex-high`, `gpt-5.3-codex-xhigh`, `gpt-5.3-codex-fast`, `gpt-5.3-codex-low-fast`, `gpt-5.3-codex-high-fast`, `gpt-5.3-codex-xhigh-fast` |
| **GPT-5.2** | `gpt-5.2`, `gpt-5.2-high`, `gpt-5.2-codex`, `gpt-5.2-codex-low`, `gpt-5.2-codex-high`, `gpt-5.2-codex-xhigh`, `gpt-5.2-codex-fast`, `gpt-5.2-codex-low-fast`, `gpt-5.2-codex-high-fast`, `gpt-5.2-codex-xhigh-fast` |
| **GPT-5.1** | `gpt-5.1-high`, `gpt-5.1-codex-max`, `gpt-5.1-codex-max-high` |
| **Claude** | `opus-4.6-thinking` (default), `opus-4.6`, `opus-4.5`, `opus-4.5-thinking`, `sonnet-4.5`, `sonnet-4.5-thinking` |
| **Other** | `gemini-3-pro`, `gemini-3-flash`, `grok` |
</Accordion>
## Amp
| Category | Values |
|----------|--------|
| **Models** | `amp-default` |
| **Modes** | `default`, `bypass` |
| **Thought levels** | Unsupported |
## Pi
| Category | Values |
|----------|--------|
| **Models** | `default` |
| **Modes** | Unsupported |
| **Thought levels** | Unsupported |
## Generating a live report
Requires a running Sandbox Agent server. `--endpoint` defaults to `http://127.0.0.1:2468`.
```bash
sandbox-agent api agents report
```
<Note>
The live report reflects what the agent adapter returns for the current credentials. Some models may be gated by subscription (e.g. Claude's `opus` requires a paid plan) and will not appear in the report if the credentials don't have access.
</Note>

View file

@ -82,49 +82,6 @@ if (sessions.items.length > 0) {
} }
``` ```
## Configure model, mode, and thought level
Set the model, mode, or thought level on a session at creation time or after:
```ts
// At creation time
const session = await sdk.createSession({
agent: "codex",
model: "gpt-5.3-codex",
mode: "auto",
thoughtLevel: "high",
});
```
```ts
// After creation
await session.setModel("gpt-5.2-codex");
await session.setMode("full-access");
await session.setThoughtLevel("medium");
```
Query available modes:
```ts
const modes = await session.getModes();
console.log(modes?.currentModeId, modes?.availableModes);
```
### Advanced config options
For config options beyond model, mode, and thought level, use `getConfigOptions` to discover what the agent supports and `setConfigOption` to set any option by ID:
```ts
const options = await session.getConfigOptions();
for (const opt of options) {
console.log(opt.id, opt.category, opt.type);
}
```
```ts
await session.setConfigOption("some-agent-option", "value");
```
## Destroy a session ## Destroy a session
```ts ```ts

View file

@ -167,65 +167,6 @@ Shared option:
```bash ```bash
sandbox-agent api agents list [--endpoint <URL>] sandbox-agent api agents list [--endpoint <URL>]
sandbox-agent api agents report [--endpoint <URL>]
sandbox-agent api agents install <AGENT> [--reinstall] [--endpoint <URL>] sandbox-agent api agents install <AGENT> [--reinstall] [--endpoint <URL>]
``` ```
#### api agents list
List all agents and their install status.
```bash
sandbox-agent api agents list
```
#### api agents report
Emit a JSON report of available models, modes, and thought levels for every agent. Calls `GET /v1/agents?config=true` and groups each agent's config options by category.
```bash
sandbox-agent api agents report --endpoint http://127.0.0.1:2468 | jq .
```
Example output:
```json
{
"generatedAtMs": 1740000000000,
"endpoint": "http://127.0.0.1:2468",
"agents": [
{
"id": "claude",
"installed": true,
"models": {
"currentValue": "default",
"values": [
{ "value": "default", "name": "Default" },
{ "value": "sonnet", "name": "Sonnet" },
{ "value": "opus", "name": "Opus" },
{ "value": "haiku", "name": "Haiku" }
]
},
"modes": {
"currentValue": "default",
"values": [
{ "value": "default", "name": "Default" },
{ "value": "acceptEdits", "name": "Accept Edits" },
{ "value": "plan", "name": "Plan" },
{ "value": "dontAsk", "name": "Don't Ask" },
{ "value": "bypassPermissions", "name": "Bypass Permissions" }
]
},
"thoughtLevels": { "values": [] }
}
]
}
```
See [Agent Capabilities](/agent-capabilities) for a full reference of supported models, modes, and thought levels per agent.
#### api agents install
```bash
sandbox-agent api agents install codex --reinstall
```

View file

@ -20,7 +20,7 @@ that BoxLite can load directly (BoxLite has its own image store separate from Do
```dockerfile ```dockerfile
FROM node:22-bookworm-slim FROM node:22-bookworm-slim
RUN apt-get update && apt-get install -y curl ca-certificates && rm -rf /var/lib/apt/lists/* RUN apt-get update && apt-get install -y curl ca-certificates && rm -rf /var/lib/apt/lists/*
RUN curl -fsSL https://releases.rivet.dev/sandbox-agent/0.3.x/install.sh | sh RUN curl -fsSL https://releases.rivet.dev/sandbox-agent/0.2.x/install.sh | sh
RUN sandbox-agent install-agent claude RUN sandbox-agent install-agent claude
RUN sandbox-agent install-agent codex RUN sandbox-agent install-agent codex
``` ```

View file

@ -25,7 +25,7 @@ cd my-sandbox
```dockerfile ```dockerfile
FROM cloudflare/sandbox:0.7.0 FROM cloudflare/sandbox:0.7.0
RUN curl -fsSL https://releases.rivet.dev/sandbox-agent/0.3.x/install.sh | sh RUN curl -fsSL https://releases.rivet.dev/sandbox-agent/0.2.x/install.sh | sh
RUN sandbox-agent install-agent claude && sandbox-agent install-agent codex RUN sandbox-agent install-agent claude && sandbox-agent install-agent codex
EXPOSE 8000 EXPOSE 8000

View file

@ -28,7 +28,7 @@ if (process.env.OPENAI_API_KEY) envVars.OPENAI_API_KEY = process.env.OPENAI_API_
const sandbox = await daytona.create({ envVars }); const sandbox = await daytona.create({ envVars });
await sandbox.process.executeCommand( await sandbox.process.executeCommand(
"curl -fsSL https://releases.rivet.dev/sandbox-agent/0.3.x/install.sh | sh" "curl -fsSL https://releases.rivet.dev/sandbox-agent/0.2.x/install.sh | sh"
); );
await sandbox.process.executeCommand("sandbox-agent install-agent claude"); await sandbox.process.executeCommand("sandbox-agent install-agent claude");
@ -64,7 +64,7 @@ if (!hasSnapshot) {
name: SNAPSHOT, name: SNAPSHOT,
image: Image.base("ubuntu:22.04").runCommands( image: Image.base("ubuntu:22.04").runCommands(
"apt-get update && apt-get install -y curl ca-certificates", "apt-get update && apt-get install -y curl ca-certificates",
"curl -fsSL https://releases.rivet.dev/sandbox-agent/0.3.x/install.sh | sh", "curl -fsSL https://releases.rivet.dev/sandbox-agent/0.2.x/install.sh | sh",
"sandbox-agent install-agent claude", "sandbox-agent install-agent claude",
"sandbox-agent install-agent codex", "sandbox-agent install-agent codex",
), ),

View file

@ -16,11 +16,17 @@ docker run --rm -p 3000:3000 \
-e ANTHROPIC_API_KEY="$ANTHROPIC_API_KEY" \ -e ANTHROPIC_API_KEY="$ANTHROPIC_API_KEY" \
-e OPENAI_API_KEY="$OPENAI_API_KEY" \ -e OPENAI_API_KEY="$OPENAI_API_KEY" \
alpine:latest sh -c "\ alpine:latest sh -c "\
apk add --no-cache curl ca-certificates libstdc++ libgcc bash nodejs npm && \ apk add --no-cache curl ca-certificates libstdc++ libgcc bash && \
curl -fsSL https://releases.rivet.dev/sandbox-agent/0.3.x/install.sh | sh && \ curl -fsSL https://releases.rivet.dev/sandbox-agent/0.2.x/install.sh | sh && \
sandbox-agent install-agent claude && \
sandbox-agent install-agent codex && \
sandbox-agent server --no-token --host 0.0.0.0 --port 3000" sandbox-agent server --no-token --host 0.0.0.0 --port 3000"
``` ```
<Note>
Alpine is required for some agent binaries that target musl libc.
</Note>
## TypeScript with dockerode ## TypeScript with dockerode
```typescript ```typescript
@ -31,18 +37,17 @@ const docker = new Docker();
const PORT = 3000; const PORT = 3000;
const container = await docker.createContainer({ const container = await docker.createContainer({
Image: "node:22-bookworm-slim", Image: "alpine:latest",
Cmd: ["sh", "-c", [ Cmd: ["sh", "-c", [
"apt-get update", "apk add --no-cache curl ca-certificates libstdc++ libgcc bash",
"DEBIAN_FRONTEND=noninteractive apt-get install -y curl ca-certificates bash libstdc++6", "curl -fsSL https://releases.rivet.dev/sandbox-agent/0.2.x/install.sh | sh",
"rm -rf /var/lib/apt/lists/*", "sandbox-agent install-agent claude",
"curl -fsSL https://releases.rivet.dev/sandbox-agent/0.3.x/install.sh | sh", "sandbox-agent install-agent codex",
`sandbox-agent server --no-token --host 0.0.0.0 --port ${PORT}`, `sandbox-agent server --no-token --host 0.0.0.0 --port ${PORT}`,
].join(" && ")], ].join(" && ")],
Env: [ Env: [
`ANTHROPIC_API_KEY=${process.env.ANTHROPIC_API_KEY}`, `ANTHROPIC_API_KEY=${process.env.ANTHROPIC_API_KEY}`,
`OPENAI_API_KEY=${process.env.OPENAI_API_KEY}`, `OPENAI_API_KEY=${process.env.OPENAI_API_KEY}`,
`CODEX_API_KEY=${process.env.CODEX_API_KEY}`,
].filter(Boolean), ].filter(Boolean),
ExposedPorts: { [`${PORT}/tcp`]: {} }, ExposedPorts: { [`${PORT}/tcp`]: {} },
HostConfig: { HostConfig: {
@ -56,7 +61,7 @@ await container.start();
const baseUrl = `http://127.0.0.1:${PORT}`; const baseUrl = `http://127.0.0.1:${PORT}`;
const sdk = await SandboxAgent.connect({ baseUrl }); const sdk = await SandboxAgent.connect({ baseUrl });
const session = await sdk.createSession({ agent: "codex" }); const session = await sdk.createSession({ agent: "claude" });
await session.prompt([{ type: "text", text: "Summarize this repository." }]); await session.prompt([{ type: "text", text: "Summarize this repository." }]);
``` ```

View file

@ -21,7 +21,7 @@ if (process.env.OPENAI_API_KEY) envs.OPENAI_API_KEY = process.env.OPENAI_API_KEY
const sandbox = await Sandbox.create({ allowInternetAccess: true, envs }); const sandbox = await Sandbox.create({ allowInternetAccess: true, envs });
await sandbox.commands.run( await sandbox.commands.run(
"curl -fsSL https://releases.rivet.dev/sandbox-agent/0.3.x/install.sh | sh" "curl -fsSL https://releases.rivet.dev/sandbox-agent/0.2.x/install.sh | sh"
); );
await sandbox.commands.run("sandbox-agent install-agent claude"); await sandbox.commands.run("sandbox-agent install-agent claude");

View file

@ -9,7 +9,7 @@ For local development, run Sandbox Agent directly on your machine.
```bash ```bash
# Install # Install
curl -fsSL https://releases.rivet.dev/sandbox-agent/0.3.x/install.sh | sh curl -fsSL https://releases.rivet.dev/sandbox-agent/0.2.x/install.sh | sh
# Run # Run
sandbox-agent server --no-token --host 127.0.0.1 --port 2468 sandbox-agent server --no-token --host 127.0.0.1 --port 2468
@ -20,12 +20,12 @@ Or with npm/Bun:
<Tabs> <Tabs>
<Tab title="npx"> <Tab title="npx">
```bash ```bash
npx @sandbox-agent/cli@0.3.x server --no-token --host 127.0.0.1 --port 2468 npx @sandbox-agent/cli@0.2.x server --no-token --host 127.0.0.1 --port 2468
``` ```
</Tab> </Tab>
<Tab title="bunx"> <Tab title="bunx">
```bash ```bash
bunx @sandbox-agent/cli@0.3.x server --no-token --host 127.0.0.1 --port 2468 bunx @sandbox-agent/cli@0.2.x server --no-token --host 127.0.0.1 --port 2468
``` ```
</Tab> </Tab>
</Tabs> </Tabs>

View file

@ -30,7 +30,7 @@ const run = async (cmd: string, args: string[] = []) => {
} }
}; };
await run("sh", ["-c", "curl -fsSL https://releases.rivet.dev/sandbox-agent/0.3.x/install.sh | sh"]); await run("sh", ["-c", "curl -fsSL https://releases.rivet.dev/sandbox-agent/0.2.x/install.sh | sh"]);
await run("sandbox-agent", ["install-agent", "claude"]); await run("sandbox-agent", ["install-agent", "claude"]);
await run("sandbox-agent", ["install-agent", "codex"]); await run("sandbox-agent", ["install-agent", "codex"]);

View file

@ -51,7 +51,6 @@
"pages": [ "pages": [
"quickstart", "quickstart",
"sdk-overview", "sdk-overview",
"react-components",
{ {
"group": "Deploy", "group": "Deploy",
"icon": "server", "icon": "server",
@ -80,7 +79,7 @@
}, },
{ {
"group": "System", "group": "System",
"pages": ["file-system", "processes"] "pages": ["file-system"]
}, },
{ {
"group": "Orchestration", "group": "Orchestration",
@ -95,7 +94,6 @@
{ {
"group": "Reference", "group": "Reference",
"pages": [ "pages": [
"agent-capabilities",
"cli", "cli",
"inspector", "inspector",
"opencode-compatibility", "opencode-compatibility",

View file

@ -34,18 +34,9 @@ console.log(url);
- Event JSON inspector - Event JSON inspector
- Prompt testing - Prompt testing
- Request/response debugging - Request/response debugging
- Process management (create, stop, kill, delete, view logs)
- Interactive PTY terminal for tty processes
- One-shot command execution
## When to use ## When to use
- Development: validate session behavior quickly - Development: validate session behavior quickly
- Debugging: inspect raw event payloads - Debugging: inspect raw event payloads
- Integration work: compare UI behavior with SDK/API calls - Integration work: compare UI behavior with SDK/API calls
## Process terminal
The Inspector includes an embedded Ghostty-based terminal for interactive tty
processes. The UI uses the SDK's high-level `connectProcessTerminal(...)`
wrapper via the shared `@sandbox-agent/react` `ProcessTerminal` component.

View file

@ -1,7 +1,7 @@
--- ---
title: "Observability" title: "Observability"
description: "Track session activity with OpenTelemetry." description: "Track session activity with OpenTelemetry."
icon: "chart-line" icon: "terminal"
--- ---
Use OpenTelemetry to instrument session traffic, then ship telemetry to your collector/backend. Use OpenTelemetry to instrument session traffic, then ship telemetry to your collector/backend.

File diff suppressed because it is too large Load diff

View file

@ -1,258 +0,0 @@
---
title: "Processes"
description: "Run commands and manage long-lived processes inside the sandbox."
sidebarTitle: "Processes"
icon: "terminal"
---
The process API supports:
- **One-shot execution** — run a command to completion and capture stdout, stderr, and exit code
- **Managed processes** — spawn, list, stop, kill, and delete long-lived processes
- **Log streaming** — fetch buffered logs or follow live output via SSE
- **Terminals** — full PTY support with bidirectional WebSocket I/O
- **Configurable limits** — control concurrency, timeouts, and buffer sizes per runtime
## Run a command
Execute a command to completion and get its output.
<CodeGroup>
```ts TypeScript
import { SandboxAgent } from "sandbox-agent";
const sdk = await SandboxAgent.connect({
baseUrl: "http://127.0.0.1:2468",
});
const result = await sdk.runProcess({
command: "ls",
args: ["-la", "/workspace"],
});
console.log(result.exitCode); // 0
console.log(result.stdout);
```
```bash cURL
curl -X POST "http://127.0.0.1:2468/v1/processes/run" \
-H "Content-Type: application/json" \
-d '{"command":"ls","args":["-la","/workspace"]}'
```
</CodeGroup>
You can set a timeout and cap output size:
<CodeGroup>
```ts TypeScript
const result = await sdk.runProcess({
command: "make",
args: ["build"],
timeoutMs: 60000,
maxOutputBytes: 1048576,
});
if (result.timedOut) {
console.log("Build timed out");
}
if (result.stdoutTruncated) {
console.log("Output was truncated");
}
```
```bash cURL
curl -X POST "http://127.0.0.1:2468/v1/processes/run" \
-H "Content-Type: application/json" \
-d '{"command":"make","args":["build"],"timeoutMs":60000,"maxOutputBytes":1048576}'
```
</CodeGroup>
## Managed processes
Create a long-lived process that you can interact with, monitor, and stop later.
### Create
<CodeGroup>
```ts TypeScript
const proc = await sdk.createProcess({
command: "node",
args: ["server.js"],
cwd: "/workspace",
});
console.log(proc.id, proc.pid); // proc_1, 12345
```
```bash cURL
curl -X POST "http://127.0.0.1:2468/v1/processes" \
-H "Content-Type: application/json" \
-d '{"command":"node","args":["server.js"],"cwd":"/workspace"}'
```
</CodeGroup>
### List and get
<CodeGroup>
```ts TypeScript
const { processes } = await sdk.listProcesses();
for (const p of processes) {
console.log(p.id, p.command, p.status);
}
const proc = await sdk.getProcess("proc_1");
```
```bash cURL
curl "http://127.0.0.1:2468/v1/processes"
curl "http://127.0.0.1:2468/v1/processes/proc_1"
```
</CodeGroup>
### Stop, kill, and delete
<CodeGroup>
```ts TypeScript
// SIGTERM with optional wait
await sdk.stopProcess("proc_1", { waitMs: 5000 });
// SIGKILL
await sdk.killProcess("proc_1", { waitMs: 1000 });
// Remove exited process record
await sdk.deleteProcess("proc_1");
```
```bash cURL
curl -X POST "http://127.0.0.1:2468/v1/processes/proc_1/stop?waitMs=5000"
curl -X POST "http://127.0.0.1:2468/v1/processes/proc_1/kill?waitMs=1000"
curl -X DELETE "http://127.0.0.1:2468/v1/processes/proc_1"
```
</CodeGroup>
## Logs
### Fetch buffered logs
<CodeGroup>
```ts TypeScript
const logs = await sdk.getProcessLogs("proc_1", {
tail: 50,
stream: "combined",
});
for (const entry of logs.entries) {
console.log(entry.stream, atob(entry.data));
}
```
```bash cURL
curl "http://127.0.0.1:2468/v1/processes/proc_1/logs?tail=50&stream=combined"
```
</CodeGroup>
### Follow logs via SSE
Stream log entries in real time. The subscription replays buffered entries first, then streams new output as it arrives.
```ts TypeScript
const sub = await sdk.followProcessLogs("proc_1", (entry) => {
console.log(entry.stream, atob(entry.data));
});
// Later, stop following
sub.close();
await sub.closed;
```
## Terminals
Create a process with `tty: true` to allocate a pseudo-terminal, then connect via WebSocket for full bidirectional I/O.
```ts TypeScript
const proc = await sdk.createProcess({
command: "bash",
tty: true,
});
```
### Write input
<CodeGroup>
```ts TypeScript
await sdk.sendProcessInput("proc_1", {
data: "echo hello\n",
encoding: "utf8",
});
```
```bash cURL
curl -X POST "http://127.0.0.1:2468/v1/processes/proc_1/input" \
-H "Content-Type: application/json" \
-d '{"data":"echo hello\n","encoding":"utf8"}'
```
</CodeGroup>
### Connect to a terminal
Use `ProcessTerminalSession` unless you need direct frame access.
```ts TypeScript
const terminal = sdk.connectProcessTerminal("proc_1");
terminal.onReady(() => {
terminal.resize({ cols: 120, rows: 40 });
terminal.sendInput("ls\n");
});
terminal.onData((bytes) => {
process.stdout.write(new TextDecoder().decode(bytes));
});
terminal.onExit((status) => {
console.log("exit:", status.exitCode);
});
terminal.onError((error) => {
console.error(error instanceof Error ? error.message : error.message);
});
terminal.onClose(() => {
console.log("terminal closed");
});
```
Since the browser WebSocket API cannot send custom headers, the endpoint accepts an `access_token` query parameter for authentication. The SDK handles this automatically.
### Browser terminal emulators
The terminal session works with any browser terminal emulator like ghostty-web or xterm.js. For a drop-in React terminal, see [React Components](/react-components).
## Configuration
Adjust runtime limits like max concurrent processes, timeouts, and buffer sizes.
<CodeGroup>
```ts TypeScript
const config = await sdk.getProcessConfig();
console.log(config);
await sdk.setProcessConfig({
...config,
maxConcurrentProcesses: 32,
defaultRunTimeoutMs: 60000,
});
```
```bash cURL
curl "http://127.0.0.1:2468/v1/processes/config"
curl -X POST "http://127.0.0.1:2468/v1/processes/config" \
-H "Content-Type: application/json" \
-d '{"maxConcurrentProcesses":32,"defaultRunTimeoutMs":60000,"maxRunTimeoutMs":300000,"maxOutputBytes":1048576,"maxLogBytesPerProcess":10485760,"maxInputBytesPerRequest":65536}'
```
</CodeGroup>

View file

@ -84,7 +84,7 @@ icon: "rocket"
Install and run the binary directly. Install and run the binary directly.
```bash ```bash
curl -fsSL https://releases.rivet.dev/sandbox-agent/0.3.x/install.sh | sh curl -fsSL https://releases.rivet.dev/sandbox-agent/0.2.x/install.sh | sh
sandbox-agent server --no-token --host 0.0.0.0 --port 2468 sandbox-agent server --no-token --host 0.0.0.0 --port 2468
``` ```
</Tab> </Tab>
@ -93,7 +93,7 @@ icon: "rocket"
Run without installing globally. Run without installing globally.
```bash ```bash
npx @sandbox-agent/cli@0.3.x server --no-token --host 0.0.0.0 --port 2468 npx @sandbox-agent/cli@0.2.x server --no-token --host 0.0.0.0 --port 2468
``` ```
</Tab> </Tab>
@ -101,7 +101,7 @@ icon: "rocket"
Run without installing globally. Run without installing globally.
```bash ```bash
bunx @sandbox-agent/cli@0.3.x server --no-token --host 0.0.0.0 --port 2468 bunx @sandbox-agent/cli@0.2.x server --no-token --host 0.0.0.0 --port 2468
``` ```
</Tab> </Tab>
@ -109,7 +109,7 @@ icon: "rocket"
Install globally, then run. Install globally, then run.
```bash ```bash
npm install -g @sandbox-agent/cli@0.3.x npm install -g @sandbox-agent/cli@0.2.x
sandbox-agent server --no-token --host 0.0.0.0 --port 2468 sandbox-agent server --no-token --host 0.0.0.0 --port 2468
``` ```
</Tab> </Tab>
@ -118,7 +118,7 @@ icon: "rocket"
Install globally, then run. Install globally, then run.
```bash ```bash
bun add -g @sandbox-agent/cli@0.3.x bun add -g @sandbox-agent/cli@0.2.x
# Allow Bun to run postinstall scripts for native binaries (required for SandboxAgent.start()). # Allow Bun to run postinstall scripts for native binaries (required for SandboxAgent.start()).
bun pm -g trust @sandbox-agent/cli-linux-x64 @sandbox-agent/cli-linux-arm64 @sandbox-agent/cli-darwin-arm64 @sandbox-agent/cli-darwin-x64 @sandbox-agent/cli-win32-x64 bun pm -g trust @sandbox-agent/cli-linux-x64 @sandbox-agent/cli-linux-arm64 @sandbox-agent/cli-darwin-arm64 @sandbox-agent/cli-darwin-x64 @sandbox-agent/cli-win32-x64
sandbox-agent server --no-token --host 0.0.0.0 --port 2468 sandbox-agent server --no-token --host 0.0.0.0 --port 2468
@ -129,7 +129,7 @@ icon: "rocket"
For local development, use `SandboxAgent.start()` to spawn and manage the server as a subprocess. For local development, use `SandboxAgent.start()` to spawn and manage the server as a subprocess.
```bash ```bash
npm install sandbox-agent@0.3.x npm install sandbox-agent@0.2.x
``` ```
```typescript ```typescript
@ -143,7 +143,7 @@ icon: "rocket"
For local development, use `SandboxAgent.start()` to spawn and manage the server as a subprocess. For local development, use `SandboxAgent.start()` to spawn and manage the server as a subprocess.
```bash ```bash
bun add sandbox-agent@0.3.x bun add sandbox-agent@0.2.x
# Allow Bun to run postinstall scripts for native binaries (required for SandboxAgent.start()). # Allow Bun to run postinstall scripts for native binaries (required for SandboxAgent.start()).
bun pm trust @sandbox-agent/cli-linux-x64 @sandbox-agent/cli-linux-arm64 @sandbox-agent/cli-darwin-arm64 @sandbox-agent/cli-darwin-x64 @sandbox-agent/cli-win32-x64 bun pm trust @sandbox-agent/cli-linux-x64 @sandbox-agent/cli-linux-arm64 @sandbox-agent/cli-darwin-arm64 @sandbox-agent/cli-darwin-x64 @sandbox-agent/cli-win32-x64
``` ```

View file

@ -1,103 +0,0 @@
---
title: "React Components"
description: "Drop-in React components for Sandbox Agent frontends."
icon: "react"
---
`@sandbox-agent/react` exposes small React components built on top of the `sandbox-agent` SDK.
## Install
```bash
npm install @sandbox-agent/react@0.3.x
```
## Full example
This example connects to a running Sandbox Agent server, starts a tty shell, renders `ProcessTerminal`, and cleans up the process when the component unmounts.
```tsx TerminalPane.tsx expandable highlight={5,32-36,71}
"use client";
import { useEffect, useState } from "react";
import { SandboxAgent } from "sandbox-agent";
import { ProcessTerminal } from "@sandbox-agent/react";
export default function TerminalPane() {
const [client, setClient] = useState<SandboxAgent | null>(null);
const [processId, setProcessId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
let sdk: SandboxAgent | null = null;
let createdProcessId: string | null = null;
const cleanup = async () => {
if (!sdk || !createdProcessId) {
return;
}
await sdk.killProcess(createdProcessId, { waitMs: 1_000 }).catch(() => {});
await sdk.deleteProcess(createdProcessId).catch(() => {});
};
const start = async () => {
try {
sdk = await SandboxAgent.connect({
baseUrl: "http://127.0.0.1:2468",
});
const process = await sdk.createProcess({
command: "sh",
interactive: true,
tty: true,
});
if (cancelled) {
createdProcessId = process.id;
await cleanup();
await sdk.dispose();
return;
}
createdProcessId = process.id;
setClient(sdk);
setProcessId(process.id);
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to start terminal.";
setError(message);
}
};
void start();
return () => {
cancelled = true;
void cleanup();
void sdk?.dispose();
};
}, []);
if (error) {
return <div>{error}</div>;
}
if (!client || !processId) {
return <div>Starting terminal...</div>;
}
return <ProcessTerminal client={client} processId={processId} height={480} />;
}
```
## Component
`ProcessTerminal` attaches to a running tty process.
- `client`: a `SandboxAgent` client
- `processId`: the process to attach to
- `height`, `style`, `terminalStyle`: optional layout overrides
- `onExit`, `onError`: optional lifecycle callbacks
See [Processes](/processes) for the lower-level terminal APIs.

View file

@ -11,12 +11,12 @@ The TypeScript SDK is centered on `sandbox-agent` and its `SandboxAgent` class.
<Tabs> <Tabs>
<Tab title="npm"> <Tab title="npm">
```bash ```bash
npm install sandbox-agent@0.3.x npm install sandbox-agent@0.2.x
``` ```
</Tab> </Tab>
<Tab title="bun"> <Tab title="bun">
```bash ```bash
bun add sandbox-agent@0.3.x bun add sandbox-agent@0.2.x
# Allow Bun to run postinstall scripts for native binaries (required for SandboxAgent.start()). # Allow Bun to run postinstall scripts for native binaries (required for SandboxAgent.start()).
bun pm trust @sandbox-agent/cli-linux-x64 @sandbox-agent/cli-linux-arm64 @sandbox-agent/cli-darwin-arm64 @sandbox-agent/cli-darwin-x64 @sandbox-agent/cli-win32-x64 bun pm trust @sandbox-agent/cli-linux-x64 @sandbox-agent/cli-linux-arm64 @sandbox-agent/cli-darwin-arm64 @sandbox-agent/cli-darwin-x64 @sandbox-agent/cli-win32-x64
``` ```
@ -26,13 +26,7 @@ The TypeScript SDK is centered on `sandbox-agent` and its `SandboxAgent` class.
## Optional persistence drivers ## Optional persistence drivers
```bash ```bash
npm install @sandbox-agent/persist-indexeddb@0.3.x @sandbox-agent/persist-sqlite@0.3.x @sandbox-agent/persist-postgres@0.3.x npm install @sandbox-agent/persist-indexeddb@0.2.x @sandbox-agent/persist-sqlite@0.2.x @sandbox-agent/persist-postgres@0.2.x
```
## Optional React components
```bash
npm install @sandbox-agent/react@0.3.x
``` ```
## Create a client ## Create a client
@ -45,8 +39,6 @@ const sdk = await SandboxAgent.connect({
}); });
``` ```
`SandboxAgent.connect(...)` now waits for `/v1/health` by default before other SDK requests proceed. To disable that gate, pass `waitForHealth: false`. To keep the default gate but fail after a bounded wait, pass `waitForHealth: { timeoutMs: 120_000 }`. To cancel the startup wait early, pass `signal: abortController.signal`.
With a custom fetch handler (for example, proxying requests inside Workers): With a custom fetch handler (for example, proxying requests inside Workers):
```ts ```ts
@ -55,19 +47,6 @@ const sdk = await SandboxAgent.connect({
}); });
``` ```
With an abort signal for the startup health gate:
```ts
const controller = new AbortController();
const sdk = await SandboxAgent.connect({
baseUrl: "http://127.0.0.1:2468",
signal: controller.signal,
});
controller.abort();
```
With persistence: With persistence:
```ts ```ts
@ -121,25 +100,6 @@ await restored.prompt([{ type: "text", text: "Continue from previous context." }
await sdk.destroySession(restored.id); await sdk.destroySession(restored.id);
``` ```
## Session configuration
Set model, mode, or thought level at creation or on an existing session:
```ts
const session = await sdk.createSession({
agent: "codex",
model: "gpt-5.3-codex",
});
await session.setModel("gpt-5.2-codex");
await session.setMode("auto");
const options = await session.getConfigOptions();
const modes = await session.getModes();
```
See [Agent Sessions](/agent-sessions) for full details on config options and error handling.
## Events ## Events
Subscribe to live events: Subscribe to live events:
@ -210,5 +170,14 @@ Parameters:
- `token` (optional): Bearer token for authenticated servers - `token` (optional): Bearer token for authenticated servers
- `headers` (optional): Additional request headers - `headers` (optional): Additional request headers
- `fetch` (optional): Custom fetch implementation used by SDK HTTP and ACP calls - `fetch` (optional): Custom fetch implementation used by SDK HTTP and ACP calls
- `waitForHealth` (optional, defaults to enabled): waits for `/v1/health` before HTTP helpers and ACP session setup proceed; pass `false` to disable or `{ timeoutMs }` to bound the wait
- `signal` (optional): aborts the startup `/v1/health` wait used by `connect()` ## Types
```ts
import type {
AgentInfo,
HealthResponse,
SessionEvent,
SessionRecord,
} from "sandbox-agent";
```

View file

@ -38,7 +38,7 @@ const sdk = await SandboxAgent.connect({
Recommended for sandbox orchestration with actor state. Recommended for sandbox orchestration with actor state.
```bash ```bash
npm install @sandbox-agent/persist-rivet@0.3.x npm install @sandbox-agent/persist-rivet@0.2.x
``` ```
```ts ```ts
@ -90,7 +90,7 @@ export default actor({
Best for browser apps that should survive reloads. Best for browser apps that should survive reloads.
```bash ```bash
npm install @sandbox-agent/persist-indexeddb@0.3.x npm install @sandbox-agent/persist-indexeddb@0.2.x
``` ```
```ts ```ts
@ -112,7 +112,7 @@ const sdk = await SandboxAgent.connect({
Best for local/server Node apps that need durable storage without a DB server. Best for local/server Node apps that need durable storage without a DB server.
```bash ```bash
npm install @sandbox-agent/persist-sqlite@0.3.x npm install @sandbox-agent/persist-sqlite@0.2.x
``` ```
```ts ```ts
@ -134,7 +134,7 @@ const sdk = await SandboxAgent.connect({
Use when you already run Postgres and want shared relational storage. Use when you already run Postgres and want shared relational storage.
```bash ```bash
npm install @sandbox-agent/persist-postgres@0.3.x npm install @sandbox-agent/persist-postgres@0.2.x
``` ```
```ts ```ts

View file

@ -1,5 +1,5 @@
FROM node:22-bookworm-slim FROM node:22-bookworm-slim
RUN apt-get update && apt-get install -y curl ca-certificates && rm -rf /var/lib/apt/lists/* RUN apt-get update && apt-get install -y curl ca-certificates && rm -rf /var/lib/apt/lists/*
RUN curl -fsSL https://releases.rivet.dev/sandbox-agent/0.3.x/install.sh | sh RUN curl -fsSL https://releases.rivet.dev/sandbox-agent/0.2.x/install.sh | sh
RUN sandbox-agent install-agent claude RUN sandbox-agent install-agent claude
RUN sandbox-agent install-agent codex RUN sandbox-agent install-agent codex

View file

@ -1,5 +1,5 @@
FROM node:22-bookworm-slim FROM node:22-bookworm-slim
RUN apt-get update && apt-get install -y curl ca-certificates && rm -rf /var/lib/apt/lists/* RUN apt-get update && apt-get install -y curl ca-certificates && rm -rf /var/lib/apt/lists/*
RUN curl -fsSL https://releases.rivet.dev/sandbox-agent/0.3.x/install.sh | sh RUN curl -fsSL https://releases.rivet.dev/sandbox-agent/0.2.x/install.sh | sh
RUN sandbox-agent install-agent claude RUN sandbox-agent install-agent claude
RUN sandbox-agent install-agent codex RUN sandbox-agent install-agent codex

View file

@ -1,6 +1,6 @@
import { SimpleBox } from "@boxlite-ai/boxlite"; import { SimpleBox } from "@boxlite-ai/boxlite";
import { SandboxAgent } from "sandbox-agent"; import { SandboxAgent } from "sandbox-agent";
import { detectAgent, buildInspectorUrl } from "@sandbox-agent/example-shared"; import { detectAgent, buildInspectorUrl, waitForHealth } from "@sandbox-agent/example-shared";
import { setupImage, OCI_DIR } from "./setup-image.ts"; import { setupImage, OCI_DIR } from "./setup-image.ts";
const env: Record<string, string> = {}; const env: Record<string, string> = {};
@ -26,7 +26,9 @@ if (result.exitCode !== 0) throw new Error(`Failed to start server: ${result.std
const baseUrl = "http://localhost:3000"; const baseUrl = "http://localhost:3000";
console.log("Connecting to server..."); console.log("Waiting for server...");
await waitForHealth({ baseUrl });
const client = await SandboxAgent.connect({ baseUrl }); const client = await SandboxAgent.connect({ baseUrl });
const session = await client.createSession({ agent: detectAgent(), sessionInit: { cwd: "/root", mcpServers: [] } }); const session = await client.createSession({ agent: detectAgent(), sessionInit: { cwd: "/root", mcpServers: [] } });
const sessionId = session.id; const sessionId = session.id;

View file

@ -1,7 +1,7 @@
FROM cloudflare/sandbox:0.7.0 FROM cloudflare/sandbox:0.7.0
# Install sandbox-agent # Install sandbox-agent
RUN curl -fsSL https://releases.rivet.dev/sandbox-agent/0.3.x/install.sh | sh RUN curl -fsSL https://releases.rivet.dev/sandbox-agent/0.2.x/install.sh | sh
# Pre-install agents # Pre-install agents
RUN sandbox-agent install-agent claude && \ RUN sandbox-agent install-agent claude && \

View file

@ -10,7 +10,7 @@ import {
type ProviderName, type ProviderName,
} from "computesdk"; } from "computesdk";
import { SandboxAgent } from "sandbox-agent"; import { SandboxAgent } from "sandbox-agent";
import { detectAgent, buildInspectorUrl } from "@sandbox-agent/example-shared"; import { detectAgent, buildInspectorUrl, waitForHealth } from "@sandbox-agent/example-shared";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { resolve } from "node:path"; import { resolve } from "node:path";
@ -116,6 +116,9 @@ export async function setupComputeSdkSandboxAgent(): Promise<{
const baseUrl = await sandbox.getUrl({ port: PORT }); const baseUrl = await sandbox.getUrl({ port: PORT });
console.log("Waiting for server...");
await waitForHealth({ baseUrl });
const cleanup = async () => { const cleanup = async () => {
try { try {
await sandbox.destroy(); await sandbox.destroy();

View file

@ -1,6 +1,6 @@
import { Daytona, Image } from "@daytonaio/sdk"; import { Daytona, Image } from "@daytonaio/sdk";
import { SandboxAgent } from "sandbox-agent"; import { SandboxAgent } from "sandbox-agent";
import { detectAgent, buildInspectorUrl } from "@sandbox-agent/example-shared"; import { detectAgent, buildInspectorUrl, waitForHealth } from "@sandbox-agent/example-shared";
const daytona = new Daytona(); const daytona = new Daytona();
@ -13,7 +13,7 @@ if (process.env.OPENAI_API_KEY)
// Build a custom image with sandbox-agent pre-installed (slower first run, faster subsequent runs) // Build a custom image with sandbox-agent pre-installed (slower first run, faster subsequent runs)
const image = Image.base("ubuntu:22.04").runCommands( const image = Image.base("ubuntu:22.04").runCommands(
"apt-get update && apt-get install -y curl ca-certificates", "apt-get update && apt-get install -y curl ca-certificates",
"curl -fsSL https://releases.rivet.dev/sandbox-agent/0.3.x/install.sh | sh", "curl -fsSL https://releases.rivet.dev/sandbox-agent/0.2.x/install.sh | sh",
); );
console.log("Creating Daytona sandbox (first run builds the base image and may take a few minutes, subsequent runs are fast)..."); console.log("Creating Daytona sandbox (first run builds the base image and may take a few minutes, subsequent runs are fast)...");
@ -25,7 +25,9 @@ await sandbox.process.executeCommand(
const baseUrl = (await sandbox.getSignedPreviewUrl(3000, 4 * 60 * 60)).url; const baseUrl = (await sandbox.getSignedPreviewUrl(3000, 4 * 60 * 60)).url;
console.log("Connecting to server..."); console.log("Waiting for server...");
await waitForHealth({ baseUrl });
const client = await SandboxAgent.connect({ baseUrl }); const client = await SandboxAgent.connect({ baseUrl });
const session = await client.createSession({ agent: detectAgent(), sessionInit: { cwd: "/home/daytona", mcpServers: [] } }); const session = await client.createSession({ agent: detectAgent(), sessionInit: { cwd: "/home/daytona", mcpServers: [] } });
const sessionId = session.id; const sessionId = session.id;

View file

@ -1,6 +1,6 @@
import { Daytona } from "@daytonaio/sdk"; import { Daytona } from "@daytonaio/sdk";
import { SandboxAgent } from "sandbox-agent"; import { SandboxAgent } from "sandbox-agent";
import { detectAgent, buildInspectorUrl } from "@sandbox-agent/example-shared"; import { detectAgent, buildInspectorUrl, waitForHealth } from "@sandbox-agent/example-shared";
const daytona = new Daytona(); const daytona = new Daytona();
@ -17,7 +17,7 @@ const sandbox = await daytona.create({ envVars, autoStopInterval: 0 });
// Install sandbox-agent and start server // Install sandbox-agent and start server
console.log("Installing sandbox-agent..."); console.log("Installing sandbox-agent...");
await sandbox.process.executeCommand( await sandbox.process.executeCommand(
"curl -fsSL https://releases.rivet.dev/sandbox-agent/0.3.x/install.sh | sh", "curl -fsSL https://releases.rivet.dev/sandbox-agent/0.2.x/install.sh | sh",
); );
console.log("Installing agents..."); console.log("Installing agents...");
@ -30,7 +30,9 @@ await sandbox.process.executeCommand(
const baseUrl = (await sandbox.getSignedPreviewUrl(3000, 4 * 60 * 60)).url; const baseUrl = (await sandbox.getSignedPreviewUrl(3000, 4 * 60 * 60)).url;
console.log("Connecting to server..."); console.log("Waiting for server...");
await waitForHealth({ baseUrl });
const client = await SandboxAgent.connect({ baseUrl }); const client = await SandboxAgent.connect({ baseUrl });
const session = await client.createSession({ agent: detectAgent(), sessionInit: { cwd: "/home/daytona", mcpServers: [] } }); const session = await client.createSession({ agent: detectAgent(), sessionInit: { cwd: "/home/daytona", mcpServers: [] } });
const sessionId = session.id; const sessionId = session.id;

View file

@ -1,16 +1,9 @@
import Docker from "dockerode"; import Docker from "dockerode";
import fs from "node:fs";
import path from "node:path";
import { SandboxAgent } from "sandbox-agent"; import { SandboxAgent } from "sandbox-agent";
import { detectAgent, buildInspectorUrl } from "@sandbox-agent/example-shared"; import { detectAgent, buildInspectorUrl, waitForHealth } from "@sandbox-agent/example-shared";
const IMAGE = "node:22-bookworm-slim"; const IMAGE = "alpine:latest";
const PORT = 3000; const PORT = 3000;
const agent = detectAgent();
const codexAuthPath = process.env.HOME ? path.join(process.env.HOME, ".codex", "auth.json") : null;
const bindMounts = codexAuthPath && fs.existsSync(codexAuthPath)
? [`${codexAuthPath}:/root/.codex/auth.json:ro`]
: [];
const docker = new Docker({ socketPath: "/var/run/docker.sock" }); const docker = new Docker({ socketPath: "/var/run/docker.sock" });
@ -31,30 +24,29 @@ console.log("Starting container...");
const container = await docker.createContainer({ const container = await docker.createContainer({
Image: IMAGE, Image: IMAGE,
Cmd: ["sh", "-c", [ Cmd: ["sh", "-c", [
"apt-get update", "apk add --no-cache curl ca-certificates libstdc++ libgcc bash",
"DEBIAN_FRONTEND=noninteractive apt-get install -y curl ca-certificates bash libstdc++6", "curl -fsSL https://releases.rivet.dev/sandbox-agent/0.2.x/install.sh | sh",
"rm -rf /var/lib/apt/lists/*", "sandbox-agent install-agent claude",
"curl -fsSL https://releases.rivet.dev/sandbox-agent/0.3.x/install.sh | sh", "sandbox-agent install-agent codex",
`sandbox-agent server --no-token --host 0.0.0.0 --port ${PORT}`, `sandbox-agent server --no-token --host 0.0.0.0 --port ${PORT}`,
].join(" && ")], ].join(" && ")],
Env: [ Env: [
process.env.ANTHROPIC_API_KEY ? `ANTHROPIC_API_KEY=${process.env.ANTHROPIC_API_KEY}` : "", process.env.ANTHROPIC_API_KEY ? `ANTHROPIC_API_KEY=${process.env.ANTHROPIC_API_KEY}` : "",
process.env.OPENAI_API_KEY ? `OPENAI_API_KEY=${process.env.OPENAI_API_KEY}` : "", process.env.OPENAI_API_KEY ? `OPENAI_API_KEY=${process.env.OPENAI_API_KEY}` : "",
process.env.CODEX_API_KEY ? `CODEX_API_KEY=${process.env.CODEX_API_KEY}` : "",
].filter(Boolean), ].filter(Boolean),
ExposedPorts: { [`${PORT}/tcp`]: {} }, ExposedPorts: { [`${PORT}/tcp`]: {} },
HostConfig: { HostConfig: {
AutoRemove: true, AutoRemove: true,
PortBindings: { [`${PORT}/tcp`]: [{ HostPort: `${PORT}` }] }, PortBindings: { [`${PORT}/tcp`]: [{ HostPort: `${PORT}` }] },
Binds: bindMounts,
}, },
}); });
await container.start(); await container.start();
const baseUrl = `http://127.0.0.1:${PORT}`; const baseUrl = `http://127.0.0.1:${PORT}`;
await waitForHealth({ baseUrl });
const client = await SandboxAgent.connect({ baseUrl }); const client = await SandboxAgent.connect({ baseUrl });
const session = await client.createSession({ agent, sessionInit: { cwd: "/root", mcpServers: [] } }); const session = await client.createSession({ agent: detectAgent(), sessionInit: { cwd: "/root", mcpServers: [] } });
const sessionId = session.id; const sessionId = session.id;
console.log(` UI: ${buildInspectorUrl({ baseUrl, sessionId })}`); console.log(` UI: ${buildInspectorUrl({ baseUrl, sessionId })}`);

View file

@ -1,6 +1,6 @@
import { Sandbox } from "@e2b/code-interpreter"; import { Sandbox } from "@e2b/code-interpreter";
import { SandboxAgent } from "sandbox-agent"; import { SandboxAgent } from "sandbox-agent";
import { detectAgent, buildInspectorUrl } from "@sandbox-agent/example-shared"; import { detectAgent, buildInspectorUrl, waitForHealth } from "@sandbox-agent/example-shared";
const envs: Record<string, string> = {}; const envs: Record<string, string> = {};
if (process.env.ANTHROPIC_API_KEY) envs.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY; if (process.env.ANTHROPIC_API_KEY) envs.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY;
@ -16,7 +16,7 @@ const run = async (cmd: string) => {
}; };
console.log("Installing sandbox-agent..."); console.log("Installing sandbox-agent...");
await run("curl -fsSL https://releases.rivet.dev/sandbox-agent/0.3.x/install.sh | sh"); await run("curl -fsSL https://releases.rivet.dev/sandbox-agent/0.2.x/install.sh | sh");
console.log("Installing agents..."); console.log("Installing agents...");
await run("sandbox-agent install-agent claude"); await run("sandbox-agent install-agent claude");
@ -27,7 +27,9 @@ await sandbox.commands.run("sandbox-agent server --no-token --host 0.0.0.0 --por
const baseUrl = `https://${sandbox.getHost(3000)}`; const baseUrl = `https://${sandbox.getHost(3000)}`;
console.log("Connecting to server..."); console.log("Waiting for server...");
await waitForHealth({ baseUrl });
const client = await SandboxAgent.connect({ baseUrl }); const client = await SandboxAgent.connect({ baseUrl });
const session = await client.createSession({ agent: detectAgent(), sessionInit: { cwd: "/home/user", mcpServers: [] } }); const session = await client.createSession({ agent: detectAgent(), sessionInit: { cwd: "/home/user", mcpServers: [] } });
const sessionId = session.id; const sessionId = session.id;

View file

@ -11,7 +11,6 @@ COPY sdks/typescript/ sdks/typescript/
COPY sdks/acp-http-client/ sdks/acp-http-client/ COPY sdks/acp-http-client/ sdks/acp-http-client/
COPY sdks/cli-shared/ sdks/cli-shared/ COPY sdks/cli-shared/ sdks/cli-shared/
COPY sdks/persist-indexeddb/ sdks/persist-indexeddb/ COPY sdks/persist-indexeddb/ sdks/persist-indexeddb/
COPY sdks/react/ sdks/react/
COPY frontend/packages/inspector/ frontend/packages/inspector/ COPY frontend/packages/inspector/ frontend/packages/inspector/
COPY docs/openapi.json docs/ COPY docs/openapi.json docs/

View file

@ -4,6 +4,7 @@ import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import { PassThrough } from "node:stream"; import { PassThrough } from "node:stream";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { waitForHealth } from "./sandbox-agent-client.ts";
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
const EXAMPLE_IMAGE = "sandbox-agent-examples:latest"; const EXAMPLE_IMAGE = "sandbox-agent-examples:latest";
@ -172,7 +173,7 @@ async function ensureExampleImage(_docker: Docker): Promise<string> {
} }
/** /**
* Start a Docker container running sandbox-agent. * Start a Docker container running sandbox-agent and wait for it to be healthy.
* Registers SIGINT/SIGTERM handlers for cleanup. * Registers SIGINT/SIGTERM handlers for cleanup.
*/ */
export async function startDockerSandbox(opts: DockerSandboxOptions): Promise<DockerSandbox> { export async function startDockerSandbox(opts: DockerSandboxOptions): Promise<DockerSandbox> {
@ -274,8 +275,18 @@ export async function startDockerSandbox(opts: DockerSandboxOptions): Promise<Do
} }
const baseUrl = `http://127.0.0.1:${mappedHostPort}`; const baseUrl = `http://127.0.0.1:${mappedHostPort}`;
try {
await waitForHealth({ baseUrl });
} catch (err) {
stopStartupLogs();
console.error(" Container logs:");
for (const chunk of logChunks) {
process.stderr.write(` ${chunk}`);
}
throw err;
}
stopStartupLogs(); stopStartupLogs();
console.log(` Started (${baseUrl})`); console.log(` Ready (${baseUrl})`);
const cleanup = async () => { const cleanup = async () => {
stopStartupLogs(); stopStartupLogs();

View file

@ -3,6 +3,8 @@
* Provides minimal helpers for connecting to and interacting with sandbox-agent servers. * Provides minimal helpers for connecting to and interacting with sandbox-agent servers.
*/ */
import { setTimeout as delay } from "node:timers/promises";
function normalizeBaseUrl(baseUrl: string): string { function normalizeBaseUrl(baseUrl: string): string {
return baseUrl.replace(/\/+$/, ""); return baseUrl.replace(/\/+$/, "");
} }
@ -72,6 +74,41 @@ export function buildHeaders({
return headers; return headers;
} }
export async function waitForHealth({
baseUrl,
token,
extraHeaders,
timeoutMs = 120_000,
}: {
baseUrl: string;
token?: string;
extraHeaders?: Record<string, string>;
timeoutMs?: number;
}): Promise<void> {
const normalized = normalizeBaseUrl(baseUrl);
const deadline = Date.now() + timeoutMs;
let lastError: unknown;
while (Date.now() < deadline) {
try {
const headers = buildHeaders({ token, extraHeaders });
const response = await fetch(`${normalized}/v1/health`, { headers });
if (response.ok) {
const data = await response.json();
if (data?.status === "ok") {
return;
}
lastError = new Error(`Unexpected health response: ${JSON.stringify(data)}`);
} else {
lastError = new Error(`Health check failed: ${response.status}`);
}
} catch (error) {
lastError = error;
}
await delay(500);
}
throw (lastError ?? new Error("Timed out waiting for /v1/health")) as Error;
}
export function generateSessionId(): string { export function generateSessionId(): string {
const chars = "abcdefghijklmnopqrstuvwxyz0123456789"; const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
let id = "session-"; let id = "session-";
@ -107,3 +144,4 @@ export function detectAgent(): string {
} }
return "claude"; return "claude";
} }

View file

@ -1,6 +1,6 @@
import { Sandbox } from "@vercel/sandbox"; import { Sandbox } from "@vercel/sandbox";
import { SandboxAgent } from "sandbox-agent"; import { SandboxAgent } from "sandbox-agent";
import { detectAgent, buildInspectorUrl } from "@sandbox-agent/example-shared"; import { detectAgent, buildInspectorUrl, waitForHealth } from "@sandbox-agent/example-shared";
const envs: Record<string, string> = {}; const envs: Record<string, string> = {};
if (process.env.ANTHROPIC_API_KEY) envs.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY; if (process.env.ANTHROPIC_API_KEY) envs.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY;
@ -22,7 +22,7 @@ const run = async (cmd: string, args: string[] = []) => {
}; };
console.log("Installing sandbox-agent..."); console.log("Installing sandbox-agent...");
await run("sh", ["-c", "curl -fsSL https://releases.rivet.dev/sandbox-agent/0.3.x/install.sh | sh"]); await run("sh", ["-c", "curl -fsSL https://releases.rivet.dev/sandbox-agent/0.2.x/install.sh | sh"]);
console.log("Installing agents..."); console.log("Installing agents...");
await run("sandbox-agent", ["install-agent", "claude"]); await run("sandbox-agent", ["install-agent", "claude"]);
@ -38,7 +38,9 @@ await sandbox.runCommand({
const baseUrl = sandbox.domain(3000); const baseUrl = sandbox.domain(3000);
console.log("Connecting to server..."); console.log("Waiting for server...");
await waitForHealth({ baseUrl });
const client = await SandboxAgent.connect({ baseUrl }); const client = await SandboxAgent.connect({ baseUrl });
const session = await client.createSession({ agent: detectAgent(), sessionInit: { cwd: "/home/vercel-sandbox", mcpServers: [] } }); const session = await client.createSession({ agent: detectAgent(), sessionInit: { cwd: "/home/vercel-sandbox", mcpServers: [] } });
const sessionId = session.id; const sessionId = session.id;

View file

@ -1 +0,0 @@
CLAUDE.md

View file

@ -1,230 +0,0 @@
# Project Instructions
## Breaking Changes
Do not preserve legacy compatibility. Implement the best current architecture, even if breaking.
## Language Policy
Use TypeScript for all source code.
- Never add raw JavaScript source files (`.js`, `.mjs`, `.cjs`).
- Prefer `.ts`/`.tsx` for runtime code, scripts, tests, and tooling.
- If touching old JavaScript, migrate it to TypeScript instead of extending it.
## Monorepo + Tooling
Use `pnpm` workspaces and Turborepo.
- Workspace root uses `pnpm-workspace.yaml` and `turbo.json`.
- Packages live in `packages/*`.
- `core` is renamed to `shared`.
- `packages/cli` is disabled and excluded from active workspace validation.
- Integrations and providers live under `packages/backend/src/{integrations,providers}`.
## CLI Status
- `packages/cli` is fully disabled for active development.
- Do not implement new behavior in `packages/cli` unless explicitly requested.
- Frontend is the primary product surface; prioritize `packages/frontend` + supporting `packages/client`/`packages/backend`.
- Workspace `build`, `typecheck`, and `test` intentionally exclude `@openhandoff/cli`.
- `pnpm-workspace.yaml` excludes `packages/cli` from workspace package resolution.
## Common Commands
- Install deps: `pnpm install`
- Full active-workspace validation: `pnpm -w typecheck`, `pnpm -w build`, `pnpm -w test`
- Start the full dev stack: `just factory-dev`
- Start the local production-build preview stack: `just factory-preview`
- Start only the backend locally: `just factory-backend-start`
- Start only the frontend locally: `pnpm --filter @openhandoff/frontend dev`
- Start the frontend against the mock workbench client: `OPENHANDOFF_FRONTEND_CLIENT_MODE=mock pnpm --filter @openhandoff/frontend dev`
- Stop the compose dev stack: `just factory-dev-down`
- Tail compose logs: `just factory-dev-logs`
- Stop the preview stack: `just factory-preview-down`
- Tail preview logs: `just factory-preview-logs`
## Frontend + Client Boundary
- Keep a browser-friendly GUI implementation aligned with the TUI interaction model wherever possible.
- Do not import `rivetkit` directly in CLI or GUI packages. RivetKit client access must stay isolated inside `packages/client`.
- All backend interaction (actor calls, metadata/health checks, backend HTTP endpoint access) must go through the dedicated client library in `packages/client`.
- Outside `packages/client`, do not call backend endpoints directly (for example `fetch(.../api/rivet...)`), except in black-box E2E tests that intentionally exercise raw transport behavior.
- GUI state should update in realtime (no manual refresh buttons). Prefer RivetKit push reactivity and actor-driven events; do not add polling/refetch for normal product flows.
- Keep the mock workbench types and mock client in `packages/shared` + `packages/client` up to date with the frontend contract. The mock is the UI testing reference implementation while backend functionality catches up.
- Keep frontend route/state coverage current in code and tests; there is no separate page-inventory doc to maintain.
- When making UI changes, verify the live flow with `agent-browser`, take screenshots of the updated UI, and offer to open those screenshots in Preview when you finish.
- When asked for screenshots, capture all relevant affected screens and modal states, not just a single viewport. Include empty, populated, success, and blocked/error states when they are part of the changed flow.
- If a screenshot catches a transition frame, blank modal, or otherwise misleading state, retake it before reporting it.
## Runtime Policy
- Runtime is Bun-native.
- Use Bun for CLI/backend execution paths and process spawning.
- Do not add Node compatibility fallbacks for OpenTUI/runtime execution.
## Defensive Error Handling
- Write code defensively: validate assumptions at boundaries and state transitions.
- If the system reaches an unexpected state, raise an explicit error with actionable context.
- Do not fail silently, swallow errors, or auto-ignore inconsistent data.
- Prefer fail-fast behavior over hidden degradation when correctness is uncertain.
## RivetKit Dependency Policy
For all Rivet/RivetKit implementation:
1. Use SQLite + Drizzle for persistent state.
2. SQLite is **per actor instance** (per actor key), not a shared backend-global database:
- Each actor instance gets its own SQLite DB.
- Schema design should assume a single actor instance owns the entire DB.
- Do not add `workspaceId`/`repoId`/`handoffId` columns just to "namespace" rows for a given actor instance; use actor state and/or the actor key instead.
- Example: the `handoff` actor instance already represents `(workspaceId, repoId, handoffId)`, so its SQLite tables should not need those columns for primary keys.
3. Do not use backend-global SQLite singletons; database access must go through actor `db` providers (`c.db`).
4. Do not use published RivetKit npm packages.
5. RivetKit is linked via pnpm `link:` protocol to `../rivet/rivetkit-typescript/packages/rivetkit`. Sub-packages (`@rivetkit/sqlite-vfs`, etc.) resolve transitively from the rivet workspace.
- Dedicated local checkout for this workspace: `/Users/nathan/conductor/workspaces/handoff/rivet-checkout`
- Dev worktree note: when working on RivetKit fixes for this repo, prefer the dedicated local checkout above and link to `../rivet-checkout/rivetkit-typescript/packages/rivetkit`.
6. Before using, build RivetKit in the rivet repo:
```bash
cd ../rivet-checkout/rivetkit-typescript
pnpm install
pnpm build -F rivetkit
```
## Inspector HTTP API (Workflow Debugging)
- The Inspector HTTP routes come from RivetKit `feat: inspector http api (#4144)` and are served from the RivetKit manager endpoint (not `/api/rivet`).
- Resolve manager endpoint from backend metadata:
```bash
curl -sS http://127.0.0.1:7741/api/rivet/metadata | jq -r '.clientEndpoint'
```
- List actors:
- `GET {manager}/actors?name=handoff`
- Inspector endpoints (path prefix: `/gateway/{actorId}/inspector`):
- `GET /state`
- `PATCH /state`
- `GET /connections`
- `GET /rpcs`
- `POST /action/{name}`
- `GET /queue?limit=50`
- `GET /traces?startMs=0&endMs=<ms>&limit=1000`
- `GET /workflow-history`
- `GET /summary`
- Auth:
- Production: send `Authorization: Bearer $RIVET_INSPECTOR_TOKEN`.
- Development: auth can be skipped when no inspector token is configured.
- Handoff workflow quick inspect:
```bash
MGR="$(curl -sS http://127.0.0.1:7741/api/rivet/metadata | jq -r '.clientEndpoint')"
HID="7df7656e-bbd2-4b8c-bf0f-30d4df2f619a"
AID="$(curl -sS "$MGR/actors?name=handoff" \
| jq -r --arg hid "$HID" '.actors[] | select(.key | endswith("/handoff/\($hid)")) | .actor_id' \
| head -n1)"
curl -sS "$MGR/gateway/$AID/inspector/workflow-history" | jq .
curl -sS "$MGR/gateway/$AID/inspector/summary" | jq .
```
- If inspector routes return `404 Not Found (RivetKit)`, the running backend is on a RivetKit build that predates `#4144`; rebuild linked RivetKit and restart backend.
## Workspace + Actor Rules
- Everything is scoped to a workspace.
- Workspace resolution order: `--workspace` flag -> config default -> `"default"`.
- `ControlPlaneActor` is replaced by `WorkspaceActor` (workspace coordinator).
- Every actor key must be prefixed with workspace namespace (`["ws", workspaceId, ...]`).
- CLI/TUI/GUI must use `@openhandoff/client` (`packages/client`) for backend access; `rivetkit/client` imports are only allowed inside `packages/client`.
- Do not add custom backend REST endpoints (no `/v1/*` shim layer).
- We own the sandbox-agent project; treat sandbox-agent defects as first-party bugs and fix them instead of working around them.
- Keep strict single-writer ownership: each table/row has exactly one actor writer.
- Parent actors (`workspace`, `project`, `handoff`, `history`, `sandbox-instance`) use command-only loops with no timeout.
- Periodic syncing lives in dedicated child actors with one timeout cadence each.
- Actor handle policy:
- Prefer explicit `get` or explicit `create` based on workflow intent; do not default to `getOrCreate`.
- Use `get`/`getForId` when the actor is expected to already exist; if missing, surface an explicit `Actor not found` error with recovery context.
- Use create semantics only on explicit provisioning/create paths where creating a new actor instance is intended.
- `getOrCreate` is a last resort for create paths when an explicit create API is unavailable; never use it in read/command paths.
- For long-lived cross-actor links (for example sandbox/session runtime access), persist actor identity (`actorId`) and keep a fallback lookup path by actor id.
- Docker dev: `compose.dev.yaml` mounts a named volume at `/root/.local/share/openhandoff/repos` to persist backend-managed git clones across restarts. Code must still work if this volume is not present (create directories as needed).
- RivetKit actor `c.state` is durable, but in Docker it is stored under `/root/.local/share/rivetkit`. If that path is not persisted, actor state-derived indexes (for example, in `project` actor state) can be lost after container recreation even when other data still exists.
- Workflow history divergence policy:
- Production: never auto-delete actor state to resolve `HistoryDivergedError`; ship explicit workflow migrations (`ctx.removed(...)`, step compatibility).
- Development: manual local state reset is allowed as an operator recovery path when migrations are not yet available.
- Storage rule of thumb:
- Put simple metadata in `c.state` (KV state): small scalars and identifiers like `{ handoffId }`, `{ repoId }`, booleans, counters, timestamps, status strings.
- If it grows beyond trivial (arrays, maps, histories, query/filter needs, relational consistency), use SQLite + Drizzle in `c.db`.
## Testing Policy
- Never use vitest mocks (`vi.mock`, `vi.spyOn`, `vi.fn`). Instead, define driver interfaces for external I/O and pass test implementations via the actor runtime context.
- All external service calls (git CLI, GitHub CLI, sandbox-agent HTTP, tmux) must go through the `BackendDriver` interface on the runtime context.
- Integration tests use `setupTest()` from `rivetkit/test` and are gated behind `HF_ENABLE_ACTOR_INTEGRATION_TESTS=1`.
- End-to-end testing must run against the dev backend started via `docker compose -f compose.dev.yaml up` (host -> container). Do not run E2E against an in-process test runtime.
- E2E tests should talk to the backend over HTTP (default `http://127.0.0.1:7741/api/rivet`) and use real GitHub repos/PRs.
- Secrets (e.g. `OPENAI_API_KEY`, `GITHUB_TOKEN`/`GH_TOKEN`) must be provided via environment variables, never hardcoded in the repo.
- Treat client E2E tests in `packages/client/test` as the primary end-to-end source of truth for product behavior.
- Keep backend tests small and targeted. Only retain backend-only tests for invariants or persistence rules that are not well-covered through client E2E.
- Do not keep large browser E2E suites around in a broken state. If a frontend browser E2E is not maintained and producing signal, remove it until it can be replaced with a reliable test.
## Config
- Keep config path at `~/.config/openhandoff/config.toml`.
- Evolve properties in place; do not move config location.
## Project Guidance
Project-specific guidance lives in `README.md`, `CONTRIBUTING.md`, and the relevant files under `research/`.
Keep those updated when:
- Commands change
- Configuration options change
- Architecture changes
- Plugins/providers change
- Actor ownership changes
## Friction Logs
Track friction at:
- `research/friction/rivet.mdx`
- `research/friction/sandbox-agent.mdx`
- `research/friction/sandboxes.mdx`
- `research/friction/general.mdx`
Category mapping:
- `rivet`: Rivet/RivetKit runtime, actor model, queues, keys
- `sandbox-agent`: sandbox-agent SDK/API behavior
- `sandboxes`: provider implementations (worktree/daytona/etc)
- `general`: everything else
Each entry must include:
- Date (`YYYY-MM-DD`)
- Commit SHA (or `uncommitted`)
- What you were implementing
- Friction/issue
- Attempted fix/workaround and outcome
## History Events
Log notable workflow changes to `events` so `hf history` remains complete:
- create
- attach
- push/sync/merge
- archive/kill
- status transitions
- PR state transitions
## Validation After Changes
Always run and fix failures:
```bash
pnpm -w typecheck
pnpm -w build
pnpm -w test
```
After making code changes, always update the dev server before declaring the work complete. If the dev stack is running through Docker Compose, restart or recreate the relevant dev services so the running app reflects the latest code.

View file

@ -1,64 +0,0 @@
# Contributing
## Development Setup
1. Clone:
```bash
git clone https://github.com/rivet-dev/openhandoff.git
cd openhandoff
```
2. Install dependencies:
```bash
pnpm install
```
3. Build all packages:
```bash
pnpm -w build
```
## Package Layout
- `packages/shared`: contracts/schemas
- `packages/backend`: RivetKit actors + DB + providers + integrations
- `packages/cli`: `hf` and `hf tui` (OpenTUI)
## Local RivetKit Dependency
Build local RivetKit before backend changes that depend on Rivet internals:
```bash
cd ../rivet
pnpm build -F rivetkit
cd /path/to/openhandoff
just sync-rivetkit
```
## Validation
Run before opening a PR:
```bash
pnpm -w typecheck
pnpm -w build
pnpm -w test
```
## Dev Backend (Docker Compose)
Start the dev backend (hot reload via `bun --watch`) and Vite frontend via Docker Compose:
```bash
just factory-dev
```
Stop it:
```bash
just factory-dev-down
```

View file

@ -1,53 +0,0 @@
# syntax=docker/dockerfile:1.7
FROM node:22-bookworm-slim AS base
ENV PNPM_HOME=/pnpm
ENV PATH=$PNPM_HOME:$PATH
WORKDIR /app
RUN corepack enable && corepack prepare pnpm@10.28.2 --activate
FROM base AS deps
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml turbo.json tsconfig.base.json ./
COPY packages/shared/package.json packages/shared/package.json
COPY packages/backend/package.json packages/backend/package.json
COPY packages/rivetkit-vendor/rivetkit/package.json packages/rivetkit-vendor/rivetkit/package.json
COPY packages/rivetkit-vendor/workflow-engine/package.json packages/rivetkit-vendor/workflow-engine/package.json
COPY packages/rivetkit-vendor/traces/package.json packages/rivetkit-vendor/traces/package.json
COPY packages/rivetkit-vendor/sqlite-vfs/package.json packages/rivetkit-vendor/sqlite-vfs/package.json
COPY packages/rivetkit-vendor/sqlite-vfs-linux-x64/package.json packages/rivetkit-vendor/sqlite-vfs-linux-x64/package.json
COPY packages/rivetkit-vendor/sqlite-vfs-linux-arm64/package.json packages/rivetkit-vendor/sqlite-vfs-linux-arm64/package.json
COPY packages/rivetkit-vendor/sqlite-vfs-darwin-arm64/package.json packages/rivetkit-vendor/sqlite-vfs-darwin-arm64/package.json
COPY packages/rivetkit-vendor/sqlite-vfs-darwin-x64/package.json packages/rivetkit-vendor/sqlite-vfs-darwin-x64/package.json
COPY packages/rivetkit-vendor/sqlite-vfs-win32-x64/package.json packages/rivetkit-vendor/sqlite-vfs-win32-x64/package.json
COPY packages/rivetkit-vendor/runner/package.json packages/rivetkit-vendor/runner/package.json
COPY packages/rivetkit-vendor/runner-protocol/package.json packages/rivetkit-vendor/runner-protocol/package.json
COPY packages/rivetkit-vendor/virtual-websocket/package.json packages/rivetkit-vendor/virtual-websocket/package.json
RUN pnpm fetch --frozen-lockfile --filter @openhandoff/backend...
FROM base AS build
COPY --from=deps /pnpm/store /pnpm/store
COPY . .
RUN pnpm install --frozen-lockfile --prefer-offline --filter @openhandoff/backend...
RUN pnpm --filter @openhandoff/shared build
RUN pnpm --filter @openhandoff/backend build
RUN pnpm --filter @openhandoff/backend deploy --prod --legacy /out
FROM oven/bun:1.2 AS runtime
ENV NODE_ENV=production
ENV HOME=/home/handoff
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates \
git \
gh \
openssh-client \
&& rm -rf /var/lib/apt/lists/*
RUN addgroup --system --gid 1001 handoff \
&& adduser --system --uid 1001 --home /home/handoff --ingroup handoff handoff \
&& mkdir -p /home/handoff \
&& chown -R handoff:handoff /home/handoff /app
COPY --from=build /out ./
USER handoff
EXPOSE 7741
CMD ["bun", "dist/index.js", "start", "--host", "0.0.0.0"]

View file

@ -1,24 +0,0 @@
# OpenHandoff
TypeScript workspace handoff system powered by RivetKit actors, SQLite/Drizzle state, and OpenTUI.
**Documentation**: [openhandoff.dev](https://openhandoff.dev)
## Quick Install
```bash
curl -fsSL https://bun.sh/install | bash
pnpm install
pnpm -w build
```
## Project Goals
- **Simple**: There's one screen. It has everything you need. You can use it blindfolded.
- **Fast**: No waiting around.
- **Collaborative**: Built for fast moving teams that need code reviewed & shipped fast.
- **Pluggable**: Works for small side projects to enterprise teams.
## License
MIT

View file

@ -1,90 +0,0 @@
name: openhandoff
services:
backend:
build:
context: ..
dockerfile: factory/docker/backend.dev.Dockerfile
image: openhandoff-backend-dev
working_dir: /app
environment:
HF_BACKEND_HOST: "0.0.0.0"
HF_BACKEND_PORT: "7741"
HF_RIVET_MANAGER_PORT: "8750"
RIVETKIT_STORAGE_PATH: "/root/.local/share/openhandoff/rivetkit"
# Pass through credentials needed for agent execution + PR creation in dev/e2e.
# Do not hardcode secrets; set these in your environment when starting compose.
ANTHROPIC_API_KEY: "${ANTHROPIC_API_KEY:-}"
CLAUDE_API_KEY: "${CLAUDE_API_KEY:-${ANTHROPIC_API_KEY:-}}"
OPENAI_API_KEY: "${OPENAI_API_KEY:-}"
# sandbox-agent codex plugin currently expects CODEX_API_KEY. Map from OPENAI_API_KEY for convenience.
CODEX_API_KEY: "${CODEX_API_KEY:-${OPENAI_API_KEY:-}}"
# Support either GITHUB_TOKEN or GITHUB_PAT in local env files.
GITHUB_TOKEN: "${GITHUB_TOKEN:-${GITHUB_PAT:-}}"
GH_TOKEN: "${GH_TOKEN:-${GITHUB_TOKEN:-${GITHUB_PAT:-}}}"
DAYTONA_ENDPOINT: "${DAYTONA_ENDPOINT:-}"
DAYTONA_API_KEY: "${DAYTONA_API_KEY:-}"
HF_DAYTONA_ENDPOINT: "${HF_DAYTONA_ENDPOINT:-}"
HF_DAYTONA_API_KEY: "${HF_DAYTONA_API_KEY:-}"
ports:
- "7741:7741"
# RivetKit manager (used by browser clients after /api/rivet metadata redirect in dev)
- "8750:8750"
volumes:
- "..:/app"
# The linked RivetKit checkout resolves from factory packages to /handoff/rivet-checkout in-container.
- "../../../handoff/rivet-checkout:/handoff/rivet-checkout:ro"
# Reuse the host Codex auth profile for local sandbox-agent Codex sessions in dev.
- "${HOME}/.codex:/root/.codex"
# Keep backend dependency installs Linux-native instead of using host node_modules.
- "openhandoff_backend_root_node_modules:/app/node_modules"
- "openhandoff_backend_backend_node_modules:/app/factory/packages/backend/node_modules"
- "openhandoff_backend_shared_node_modules:/app/factory/packages/shared/node_modules"
- "openhandoff_backend_persist_rivet_node_modules:/app/sdks/persist-rivet/node_modules"
- "openhandoff_backend_typescript_node_modules:/app/sdks/typescript/node_modules"
- "openhandoff_backend_pnpm_store:/root/.local/share/pnpm/store"
# Persist backend-managed local git clones across container restarts.
- "openhandoff_git_repos:/root/.local/share/openhandoff/repos"
# Persist RivetKit local storage across container restarts.
- "openhandoff_rivetkit_storage:/root/.local/share/openhandoff/rivetkit"
frontend:
build:
context: ..
dockerfile: factory/docker/frontend.dev.Dockerfile
working_dir: /app
depends_on:
- backend
environment:
HOME: "/tmp"
HF_BACKEND_HTTP: "http://backend:7741"
ports:
- "4173:4173"
volumes:
- "..:/app"
# Ensure logs in .openhandoff/ persist on the host even if we change source mounts later.
- "./.openhandoff:/app/factory/.openhandoff"
- "../../../handoff/rivet-checkout:/handoff/rivet-checkout:ro"
# Use Linux-native workspace dependencies inside the container instead of host node_modules.
- "openhandoff_node_modules:/app/node_modules"
- "openhandoff_client_node_modules:/app/factory/packages/client/node_modules"
- "openhandoff_frontend_errors_node_modules:/app/factory/packages/frontend-errors/node_modules"
- "openhandoff_frontend_node_modules:/app/factory/packages/frontend/node_modules"
- "openhandoff_shared_node_modules:/app/factory/packages/shared/node_modules"
- "openhandoff_pnpm_store:/tmp/.local/share/pnpm/store"
volumes:
openhandoff_backend_root_node_modules: {}
openhandoff_backend_backend_node_modules: {}
openhandoff_backend_shared_node_modules: {}
openhandoff_backend_persist_rivet_node_modules: {}
openhandoff_backend_typescript_node_modules: {}
openhandoff_backend_pnpm_store: {}
openhandoff_git_repos: {}
openhandoff_rivetkit_storage: {}
openhandoff_node_modules: {}
openhandoff_client_node_modules: {}
openhandoff_frontend_errors_node_modules: {}
openhandoff_frontend_node_modules: {}
openhandoff_shared_node_modules: {}
openhandoff_pnpm_store: {}

View file

@ -1,44 +0,0 @@
name: openhandoff-preview
services:
backend:
build:
context: ..
dockerfile: quebec/docker/backend.preview.Dockerfile
image: openhandoff-backend-preview
environment:
HF_BACKEND_HOST: "0.0.0.0"
HF_BACKEND_PORT: "7841"
HF_RIVET_MANAGER_PORT: "8850"
RIVETKIT_STORAGE_PATH: "/root/.local/share/openhandoff/rivetkit"
ANTHROPIC_API_KEY: "${ANTHROPIC_API_KEY:-}"
CLAUDE_API_KEY: "${CLAUDE_API_KEY:-${ANTHROPIC_API_KEY:-}}"
OPENAI_API_KEY: "${OPENAI_API_KEY:-}"
CODEX_API_KEY: "${CODEX_API_KEY:-${OPENAI_API_KEY:-}}"
GITHUB_TOKEN: "${GITHUB_TOKEN:-${GITHUB_PAT:-}}"
GH_TOKEN: "${GH_TOKEN:-${GITHUB_TOKEN:-${GITHUB_PAT:-}}}"
DAYTONA_ENDPOINT: "${DAYTONA_ENDPOINT:-}"
DAYTONA_API_KEY: "${DAYTONA_API_KEY:-}"
HF_DAYTONA_ENDPOINT: "${HF_DAYTONA_ENDPOINT:-}"
HF_DAYTONA_API_KEY: "${HF_DAYTONA_API_KEY:-}"
ports:
- "7841:7841"
- "8850:8850"
volumes:
- "${HOME}/.codex:/root/.codex"
- "openhandoff_preview_git_repos:/root/.local/share/openhandoff/repos"
- "openhandoff_preview_rivetkit_storage:/root/.local/share/openhandoff/rivetkit"
frontend:
build:
context: ..
dockerfile: quebec/docker/frontend.preview.Dockerfile
image: openhandoff-frontend-preview
depends_on:
- backend
ports:
- "4273:4273"
volumes:
openhandoff_preview_git_repos: {}
openhandoff_preview_rivetkit_storage: {}

View file

@ -1,42 +0,0 @@
# syntax=docker/dockerfile:1.7
FROM oven/bun:1.3
ARG GIT_SPICE_VERSION=v0.23.0
ARG SANDBOX_AGENT_VERSION=0.3.0
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates \
curl \
git \
gh \
nodejs \
npm \
openssh-client \
&& rm -rf /var/lib/apt/lists/*
RUN npm install -g pnpm@10.28.2
RUN set -eux; \
arch="$(dpkg --print-architecture)"; \
case "$arch" in \
amd64) spice_arch="x86_64" ;; \
arm64) spice_arch="aarch64" ;; \
*) echo "Unsupported architecture for git-spice: $arch" >&2; exit 1 ;; \
esac; \
tmpdir="$(mktemp -d)"; \
curl -fsSL "https://github.com/abhinav/git-spice/releases/download/${GIT_SPICE_VERSION}/git-spice.Linux-${spice_arch}.tar.gz" -o "${tmpdir}/git-spice.tgz"; \
tar -xzf "${tmpdir}/git-spice.tgz" -C "${tmpdir}"; \
install -m 0755 "${tmpdir}/gs" /usr/local/bin/gs; \
ln -sf /usr/local/bin/gs /usr/local/bin/git-spice; \
rm -rf "${tmpdir}"
RUN curl -fsSL "https://releases.rivet.dev/sandbox-agent/${SANDBOX_AGENT_VERSION}/install.sh" | sh
ENV PATH="/root/.local/bin:${PATH}"
ENV SANDBOX_AGENT_BIN="/root/.local/bin/sandbox-agent"
WORKDIR /app
CMD ["bash", "-lc", "git config --global --add safe.directory /app >/dev/null 2>&1 || true; pnpm install --force --frozen-lockfile --filter @openhandoff/backend... && exec bun factory/packages/backend/src/index.ts start --host 0.0.0.0 --port 7741"]

View file

@ -1,49 +0,0 @@
# syntax=docker/dockerfile:1.7
FROM oven/bun:1.3
ARG GIT_SPICE_VERSION=v0.23.0
ARG SANDBOX_AGENT_VERSION=0.3.0
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates \
curl \
git \
gh \
nodejs \
npm \
openssh-client \
&& npm install -g pnpm@10.28.2 \
&& rm -rf /var/lib/apt/lists/*
RUN set -eux; \
arch="$(dpkg --print-architecture)"; \
case "$arch" in \
amd64) spice_arch="x86_64" ;; \
arm64) spice_arch="aarch64" ;; \
*) echo "Unsupported architecture for git-spice: $arch" >&2; exit 1 ;; \
esac; \
tmpdir="$(mktemp -d)"; \
curl -fsSL "https://github.com/abhinav/git-spice/releases/download/${GIT_SPICE_VERSION}/git-spice.Linux-${spice_arch}.tar.gz" -o "${tmpdir}/git-spice.tgz"; \
tar -xzf "${tmpdir}/git-spice.tgz" -C "${tmpdir}"; \
install -m 0755 "${tmpdir}/gs" /usr/local/bin/gs; \
ln -sf /usr/local/bin/gs /usr/local/bin/git-spice; \
rm -rf "${tmpdir}"
RUN curl -fsSL "https://releases.rivet.dev/sandbox-agent/${SANDBOX_AGENT_VERSION}/install.sh" | sh
ENV PATH="/root/.local/bin:${PATH}"
ENV SANDBOX_AGENT_BIN="/root/.local/bin/sandbox-agent"
WORKDIR /workspace/quebec
COPY quebec /workspace/quebec
COPY rivet-checkout /workspace/rivet-checkout
RUN pnpm install --frozen-lockfile
RUN pnpm --filter @openhandoff/shared build
RUN pnpm --filter @openhandoff/client build
RUN pnpm --filter @openhandoff/backend build
CMD ["bash", "-lc", "git config --global --add safe.directory /workspace/quebec >/dev/null 2>&1 || true; exec bun packages/backend/dist/index.js start --host 0.0.0.0 --port 7841"]

View file

@ -1,11 +0,0 @@
# syntax=docker/dockerfile:1.7
FROM node:22-bookworm-slim
# Install pnpm into the image so we can run as a non-root user at runtime.
# Using npm here avoids Corepack's first-run download behavior.
RUN npm install -g pnpm@10.28.2
WORKDIR /app
CMD ["bash", "-lc", "pnpm install --force --frozen-lockfile --filter @openhandoff/frontend... && cd factory/packages/frontend && exec pnpm vite --host 0.0.0.0 --port 4173"]

View file

@ -1,23 +0,0 @@
# syntax=docker/dockerfile:1.7
FROM node:22-bookworm-slim AS build
RUN npm install -g pnpm@10.28.2
WORKDIR /workspace/quebec
COPY quebec /workspace/quebec
COPY rivet-checkout /workspace/rivet-checkout
RUN pnpm install --frozen-lockfile
RUN pnpm --filter @openhandoff/shared build
RUN pnpm --filter @openhandoff/client build
RUN pnpm --filter @openhandoff/frontend-errors build
RUN pnpm --filter @openhandoff/frontend build
FROM nginx:1.27-alpine
COPY quebec/docker/nginx.preview.conf /etc/nginx/conf.d/default.conf
COPY --from=build /workspace/quebec/packages/frontend/dist /usr/share/nginx/html
EXPOSE 4273

View file

@ -1,31 +0,0 @@
server {
listen 4273;
server_name _;
root /usr/share/nginx/html;
index index.html;
location /api/rivet/ {
proxy_pass http://backend:7841/api/rivet/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
location = /api/rivet {
proxy_pass http://backend:7841/api/rivet;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
location / {
try_files $uri $uri/ /index.html;
}
}

View file

@ -1 +0,0 @@
wb-mmilw7yh

View file

@ -1 +0,0 @@
wb-mmilzdwf

View file

@ -1,82 +0,0 @@
## workflow
### terminal
1. hf create "do something"
2. notifies via openclaw
### claude code/opencode
1. "handoff this task to do xxxx"
2. ask clarifying questions
3. works in background (attach opencode session with `hf attach` and switch to session with `hf switch`)
4. automatically submits draft pr (if configured)
5. notifies via openclaw (wip)
### openclaw
(similar to claude code)
### mobile
1. open opencode web ui
## todo
- add -a flag to add to create to attach to it
- backend mode
- fix our tests
- update icons.rs to include colors for the icons
## ideas
- reminders (ctrl r)
- notifications
- check for duplicates/simlar prs
- if plan -> searches for exsiting funcionality, creates plan asking clarying questions
- automatically check off of todo list when done
- fix opencode path, cannot find config file
- unread indicato
- add inbox that is the source of truth for this
- show this on hf above everything else
- sync command
- refactor sessions: ~/.claude/plans/sleepy-frolicking-nest.md
- keep switch active after archive
- add an icon if there are merge conflicts
- add `hf -`
- ask -> do research in a codebase
- todo list integrations (linear, github, etc)
- show issues due soon in switch
- search issues from cli
- create issues from cli
- keep tmux window name in sync with the agent status
- move all tools (github, graphite, git) too tools/ folder
- show git tree
- editor plugins
- vs code
- tmux
- zed
- opencode web
- have hf switch periodically refresh on agent status
- add new columns
- model (for the agent)
- todo list & plan management -> with simplenote sync
- sqlite (global)
- list of all global handoff repos
- heartbeat status to tell openclaw what it needs to send you
- sandbox agent sdk support
- serve command to run server
- multi-repo support (list for all repos)
- pluggable notification system
- cron jobs
- sandbox support
- auto-boot sandboxes for prs
- menubar
- notes integration
## cool details
- automatically uses your opencode theme
- auto symlink target/node_modules/etc
- auto-archives handoffs when closed
- shows agent status in the tmux window name

View file

@ -1,36 +0,0 @@
# Backend Notes
## Actor Hierarchy
Keep the backend actor tree aligned with this shape unless we explicitly decide to change it:
```text
WorkspaceActor
├─ HistoryActor(workspace-scoped global feed)
├─ ProjectActor(repo)
│ ├─ ProjectBranchSyncActor
│ ├─ ProjectPrSyncActor
│ └─ HandoffActor(handoff)
│ ├─ HandoffSessionActor(session) × N
│ │ └─ SessionStatusSyncActor(session) × 0..1
│ └─ Handoff-local workbench state
└─ SandboxInstanceActor(providerId, sandboxId) × N
```
## Ownership Rules
- `WorkspaceActor` is the workspace coordinator and lookup/index owner.
- `HistoryActor` is workspace-scoped. There is one workspace-level history feed.
- `ProjectActor` is the repo coordinator and owns repo-local caches/indexes.
- `HandoffActor` is one branch. Treat `1 handoff = 1 branch` once branch assignment is finalized.
- `HandoffActor` can have many sessions.
- `HandoffActor` can reference many sandbox instances historically, but should have only one active sandbox/session at a time.
- Session unread state and draft prompts are backend-owned workbench state, not frontend-local state.
- Branch rename is a real git operation, not just metadata.
- `SandboxInstanceActor` stays separate from `HandoffActor`; handoffs/sessions reference it by identity.
- Sync actors are polling workers only. They feed parent actors and should not become the source of truth.
## Maintenance
- Keep this file up to date whenever actor ownership, hierarchy, or lifecycle responsibilities change.
- If the real actor tree diverges from this document, update this document in the same change.

View file

@ -1,35 +0,0 @@
{
"name": "@openhandoff/backend",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsup src/index.ts --format esm --external bun:sqlite",
"db:generate": "find src/actors -name drizzle.config.ts -exec pnpm exec drizzle-kit generate --config {} \\; && \"$HOME/.bun/bin/bun\" src/actors/_scripts/generate-actor-migrations.ts",
"typecheck": "tsc --noEmit",
"test": "$HOME/.bun/bin/bun x vitest run",
"start": "bun dist/index.js start"
},
"dependencies": {
"@daytonaio/sdk": "0.141.0",
"@hono/node-server": "^1.19.7",
"@hono/node-ws": "^1.3.0",
"@iarna/toml": "^2.2.5",
"@openhandoff/shared": "workspace:*",
"@sandbox-agent/persist-rivet": "workspace:*",
"drizzle-orm": "^0.44.5",
"hono": "^4.11.9",
"pino": "^10.3.1",
"rivetkit": "link:../../../../../handoff/rivet-checkout/rivetkit-typescript/packages/rivetkit",
"sandbox-agent": "workspace:*",
"uuid": "^13.0.0",
"zod": "^4.1.5"
},
"devDependencies": {
"@types/bun": "^1.3.9",
"drizzle-kit": "^0.31.8",
"tsup": "^8.5.0"
}
}

View file

@ -1,136 +0,0 @@
import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
type Journal = {
entries?: Array<{
idx: number;
when: number;
tag: string;
breakpoints?: boolean;
version?: string;
}>;
};
function padMigrationKey(idx: number): string {
return `m${String(idx).padStart(4, "0")}`;
}
function escapeTemplateLiteral(value: string): string {
return value.replace(/`/g, "\\`").replace(/\$\{/g, "\\${");
}
async function fileExists(path: string): Promise<boolean> {
try {
await readFile(path);
return true;
} catch {
return false;
}
}
async function walkDirectories(root: string, onDir: (dir: string) => Promise<void>): Promise<void> {
const entries = await readdir(root, { withFileTypes: true });
await onDir(root);
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (entry.name === "node_modules" || entry.name === "dist" || entry.name.startsWith(".")) {
continue;
}
await walkDirectories(join(root, entry.name), onDir);
}
}
async function generateOne(drizzleDir: string): Promise<void> {
const metaDir = resolve(drizzleDir, "meta");
const journalPath = resolve(metaDir, "_journal.json");
if (!(await fileExists(journalPath))) {
return;
}
const drizzleEntries = (await readdir(drizzleDir, { withFileTypes: true }))
.filter((entry) => entry.isFile() && entry.name.endsWith(".sql"))
.map((entry) => entry.name)
.sort();
if (drizzleEntries.length === 0) {
return;
}
const journalRaw = await readFile(journalPath, "utf8");
const journal = JSON.parse(journalRaw) as Journal;
const entries = journal.entries ?? [];
const sqlByKey = new Map<string, string>();
for (const entry of entries) {
const file = drizzleEntries[entry.idx];
if (!file) {
throw new Error(`Missing migration SQL file for idx=${entry.idx} in ${drizzleDir}`);
}
const sqlPath = resolve(drizzleDir, file);
const sqlRaw = await readFile(sqlPath, "utf8");
sqlByKey.set(padMigrationKey(entry.idx), sqlRaw);
}
const migrationsObjectLines: string[] = [];
for (const entry of entries) {
const key = padMigrationKey(entry.idx);
const sql = sqlByKey.get(key);
if (!sql) continue;
migrationsObjectLines.push(` ${key}: \`${escapeTemplateLiteral(sql)}\`,`);
}
const banner = `// This file is generated by src/actors/_scripts/generate-actor-migrations.ts.
// Source of truth is drizzle-kit output under ./drizzle (meta/_journal.json + *.sql).
// Do not hand-edit this file.
`;
const journalLiteral = JSON.stringify(
{
entries: entries.map((entry) => ({
idx: entry.idx,
when: entry.when,
tag: entry.tag,
breakpoints: Boolean(entry.breakpoints),
})),
},
null,
2
);
const outPath = resolve(drizzleDir, "..", "migrations.ts");
const content = `${banner}
const journal = ${journalLiteral} as const;
export default {
journal,
migrations: {
${migrationsObjectLines.join("\n")}
} as const
};
`;
await mkdir(dirname(outPath), { recursive: true });
await writeFile(outPath, content, "utf8");
// drizzle-kit generates a JS helper file by default; delete to keep TS-only sources.
await rm(resolve(drizzleDir, "migrations.js"), { force: true });
}
async function main(): Promise<void> {
const packageRoot = resolve(import.meta.dirname, "..", "..", ".."); // packages/backend
const actorsRoot = resolve(packageRoot, "src", "actors");
await walkDirectories(actorsRoot, async (dir) => {
if (dir.endsWith(`${join("db", "drizzle")}`)) {
await generateOne(dir);
}
});
}
main().catch((error: unknown) => {
const message = error instanceof Error ? error.stack ?? error.message : String(error);
// eslint-disable-next-line no-console
console.error(message);
process.exitCode = 1;
});

View file

@ -1,43 +0,0 @@
import type { AppConfig } from "@openhandoff/shared";
import type { BackendDriver } from "../driver.js";
import type { NotificationService } from "../notifications/index.js";
import type { ProviderRegistry } from "../providers/index.js";
let runtimeConfig: AppConfig | null = null;
let providerRegistry: ProviderRegistry | null = null;
let notificationService: NotificationService | null = null;
let runtimeDriver: BackendDriver | null = null;
export function initActorRuntimeContext(
config: AppConfig,
providers: ProviderRegistry,
notifications?: NotificationService,
driver?: BackendDriver
): void {
runtimeConfig = config;
providerRegistry = providers;
notificationService = notifications ?? null;
runtimeDriver = driver ?? null;
}
export function getActorRuntimeContext(): {
config: AppConfig;
providers: ProviderRegistry;
notifications: NotificationService | null;
driver: BackendDriver;
} {
if (!runtimeConfig || !providerRegistry) {
throw new Error("Actor runtime context not initialized");
}
if (!runtimeDriver) {
throw new Error("Actor runtime context missing driver");
}
return {
config: runtimeConfig,
providers: providerRegistry,
notifications: notificationService,
driver: runtimeDriver,
};
}

View file

@ -1,112 +0,0 @@
import type { HandoffStatus, ProviderId } from "@openhandoff/shared";
export interface HandoffCreatedEvent {
workspaceId: string;
repoId: string;
handoffId: string;
providerId: ProviderId;
branchName: string;
title: string;
}
export interface HandoffStatusEvent {
workspaceId: string;
repoId: string;
handoffId: string;
status: HandoffStatus;
message: string;
}
export interface ProjectSnapshotEvent {
workspaceId: string;
repoId: string;
updatedAt: number;
}
export interface AgentStartedEvent {
workspaceId: string;
repoId: string;
handoffId: string;
sessionId: string;
}
export interface AgentIdleEvent {
workspaceId: string;
repoId: string;
handoffId: string;
sessionId: string;
}
export interface AgentErrorEvent {
workspaceId: string;
repoId: string;
handoffId: string;
message: string;
}
export interface PrCreatedEvent {
workspaceId: string;
repoId: string;
handoffId: string;
prNumber: number;
url: string;
}
export interface PrClosedEvent {
workspaceId: string;
repoId: string;
handoffId: string;
prNumber: number;
merged: boolean;
}
export interface PrReviewEvent {
workspaceId: string;
repoId: string;
handoffId: string;
prNumber: number;
reviewer: string;
status: string;
}
export interface CiStatusChangedEvent {
workspaceId: string;
repoId: string;
handoffId: string;
prNumber: number;
status: string;
}
export type HandoffStepName = "auto_commit" | "push" | "pr_submit";
export type HandoffStepStatus = "started" | "completed" | "skipped" | "failed";
export interface HandoffStepEvent {
workspaceId: string;
repoId: string;
handoffId: string;
step: HandoffStepName;
status: HandoffStepStatus;
message: string;
}
export interface BranchSwitchedEvent {
workspaceId: string;
repoId: string;
handoffId: string;
branchName: string;
}
export interface SessionAttachedEvent {
workspaceId: string;
repoId: string;
handoffId: string;
sessionId: string;
}
export interface BranchSyncedEvent {
workspaceId: string;
repoId: string;
handoffId: string;
branchName: string;
strategy: string;
}

View file

@ -1,160 +0,0 @@
import {
handoffKey,
handoffStatusSyncKey,
historyKey,
projectBranchSyncKey,
projectKey,
projectPrSyncKey,
sandboxInstanceKey,
workspaceKey
} from "./keys.js";
import type { ProviderId } from "@openhandoff/shared";
export function actorClient(c: any) {
return c.client();
}
export async function getOrCreateWorkspace(c: any, workspaceId: string) {
return await actorClient(c).workspace.getOrCreate(workspaceKey(workspaceId), {
createWithInput: workspaceId
});
}
export async function getOrCreateProject(c: any, workspaceId: string, repoId: string, remoteUrl: string) {
return await actorClient(c).project.getOrCreate(projectKey(workspaceId, repoId), {
createWithInput: {
workspaceId,
repoId,
remoteUrl
}
});
}
export function getProject(c: any, workspaceId: string, repoId: string) {
return actorClient(c).project.get(projectKey(workspaceId, repoId));
}
export function getHandoff(c: any, workspaceId: string, repoId: string, handoffId: string) {
return actorClient(c).handoff.get(handoffKey(workspaceId, repoId, handoffId));
}
export async function getOrCreateHandoff(
c: any,
workspaceId: string,
repoId: string,
handoffId: string,
createWithInput: Record<string, unknown>
) {
return await actorClient(c).handoff.getOrCreate(handoffKey(workspaceId, repoId, handoffId), {
createWithInput
});
}
export async function getOrCreateHistory(c: any, workspaceId: string, repoId: string) {
return await actorClient(c).history.getOrCreate(historyKey(workspaceId, repoId), {
createWithInput: {
workspaceId,
repoId
}
});
}
export async function getOrCreateProjectPrSync(
c: any,
workspaceId: string,
repoId: string,
repoPath: string,
intervalMs: number
) {
return await actorClient(c).projectPrSync.getOrCreate(projectPrSyncKey(workspaceId, repoId), {
createWithInput: {
workspaceId,
repoId,
repoPath,
intervalMs
}
});
}
export async function getOrCreateProjectBranchSync(
c: any,
workspaceId: string,
repoId: string,
repoPath: string,
intervalMs: number
) {
return await actorClient(c).projectBranchSync.getOrCreate(projectBranchSyncKey(workspaceId, repoId), {
createWithInput: {
workspaceId,
repoId,
repoPath,
intervalMs
}
});
}
export function getSandboxInstance(c: any, workspaceId: string, providerId: ProviderId, sandboxId: string) {
return actorClient(c).sandboxInstance.get(sandboxInstanceKey(workspaceId, providerId, sandboxId));
}
export async function getOrCreateSandboxInstance(
c: any,
workspaceId: string,
providerId: ProviderId,
sandboxId: string,
createWithInput: Record<string, unknown>
) {
return await actorClient(c).sandboxInstance.getOrCreate(
sandboxInstanceKey(workspaceId, providerId, sandboxId),
{ createWithInput }
);
}
export async function getOrCreateHandoffStatusSync(
c: any,
workspaceId: string,
repoId: string,
handoffId: string,
sandboxId: string,
sessionId: string,
createWithInput: Record<string, unknown>
) {
return await actorClient(c).handoffStatusSync.getOrCreate(
handoffStatusSyncKey(workspaceId, repoId, handoffId, sandboxId, sessionId),
{
createWithInput
}
);
}
export function selfProjectPrSync(c: any) {
return actorClient(c).projectPrSync.getForId(c.actorId);
}
export function selfProjectBranchSync(c: any) {
return actorClient(c).projectBranchSync.getForId(c.actorId);
}
export function selfHandoffStatusSync(c: any) {
return actorClient(c).handoffStatusSync.getForId(c.actorId);
}
export function selfHistory(c: any) {
return actorClient(c).history.getForId(c.actorId);
}
export function selfHandoff(c: any) {
return actorClient(c).handoff.getForId(c.actorId);
}
export function selfWorkspace(c: any) {
return actorClient(c).workspace.getForId(c.actorId);
}
export function selfProject(c: any) {
return actorClient(c).project.getForId(c.actorId);
}
export function selfSandboxInstance(c: any) {
return actorClient(c).sandboxInstance.getForId(c.actorId);
}

View file

@ -1,108 +0,0 @@
import { actor, queue } from "rivetkit";
import { workflow } from "rivetkit/workflow";
import type { ProviderId } from "@openhandoff/shared";
import { getHandoff, getSandboxInstance, selfHandoffStatusSync } from "../handles.js";
import { logActorWarning, resolveErrorMessage, resolveErrorStack } from "../logging.js";
import { type PollingControlState, runWorkflowPollingLoop } from "../polling.js";
export interface HandoffStatusSyncInput {
workspaceId: string;
repoId: string;
handoffId: string;
providerId: ProviderId;
sandboxId: string;
sessionId: string;
intervalMs: number;
}
interface SetIntervalCommand {
intervalMs: number;
}
interface HandoffStatusSyncState extends PollingControlState {
workspaceId: string;
repoId: string;
handoffId: string;
providerId: ProviderId;
sandboxId: string;
sessionId: string;
}
const CONTROL = {
start: "handoff.status_sync.control.start",
stop: "handoff.status_sync.control.stop",
setInterval: "handoff.status_sync.control.set_interval",
force: "handoff.status_sync.control.force"
} as const;
async function pollSessionStatus(c: { state: HandoffStatusSyncState }): Promise<void> {
const sandboxInstance = getSandboxInstance(c, c.state.workspaceId, c.state.providerId, c.state.sandboxId);
const status = await sandboxInstance.sessionStatus({ sessionId: c.state.sessionId });
const parent = getHandoff(c, c.state.workspaceId, c.state.repoId, c.state.handoffId);
await parent.syncWorkbenchSessionStatus({
sessionId: c.state.sessionId,
status: status.status,
at: Date.now()
});
}
export const handoffStatusSync = actor({
queues: {
[CONTROL.start]: queue(),
[CONTROL.stop]: queue(),
[CONTROL.setInterval]: queue(),
[CONTROL.force]: queue(),
},
options: {
// Polling actors rely on timer-based wakeups; sleeping would pause the timer and stop polling.
noSleep: true
},
createState: (_c, input: HandoffStatusSyncInput): HandoffStatusSyncState => ({
workspaceId: input.workspaceId,
repoId: input.repoId,
handoffId: input.handoffId,
providerId: input.providerId,
sandboxId: input.sandboxId,
sessionId: input.sessionId,
intervalMs: input.intervalMs,
running: true
}),
actions: {
async start(c): Promise<void> {
const self = selfHandoffStatusSync(c);
await self.send(CONTROL.start, {}, { wait: true, timeout: 15_000 });
},
async stop(c): Promise<void> {
const self = selfHandoffStatusSync(c);
await self.send(CONTROL.stop, {}, { wait: true, timeout: 15_000 });
},
async setIntervalMs(c, payload: SetIntervalCommand): Promise<void> {
const self = selfHandoffStatusSync(c);
await self.send(CONTROL.setInterval, payload, { wait: true, timeout: 15_000 });
},
async force(c): Promise<void> {
const self = selfHandoffStatusSync(c);
await self.send(CONTROL.force, {}, { wait: true, timeout: 5 * 60_000 });
}
},
run: workflow(async (ctx) => {
await runWorkflowPollingLoop<HandoffStatusSyncState>(ctx, {
loopName: "handoff-status-sync-loop",
control: CONTROL,
onPoll: async (loopCtx) => {
try {
await pollSessionStatus(loopCtx);
} catch (error) {
logActorWarning("handoff-status-sync", "poll failed", {
error: resolveErrorMessage(error),
stack: resolveErrorStack(error)
});
}
}
});
})
});

View file

@ -1,10 +0,0 @@
import { actorSqliteDb } from "../../../db/actor-sqlite.js";
import * as schema from "./schema.js";
import migrations from "./migrations.js";
export const handoffDb = actorSqliteDb({
actorName: "handoff",
schema,
migrations,
migrationsFolderUrl: new URL("./drizzle/", import.meta.url),
});

View file

@ -1,7 +0,0 @@
import { defineConfig } from "rivetkit/db/drizzle";
export default defineConfig({
out: "./src/actors/handoff/db/drizzle",
schema: "./src/actors/handoff/db/schema.ts",
});

View file

@ -1,24 +0,0 @@
CREATE TABLE `handoff` (
`id` integer PRIMARY KEY NOT NULL,
`branch_name` text NOT NULL,
`title` text NOT NULL,
`task` text NOT NULL,
`provider_id` text NOT NULL,
`status` text NOT NULL,
`agent_type` text DEFAULT 'claude',
`auto_committed` integer DEFAULT 0,
`pushed` integer DEFAULT 0,
`pr_submitted` integer DEFAULT 0,
`needs_push` integer DEFAULT 0,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL
);
--> statement-breakpoint
CREATE TABLE `handoff_runtime` (
`id` integer PRIMARY KEY NOT NULL,
`sandbox_id` text,
`session_id` text,
`switch_target` text,
`status_message` text,
`updated_at` integer NOT NULL
);

View file

@ -1,3 +0,0 @@
ALTER TABLE `handoff` DROP COLUMN `auto_committed`;--> statement-breakpoint
ALTER TABLE `handoff` DROP COLUMN `pushed`;--> statement-breakpoint
ALTER TABLE `handoff` DROP COLUMN `needs_push`;

View file

@ -1,38 +0,0 @@
ALTER TABLE `handoff_runtime` RENAME COLUMN "sandbox_id" TO "active_sandbox_id";--> statement-breakpoint
ALTER TABLE `handoff_runtime` RENAME COLUMN "session_id" TO "active_session_id";--> statement-breakpoint
ALTER TABLE `handoff_runtime` RENAME COLUMN "switch_target" TO "active_switch_target";--> statement-breakpoint
CREATE TABLE `handoff_sandboxes` (
`sandbox_id` text PRIMARY KEY NOT NULL,
`provider_id` text NOT NULL,
`switch_target` text NOT NULL,
`cwd` text,
`status_message` text,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL
);
--> statement-breakpoint
ALTER TABLE `handoff_runtime` ADD `active_cwd` text;
--> statement-breakpoint
INSERT INTO `handoff_sandboxes` (
`sandbox_id`,
`provider_id`,
`switch_target`,
`cwd`,
`status_message`,
`created_at`,
`updated_at`
)
SELECT
r.`active_sandbox_id`,
(SELECT h.`provider_id` FROM `handoff` h WHERE h.`id` = 1),
r.`active_switch_target`,
r.`active_cwd`,
r.`status_message`,
COALESCE((SELECT h.`created_at` FROM `handoff` h WHERE h.`id` = 1), r.`updated_at`),
r.`updated_at`
FROM `handoff_runtime` r
WHERE
r.`id` = 1
AND r.`active_sandbox_id` IS NOT NULL
AND r.`active_switch_target` IS NOT NULL
ON CONFLICT(`sandbox_id`) DO NOTHING;

View file

@ -1,48 +0,0 @@
-- Allow handoffs to exist before their branch/title are determined.
-- Drizzle doesn't support altering column nullability in SQLite directly, so rebuild the table.
PRAGMA foreign_keys=off;
CREATE TABLE `handoff__new` (
`id` integer PRIMARY KEY NOT NULL,
`branch_name` text,
`title` text,
`task` text NOT NULL,
`provider_id` text NOT NULL,
`status` text NOT NULL,
`agent_type` text DEFAULT 'claude',
`pr_submitted` integer DEFAULT 0,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL
);
INSERT INTO `handoff__new` (
`id`,
`branch_name`,
`title`,
`task`,
`provider_id`,
`status`,
`agent_type`,
`pr_submitted`,
`created_at`,
`updated_at`
)
SELECT
`id`,
`branch_name`,
`title`,
`task`,
`provider_id`,
`status`,
`agent_type`,
`pr_submitted`,
`created_at`,
`updated_at`
FROM `handoff`;
DROP TABLE `handoff`;
ALTER TABLE `handoff__new` RENAME TO `handoff`;
PRAGMA foreign_keys=on;

View file

@ -1,57 +0,0 @@
-- Fix: make branch_name/title nullable during initial "naming" stage.
-- 0003 was missing statement breakpoints, so drizzle's migrator marked it applied without executing all statements.
-- Rebuild the table again with proper statement breakpoints.
PRAGMA foreign_keys=off;
--> statement-breakpoint
DROP TABLE IF EXISTS `handoff__new`;
--> statement-breakpoint
CREATE TABLE `handoff__new` (
`id` integer PRIMARY KEY NOT NULL,
`branch_name` text,
`title` text,
`task` text NOT NULL,
`provider_id` text NOT NULL,
`status` text NOT NULL,
`agent_type` text DEFAULT 'claude',
`pr_submitted` integer DEFAULT 0,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL
);
--> statement-breakpoint
INSERT INTO `handoff__new` (
`id`,
`branch_name`,
`title`,
`task`,
`provider_id`,
`status`,
`agent_type`,
`pr_submitted`,
`created_at`,
`updated_at`
)
SELECT
`id`,
`branch_name`,
`title`,
`task`,
`provider_id`,
`status`,
`agent_type`,
`pr_submitted`,
`created_at`,
`updated_at`
FROM `handoff`;
--> statement-breakpoint
DROP TABLE `handoff`;
--> statement-breakpoint
ALTER TABLE `handoff__new` RENAME TO `handoff`;
--> statement-breakpoint
PRAGMA foreign_keys=on;

View file

@ -1 +0,0 @@
ALTER TABLE `handoff_sandboxes` ADD `sandbox_actor_id` text;

View file

@ -1,14 +0,0 @@
CREATE TABLE `handoff_workbench_sessions` (
`session_id` text PRIMARY KEY NOT NULL,
`session_name` text NOT NULL,
`model` text NOT NULL,
`unread` integer DEFAULT 0 NOT NULL,
`draft_text` text DEFAULT '' NOT NULL,
`draft_attachments_json` text DEFAULT '[]' NOT NULL,
`draft_updated_at` integer,
`created` integer DEFAULT 1 NOT NULL,
`closed` integer DEFAULT 0 NOT NULL,
`thinking_since_ms` integer,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL
);

View file

@ -1,176 +0,0 @@
{
"version": "6",
"dialect": "sqlite",
"id": "9b004d3b-0722-4bb5-a410-d47635db7df3",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"handoff": {
"name": "handoff",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"branch_name": {
"name": "branch_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"task": {
"name": "task",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"agent_type": {
"name": "agent_type",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'claude'"
},
"auto_committed": {
"name": "auto_committed",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
},
"pushed": {
"name": "pushed",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
},
"pr_submitted": {
"name": "pr_submitted",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
},
"needs_push": {
"name": "needs_push",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"handoff_runtime": {
"name": "handoff_runtime",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"sandbox_id": {
"name": "sandbox_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"session_id": {
"name": "session_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"switch_target": {
"name": "switch_target",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"status_message": {
"name": "status_message",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View file

@ -1,152 +0,0 @@
{
"version": "6",
"dialect": "sqlite",
"id": "0fca0f14-69df-4fca-bc52-29e902247909",
"prevId": "9b004d3b-0722-4bb5-a410-d47635db7df3",
"tables": {
"handoff": {
"name": "handoff",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"branch_name": {
"name": "branch_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"task": {
"name": "task",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"agent_type": {
"name": "agent_type",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'claude'"
},
"pr_submitted": {
"name": "pr_submitted",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"handoff_runtime": {
"name": "handoff_runtime",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"sandbox_id": {
"name": "sandbox_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"session_id": {
"name": "session_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"switch_target": {
"name": "switch_target",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"status_message": {
"name": "status_message",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View file

@ -1,222 +0,0 @@
{
"version": "6",
"dialect": "sqlite",
"id": "72cef919-e545-48be-a7c0-7ac74cfcf9e6",
"prevId": "0fca0f14-69df-4fca-bc52-29e902247909",
"tables": {
"handoff": {
"name": "handoff",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"branch_name": {
"name": "branch_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"task": {
"name": "task",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"agent_type": {
"name": "agent_type",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'claude'"
},
"pr_submitted": {
"name": "pr_submitted",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"handoff_runtime": {
"name": "handoff_runtime",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"active_sandbox_id": {
"name": "active_sandbox_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"active_session_id": {
"name": "active_session_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"active_switch_target": {
"name": "active_switch_target",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"active_cwd": {
"name": "active_cwd",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"status_message": {
"name": "status_message",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"handoff_sandboxes": {
"name": "handoff_sandboxes",
"columns": {
"sandbox_id": {
"name": "sandbox_id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"switch_target": {
"name": "switch_target",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"cwd": {
"name": "cwd",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"status_message": {
"name": "status_message",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {
"\"handoff_runtime\".\"sandbox_id\"": "\"handoff_runtime\".\"active_sandbox_id\"",
"\"handoff_runtime\".\"session_id\"": "\"handoff_runtime\".\"active_session_id\"",
"\"handoff_runtime\".\"switch_target\"": "\"handoff_runtime\".\"active_switch_target\""
}
},
"internal": {
"indexes": {}
}
}

View file

@ -1,48 +0,0 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1770924374665,
"tag": "0000_condemned_maria_hill",
"breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1770947251055,
"tag": "0001_rapid_eddie_brock",
"breakpoints": true
},
{
"idx": 2,
"version": "6",
"when": 1770948428907,
"tag": "0002_lazy_moira_mactaggert",
"breakpoints": true
},
{
"idx": 3,
"version": "6",
"when": 1771027535276,
"tag": "0003_plucky_bran",
"breakpoints": true
},
{
"idx": 4,
"version": "6",
"when": 1771097651912,
"tag": "0004_focused_shuri",
"breakpoints": true
},
{
"idx": 5,
"version": "6",
"when": 1771370000000,
"tag": "0005_sandbox_actor_id",
"breakpoints": true
}
]
}

View file

@ -1,245 +0,0 @@
// This file is generated by src/actors/_scripts/generate-actor-migrations.ts.
// Source of truth is drizzle-kit output under ./drizzle (meta/_journal.json + *.sql).
// Do not hand-edit this file.
const journal = {
"entries": [
{
"idx": 0,
"when": 1770924374665,
"tag": "0000_condemned_maria_hill",
"breakpoints": true
},
{
"idx": 1,
"when": 1770947251055,
"tag": "0001_rapid_eddie_brock",
"breakpoints": true
},
{
"idx": 2,
"when": 1770948428907,
"tag": "0002_lazy_moira_mactaggert",
"breakpoints": true
},
{
"idx": 3,
"when": 1771027535276,
"tag": "0003_plucky_bran",
"breakpoints": true
},
{
"idx": 4,
"when": 1771097651912,
"tag": "0004_focused_shuri",
"breakpoints": true
},
{
"idx": 5,
"when": 1771370000000,
"tag": "0005_sandbox_actor_id",
"breakpoints": true
},
{
"idx": 6,
"when": 1773020000000,
"tag": "0006_workbench_sessions",
"breakpoints": true
}
]
} as const;
export default {
journal,
migrations: {
m0000: `CREATE TABLE \`handoff\` (
\`id\` integer PRIMARY KEY NOT NULL,
\`branch_name\` text NOT NULL,
\`title\` text NOT NULL,
\`task\` text NOT NULL,
\`provider_id\` text NOT NULL,
\`status\` text NOT NULL,
\`agent_type\` text DEFAULT 'claude',
\`auto_committed\` integer DEFAULT 0,
\`pushed\` integer DEFAULT 0,
\`pr_submitted\` integer DEFAULT 0,
\`needs_push\` integer DEFAULT 0,
\`created_at\` integer NOT NULL,
\`updated_at\` integer NOT NULL
);
--> statement-breakpoint
CREATE TABLE \`handoff_runtime\` (
\`id\` integer PRIMARY KEY NOT NULL,
\`sandbox_id\` text,
\`session_id\` text,
\`switch_target\` text,
\`status_message\` text,
\`updated_at\` integer NOT NULL
);
`,
m0001: `ALTER TABLE \`handoff\` DROP COLUMN \`auto_committed\`;--> statement-breakpoint
ALTER TABLE \`handoff\` DROP COLUMN \`pushed\`;--> statement-breakpoint
ALTER TABLE \`handoff\` DROP COLUMN \`needs_push\`;`,
m0002: `ALTER TABLE \`handoff_runtime\` RENAME COLUMN "sandbox_id" TO "active_sandbox_id";--> statement-breakpoint
ALTER TABLE \`handoff_runtime\` RENAME COLUMN "session_id" TO "active_session_id";--> statement-breakpoint
ALTER TABLE \`handoff_runtime\` RENAME COLUMN "switch_target" TO "active_switch_target";--> statement-breakpoint
CREATE TABLE \`handoff_sandboxes\` (
\`sandbox_id\` text PRIMARY KEY NOT NULL,
\`provider_id\` text NOT NULL,
\`switch_target\` text NOT NULL,
\`cwd\` text,
\`status_message\` text,
\`created_at\` integer NOT NULL,
\`updated_at\` integer NOT NULL
);
--> statement-breakpoint
ALTER TABLE \`handoff_runtime\` ADD \`active_cwd\` text;
--> statement-breakpoint
INSERT INTO \`handoff_sandboxes\` (
\`sandbox_id\`,
\`provider_id\`,
\`switch_target\`,
\`cwd\`,
\`status_message\`,
\`created_at\`,
\`updated_at\`
)
SELECT
r.\`active_sandbox_id\`,
(SELECT h.\`provider_id\` FROM \`handoff\` h WHERE h.\`id\` = 1),
r.\`active_switch_target\`,
r.\`active_cwd\`,
r.\`status_message\`,
COALESCE((SELECT h.\`created_at\` FROM \`handoff\` h WHERE h.\`id\` = 1), r.\`updated_at\`),
r.\`updated_at\`
FROM \`handoff_runtime\` r
WHERE
r.\`id\` = 1
AND r.\`active_sandbox_id\` IS NOT NULL
AND r.\`active_switch_target\` IS NOT NULL
ON CONFLICT(\`sandbox_id\`) DO NOTHING;
`,
m0003: `-- Allow handoffs to exist before their branch/title are determined.
-- Drizzle doesn't support altering column nullability in SQLite directly, so rebuild the table.
PRAGMA foreign_keys=off;
CREATE TABLE \`handoff__new\` (
\`id\` integer PRIMARY KEY NOT NULL,
\`branch_name\` text,
\`title\` text,
\`task\` text NOT NULL,
\`provider_id\` text NOT NULL,
\`status\` text NOT NULL,
\`agent_type\` text DEFAULT 'claude',
\`pr_submitted\` integer DEFAULT 0,
\`created_at\` integer NOT NULL,
\`updated_at\` integer NOT NULL
);
INSERT INTO \`handoff__new\` (
\`id\`,
\`branch_name\`,
\`title\`,
\`task\`,
\`provider_id\`,
\`status\`,
\`agent_type\`,
\`pr_submitted\`,
\`created_at\`,
\`updated_at\`
)
SELECT
\`id\`,
\`branch_name\`,
\`title\`,
\`task\`,
\`provider_id\`,
\`status\`,
\`agent_type\`,
\`pr_submitted\`,
\`created_at\`,
\`updated_at\`
FROM \`handoff\`;
DROP TABLE \`handoff\`;
ALTER TABLE \`handoff__new\` RENAME TO \`handoff\`;
PRAGMA foreign_keys=on;
`,
m0004: `-- Fix: make branch_name/title nullable during initial "naming" stage.
-- 0003 was missing statement breakpoints, so drizzle's migrator marked it applied without executing all statements.
-- Rebuild the table again with proper statement breakpoints.
PRAGMA foreign_keys=off;
--> statement-breakpoint
DROP TABLE IF EXISTS \`handoff__new\`;
--> statement-breakpoint
CREATE TABLE \`handoff__new\` (
\`id\` integer PRIMARY KEY NOT NULL,
\`branch_name\` text,
\`title\` text,
\`task\` text NOT NULL,
\`provider_id\` text NOT NULL,
\`status\` text NOT NULL,
\`agent_type\` text DEFAULT 'claude',
\`pr_submitted\` integer DEFAULT 0,
\`created_at\` integer NOT NULL,
\`updated_at\` integer NOT NULL
);
--> statement-breakpoint
INSERT INTO \`handoff__new\` (
\`id\`,
\`branch_name\`,
\`title\`,
\`task\`,
\`provider_id\`,
\`status\`,
\`agent_type\`,
\`pr_submitted\`,
\`created_at\`,
\`updated_at\`
)
SELECT
\`id\`,
\`branch_name\`,
\`title\`,
\`task\`,
\`provider_id\`,
\`status\`,
\`agent_type\`,
\`pr_submitted\`,
\`created_at\`,
\`updated_at\`
FROM \`handoff\`;
--> statement-breakpoint
DROP TABLE \`handoff\`;
--> statement-breakpoint
ALTER TABLE \`handoff__new\` RENAME TO \`handoff\`;
--> statement-breakpoint
PRAGMA foreign_keys=on;
`,
m0005: `ALTER TABLE \`handoff_sandboxes\` ADD \`sandbox_actor_id\` text;`,
m0006: `CREATE TABLE \`handoff_workbench_sessions\` (
\`session_id\` text PRIMARY KEY NOT NULL,
\`session_name\` text NOT NULL,
\`model\` text NOT NULL,
\`unread\` integer DEFAULT 0 NOT NULL,
\`draft_text\` text DEFAULT '' NOT NULL,
\`draft_attachments_json\` text DEFAULT '[]' NOT NULL,
\`draft_updated_at\` integer,
\`created\` integer DEFAULT 1 NOT NULL,
\`closed\` integer DEFAULT 0 NOT NULL,
\`thinking_since_ms\` integer,
\`created_at\` integer NOT NULL,
\`updated_at\` integer NOT NULL
);`,
} as const
};

View file

@ -1,51 +0,0 @@
import { integer, sqliteTable, text } from "rivetkit/db/drizzle";
// SQLite is per handoff actor instance, so these tables only ever store one row (id=1).
export const handoff = sqliteTable("handoff", {
id: integer("id").primaryKey(),
branchName: text("branch_name"),
title: text("title"),
task: text("task").notNull(),
providerId: text("provider_id").notNull(),
status: text("status").notNull(),
agentType: text("agent_type").default("claude"),
prSubmitted: integer("pr_submitted").default(0),
createdAt: integer("created_at").notNull(),
updatedAt: integer("updated_at").notNull(),
});
export const handoffRuntime = sqliteTable("handoff_runtime", {
id: integer("id").primaryKey(),
activeSandboxId: text("active_sandbox_id"),
activeSessionId: text("active_session_id"),
activeSwitchTarget: text("active_switch_target"),
activeCwd: text("active_cwd"),
statusMessage: text("status_message"),
updatedAt: integer("updated_at").notNull(),
});
export const handoffSandboxes = sqliteTable("handoff_sandboxes", {
sandboxId: text("sandbox_id").notNull().primaryKey(),
providerId: text("provider_id").notNull(),
sandboxActorId: text("sandbox_actor_id"),
switchTarget: text("switch_target").notNull(),
cwd: text("cwd"),
statusMessage: text("status_message"),
createdAt: integer("created_at").notNull(),
updatedAt: integer("updated_at").notNull(),
});
export const handoffWorkbenchSessions = sqliteTable("handoff_workbench_sessions", {
sessionId: text("session_id").notNull().primaryKey(),
sessionName: text("session_name").notNull(),
model: text("model").notNull(),
unread: integer("unread").notNull().default(0),
draftText: text("draft_text").notNull().default(""),
draftAttachmentsJson: text("draft_attachments_json").notNull().default("[]"),
draftUpdatedAt: integer("draft_updated_at"),
created: integer("created").notNull().default(1),
closed: integer("closed").notNull().default(0),
thinkingSinceMs: integer("thinking_since_ms"),
createdAt: integer("created_at").notNull(),
updatedAt: integer("updated_at").notNull(),
});

View file

@ -1,399 +0,0 @@
import { actor, queue } from "rivetkit";
import { workflow } from "rivetkit/workflow";
import type {
AgentType,
HandoffRecord,
HandoffWorkbenchChangeModelInput,
HandoffWorkbenchRenameInput,
HandoffWorkbenchRenameSessionInput,
HandoffWorkbenchSetSessionUnreadInput,
HandoffWorkbenchSendMessageInput,
HandoffWorkbenchUpdateDraftInput,
ProviderId
} from "@openhandoff/shared";
import { expectQueueResponse } from "../../services/queue.js";
import { selfHandoff } from "../handles.js";
import { handoffDb } from "./db/db.js";
import { getCurrentRecord } from "./workflow/common.js";
import {
changeWorkbenchModel,
closeWorkbenchSession,
createWorkbenchSession,
getWorkbenchHandoff,
markWorkbenchUnread,
publishWorkbenchPr,
renameWorkbenchBranch,
renameWorkbenchHandoff,
renameWorkbenchSession,
revertWorkbenchFile,
sendWorkbenchMessage,
syncWorkbenchSessionStatus,
setWorkbenchSessionUnread,
stopWorkbenchSession,
updateWorkbenchDraft
} from "./workbench.js";
import {
HANDOFF_QUEUE_NAMES,
handoffWorkflowQueueName,
runHandoffWorkflow
} from "./workflow/index.js";
export interface HandoffInput {
workspaceId: string;
repoId: string;
handoffId: string;
repoRemote: string;
repoLocalPath: string;
branchName: string | null;
title: string | null;
task: string;
providerId: ProviderId;
agentType: AgentType | null;
explicitTitle: string | null;
explicitBranchName: string | null;
}
interface InitializeCommand {
providerId?: ProviderId;
}
interface HandoffActionCommand {
reason?: string;
}
interface HandoffTabCommand {
tabId: string;
}
interface HandoffStatusSyncCommand {
sessionId: string;
status: "running" | "idle" | "error";
at: number;
}
interface HandoffWorkbenchValueCommand {
value: string;
}
interface HandoffWorkbenchSessionTitleCommand {
sessionId: string;
title: string;
}
interface HandoffWorkbenchSessionUnreadCommand {
sessionId: string;
unread: boolean;
}
interface HandoffWorkbenchUpdateDraftCommand {
sessionId: string;
text: string;
attachments: Array<any>;
}
interface HandoffWorkbenchChangeModelCommand {
sessionId: string;
model: string;
}
interface HandoffWorkbenchSendMessageCommand {
sessionId: string;
text: string;
attachments: Array<any>;
}
interface HandoffWorkbenchCreateSessionCommand {
model?: string;
}
interface HandoffWorkbenchSessionCommand {
sessionId: string;
}
export const handoff = actor({
db: handoffDb,
queues: Object.fromEntries(HANDOFF_QUEUE_NAMES.map((name) => [name, queue()])),
options: {
actionTimeout: 5 * 60_000
},
createState: (_c, input: HandoffInput) => ({
workspaceId: input.workspaceId,
repoId: input.repoId,
handoffId: input.handoffId,
repoRemote: input.repoRemote,
repoLocalPath: input.repoLocalPath,
branchName: input.branchName,
title: input.title,
task: input.task,
providerId: input.providerId,
agentType: input.agentType,
explicitTitle: input.explicitTitle,
explicitBranchName: input.explicitBranchName,
initialized: false,
previousStatus: null as string | null,
}),
actions: {
async initialize(c, cmd: InitializeCommand): Promise<HandoffRecord> {
const self = selfHandoff(c);
const result = await self.send(handoffWorkflowQueueName("handoff.command.initialize"), cmd ?? {}, {
wait: true,
timeout: 60_000,
});
return expectQueueResponse<HandoffRecord>(result);
},
async provision(c, cmd: InitializeCommand): Promise<{ ok: true }> {
const self = selfHandoff(c);
await self.send(handoffWorkflowQueueName("handoff.command.provision"), cmd ?? {}, {
wait: true,
timeout: 30 * 60_000,
});
return { ok: true };
},
async attach(c, cmd?: HandoffActionCommand): Promise<{ target: string; sessionId: string | null }> {
const self = selfHandoff(c);
const result = await self.send(handoffWorkflowQueueName("handoff.command.attach"), cmd ?? {}, {
wait: true,
timeout: 20_000
});
return expectQueueResponse<{ target: string; sessionId: string | null }>(result);
},
async switch(c): Promise<{ switchTarget: string }> {
const self = selfHandoff(c);
const result = await self.send(handoffWorkflowQueueName("handoff.command.switch"), {}, {
wait: true,
timeout: 20_000
});
return expectQueueResponse<{ switchTarget: string }>(result);
},
async push(c, cmd?: HandoffActionCommand): Promise<void> {
const self = selfHandoff(c);
await self.send(handoffWorkflowQueueName("handoff.command.push"), cmd ?? {}, {
wait: true,
timeout: 180_000
});
},
async sync(c, cmd?: HandoffActionCommand): Promise<void> {
const self = selfHandoff(c);
await self.send(handoffWorkflowQueueName("handoff.command.sync"), cmd ?? {}, {
wait: true,
timeout: 30_000
});
},
async merge(c, cmd?: HandoffActionCommand): Promise<void> {
const self = selfHandoff(c);
await self.send(handoffWorkflowQueueName("handoff.command.merge"), cmd ?? {}, {
wait: true,
timeout: 30_000
});
},
async archive(c, cmd?: HandoffActionCommand): Promise<void> {
const self = selfHandoff(c);
void self
.send(handoffWorkflowQueueName("handoff.command.archive"), cmd ?? {}, {
wait: true,
timeout: 60_000,
})
.catch((error: unknown) => {
c.log.warn({
msg: "archive command failed",
error: error instanceof Error ? error.message : String(error),
});
});
},
async kill(c, cmd?: HandoffActionCommand): Promise<void> {
const self = selfHandoff(c);
await self.send(handoffWorkflowQueueName("handoff.command.kill"), cmd ?? {}, {
wait: true,
timeout: 60_000
});
},
async get(c): Promise<HandoffRecord> {
return await getCurrentRecord({ db: c.db, state: c.state });
},
async getWorkbench(c) {
return await getWorkbenchHandoff(c);
},
async markWorkbenchUnread(c): Promise<void> {
const self = selfHandoff(c);
await self.send(handoffWorkflowQueueName("handoff.command.workbench.mark_unread"), {}, {
wait: true,
timeout: 20_000,
});
},
async renameWorkbenchHandoff(c, input: HandoffWorkbenchRenameInput): Promise<void> {
const self = selfHandoff(c);
await self.send(
handoffWorkflowQueueName("handoff.command.workbench.rename_handoff"),
{ value: input.value } satisfies HandoffWorkbenchValueCommand,
{
wait: true,
timeout: 20_000,
},
);
},
async renameWorkbenchBranch(c, input: HandoffWorkbenchRenameInput): Promise<void> {
const self = selfHandoff(c);
await self.send(
handoffWorkflowQueueName("handoff.command.workbench.rename_branch"),
{ value: input.value } satisfies HandoffWorkbenchValueCommand,
{
wait: true,
timeout: 5 * 60_000,
},
);
},
async createWorkbenchSession(c, input?: { model?: string }): Promise<{ tabId: string }> {
const self = selfHandoff(c);
const result = await self.send(
handoffWorkflowQueueName("handoff.command.workbench.create_session"),
{ ...(input?.model ? { model: input.model } : {}) } satisfies HandoffWorkbenchCreateSessionCommand,
{
wait: true,
timeout: 5 * 60_000,
},
);
return expectQueueResponse<{ tabId: string }>(result);
},
async renameWorkbenchSession(c, input: HandoffWorkbenchRenameSessionInput): Promise<void> {
const self = selfHandoff(c);
await self.send(
handoffWorkflowQueueName("handoff.command.workbench.rename_session"),
{ sessionId: input.tabId, title: input.title } satisfies HandoffWorkbenchSessionTitleCommand,
{
wait: true,
timeout: 20_000,
},
);
},
async setWorkbenchSessionUnread(c, input: HandoffWorkbenchSetSessionUnreadInput): Promise<void> {
const self = selfHandoff(c);
await self.send(
handoffWorkflowQueueName("handoff.command.workbench.set_session_unread"),
{ sessionId: input.tabId, unread: input.unread } satisfies HandoffWorkbenchSessionUnreadCommand,
{
wait: true,
timeout: 20_000,
},
);
},
async updateWorkbenchDraft(c, input: HandoffWorkbenchUpdateDraftInput): Promise<void> {
const self = selfHandoff(c);
await self.send(
handoffWorkflowQueueName("handoff.command.workbench.update_draft"),
{
sessionId: input.tabId,
text: input.text,
attachments: input.attachments,
} satisfies HandoffWorkbenchUpdateDraftCommand,
{
wait: true,
timeout: 20_000,
},
);
},
async changeWorkbenchModel(c, input: HandoffWorkbenchChangeModelInput): Promise<void> {
const self = selfHandoff(c);
await self.send(
handoffWorkflowQueueName("handoff.command.workbench.change_model"),
{ sessionId: input.tabId, model: input.model } satisfies HandoffWorkbenchChangeModelCommand,
{
wait: true,
timeout: 20_000,
},
);
},
async sendWorkbenchMessage(c, input: HandoffWorkbenchSendMessageInput): Promise<void> {
const self = selfHandoff(c);
await self.send(
handoffWorkflowQueueName("handoff.command.workbench.send_message"),
{
sessionId: input.tabId,
text: input.text,
attachments: input.attachments,
} satisfies HandoffWorkbenchSendMessageCommand,
{
wait: true,
timeout: 10 * 60_000,
},
);
},
async stopWorkbenchSession(c, input: HandoffTabCommand): Promise<void> {
const self = selfHandoff(c);
await self.send(
handoffWorkflowQueueName("handoff.command.workbench.stop_session"),
{ sessionId: input.tabId } satisfies HandoffWorkbenchSessionCommand,
{
wait: true,
timeout: 5 * 60_000,
},
);
},
async syncWorkbenchSessionStatus(c, input: HandoffStatusSyncCommand): Promise<void> {
const self = selfHandoff(c);
await self.send(
handoffWorkflowQueueName("handoff.command.workbench.sync_session_status"),
input,
{
wait: true,
timeout: 20_000,
},
);
},
async closeWorkbenchSession(c, input: HandoffTabCommand): Promise<void> {
const self = selfHandoff(c);
await self.send(
handoffWorkflowQueueName("handoff.command.workbench.close_session"),
{ sessionId: input.tabId } satisfies HandoffWorkbenchSessionCommand,
{
wait: true,
timeout: 5 * 60_000,
},
);
},
async publishWorkbenchPr(c): Promise<void> {
const self = selfHandoff(c);
await self.send(handoffWorkflowQueueName("handoff.command.workbench.publish_pr"), {}, {
wait: true,
timeout: 10 * 60_000,
});
},
async revertWorkbenchFile(c, input: { path: string }): Promise<void> {
const self = selfHandoff(c);
await self.send(
handoffWorkflowQueueName("handoff.command.workbench.revert_file"),
input,
{
wait: true,
timeout: 5 * 60_000,
},
);
}
},
run: workflow(runHandoffWorkflow)
});
export { HANDOFF_QUEUE_NAMES };

View file

@ -1,861 +0,0 @@
// @ts-nocheck
import { basename } from "node:path";
import { asc, eq } from "drizzle-orm";
import { getActorRuntimeContext } from "../context.js";
import {
getOrCreateHandoffStatusSync,
getOrCreateProject,
getOrCreateWorkspace,
getSandboxInstance,
} from "../handles.js";
import { handoff as handoffTable, handoffRuntime, handoffWorkbenchSessions } from "./db/schema.js";
import { getCurrentRecord } from "./workflow/common.js";
const STATUS_SYNC_INTERVAL_MS = 1_000;
async function ensureWorkbenchSessionTable(c: any): Promise<void> {
await c.db.execute(`
CREATE TABLE IF NOT EXISTS handoff_workbench_sessions (
session_id text PRIMARY KEY NOT NULL,
session_name text NOT NULL,
model text NOT NULL,
unread integer DEFAULT 0 NOT NULL,
draft_text text DEFAULT '' NOT NULL,
draft_attachments_json text DEFAULT '[]' NOT NULL,
draft_updated_at integer,
created integer DEFAULT 1 NOT NULL,
closed integer DEFAULT 0 NOT NULL,
thinking_since_ms integer,
created_at integer NOT NULL,
updated_at integer NOT NULL
)
`);
}
function defaultModelForAgent(agentType: string | null | undefined) {
return agentType === "codex" ? "gpt-4o" : "claude-sonnet-4";
}
function agentKindForModel(model: string) {
if (model === "gpt-4o" || model === "o3") {
return "Codex";
}
return "Claude";
}
export function agentTypeForModel(model: string) {
if (model === "gpt-4o" || model === "o3") {
return "codex";
}
return "claude";
}
function repoLabelFromRemote(remoteUrl: string): string {
const trimmed = remoteUrl.trim();
try {
const url = new URL(trimmed.startsWith("http") ? trimmed : `https://${trimmed}`);
const parts = url.pathname.replace(/\/+$/, "").split("/").filter(Boolean);
if (parts.length >= 2) {
return `${parts[0]}/${(parts[1] ?? "").replace(/\.git$/, "")}`;
}
} catch {
// ignore
}
return basename(trimmed.replace(/\.git$/, ""));
}
function parseDraftAttachments(value: string | null | undefined): Array<any> {
if (!value) {
return [];
}
try {
const parsed = JSON.parse(value) as unknown;
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
export function shouldMarkSessionUnreadForStatus(meta: { thinkingSinceMs?: number | null }, status: "running" | "idle" | "error"): boolean {
if (status === "running") {
return false;
}
// Only mark unread when we observe the transition out of an active thinking state.
// Repeated idle polls for an already-finished session must not flip unread back on.
return Boolean(meta.thinkingSinceMs);
}
async function listSessionMetaRows(c: any, options?: { includeClosed?: boolean }): Promise<Array<any>> {
await ensureWorkbenchSessionTable(c);
const rows = await c.db
.select()
.from(handoffWorkbenchSessions)
.orderBy(asc(handoffWorkbenchSessions.createdAt))
.all();
const mapped = rows.map((row: any) => ({
...row,
id: row.sessionId,
sessionId: row.sessionId,
draftAttachments: parseDraftAttachments(row.draftAttachmentsJson),
draftUpdatedAtMs: row.draftUpdatedAt ?? null,
unread: row.unread === 1,
created: row.created === 1,
closed: row.closed === 1,
}));
if (options?.includeClosed === true) {
return mapped;
}
return mapped.filter((row: any) => row.closed !== true);
}
async function nextSessionName(c: any): Promise<string> {
const rows = await listSessionMetaRows(c, { includeClosed: true });
return `Session ${rows.length + 1}`;
}
async function readSessionMeta(c: any, sessionId: string): Promise<any | null> {
await ensureWorkbenchSessionTable(c);
const row = await c.db
.select()
.from(handoffWorkbenchSessions)
.where(eq(handoffWorkbenchSessions.sessionId, sessionId))
.get();
if (!row) {
return null;
}
return {
...row,
id: row.sessionId,
sessionId: row.sessionId,
draftAttachments: parseDraftAttachments(row.draftAttachmentsJson),
draftUpdatedAtMs: row.draftUpdatedAt ?? null,
unread: row.unread === 1,
created: row.created === 1,
closed: row.closed === 1,
};
}
async function ensureSessionMeta(c: any, params: {
sessionId: string;
model?: string;
sessionName?: string;
unread?: boolean;
}): Promise<any> {
await ensureWorkbenchSessionTable(c);
const existing = await readSessionMeta(c, params.sessionId);
if (existing) {
return existing;
}
const now = Date.now();
const sessionName = params.sessionName ?? (await nextSessionName(c));
const model = params.model ?? defaultModelForAgent(c.state.agentType);
const unread = params.unread ?? false;
await c.db
.insert(handoffWorkbenchSessions)
.values({
sessionId: params.sessionId,
sessionName,
model,
unread: unread ? 1 : 0,
draftText: "",
draftAttachmentsJson: "[]",
draftUpdatedAt: null,
created: 1,
closed: 0,
thinkingSinceMs: null,
createdAt: now,
updatedAt: now,
})
.run();
return await readSessionMeta(c, params.sessionId);
}
async function updateSessionMeta(c: any, sessionId: string, values: Record<string, unknown>): Promise<any> {
await ensureSessionMeta(c, { sessionId });
await c.db
.update(handoffWorkbenchSessions)
.set({
...values,
updatedAt: Date.now(),
})
.where(eq(handoffWorkbenchSessions.sessionId, sessionId))
.run();
return await readSessionMeta(c, sessionId);
}
async function notifyWorkbenchUpdated(c: any): Promise<void> {
const workspace = await getOrCreateWorkspace(c, c.state.workspaceId);
await workspace.notifyWorkbenchUpdated({});
}
function shellFragment(parts: string[]): string {
return parts.join(" && ");
}
async function executeInSandbox(c: any, params: {
sandboxId: string;
cwd: string;
command: string;
label: string;
}): Promise<{ exitCode: number; result: string }> {
const { providers } = getActorRuntimeContext();
const provider = providers.get(c.state.providerId);
return await provider.executeCommand({
workspaceId: c.state.workspaceId,
sandboxId: params.sandboxId,
command: `bash -lc ${JSON.stringify(shellFragment([`cd ${JSON.stringify(params.cwd)}`, params.command]))}`,
label: params.label,
});
}
function parseGitStatus(output: string): Array<{ path: string; type: "M" | "A" | "D" }> {
return output
.split("\n")
.map((line) => line.trimEnd())
.filter(Boolean)
.map((line) => {
const status = line.slice(0, 2).trim();
const rawPath = line.slice(3).trim();
const path = rawPath.includes(" -> ") ? rawPath.split(" -> ").pop() ?? rawPath : rawPath;
const type =
status.includes("D")
? "D"
: status.includes("A") || status === "??"
? "A"
: "M";
return { path, type };
});
}
function parseNumstat(output: string): Map<string, { added: number; removed: number }> {
const map = new Map<string, { added: number; removed: number }>();
for (const line of output.split("\n")) {
const trimmed = line.trim();
if (!trimmed) continue;
const [addedRaw, removedRaw, ...pathParts] = trimmed.split("\t");
const path = pathParts.join("\t").trim();
if (!path) continue;
map.set(path, {
added: Number.parseInt(addedRaw ?? "0", 10) || 0,
removed: Number.parseInt(removedRaw ?? "0", 10) || 0,
});
}
return map;
}
function buildFileTree(paths: string[]): Array<any> {
const root = {
children: new Map<string, any>(),
};
for (const path of paths) {
const parts = path.split("/").filter(Boolean);
let current = root;
let currentPath = "";
for (let index = 0; index < parts.length; index += 1) {
const part = parts[index]!;
currentPath = currentPath ? `${currentPath}/${part}` : part;
const isDir = index < parts.length - 1;
let node = current.children.get(part);
if (!node) {
node = {
name: part,
path: currentPath,
isDir,
children: isDir ? new Map<string, any>() : undefined,
};
current.children.set(part, node);
} else if (isDir && !(node.children instanceof Map)) {
node.children = new Map<string, any>();
}
current = node;
}
}
function sortNodes(nodes: Iterable<any>): Array<any> {
return [...nodes]
.map((node) =>
node.isDir
? {
name: node.name,
path: node.path,
isDir: true,
children: sortNodes(node.children?.values?.() ?? []),
}
: {
name: node.name,
path: node.path,
isDir: false,
},
)
.sort((left, right) => {
if (left.isDir !== right.isDir) {
return left.isDir ? -1 : 1;
}
return left.path.localeCompare(right.path);
});
}
return sortNodes(root.children.values());
}
async function collectWorkbenchGitState(c: any, record: any) {
const activeSandboxId = record.activeSandboxId;
const activeSandbox =
activeSandboxId != null
? (record.sandboxes ?? []).find((candidate: any) => candidate.sandboxId === activeSandboxId) ?? null
: null;
const cwd = activeSandbox?.cwd ?? record.sandboxes?.[0]?.cwd ?? null;
if (!activeSandboxId || !cwd) {
return {
fileChanges: [],
diffs: {},
fileTree: [],
};
}
const statusResult = await executeInSandbox(c, {
sandboxId: activeSandboxId,
cwd,
command: "git status --porcelain=v1 -uall",
label: "git status",
});
if (statusResult.exitCode !== 0) {
return {
fileChanges: [],
diffs: {},
fileTree: [],
};
}
const statusRows = parseGitStatus(statusResult.result);
const numstatResult = await executeInSandbox(c, {
sandboxId: activeSandboxId,
cwd,
command: "git diff --numstat",
label: "git diff numstat",
});
const numstat = parseNumstat(numstatResult.result);
const diffs: Record<string, string> = {};
for (const row of statusRows) {
const diffResult = await executeInSandbox(c, {
sandboxId: activeSandboxId,
cwd,
command: `if git ls-files --error-unmatch -- ${JSON.stringify(row.path)} >/dev/null 2>&1; then git diff -- ${JSON.stringify(row.path)}; else git diff --no-index -- /dev/null ${JSON.stringify(row.path)} || true; fi`,
label: `git diff ${row.path}`,
});
diffs[row.path] = diffResult.result;
}
const filesResult = await executeInSandbox(c, {
sandboxId: activeSandboxId,
cwd,
command: "git ls-files --cached --others --exclude-standard",
label: "git ls-files",
});
const allPaths = filesResult.result
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
return {
fileChanges: statusRows.map((row) => {
const counts = numstat.get(row.path) ?? { added: 0, removed: 0 };
return {
path: row.path,
added: counts.added,
removed: counts.removed,
type: row.type,
};
}),
diffs,
fileTree: buildFileTree(allPaths),
};
}
async function readSessionTranscript(c: any, record: any, sessionId: string) {
const sandboxId = record.activeSandboxId ?? record.sandboxes?.[0]?.sandboxId ?? null;
if (!sandboxId) {
return [];
}
const sandbox = getSandboxInstance(c, c.state.workspaceId, c.state.providerId, sandboxId);
const page = await sandbox.listSessionEvents({
sessionId,
limit: 500,
});
return page.items.map((event: any) => ({
id: event.id,
eventIndex: event.eventIndex,
sessionId: event.sessionId,
createdAt: event.createdAt,
connectionId: event.connectionId,
sender: event.sender,
payload: event.payload,
}));
}
async function activeSessionStatus(c: any, record: any, sessionId: string) {
if (record.activeSessionId !== sessionId || !record.activeSandboxId) {
return "idle";
}
const sandbox = getSandboxInstance(c, c.state.workspaceId, c.state.providerId, record.activeSandboxId);
const status = await sandbox.sessionStatus({ sessionId });
return status.status;
}
async function readPullRequestSummary(c: any, branchName: string | null) {
if (!branchName) {
return null;
}
try {
const project = await getOrCreateProject(
c,
c.state.workspaceId,
c.state.repoId,
c.state.repoRemote,
);
return await project.getPullRequestForBranch({ branchName });
} catch {
return null;
}
}
export async function ensureWorkbenchSeeded(c: any): Promise<any> {
const record = await getCurrentRecord({ db: c.db, state: c.state });
if (record.activeSessionId) {
await ensureSessionMeta(c, {
sessionId: record.activeSessionId,
model: defaultModelForAgent(record.agentType),
sessionName: "Session 1",
});
}
return record;
}
export async function getWorkbenchHandoff(c: any): Promise<any> {
const record = await ensureWorkbenchSeeded(c);
const gitState = await collectWorkbenchGitState(c, record);
const sessions = await listSessionMetaRows(c);
const tabs = [];
for (const meta of sessions) {
const status = await activeSessionStatus(c, record, meta.sessionId);
let thinkingSinceMs = meta.thinkingSinceMs ?? null;
let unread = Boolean(meta.unread);
if (thinkingSinceMs && status !== "running") {
thinkingSinceMs = null;
unread = true;
}
tabs.push({
id: meta.id,
sessionId: meta.sessionId,
sessionName: meta.sessionName,
agent: agentKindForModel(meta.model),
model: meta.model,
status,
thinkingSinceMs: status === "running" ? thinkingSinceMs : null,
unread,
created: Boolean(meta.created),
draft: {
text: meta.draftText ?? "",
attachments: Array.isArray(meta.draftAttachments) ? meta.draftAttachments : [],
updatedAtMs: meta.draftUpdatedAtMs ?? null,
},
transcript: await readSessionTranscript(c, record, meta.sessionId),
});
}
return {
id: c.state.handoffId,
repoId: c.state.repoId,
title: record.title ?? "New Handoff",
status: record.status === "archived" ? "archived" : record.status === "running" ? "running" : record.status === "idle" ? "idle" : "new",
repoName: repoLabelFromRemote(c.state.repoRemote),
updatedAtMs: record.updatedAt,
branch: record.branchName,
pullRequest: await readPullRequestSummary(c, record.branchName),
tabs,
fileChanges: gitState.fileChanges,
diffs: gitState.diffs,
fileTree: gitState.fileTree,
};
}
export async function renameWorkbenchHandoff(c: any, value: string): Promise<void> {
const nextTitle = value.trim();
if (!nextTitle) {
throw new Error("handoff title is required");
}
await c.db
.update(handoffTable)
.set({
title: nextTitle,
updatedAt: Date.now(),
})
.where(eq(handoffTable.id, 1))
.run();
c.state.title = nextTitle;
await notifyWorkbenchUpdated(c);
}
export async function renameWorkbenchBranch(c: any, value: string): Promise<void> {
const nextBranch = value.trim();
if (!nextBranch) {
throw new Error("branch name is required");
}
const record = await ensureWorkbenchSeeded(c);
if (!record.branchName) {
throw new Error("cannot rename branch before handoff branch exists");
}
if (!record.activeSandboxId) {
throw new Error("cannot rename branch without an active sandbox");
}
const activeSandbox =
(record.sandboxes ?? []).find((candidate: any) => candidate.sandboxId === record.activeSandboxId) ?? null;
if (!activeSandbox?.cwd) {
throw new Error("cannot rename branch without a sandbox cwd");
}
const renameResult = await executeInSandbox(c, {
sandboxId: record.activeSandboxId,
cwd: activeSandbox.cwd,
command: [
`git branch -m ${JSON.stringify(record.branchName)} ${JSON.stringify(nextBranch)}`,
`if git ls-remote --exit-code --heads origin ${JSON.stringify(record.branchName)} >/dev/null 2>&1; then git push origin :${JSON.stringify(record.branchName)}; fi`,
`git push origin ${JSON.stringify(nextBranch)}`,
`git branch --set-upstream-to=${JSON.stringify(`origin/${nextBranch}`)} ${JSON.stringify(nextBranch)} || git push --set-upstream origin ${JSON.stringify(nextBranch)}`,
].join(" && "),
label: `git branch -m ${record.branchName} ${nextBranch}`,
});
if (renameResult.exitCode !== 0) {
throw new Error(`branch rename failed (${renameResult.exitCode}): ${renameResult.result}`);
}
await c.db
.update(handoffTable)
.set({
branchName: nextBranch,
updatedAt: Date.now(),
})
.where(eq(handoffTable.id, 1))
.run();
c.state.branchName = nextBranch;
const project = await getOrCreateProject(c, c.state.workspaceId, c.state.repoId, c.state.repoRemote);
await project.registerHandoffBranch({
handoffId: c.state.handoffId,
branchName: nextBranch,
});
await notifyWorkbenchUpdated(c);
}
export async function createWorkbenchSession(c: any, model?: string): Promise<{ tabId: string }> {
const record = await ensureWorkbenchSeeded(c);
if (!record.activeSandboxId) {
throw new Error("cannot create session without an active sandbox");
}
const activeSandbox =
(record.sandboxes ?? []).find((candidate: any) => candidate.sandboxId === record.activeSandboxId) ?? null;
const cwd = activeSandbox?.cwd ?? record.sandboxes?.[0]?.cwd ?? null;
if (!cwd) {
throw new Error("cannot create session without a sandbox cwd");
}
const sandbox = getSandboxInstance(c, c.state.workspaceId, c.state.providerId, record.activeSandboxId);
const created = await sandbox.createSession({
prompt: "",
cwd,
agent: agentTypeForModel(model ?? defaultModelForAgent(record.agentType)),
});
if (!created.id) {
throw new Error(created.error ?? "sandbox-agent session creation failed");
}
await ensureSessionMeta(c, {
sessionId: created.id,
model: model ?? defaultModelForAgent(record.agentType),
});
await notifyWorkbenchUpdated(c);
return { tabId: created.id };
}
export async function renameWorkbenchSession(c: any, sessionId: string, title: string): Promise<void> {
const trimmed = title.trim();
if (!trimmed) {
throw new Error("session title is required");
}
await updateSessionMeta(c, sessionId, {
sessionName: trimmed,
});
await notifyWorkbenchUpdated(c);
}
export async function setWorkbenchSessionUnread(c: any, sessionId: string, unread: boolean): Promise<void> {
await updateSessionMeta(c, sessionId, {
unread: unread ? 1 : 0,
});
await notifyWorkbenchUpdated(c);
}
export async function updateWorkbenchDraft(c: any, sessionId: string, text: string, attachments: Array<any>): Promise<void> {
await updateSessionMeta(c, sessionId, {
draftText: text,
draftAttachmentsJson: JSON.stringify(attachments),
draftUpdatedAt: Date.now(),
});
await notifyWorkbenchUpdated(c);
}
export async function changeWorkbenchModel(c: any, sessionId: string, model: string): Promise<void> {
await updateSessionMeta(c, sessionId, {
model,
});
await notifyWorkbenchUpdated(c);
}
export async function sendWorkbenchMessage(c: any, sessionId: string, text: string, attachments: Array<any>): Promise<void> {
const record = await ensureWorkbenchSeeded(c);
if (!record.activeSandboxId) {
throw new Error("cannot send message without an active sandbox");
}
await ensureSessionMeta(c, { sessionId });
const sandbox = getSandboxInstance(c, c.state.workspaceId, c.state.providerId, record.activeSandboxId);
const prompt = [
text.trim(),
...attachments.map((attachment: any) => `@ ${attachment.filePath}:${attachment.lineNumber}\n${attachment.lineContent}`),
]
.filter(Boolean)
.join("\n\n");
if (!prompt) {
throw new Error("message text is required");
}
await sandbox.sendPrompt({
sessionId,
prompt,
notification: true,
});
await updateSessionMeta(c, sessionId, {
unread: 0,
created: 1,
draftText: "",
draftAttachmentsJson: "[]",
draftUpdatedAt: Date.now(),
thinkingSinceMs: Date.now(),
});
await c.db
.update(handoffRuntime)
.set({
activeSessionId: sessionId,
updatedAt: Date.now(),
})
.where(eq(handoffRuntime.id, 1))
.run();
const sync = await getOrCreateHandoffStatusSync(
c,
c.state.workspaceId,
c.state.repoId,
c.state.handoffId,
record.activeSandboxId,
sessionId,
{
workspaceId: c.state.workspaceId,
repoId: c.state.repoId,
handoffId: c.state.handoffId,
providerId: c.state.providerId,
sandboxId: record.activeSandboxId,
sessionId,
intervalMs: STATUS_SYNC_INTERVAL_MS,
},
);
await sync.setIntervalMs({ intervalMs: STATUS_SYNC_INTERVAL_MS });
await sync.start();
await sync.force();
await notifyWorkbenchUpdated(c);
}
export async function stopWorkbenchSession(c: any, sessionId: string): Promise<void> {
const record = await ensureWorkbenchSeeded(c);
if (!record.activeSandboxId) {
return;
}
const sandbox = getSandboxInstance(c, c.state.workspaceId, c.state.providerId, record.activeSandboxId);
await sandbox.cancelSession({ sessionId });
await updateSessionMeta(c, sessionId, {
thinkingSinceMs: null,
});
await notifyWorkbenchUpdated(c);
}
export async function syncWorkbenchSessionStatus(
c: any,
sessionId: string,
status: "running" | "idle" | "error",
at: number,
): Promise<void> {
const record = await ensureWorkbenchSeeded(c);
const meta = await ensureSessionMeta(c, { sessionId });
let changed = false;
if (record.activeSessionId === sessionId) {
const mappedStatus = status === "running" ? "running" : status === "error" ? "error" : "idle";
if (record.status !== mappedStatus) {
await c.db
.update(handoffTable)
.set({
status: mappedStatus,
updatedAt: at,
})
.where(eq(handoffTable.id, 1))
.run();
changed = true;
}
const statusMessage = `session:${status}`;
if (record.statusMessage !== statusMessage) {
await c.db
.update(handoffRuntime)
.set({
statusMessage,
updatedAt: at,
})
.where(eq(handoffRuntime.id, 1))
.run();
changed = true;
}
}
if (status === "running") {
if (!meta.thinkingSinceMs) {
await updateSessionMeta(c, sessionId, {
thinkingSinceMs: at,
});
changed = true;
}
} else {
if (meta.thinkingSinceMs) {
await updateSessionMeta(c, sessionId, {
thinkingSinceMs: null,
});
changed = true;
}
if (!meta.unread && shouldMarkSessionUnreadForStatus(meta, status)) {
await updateSessionMeta(c, sessionId, {
unread: 1,
});
changed = true;
}
}
if (changed) {
await notifyWorkbenchUpdated(c);
}
}
export async function closeWorkbenchSession(c: any, sessionId: string): Promise<void> {
const record = await ensureWorkbenchSeeded(c);
if (!record.activeSandboxId) {
return;
}
const sessions = await listSessionMetaRows(c);
if (sessions.filter((candidate) => candidate.closed !== true).length <= 1) {
return;
}
const sandbox = getSandboxInstance(c, c.state.workspaceId, c.state.providerId, record.activeSandboxId);
await sandbox.destroySession({ sessionId });
await updateSessionMeta(c, sessionId, {
closed: 1,
thinkingSinceMs: null,
});
if (record.activeSessionId === sessionId) {
await c.db
.update(handoffRuntime)
.set({
activeSessionId: null,
updatedAt: Date.now(),
})
.where(eq(handoffRuntime.id, 1))
.run();
}
await notifyWorkbenchUpdated(c);
}
export async function markWorkbenchUnread(c: any): Promise<void> {
const sessions = await listSessionMetaRows(c);
const latest = sessions[sessions.length - 1];
if (!latest) {
return;
}
await updateSessionMeta(c, latest.sessionId, {
unread: 1,
});
await notifyWorkbenchUpdated(c);
}
export async function publishWorkbenchPr(c: any): Promise<void> {
const record = await ensureWorkbenchSeeded(c);
if (!record.branchName) {
throw new Error("cannot publish PR without a branch");
}
const { driver } = getActorRuntimeContext();
const created = await driver.github.createPr(
c.state.repoLocalPath,
record.branchName,
record.title ?? c.state.task,
);
await c.db
.update(handoffTable)
.set({
prSubmitted: 1,
updatedAt: Date.now(),
})
.where(eq(handoffTable.id, 1))
.run();
await notifyWorkbenchUpdated(c);
}
export async function revertWorkbenchFile(c: any, path: string): Promise<void> {
const record = await ensureWorkbenchSeeded(c);
if (!record.activeSandboxId) {
throw new Error("cannot revert file without an active sandbox");
}
const activeSandbox =
(record.sandboxes ?? []).find((candidate: any) => candidate.sandboxId === record.activeSandboxId) ?? null;
if (!activeSandbox?.cwd) {
throw new Error("cannot revert file without a sandbox cwd");
}
const result = await executeInSandbox(c, {
sandboxId: record.activeSandboxId,
cwd: activeSandbox.cwd,
command: `if git ls-files --error-unmatch -- ${JSON.stringify(path)} >/dev/null 2>&1; then git restore --staged --worktree -- ${JSON.stringify(path)} || git checkout -- ${JSON.stringify(path)}; else rm -f ${JSON.stringify(path)}; fi`,
label: `git restore ${path}`,
});
if (result.exitCode !== 0) {
throw new Error(`file revert failed (${result.exitCode}): ${result.result}`);
}
await notifyWorkbenchUpdated(c);
}

View file

@ -1,209 +0,0 @@
// @ts-nocheck
import { eq } from "drizzle-orm";
import { getActorRuntimeContext } from "../../context.js";
import { getOrCreateHandoffStatusSync } from "../../handles.js";
import { logActorWarning, resolveErrorMessage } from "../../logging.js";
import { handoff as handoffTable, handoffRuntime } from "../db/schema.js";
import { HANDOFF_ROW_ID, appendHistory, getCurrentRecord, setHandoffState } from "./common.js";
import { pushActiveBranchActivity } from "./push.js";
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
promise,
new Promise<T>((_resolve, reject) => {
timer = setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs}ms`)), timeoutMs);
})
]);
} finally {
if (timer) {
clearTimeout(timer);
}
}
}
export async function handleAttachActivity(loopCtx: any, msg: any): Promise<void> {
const record = await getCurrentRecord(loopCtx);
const { providers } = getActorRuntimeContext();
const activeSandbox =
record.activeSandboxId
? record.sandboxes.find((sb: any) => sb.sandboxId === record.activeSandboxId) ?? null
: null;
const provider = providers.get(activeSandbox?.providerId ?? record.providerId);
const target = await provider.attachTarget({
workspaceId: loopCtx.state.workspaceId,
sandboxId: record.activeSandboxId ?? ""
});
await appendHistory(loopCtx, "handoff.attach", {
target: target.target,
sessionId: record.activeSessionId
});
await msg.complete({
target: target.target,
sessionId: record.activeSessionId
});
}
export async function handleSwitchActivity(loopCtx: any, msg: any): Promise<void> {
const db = loopCtx.db;
const runtime = await db
.select({ switchTarget: handoffRuntime.activeSwitchTarget })
.from(handoffRuntime)
.where(eq(handoffRuntime.id, HANDOFF_ROW_ID))
.get();
await msg.complete({ switchTarget: runtime?.switchTarget ?? "" });
}
export async function handlePushActivity(loopCtx: any, msg: any): Promise<void> {
await pushActiveBranchActivity(loopCtx, {
reason: msg.body?.reason ?? null,
historyKind: "handoff.push"
});
await msg.complete({ ok: true });
}
export async function handleSimpleCommandActivity(
loopCtx: any,
msg: any,
statusMessage: string,
historyKind: string
): Promise<void> {
const db = loopCtx.db;
await db
.update(handoffRuntime)
.set({ statusMessage, updatedAt: Date.now() })
.where(eq(handoffRuntime.id, HANDOFF_ROW_ID))
.run();
await appendHistory(loopCtx, historyKind, { reason: msg.body?.reason ?? null });
await msg.complete({ ok: true });
}
export async function handleArchiveActivity(loopCtx: any, msg: any): Promise<void> {
await setHandoffState(loopCtx, "archive_stop_status_sync", "stopping status sync");
const record = await getCurrentRecord(loopCtx);
if (record.activeSandboxId && record.activeSessionId) {
try {
const sync = await getOrCreateHandoffStatusSync(
loopCtx,
loopCtx.state.workspaceId,
loopCtx.state.repoId,
loopCtx.state.handoffId,
record.activeSandboxId,
record.activeSessionId,
{
workspaceId: loopCtx.state.workspaceId,
repoId: loopCtx.state.repoId,
handoffId: loopCtx.state.handoffId,
providerId: record.providerId,
sandboxId: record.activeSandboxId,
sessionId: record.activeSessionId,
intervalMs: 2_000
}
);
await withTimeout(sync.stop(), 15_000, "handoff status sync stop");
} catch (error) {
logActorWarning("handoff.commands", "failed to stop status sync during archive", {
workspaceId: loopCtx.state.workspaceId,
repoId: loopCtx.state.repoId,
handoffId: loopCtx.state.handoffId,
sandboxId: record.activeSandboxId,
sessionId: record.activeSessionId,
error: resolveErrorMessage(error)
});
}
}
if (record.activeSandboxId) {
await setHandoffState(loopCtx, "archive_release_sandbox", "releasing sandbox");
const { providers } = getActorRuntimeContext();
const activeSandbox =
record.sandboxes.find((sb: any) => sb.sandboxId === record.activeSandboxId) ?? null;
const provider = providers.get(activeSandbox?.providerId ?? record.providerId);
const workspaceId = loopCtx.state.workspaceId;
const repoId = loopCtx.state.repoId;
const handoffId = loopCtx.state.handoffId;
const sandboxId = record.activeSandboxId;
// Do not block archive finalization on provider stop. Some provider stop calls can
// run longer than the synchronous archive UX budget.
void withTimeout(
provider.releaseSandbox({
workspaceId,
sandboxId
}),
45_000,
"provider releaseSandbox"
).catch((error) => {
logActorWarning("handoff.commands", "failed to release sandbox during archive", {
workspaceId,
repoId,
handoffId,
sandboxId,
error: resolveErrorMessage(error)
});
});
}
const db = loopCtx.db;
await setHandoffState(loopCtx, "archive_finalize", "finalizing archive");
await db
.update(handoffTable)
.set({ status: "archived", updatedAt: Date.now() })
.where(eq(handoffTable.id, HANDOFF_ROW_ID))
.run();
await db
.update(handoffRuntime)
.set({ activeSessionId: null, statusMessage: "archived", updatedAt: Date.now() })
.where(eq(handoffRuntime.id, HANDOFF_ROW_ID))
.run();
await appendHistory(loopCtx, "handoff.archive", { reason: msg.body?.reason ?? null });
await msg.complete({ ok: true });
}
export async function killDestroySandboxActivity(loopCtx: any): Promise<void> {
await setHandoffState(loopCtx, "kill_destroy_sandbox", "destroying sandbox");
const record = await getCurrentRecord(loopCtx);
if (!record.activeSandboxId) {
return;
}
const { providers } = getActorRuntimeContext();
const activeSandbox =
record.sandboxes.find((sb: any) => sb.sandboxId === record.activeSandboxId) ?? null;
const provider = providers.get(activeSandbox?.providerId ?? record.providerId);
await provider.destroySandbox({
workspaceId: loopCtx.state.workspaceId,
sandboxId: record.activeSandboxId
});
}
export async function killWriteDbActivity(loopCtx: any, msg: any): Promise<void> {
await setHandoffState(loopCtx, "kill_finalize", "finalizing kill");
const db = loopCtx.db;
await db
.update(handoffTable)
.set({ status: "killed", updatedAt: Date.now() })
.where(eq(handoffTable.id, HANDOFF_ROW_ID))
.run();
await db
.update(handoffRuntime)
.set({ statusMessage: "killed", updatedAt: Date.now() })
.where(eq(handoffRuntime.id, HANDOFF_ROW_ID))
.run();
await appendHistory(loopCtx, "handoff.kill", { reason: msg.body?.reason ?? null });
await msg.complete({ ok: true });
}
export async function handleGetActivity(loopCtx: any, msg: any): Promise<void> {
await msg.complete(await getCurrentRecord(loopCtx));
}

View file

@ -1,192 +0,0 @@
// @ts-nocheck
import { eq } from "drizzle-orm";
import type { HandoffRecord, HandoffStatus } from "@openhandoff/shared";
import { getOrCreateWorkspace } from "../../handles.js";
import { handoff as handoffTable, handoffRuntime, handoffSandboxes } from "../db/schema.js";
import { historyKey } from "../../keys.js";
export const HANDOFF_ROW_ID = 1;
export function collectErrorMessages(error: unknown): string[] {
if (error == null) {
return [];
}
const out: string[] = [];
const seen = new Set<unknown>();
let current: unknown = error;
while (current != null && !seen.has(current)) {
seen.add(current);
if (current instanceof Error) {
const message = current.message?.trim();
if (message) {
out.push(message);
}
current = (current as { cause?: unknown }).cause;
continue;
}
if (typeof current === "string") {
const message = current.trim();
if (message) {
out.push(message);
}
break;
}
break;
}
return out.filter((msg, index) => out.indexOf(msg) === index);
}
export function resolveErrorDetail(error: unknown): string {
const messages = collectErrorMessages(error);
if (messages.length === 0) {
return String(error);
}
const nonWorkflowWrapper = messages.find(
(msg) => !/^Step\s+"[^"]+"\s+failed\b/i.test(msg)
);
return nonWorkflowWrapper ?? messages[0]!;
}
export function buildAgentPrompt(task: string): string {
return task.trim();
}
export async function setHandoffState(
ctx: any,
status: HandoffStatus,
statusMessage?: string
): Promise<void> {
const now = Date.now();
const db = ctx.db;
await db
.update(handoffTable)
.set({ status, updatedAt: now })
.where(eq(handoffTable.id, HANDOFF_ROW_ID))
.run();
if (statusMessage != null) {
await db
.insert(handoffRuntime)
.values({
id: HANDOFF_ROW_ID,
activeSandboxId: null,
activeSessionId: null,
activeSwitchTarget: null,
activeCwd: null,
statusMessage,
updatedAt: now
})
.onConflictDoUpdate({
target: handoffRuntime.id,
set: {
statusMessage,
updatedAt: now
}
})
.run();
}
const workspace = await getOrCreateWorkspace(ctx, ctx.state.workspaceId);
await workspace.notifyWorkbenchUpdated({});
}
export async function getCurrentRecord(ctx: any): Promise<HandoffRecord> {
const db = ctx.db;
const row = await db
.select({
branchName: handoffTable.branchName,
title: handoffTable.title,
task: handoffTable.task,
providerId: handoffTable.providerId,
status: handoffTable.status,
statusMessage: handoffRuntime.statusMessage,
activeSandboxId: handoffRuntime.activeSandboxId,
activeSessionId: handoffRuntime.activeSessionId,
agentType: handoffTable.agentType,
prSubmitted: handoffTable.prSubmitted,
createdAt: handoffTable.createdAt,
updatedAt: handoffTable.updatedAt
})
.from(handoffTable)
.leftJoin(handoffRuntime, eq(handoffTable.id, handoffRuntime.id))
.where(eq(handoffTable.id, HANDOFF_ROW_ID))
.get();
if (!row) {
throw new Error(`Handoff not found: ${ctx.state.handoffId}`);
}
const sandboxes = await db
.select({
sandboxId: handoffSandboxes.sandboxId,
providerId: handoffSandboxes.providerId,
sandboxActorId: handoffSandboxes.sandboxActorId,
switchTarget: handoffSandboxes.switchTarget,
cwd: handoffSandboxes.cwd,
createdAt: handoffSandboxes.createdAt,
updatedAt: handoffSandboxes.updatedAt,
})
.from(handoffSandboxes)
.all();
return {
workspaceId: ctx.state.workspaceId,
repoId: ctx.state.repoId,
repoRemote: ctx.state.repoRemote,
handoffId: ctx.state.handoffId,
branchName: row.branchName,
title: row.title,
task: row.task,
providerId: row.providerId,
status: row.status,
statusMessage: row.statusMessage ?? null,
activeSandboxId: row.activeSandboxId ?? null,
activeSessionId: row.activeSessionId ?? null,
sandboxes: sandboxes.map((sb) => ({
sandboxId: sb.sandboxId,
providerId: sb.providerId,
sandboxActorId: sb.sandboxActorId ?? null,
switchTarget: sb.switchTarget,
cwd: sb.cwd ?? null,
createdAt: sb.createdAt,
updatedAt: sb.updatedAt,
})),
agentType: row.agentType ?? null,
prSubmitted: Boolean(row.prSubmitted),
diffStat: null,
hasUnpushed: null,
conflictsWithMain: null,
parentBranch: null,
prUrl: null,
prAuthor: null,
ciStatus: null,
reviewStatus: null,
reviewer: null,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
} as HandoffRecord;
}
export async function appendHistory(ctx: any, kind: string, payload: Record<string, unknown>): Promise<void> {
const client = ctx.client();
const history = await client.history.getOrCreate(
historyKey(ctx.state.workspaceId, ctx.state.repoId),
{ createWithInput: { workspaceId: ctx.state.workspaceId, repoId: ctx.state.repoId } }
);
await history.append({
kind,
handoffId: ctx.state.handoffId,
branchName: ctx.state.branchName,
payload
});
const workspace = await getOrCreateWorkspace(ctx, ctx.state.workspaceId);
await workspace.notifyWorkbenchUpdated({});
}

View file

@ -1,290 +0,0 @@
import { Loop } from "rivetkit/workflow";
import { getActorRuntimeContext } from "../../context.js";
import { logActorWarning, resolveErrorMessage } from "../../logging.js";
import { getCurrentRecord } from "./common.js";
import {
initAssertNameActivity,
initBootstrapDbActivity,
initCompleteActivity,
initCreateSandboxActivity,
initCreateSessionActivity,
initEnsureAgentActivity,
initEnsureNameActivity,
initFailedActivity,
initStartSandboxInstanceActivity,
initStartStatusSyncActivity,
initWriteDbActivity
} from "./init.js";
import {
handleArchiveActivity,
handleAttachActivity,
handleGetActivity,
handlePushActivity,
handleSimpleCommandActivity,
handleSwitchActivity,
killDestroySandboxActivity,
killWriteDbActivity
} from "./commands.js";
import { idleNotifyActivity, idleSubmitPrActivity, statusUpdateActivity } from "./status-sync.js";
import { HANDOFF_QUEUE_NAMES } from "./queue.js";
import {
changeWorkbenchModel,
closeWorkbenchSession,
createWorkbenchSession,
markWorkbenchUnread,
publishWorkbenchPr,
renameWorkbenchBranch,
renameWorkbenchHandoff,
renameWorkbenchSession,
revertWorkbenchFile,
sendWorkbenchMessage,
setWorkbenchSessionUnread,
stopWorkbenchSession,
syncWorkbenchSessionStatus,
updateWorkbenchDraft,
} from "../workbench.js";
export { HANDOFF_QUEUE_NAMES, handoffWorkflowQueueName } from "./queue.js";
type HandoffQueueName = (typeof HANDOFF_QUEUE_NAMES)[number];
type WorkflowHandler = (loopCtx: any, msg: { name: HandoffQueueName; body: any; complete: (response: unknown) => Promise<void> }) => Promise<void>;
const commandHandlers: Record<HandoffQueueName, WorkflowHandler> = {
"handoff.command.initialize": async (loopCtx, msg) => {
const body = msg.body;
await loopCtx.step("init-bootstrap-db", async () => initBootstrapDbActivity(loopCtx, body));
await loopCtx.removed("init-enqueue-provision", "step");
await loopCtx.removed("init-dispatch-provision-v2", "step");
const currentRecord = await loopCtx.step(
"init-read-current-record",
async () => getCurrentRecord(loopCtx)
);
try {
await msg.complete(currentRecord);
} catch (error) {
logActorWarning("handoff.workflow", "initialize completion failed", {
error: resolveErrorMessage(error)
});
}
},
"handoff.command.provision": async (loopCtx, msg) => {
const body = msg.body;
await loopCtx.removed("init-failed", "step");
try {
await loopCtx.step("init-ensure-name", async () => initEnsureNameActivity(loopCtx));
await loopCtx.step("init-assert-name", async () => initAssertNameActivity(loopCtx));
const sandbox = await loopCtx.step({
name: "init-create-sandbox",
timeout: 180_000,
run: async () => initCreateSandboxActivity(loopCtx, body),
});
const agent = await loopCtx.step({
name: "init-ensure-agent",
timeout: 180_000,
run: async () => initEnsureAgentActivity(loopCtx, body, sandbox),
});
const sandboxInstanceReady = await loopCtx.step({
name: "init-start-sandbox-instance",
timeout: 60_000,
run: async () => initStartSandboxInstanceActivity(loopCtx, body, sandbox, agent),
});
const session = await loopCtx.step({
name: "init-create-session",
timeout: 180_000,
run: async () => initCreateSessionActivity(loopCtx, body, sandbox, sandboxInstanceReady),
});
await loopCtx.step(
"init-write-db",
async () => initWriteDbActivity(loopCtx, body, sandbox, session, sandboxInstanceReady)
);
await loopCtx.step("init-start-status-sync", async () => initStartStatusSyncActivity(loopCtx, body, sandbox, session));
await loopCtx.step("init-complete", async () => initCompleteActivity(loopCtx, body, sandbox, session));
await msg.complete({ ok: true });
} catch (error) {
await loopCtx.step("init-failed-v2", async () => initFailedActivity(loopCtx, error));
await msg.complete({ ok: false });
}
},
"handoff.command.attach": async (loopCtx, msg) => {
await loopCtx.step("handle-attach", async () => handleAttachActivity(loopCtx, msg));
},
"handoff.command.switch": async (loopCtx, msg) => {
await loopCtx.step("handle-switch", async () => handleSwitchActivity(loopCtx, msg));
},
"handoff.command.push": async (loopCtx, msg) => {
await loopCtx.step("handle-push", async () => handlePushActivity(loopCtx, msg));
},
"handoff.command.sync": async (loopCtx, msg) => {
await loopCtx.step(
"handle-sync",
async () => handleSimpleCommandActivity(loopCtx, msg, "sync requested", "handoff.sync")
);
},
"handoff.command.merge": async (loopCtx, msg) => {
await loopCtx.step(
"handle-merge",
async () => handleSimpleCommandActivity(loopCtx, msg, "merge requested", "handoff.merge")
);
},
"handoff.command.archive": async (loopCtx, msg) => {
await loopCtx.step("handle-archive", async () => handleArchiveActivity(loopCtx, msg));
},
"handoff.command.kill": async (loopCtx, msg) => {
await loopCtx.step("kill-destroy-sandbox", async () => killDestroySandboxActivity(loopCtx));
await loopCtx.step("kill-write-db", async () => killWriteDbActivity(loopCtx, msg));
},
"handoff.command.get": async (loopCtx, msg) => {
await loopCtx.step("handle-get", async () => handleGetActivity(loopCtx, msg));
},
"handoff.command.workbench.mark_unread": async (loopCtx, msg) => {
await loopCtx.step("workbench-mark-unread", async () => markWorkbenchUnread(loopCtx));
await msg.complete({ ok: true });
},
"handoff.command.workbench.rename_handoff": async (loopCtx, msg) => {
await loopCtx.step("workbench-rename-handoff", async () => renameWorkbenchHandoff(loopCtx, msg.body.value));
await msg.complete({ ok: true });
},
"handoff.command.workbench.rename_branch": async (loopCtx, msg) => {
await loopCtx.step({
name: "workbench-rename-branch",
timeout: 5 * 60_000,
run: async () => renameWorkbenchBranch(loopCtx, msg.body.value),
});
await msg.complete({ ok: true });
},
"handoff.command.workbench.create_session": async (loopCtx, msg) => {
const created = await loopCtx.step({
name: "workbench-create-session",
timeout: 5 * 60_000,
run: async () => createWorkbenchSession(loopCtx, msg.body?.model),
});
await msg.complete(created);
},
"handoff.command.workbench.rename_session": async (loopCtx, msg) => {
await loopCtx.step("workbench-rename-session", async () =>
renameWorkbenchSession(loopCtx, msg.body.sessionId, msg.body.title),
);
await msg.complete({ ok: true });
},
"handoff.command.workbench.set_session_unread": async (loopCtx, msg) => {
await loopCtx.step("workbench-set-session-unread", async () =>
setWorkbenchSessionUnread(loopCtx, msg.body.sessionId, msg.body.unread),
);
await msg.complete({ ok: true });
},
"handoff.command.workbench.update_draft": async (loopCtx, msg) => {
await loopCtx.step("workbench-update-draft", async () =>
updateWorkbenchDraft(loopCtx, msg.body.sessionId, msg.body.text, msg.body.attachments),
);
await msg.complete({ ok: true });
},
"handoff.command.workbench.change_model": async (loopCtx, msg) => {
await loopCtx.step("workbench-change-model", async () =>
changeWorkbenchModel(loopCtx, msg.body.sessionId, msg.body.model),
);
await msg.complete({ ok: true });
},
"handoff.command.workbench.send_message": async (loopCtx, msg) => {
await loopCtx.step({
name: "workbench-send-message",
timeout: 10 * 60_000,
run: async () => sendWorkbenchMessage(loopCtx, msg.body.sessionId, msg.body.text, msg.body.attachments),
});
await msg.complete({ ok: true });
},
"handoff.command.workbench.stop_session": async (loopCtx, msg) => {
await loopCtx.step({
name: "workbench-stop-session",
timeout: 5 * 60_000,
run: async () => stopWorkbenchSession(loopCtx, msg.body.sessionId),
});
await msg.complete({ ok: true });
},
"handoff.command.workbench.sync_session_status": async (loopCtx, msg) => {
await loopCtx.step("workbench-sync-session-status", async () =>
syncWorkbenchSessionStatus(loopCtx, msg.body.sessionId, msg.body.status, msg.body.at),
);
await msg.complete({ ok: true });
},
"handoff.command.workbench.close_session": async (loopCtx, msg) => {
await loopCtx.step({
name: "workbench-close-session",
timeout: 5 * 60_000,
run: async () => closeWorkbenchSession(loopCtx, msg.body.sessionId),
});
await msg.complete({ ok: true });
},
"handoff.command.workbench.publish_pr": async (loopCtx, msg) => {
await loopCtx.step({
name: "workbench-publish-pr",
timeout: 10 * 60_000,
run: async () => publishWorkbenchPr(loopCtx),
});
await msg.complete({ ok: true });
},
"handoff.command.workbench.revert_file": async (loopCtx, msg) => {
await loopCtx.step({
name: "workbench-revert-file",
timeout: 5 * 60_000,
run: async () => revertWorkbenchFile(loopCtx, msg.body.path),
});
await msg.complete({ ok: true });
},
"handoff.status_sync.result": async (loopCtx, msg) => {
const transitionedToIdle = await loopCtx.step("status-update", async () => statusUpdateActivity(loopCtx, msg.body));
if (transitionedToIdle) {
const { config } = getActorRuntimeContext();
if (config.auto_submit) {
await loopCtx.step("idle-submit-pr", async () => idleSubmitPrActivity(loopCtx));
}
await loopCtx.step("idle-notify", async () => idleNotifyActivity(loopCtx));
}
}
};
export async function runHandoffWorkflow(ctx: any): Promise<void> {
await ctx.loop("handoff-command-loop", async (loopCtx: any) => {
const msg = await loopCtx.queue.next("next-command", {
names: [...HANDOFF_QUEUE_NAMES],
completable: true
});
if (!msg) {
return Loop.continue(undefined);
}
const handler = commandHandlers[msg.name as HandoffQueueName];
if (handler) {
await handler(loopCtx, msg);
}
return Loop.continue(undefined);
});
}

View file

@ -1,643 +0,0 @@
// @ts-nocheck
import { desc, eq } from "drizzle-orm";
import { resolveCreateFlowDecision } from "../../../services/create-flow.js";
import { getActorRuntimeContext } from "../../context.js";
import {
getOrCreateHandoffStatusSync,
getOrCreateHistory,
getOrCreateProject,
getOrCreateSandboxInstance,
getSandboxInstance,
selfHandoff
} from "../../handles.js";
import { logActorWarning, resolveErrorMessage } from "../../logging.js";
import { handoff as handoffTable, handoffRuntime, handoffSandboxes } from "../db/schema.js";
import {
HANDOFF_ROW_ID,
appendHistory,
buildAgentPrompt,
collectErrorMessages,
resolveErrorDetail,
setHandoffState
} from "./common.js";
import { handoffWorkflowQueueName } from "./queue.js";
const DEFAULT_INIT_CREATE_SANDBOX_ACTIVITY_TIMEOUT_MS = 180_000;
function getInitCreateSandboxActivityTimeoutMs(): number {
const raw = process.env.HF_INIT_CREATE_SANDBOX_ACTIVITY_TIMEOUT_MS;
if (!raw) {
return DEFAULT_INIT_CREATE_SANDBOX_ACTIVITY_TIMEOUT_MS;
}
const parsed = Number(raw);
if (!Number.isFinite(parsed) || parsed <= 0) {
return DEFAULT_INIT_CREATE_SANDBOX_ACTIVITY_TIMEOUT_MS;
}
return Math.floor(parsed);
}
function debugInit(loopCtx: any, message: string, context?: Record<string, unknown>): void {
loopCtx.log.debug({
msg: message,
scope: "handoff.init",
workspaceId: loopCtx.state.workspaceId,
repoId: loopCtx.state.repoId,
handoffId: loopCtx.state.handoffId,
...(context ?? {})
});
}
async function withActivityTimeout<T>(
timeoutMs: number,
label: string,
run: () => Promise<T>
): Promise<T> {
let timer: ReturnType<typeof setTimeout> | null = null;
try {
return await Promise.race([
run(),
new Promise<T>((_, reject) => {
timer = setTimeout(() => {
reject(new Error(`${label} timed out after ${timeoutMs}ms`));
}, timeoutMs);
})
]);
} finally {
if (timer) {
clearTimeout(timer);
}
}
}
export async function initBootstrapDbActivity(loopCtx: any, body: any): Promise<void> {
const providerId = body?.providerId ?? loopCtx.state.providerId;
const { config } = getActorRuntimeContext();
const now = Date.now();
const db = loopCtx.db;
const initialStatusMessage = loopCtx.state.branchName && loopCtx.state.title ? "provisioning" : "naming";
try {
await db
.insert(handoffTable)
.values({
id: HANDOFF_ROW_ID,
branchName: loopCtx.state.branchName,
title: loopCtx.state.title,
task: loopCtx.state.task,
providerId,
status: "init_bootstrap_db",
agentType: loopCtx.state.agentType ?? config.default_agent,
createdAt: now,
updatedAt: now
})
.onConflictDoUpdate({
target: handoffTable.id,
set: {
branchName: loopCtx.state.branchName,
title: loopCtx.state.title,
task: loopCtx.state.task,
providerId,
status: "init_bootstrap_db",
agentType: loopCtx.state.agentType ?? config.default_agent,
updatedAt: now
}
})
.run();
await db
.insert(handoffRuntime)
.values({
id: HANDOFF_ROW_ID,
activeSandboxId: null,
activeSessionId: null,
activeSwitchTarget: null,
activeCwd: null,
statusMessage: initialStatusMessage,
updatedAt: now
})
.onConflictDoUpdate({
target: handoffRuntime.id,
set: {
activeSandboxId: null,
activeSessionId: null,
activeSwitchTarget: null,
activeCwd: null,
statusMessage: initialStatusMessage,
updatedAt: now
}
})
.run();
} catch (error) {
const detail = resolveErrorMessage(error);
throw new Error(`handoff init bootstrap db failed: ${detail}`);
}
}
export async function initEnqueueProvisionActivity(loopCtx: any, body: any): Promise<void> {
await setHandoffState(loopCtx, "init_enqueue_provision", "provision queued");
const self = selfHandoff(loopCtx);
void self
.send(handoffWorkflowQueueName("handoff.command.provision"), body, {
wait: false,
})
.catch((error: unknown) => {
logActorWarning("handoff.init", "background provision command failed", {
workspaceId: loopCtx.state.workspaceId,
repoId: loopCtx.state.repoId,
handoffId: loopCtx.state.handoffId,
error: resolveErrorMessage(error),
});
});
}
export async function initEnsureNameActivity(loopCtx: any): Promise<void> {
await setHandoffState(loopCtx, "init_ensure_name", "determining title and branch");
const existing = await loopCtx.db
.select({
branchName: handoffTable.branchName,
title: handoffTable.title
})
.from(handoffTable)
.where(eq(handoffTable.id, HANDOFF_ROW_ID))
.get();
if (existing?.branchName && existing?.title) {
loopCtx.state.branchName = existing.branchName;
loopCtx.state.title = existing.title;
return;
}
const { driver } = getActorRuntimeContext();
try {
await driver.git.fetch(loopCtx.state.repoLocalPath);
} catch (error) {
logActorWarning("handoff.init", "fetch before naming failed", {
workspaceId: loopCtx.state.workspaceId,
repoId: loopCtx.state.repoId,
handoffId: loopCtx.state.handoffId,
error: resolveErrorMessage(error)
});
}
const remoteBranches = (await driver.git.listRemoteBranches(loopCtx.state.repoLocalPath)).map(
(branch: any) => branch.branchName
);
const project = await getOrCreateProject(
loopCtx,
loopCtx.state.workspaceId,
loopCtx.state.repoId,
loopCtx.state.repoRemote
);
const reservedBranches = await project.listReservedBranches({});
const resolved = resolveCreateFlowDecision({
task: loopCtx.state.task,
explicitTitle: loopCtx.state.explicitTitle ?? undefined,
explicitBranchName: loopCtx.state.explicitBranchName ?? undefined,
localBranches: remoteBranches,
handoffBranches: reservedBranches
});
const now = Date.now();
await loopCtx.db
.update(handoffTable)
.set({
branchName: resolved.branchName,
title: resolved.title,
updatedAt: now
})
.where(eq(handoffTable.id, HANDOFF_ROW_ID))
.run();
loopCtx.state.branchName = resolved.branchName;
loopCtx.state.title = resolved.title;
loopCtx.state.explicitTitle = null;
loopCtx.state.explicitBranchName = null;
await loopCtx.db
.update(handoffRuntime)
.set({
statusMessage: "provisioning",
updatedAt: now
})
.where(eq(handoffRuntime.id, HANDOFF_ROW_ID))
.run();
await project.registerHandoffBranch({
handoffId: loopCtx.state.handoffId,
branchName: resolved.branchName
});
await appendHistory(loopCtx, "handoff.named", {
title: resolved.title,
branchName: resolved.branchName
});
}
export async function initAssertNameActivity(loopCtx: any): Promise<void> {
await setHandoffState(loopCtx, "init_assert_name", "validating naming");
if (!loopCtx.state.branchName) {
throw new Error("handoff branchName is not initialized");
}
}
export async function initCreateSandboxActivity(loopCtx: any, body: any): Promise<any> {
await setHandoffState(loopCtx, "init_create_sandbox", "creating sandbox");
const { providers } = getActorRuntimeContext();
const providerId = body?.providerId ?? loopCtx.state.providerId;
const provider = providers.get(providerId);
const timeoutMs = getInitCreateSandboxActivityTimeoutMs();
const startedAt = Date.now();
debugInit(loopCtx, "init_create_sandbox started", {
providerId,
timeoutMs,
supportsSessionReuse: provider.capabilities().supportsSessionReuse
});
if (provider.capabilities().supportsSessionReuse) {
const runtime = await loopCtx.db
.select({ activeSandboxId: handoffRuntime.activeSandboxId })
.from(handoffRuntime)
.where(eq(handoffRuntime.id, HANDOFF_ROW_ID))
.get();
const existing = await loopCtx.db
.select({ sandboxId: handoffSandboxes.sandboxId })
.from(handoffSandboxes)
.where(eq(handoffSandboxes.providerId, providerId))
.orderBy(desc(handoffSandboxes.updatedAt))
.limit(1)
.get();
const sandboxId = runtime?.activeSandboxId ?? existing?.sandboxId ?? null;
if (sandboxId) {
debugInit(loopCtx, "init_create_sandbox attempting resume", { sandboxId });
try {
const resumed = await withActivityTimeout(
timeoutMs,
"resumeSandbox",
async () => provider.resumeSandbox({
workspaceId: loopCtx.state.workspaceId,
sandboxId
})
);
debugInit(loopCtx, "init_create_sandbox resume succeeded", {
sandboxId: resumed.sandboxId,
durationMs: Date.now() - startedAt
});
return resumed;
} catch (error) {
logActorWarning("handoff.init", "resume sandbox failed; creating a new sandbox", {
workspaceId: loopCtx.state.workspaceId,
repoId: loopCtx.state.repoId,
handoffId: loopCtx.state.handoffId,
sandboxId,
error: resolveErrorMessage(error)
});
}
}
}
debugInit(loopCtx, "init_create_sandbox creating fresh sandbox", {
branchName: loopCtx.state.branchName
});
try {
const sandbox = await withActivityTimeout(
timeoutMs,
"createSandbox",
async () => provider.createSandbox({
workspaceId: loopCtx.state.workspaceId,
repoId: loopCtx.state.repoId,
repoRemote: loopCtx.state.repoRemote,
branchName: loopCtx.state.branchName,
handoffId: loopCtx.state.handoffId,
debug: (message, context) => debugInit(loopCtx, message, context)
})
);
debugInit(loopCtx, "init_create_sandbox create succeeded", {
sandboxId: sandbox.sandboxId,
durationMs: Date.now() - startedAt
});
return sandbox;
} catch (error) {
debugInit(loopCtx, "init_create_sandbox failed", {
durationMs: Date.now() - startedAt,
error: resolveErrorMessage(error)
});
throw error;
}
}
export async function initEnsureAgentActivity(loopCtx: any, body: any, sandbox: any): Promise<any> {
await setHandoffState(loopCtx, "init_ensure_agent", "ensuring sandbox agent");
const { providers } = getActorRuntimeContext();
const providerId = body?.providerId ?? loopCtx.state.providerId;
const provider = providers.get(providerId);
return await provider.ensureSandboxAgent({
workspaceId: loopCtx.state.workspaceId,
sandboxId: sandbox.sandboxId
});
}
export async function initStartSandboxInstanceActivity(
loopCtx: any,
body: any,
sandbox: any,
agent: any
): Promise<any> {
await setHandoffState(loopCtx, "init_start_sandbox_instance", "starting sandbox runtime");
try {
const providerId = body?.providerId ?? loopCtx.state.providerId;
const sandboxInstance = await getOrCreateSandboxInstance(
loopCtx,
loopCtx.state.workspaceId,
providerId,
sandbox.sandboxId,
{
workspaceId: loopCtx.state.workspaceId,
providerId,
sandboxId: sandbox.sandboxId
}
);
await sandboxInstance.ensure({
metadata: sandbox.metadata,
status: "ready",
agentEndpoint: agent.endpoint,
agentToken: agent.token
});
const actorId = typeof (sandboxInstance as any).resolve === "function"
? await (sandboxInstance as any).resolve()
: null;
return {
ok: true as const,
actorId: typeof actorId === "string" ? actorId : null
};
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
return {
ok: false as const,
error: `sandbox-instance ensure failed: ${detail}`
};
}
}
export async function initCreateSessionActivity(
loopCtx: any,
body: any,
sandbox: any,
sandboxInstanceReady: any
): Promise<any> {
await setHandoffState(loopCtx, "init_create_session", "creating agent session");
if (!sandboxInstanceReady.ok) {
return {
id: null,
status: "error",
error: sandboxInstanceReady.error ?? "sandbox instance is not ready"
} as const;
}
const { config } = getActorRuntimeContext();
const providerId = body?.providerId ?? loopCtx.state.providerId;
const sandboxInstance = getSandboxInstance(loopCtx, loopCtx.state.workspaceId, providerId, sandbox.sandboxId);
const cwd =
sandbox.metadata && typeof (sandbox.metadata as any).cwd === "string"
? ((sandbox.metadata as any).cwd as string)
: undefined;
return await sandboxInstance.createSession({
prompt: buildAgentPrompt(loopCtx.state.task),
cwd,
agent: (loopCtx.state.agentType ?? config.default_agent) as any
});
}
export async function initWriteDbActivity(
loopCtx: any,
body: any,
sandbox: any,
session: any,
sandboxInstanceReady?: { actorId?: string | null }
): Promise<void> {
await setHandoffState(loopCtx, "init_write_db", "persisting handoff runtime");
const providerId = body?.providerId ?? loopCtx.state.providerId;
const { config } = getActorRuntimeContext();
const now = Date.now();
const db = loopCtx.db;
const sessionId = session?.id ?? null;
const sessionHealthy = Boolean(sessionId) && session?.status !== "error";
const activeSessionId = sessionHealthy ? sessionId : null;
const statusMessage =
sessionHealthy
? "session created"
: session?.status === "error"
? (session.error ?? "session create failed")
: "session unavailable";
const activeCwd =
sandbox.metadata && typeof (sandbox.metadata as any).cwd === "string"
? ((sandbox.metadata as any).cwd as string)
: null;
const sandboxActorId =
typeof sandboxInstanceReady?.actorId === "string" && sandboxInstanceReady.actorId.length > 0
? sandboxInstanceReady.actorId
: null;
await db
.update(handoffTable)
.set({
providerId,
status: sessionHealthy ? "running" : "error",
agentType: loopCtx.state.agentType ?? config.default_agent,
updatedAt: now
})
.where(eq(handoffTable.id, HANDOFF_ROW_ID))
.run();
await db
.insert(handoffSandboxes)
.values({
sandboxId: sandbox.sandboxId,
providerId,
sandboxActorId,
switchTarget: sandbox.switchTarget,
cwd: activeCwd,
statusMessage,
createdAt: now,
updatedAt: now
})
.onConflictDoUpdate({
target: handoffSandboxes.sandboxId,
set: {
providerId,
sandboxActorId,
switchTarget: sandbox.switchTarget,
cwd: activeCwd,
statusMessage,
updatedAt: now
}
})
.run();
await db
.insert(handoffRuntime)
.values({
id: HANDOFF_ROW_ID,
activeSandboxId: sandbox.sandboxId,
activeSessionId,
activeSwitchTarget: sandbox.switchTarget,
activeCwd,
statusMessage,
updatedAt: now
})
.onConflictDoUpdate({
target: handoffRuntime.id,
set: {
activeSandboxId: sandbox.sandboxId,
activeSessionId,
activeSwitchTarget: sandbox.switchTarget,
activeCwd,
statusMessage,
updatedAt: now
}
})
.run();
}
export async function initStartStatusSyncActivity(
loopCtx: any,
body: any,
sandbox: any,
session: any
): Promise<void> {
const sessionId = session?.id ?? null;
if (!sessionId || session?.status === "error") {
return;
}
await setHandoffState(loopCtx, "init_start_status_sync", "starting session status sync");
const providerId = body?.providerId ?? loopCtx.state.providerId;
const sync = await getOrCreateHandoffStatusSync(
loopCtx,
loopCtx.state.workspaceId,
loopCtx.state.repoId,
loopCtx.state.handoffId,
sandbox.sandboxId,
sessionId,
{
workspaceId: loopCtx.state.workspaceId,
repoId: loopCtx.state.repoId,
handoffId: loopCtx.state.handoffId,
providerId,
sandboxId: sandbox.sandboxId,
sessionId,
intervalMs: 2_000
}
);
await sync.start();
await sync.force();
}
export async function initCompleteActivity(loopCtx: any, body: any, sandbox: any, session: any): Promise<void> {
const providerId = body?.providerId ?? loopCtx.state.providerId;
const sessionId = session?.id ?? null;
const sessionHealthy = Boolean(sessionId) && session?.status !== "error";
if (sessionHealthy) {
await setHandoffState(loopCtx, "init_complete", "handoff initialized");
const history = await getOrCreateHistory(loopCtx, loopCtx.state.workspaceId, loopCtx.state.repoId);
await history.append({
kind: "handoff.initialized",
handoffId: loopCtx.state.handoffId,
branchName: loopCtx.state.branchName,
payload: { providerId, sandboxId: sandbox.sandboxId, sessionId }
});
loopCtx.state.initialized = true;
return;
}
const detail =
session?.status === "error"
? (session.error ?? "session create failed")
: "session unavailable";
await setHandoffState(loopCtx, "error", detail);
await appendHistory(loopCtx, "handoff.error", {
detail,
messages: [detail]
});
loopCtx.state.initialized = false;
}
export async function initFailedActivity(loopCtx: any, error: unknown): Promise<void> {
const now = Date.now();
const detail = resolveErrorDetail(error);
const messages = collectErrorMessages(error);
const db = loopCtx.db;
const { config, providers } = getActorRuntimeContext();
const providerId = loopCtx.state.providerId ?? providers.defaultProviderId();
await db
.insert(handoffTable)
.values({
id: HANDOFF_ROW_ID,
branchName: loopCtx.state.branchName ?? null,
title: loopCtx.state.title ?? null,
task: loopCtx.state.task,
providerId,
status: "error",
agentType: loopCtx.state.agentType ?? config.default_agent,
createdAt: now,
updatedAt: now
})
.onConflictDoUpdate({
target: handoffTable.id,
set: {
branchName: loopCtx.state.branchName ?? null,
title: loopCtx.state.title ?? null,
task: loopCtx.state.task,
providerId,
status: "error",
agentType: loopCtx.state.agentType ?? config.default_agent,
updatedAt: now
}
})
.run();
await db
.insert(handoffRuntime)
.values({
id: HANDOFF_ROW_ID,
activeSandboxId: null,
activeSessionId: null,
activeSwitchTarget: null,
activeCwd: null,
statusMessage: detail,
updatedAt: now
})
.onConflictDoUpdate({
target: handoffRuntime.id,
set: {
activeSandboxId: null,
activeSessionId: null,
activeSwitchTarget: null,
activeCwd: null,
statusMessage: detail,
updatedAt: now
}
})
.run();
await appendHistory(loopCtx, "handoff.error", {
detail,
messages
});
}

Some files were not shown because too many files have changed in this diff Show more