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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ Run AI coding agents in isolated sandboxes, one task at a time.

Ox automates the entire workflow of starting a coding task: it creates a feature branch, optionally forks your database, and launches an AI agent inside an isolated sandbox -- all from a single command or an interactive terminal UI.

![Three interactive ox sessions, one each for Claude Code, OpenCode, and Codex, switching between them with detach and attach](docs/images/multi-agent-sessions.gif)

### Features

- **Sandboxed execution** -- Agents run in isolated Docker containers or cloud sandboxes, never on your host machine
Expand Down
Binary file added docs/images/multi-agent-sessions.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions docs/tapes/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,12 @@ Generated GIFs are written to `docs/images/`.
| `slash-commands.tape` | `slash-commands.gif` | The slash command popover on the prompt screen |
| `start-task.tape` | `start-task.gif` | Starting a task from the CLI in follow mode |
| `theme-picker.tape` | `theme-picker.gif` | The theme picker with live preview |
| `multi-agent-sessions.tape` | `multi-agent-sessions.gif` | Three interactive sessions, one per agent, with detach and reattach flow |

## Notes

- Tape files assume `ox` is available on your PATH or in the current directory
- Some tapes require Docker to be running (for sessions list to show real data)
- The multi-agent session tape seeds real Docker-backed interactive demo sessions before recording
- The `generate.sh` script runs all tapes sequentially
- Edit tape timing (`Sleep` values) if animations are too fast or slow
10 changes: 10 additions & 0 deletions docs/tapes/generate.sh
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ fi
cleanup() {
rm -rf "$TAPE_BIN"
"$REPO_ROOT/bun" "$SCRIPT_DIR/seed-sessions.ts" --cleanup 2>/dev/null || true
"$REPO_ROOT/bun" "$SCRIPT_DIR/seedInteractiveSessions.ts" --cleanup 2>/dev/null || true
if [ "$CREATED_CONFIG" = "1" ]; then
rm -f "$OX_CONFIG"
rmdir "$REPO_ROOT/.ox" 2>/dev/null || true
Expand All @@ -65,13 +66,22 @@ run_tape() {
"$REPO_ROOT/bun" "$SCRIPT_DIR/seed-sessions.ts"
fi

if [ "$name" = "multi-agent-sessions" ]; then
echo "Seeding interactive demo sessions..."
"$REPO_ROOT/bun" "$SCRIPT_DIR/seedInteractiveSessions.ts"
fi

echo "Generating $name.gif..."
(cd "$REPO_ROOT" && vhs "$tape")
echo " -> docs/images/$name.gif"

if [ "$name" = "sessions-list" ]; then
"$REPO_ROOT/bun" "$SCRIPT_DIR/seed-sessions.ts" --cleanup
fi

if [ "$name" = "multi-agent-sessions" ]; then
"$REPO_ROOT/bun" "$SCRIPT_DIR/seedInteractiveSessions.ts" --cleanup
fi
}

if [ $# -gt 0 ]; then
Expand Down
35 changes: 35 additions & 0 deletions docs/tapes/multi-agent-sessions.tape
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Multi-Agent Interactive Sessions
# Shows real ox session listing and attachment across three interactive sessions.

Output docs/images/multi-agent-sessions.gif

Set Shell bash
Set Width 1200
Set Height 720
Set FontSize 14
Set Theme "Catppuccin Mocha"
Set Padding 20
Set Framerate 30
Set TypingSpeed 55ms

Type "./bun index.ts sessions"
Enter
Sleep 2s

Type "./bun index.ts session attach demo-claude-auth"
Enter
Sleep 3s
Ctrl+\
Sleep 1500ms

Type "./bun index.ts session attach demo-opencode-tests"
Enter
Sleep 3s
Ctrl+\
Sleep 1500ms

Type "./bun index.ts session attach demo-codex-validation"
Enter
Sleep 3s
Ctrl+\
Sleep 1500ms
179 changes: 179 additions & 0 deletions docs/tapes/seedInteractiveSessions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
#!/usr/bin/env bun

import { rm } from 'node:fs/promises';
import { resolve } from 'node:path';
import { getSession, startContainer } from '../../src/services/docker.ts';
import { getRepoInfo } from '../../src/services/git.ts';
import { log } from '../../src/services/logger.ts';
import {
type AgentType,
getSandboxProvider,
type OxSession,
} from '../../src/services/sandbox/index.ts';

const provider = getSandboxProvider('docker');
const repoRoot = resolve(import.meta.dir, '../..');

const SESSIONS = [
{
name: 'demo-claude-auth',
branchName: 'demo-claude-auth',
agent: 'claude',
model: 'sonnet',
prompt: 'Refactor the auth middleware into smaller composable steps',
title: 'Claude Code',
statusLines: [
'Scanning auth entry points and middleware boundaries',
'Reviewing request context propagation across handlers',
'Preparing a smaller chain of reusable auth helpers',
],
},
{
name: 'demo-opencode-tests',
branchName: 'demo-opencode-tests',
agent: 'opencode',
model: 'anthropic/claude-sonnet-4-5',
prompt: 'Add integration coverage for the dashboard loading states',
title: 'OpenCode',
statusLines: [
'Mapping dashboard fixtures to integration coverage',
'Adding cases for empty, loading, and error transitions',
'Verifying assertions around skeleton and retry states',
],
},
{
name: 'demo-codex-validation',
branchName: 'demo-codex-validation',
agent: 'codex',
model: 'gpt-5.4',
prompt: 'Tighten signup validation and preserve inline form errors',
title: 'Codex',
statusLines: [
'Tracing signup validation from schema to submit handler',
'Checking edge cases for password and email normalization',
'Preparing updates to preserve inline form error rendering',
],
},
] as const satisfies readonly DemoSessionDefinition[];

interface DemoSessionDefinition {
name: string;
branchName: string;
agent: AgentType;
model: string;
prompt: string;
title: string;
statusLines: readonly string[];
}

async function cleanup(): Promise<void> {
const sessions = await provider.list();
const demoSessions = sessions.filter((session) =>
SESSIONS.some((demo) => demo.name === session.name),
);

await Promise.all(
demoSessions.map(async (session) => {
try {
await provider.remove(session.id);
} catch (error) {
log.debug({ error, session: session.name }, 'Failed to remove session');
}
}),
);

await rm(resolve(repoRoot, '.ox', 'overlayMounts'), {
recursive: true,
force: true,
}).catch(() => {});
}

function buildInitScript(session: DemoSessionDefinition): string {
const promptJson = JSON.stringify(session.prompt);
const titleJson = JSON.stringify(session.title);
const branchJson = JSON.stringify(session.branchName);
const agentJson = JSON.stringify(session.agent);
const lineBlock = session.statusLines
.map((line) => JSON.stringify(line))
.join(' ');

return `
mkdir -p /tmp/ox-demo-bin
cat > /tmp/ox-demo-bin/${session.agent} <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
prompt=${promptJson}
title=${titleJson}
branch=${branchJson}
agent=${agentJson}
status_lines=(${lineBlock})
if [ "$#" -gt 0 ]; then
last_arg="\${!#}"
if [ -n "$last_arg" ] && [ "$last_arg" != "--dangerously-skip-permissions" ] && [ "$last_arg" != "--dangerously-bypass-approvals-and-sandbox" ]; then
prompt="$last_arg"
fi
fi
frames=('⠋' '⠙' '⠹' '⠸' '⠼' '⠴' '⠦' '⠧' '⠇' '⠏')
i=0
while true; do
frame="\${frames[$((i % \${#frames[@]}))]}"
printf '\\033[2J\\033[H'
printf 'ox interactive session attached to %s\\n\\n' "$branch"
printf '%s %s\\n' "$frame" "$title"
printf 'Agent: %s\\n' "$agent"
printf 'Mode: interactive\\n'
printf 'Branch: %s\\n' "$branch"
printf 'Prompt: %s\\n\\n' "$prompt"
printf 'Live activity\\n'
for line in "\${status_lines[@]}"; do
printf ' - %s\\n' "$line"
done
printf '\\nDetach with ctrl+\\\\ and reattach with ox session attach %s\\n' "$branch"
sleep 0.8
i=$((i + 1))
done
EOF
chmod +x /tmp/ox-demo-bin/${session.agent}
export PATH="/tmp/ox-demo-bin:$PATH"
`;
}

async function createSessions(): Promise<OxSession[]> {
await provider.ensureReady();
const repoInfo = await getRepoInfo();

const created: OxSession[] = [];
for (const session of SESSIONS) {
const dockerImage = await provider.ensureImage({ agent: session.agent });
const containerName = await startContainer({
branchName: session.branchName,
prompt: session.prompt,
repoInfo,
agent: session.agent,
model: session.model,
interactive: true,
mountDir: repoRoot,
isGitRepo: false,
agentMode: 'interactive',
initScript: buildInitScript(session),
dockerImage,
});
const createdSession = await getSession(containerName);
if (!createdSession) {
throw new Error(`Failed to load created session: ${session.name}`);
}
created.push(createdSession);
}

return created;
}

if (process.argv.includes('--cleanup')) {
await cleanup();
process.exit(0);
}

await cleanup();
const sessions = await createSessions();
console.log(`Seeded ${sessions.length} interactive demo sessions.`);
process.exit(0);
29 changes: 24 additions & 5 deletions src/services/docker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1229,6 +1229,12 @@ export interface StartContainerOptions {
agentMode?: AgentMode;
/** Pre-resolved Docker image to use (e.g., agent overlay image). If not set, uses the default resolved image. */
dockerImage?: string;
/** Extra initialization commands to run in the container before the agent starts. */
initScript?: string;
/** Extra root-level initialization commands to run before the container starts. */
rootInitScript?: string;
/** Extra overlay mount paths to isolate inside the mounted worktree. */
overlayMounts?: string[];
}

// ============================================================================
Expand Down Expand Up @@ -1976,6 +1982,9 @@ export async function startContainer(
agentArgs,
agentMode,
dockerImage,
initScript,
rootInitScript,
overlayMounts,
} = options;

const oxEnvPath = '.ox/.env';
Expand Down Expand Up @@ -2054,9 +2063,13 @@ export async function startContainer(

// Add overlay bind mounts for paths that need container isolation
// These must come after the bind mount so they overlay on top
const effectiveOverlayMounts = [
...(config.overlayMounts ?? []),
...(overlayMounts ?? []),
];
const overlayVolumes = await createOverlayDirs(
containerName,
config.overlayMounts,
effectiveOverlayMounts,
);
volumes.push(...overlayVolumes);
}
Expand Down Expand Up @@ -2085,6 +2098,10 @@ Unless otherwise instructed above, use the \`gh\` command to create a PR when do
? prompt
: null;

const effectiveInitScript = [config.initScript, initScript]
.filter((value) => value && value.trim().length > 0)
.join('\n');

// Different startup script based on mount mode and git repo status
let startupScript: string;
if (absoluteMountDir) {
Expand All @@ -2099,15 +2116,15 @@ current_branch=$(git rev-parse --abbrev-ref HEAD)
if [ "$current_branch" = "main" ] || [ "$current_branch" = "master" ]; then
git switch -c "ox/${branchName}"
fi
${config.initScript || ''}
${effectiveInitScript}
${escapePrompt(agentCommand, agent, fullPrompt, interactive)}
`.trim();
} else {
// Mount mode outside a git repo - skip all git/gh operations
startupScript = `
set -e
cd /work/app
${config.initScript || ''}
${effectiveInitScript}
${escapePrompt(agentCommand, agent, fullPrompt, interactive)}
`.trim();
}
Expand All @@ -2123,7 +2140,7 @@ gh auth setup-git
gh repo clone ${repoInfo.fullName} app
cd app
git switch -c "ox/${branchName}"
${config.initScript || ''}
${effectiveInitScript}
${escapePrompt(agentCommand, agent, fullPrompt, interactive)}
`.trim();
}
Expand Down Expand Up @@ -2163,7 +2180,9 @@ ${escapePrompt(agentCommand, agent, fullPrompt, interactive)}
files,
labels: oxLabels,
privileged: config.privileged,
rootExecBeforeStart: config.rootInitScript,
rootExecBeforeStart: [config.rootInitScript, rootInitScript]
.filter((value) => value && value.trim().length > 0)
.join('\n'),
});
await result.exited;
return containerName;
Expand Down
3 changes: 3 additions & 0 deletions src/services/sandbox/dockerProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,9 @@ export class DockerSandboxProvider implements SandboxProvider {
agentArgs: options.agentArgs,
agentMode: options.agentMode,
dockerImage: agentImage,
initScript: options.initScript,
rootInitScript: options.rootInitScript,
overlayMounts: options.overlayMounts,
});

// Fetch the full session info for the container
Expand Down
Loading