Skip to content

Commit ba235a7

Browse files
authored
Merge pull request #1 from bgrgicak/add-opencode-agent
Add opencode as a second agent
2 parents 6c7810b + 580749b commit ba235a7

18 files changed

Lines changed: 377 additions & 17 deletions

File tree

.github/workflows/ci.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ jobs:
2828
run: npm test
2929
- name: E2E tests (sessions)
3030
run: npx tsx --test 'packages/cli/test/e2e/sessions.e2e.ts'
31+
- name: Install opencode
32+
run: npm install -g opencode-ai
33+
- name: E2E tests (opencode)
34+
run: npx tsx --test 'packages/cli/test/e2e/opencode.e2e.ts'
3135

3236
e2e-docker:
3337
runs-on: ubuntu-latest

README.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# flout
22

3-
Manage persistent AI agent sessions with tmux. Start, stop, join, and restart long-running Claude Code sessions from the command line — locally or in container sandboxes (Docker, Podman, or containerd via Colima).
3+
Manage persistent AI agent sessions with tmux. Start, stop, join, and restart long-running coding-agent sessions (Claude Code or opencode) from the command line — locally or in container sandboxes (Docker, Podman, or containerd via Colima).
44

55
## Install
66

@@ -35,6 +35,17 @@ flout join myproject # reattach after disconnecting
3535
flout stop myproject # tear it down
3636
```
3737

38+
### Choosing an agent
39+
40+
flout defaults to Claude Code. Pass `--agent opencode` (or set `FLOUT_AGENT=opencode`) to use [opencode](https://opencode.ai) instead. opencode does not have a remote-control mode, so `flout remote` and `flout restart` only work with Claude.
41+
42+
```bash
43+
flout setup --agent opencode # one-time setup for opencode
44+
flout start myproject --agent opencode # start an opencode session
45+
```
46+
47+
opencode ships with built-in models that work without an API key, so `flout status --agent opencode` is happy as soon as the `opencode` binary is on your `PATH`. Run `opencode auth login` only if you want to register additional providers.
48+
3849
### Remote sessions
3950

4051
Remote sessions auto-restart if the agent exits, so they stay running unattended.

package-lock.json

Lines changed: 13 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/cli/AGENTS.md

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ packages/
1515
claude/ @flout/claude — Claude Code provider
1616
src/index.ts Claude-specific commands, auth, and trust checking
1717
18+
opencode/ @flout/opencode — opencode provider (no remote-control mode)
19+
src/index.ts opencode-specific commands and auth checking
20+
1821
sandbox/ @flout/sandbox — Multi-engine container sandbox
1922
src/engine.ts Engine detection (Docker, Podman, nerdctl) and abstraction
2023
src/colima.ts Colima VM lifecycle management
@@ -35,22 +38,24 @@ interface Agent {
3538
name: string;
3639
binary: string;
3740
installHint?: string;
38-
remoteCommand(sessionName: string): string;
41+
remoteCommand?(sessionName: string): string; // optional — omit if no remote-control mode
3942
startCommand(): string;
4043
loginCommand(): string;
41-
setupTokenCommand(): string;
44+
setupTokenCommand?(): string; // optional — omit if login is sufficient
4245
isAuthenticated(): boolean;
4346
isTrusted(dir: string): boolean;
4447
trustCommand(): string;
4548
encodePath(dir: string): string;
4649
}
4750
```
4851

52+
`remoteCommand` and `setupTokenCommand` are optional. When `remoteCommand` is missing, `flout remote` and `flout restart` exit with an error for that agent.
53+
4954
## Adding a new agent
5055

5156
1. Create a new package `packages/<name>/` with `src/index.ts` exporting an object satisfying `Agent`
52-
2. Add it as a workspace dependency in `packages/cli/package.json`
53-
3. Wire it up in `packages/cli/src/cli.ts`
57+
2. Add it as a workspace dependency in `packages/cli/package.json` and a tsconfig reference in `packages/cli/tsconfig.json` and the root `tsconfig.json`
58+
3. Register it in the `AGENTS` map in `packages/cli/src/cli.ts` so it can be selected via `--agent <name>` (or `FLOUT_AGENT=<name>`)
5459

5560
## Container sandbox
5661

@@ -76,6 +81,16 @@ npm test # runs tests across all workspaces
7681

7782
Tests use `node:test` and `node:assert`. Integration tests use a real bash-based agent and real tmux sessions — no mocks.
7883

84+
### E2E tests
85+
86+
E2E suites live in `packages/cli/test/e2e/` and run real flout commands end-to-end:
87+
88+
- `sessions.e2e.ts` — exercises the Claude path. Uses `@flout/claude-mock-api` (HTTP mock) and a fake `claude` binary that just sleeps, so the suite needs no external services.
89+
- `opencode.e2e.ts` — exercises the opencode path. Runs the **real** `opencode` binary (no mock), since opencode ships with built-in models that work without any external API key. The suite skips itself when `opencode` is not in `PATH`, so local devs don't have to install it.
90+
- `sandbox.e2e.ts` / `sandbox-engines.e2e.ts` / `docker.e2e.ts` — container-engine integration.
91+
92+
Run a single suite with `npx tsx --test packages/cli/test/e2e/<file>.e2e.ts`. CI installs opencode via `npm i -g opencode-ai` and runs the opencode suite as part of the standard build-lint-test job.
93+
7994
## Constraints
8095

8196
- Minimal dependencies — only TypeScript and @types/node as dev deps

packages/cli/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
"dependencies": {
3939
"@flout/claude": "*",
4040
"@flout/docker": "*",
41+
"@flout/opencode": "*",
4142
"@flout/sandbox": "*"
4243
},
4344
"engines": {

packages/cli/src/cli.ts

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,36 @@
11
import path from 'path';
22
import claude from '@flout/claude';
3+
import opencode from '@flout/opencode';
34
import * as docker from '@flout/docker';
45
import * as sandbox from '@flout/sandbox';
56
import { normalizeEngine } from '@flout/sandbox';
67
import * as sessions from './sessions.js';
78
import { setup, trust } from './setup.js';
89
import type { Agent } from './types.js';
910

10-
const agent: Agent = claude;
11+
const AGENTS: Record<string, Agent> = { claude, opencode };
12+
1113
const args = process.argv.slice(2);
12-
const command = args[0];
1314

1415
function getFlag(flag: string): string | null {
1516
const idx = args.indexOf(flag);
1617
if (idx === -1 || idx + 1 >= args.length) return null;
1718
return args[idx + 1];
1819
}
1920

21+
function resolveAgent(): Agent {
22+
const name = getFlag('--agent') || process.env.FLOUT_AGENT || 'claude';
23+
const a = AGENTS[name];
24+
if (!a) {
25+
console.error(`Unknown agent '${name}'. Available: ${Object.keys(AGENTS).join(', ')}`);
26+
process.exit(1);
27+
}
28+
return a;
29+
}
30+
31+
const agent: Agent = resolveAgent();
32+
const command = args[0];
33+
2034
function getPassthroughArgs(): string[] {
2135
const idx = args.indexOf('--');
2236
if (idx === -1) return [];
@@ -44,8 +58,12 @@ Usage:
4458
flout trust <dir> Trust a project directory
4559
flout sandbox <cmd> [<name|id>] Manage container sandboxes (start|stop|shell|claude|status)
4660
61+
Global flags:
62+
--agent <claude|opencode> Pick the agent (default: claude, or $FLOUT_AGENT)
63+
4764
Session names are optional for start/remote (defaults to directory basename).
48-
When multiple sessions share a name, use the full ID shown by flout list.`);
65+
When multiple sessions share a name, use the full ID shown by flout list.
66+
Note: 'remote' and 'restart' require an agent that supports remote-control mode.`);
4967
}
5068

5169
switch (command) {

packages/cli/src/sessions.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,10 @@ export function start(label: string, dir: string, agent: Agent): string {
7373
}
7474

7575
export function remote(label: string, dir: string, agent: Agent): string {
76+
if (!agent.remoteCommand) {
77+
console.error(`Agent '${agent.name}' does not support remote-control sessions.`);
78+
process.exit(1);
79+
}
7680
const resolved = path.resolve(dir);
7781
if (!fs.existsSync(resolved)) {
7882
console.error(`Error: directory '${resolved}' does not exist`);
@@ -130,6 +134,10 @@ export function list(): void {
130134
}
131135

132136
export function restart(query: string, dir: string, agent: Agent): string {
137+
if (!agent.remoteCommand) {
138+
console.error(`Agent '${agent.name}' does not support remote-control sessions.`);
139+
process.exit(1);
140+
}
133141
const session = resolveSession(query);
134142
spawnSync('tmux', ['kill-session', '-t', session]);
135143
const label = session.replace(/^flout-\d{4}-\d{6}-/, '');
@@ -138,8 +146,10 @@ export function restart(query: string, dir: string, agent: Agent): string {
138146

139147
export function status(agent: Agent): void {
140148
if (!agent.isAuthenticated()) {
141-
console.log('Not logged in. Run: claude');
142-
console.log('Go through the setup process, then run: flout remote claude');
149+
console.log(`Not logged in. Run: ${agent.loginCommand()}`);
150+
if (agent.remoteCommand) {
151+
console.log(`Go through the setup process, then run: flout remote ${agent.name}`);
152+
}
143153
return;
144154
}
145155
list();

packages/cli/src/setup.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -59,11 +59,13 @@ export function setup(agent: Agent): void {
5959
process.exit(1);
6060
}
6161

62-
console.log('\nSetting up long-lived token...');
63-
const token = spawnSync(agent.setupTokenCommand(), { stdio: 'inherit', shell: true });
64-
if (token.status !== 0) {
65-
console.error('Token setup failed.');
66-
process.exit(1);
62+
if (agent.setupTokenCommand) {
63+
console.log('\nSetting up long-lived token...');
64+
const token = spawnSync(agent.setupTokenCommand(), { stdio: 'inherit', shell: true });
65+
if (token.status !== 0) {
66+
console.error('Token setup failed.');
67+
process.exit(1);
68+
}
6769
}
6870

6971
console.log('\nSetup complete.');

packages/cli/src/types.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@ export interface Agent {
22
name: string;
33
binary: string;
44
installHint?: string;
5-
remoteCommand(sessionName: string): string;
5+
/** Long-running remote-control mode. Omit if the agent has no equivalent. */
6+
remoteCommand?(sessionName: string): string;
67
startCommand(): string;
78
loginCommand(): string;
8-
setupTokenCommand(): string;
9+
/** Optional long-lived token setup step run after login. */
10+
setupTokenCommand?(): string;
911
isAuthenticated(): boolean;
1012
isTrusted(dir: string): boolean;
1113
trustCommand(): string;

packages/cli/test/cli.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,4 +89,28 @@ describe('CLI', () => {
8989
assert.ok(stdout.includes('start [name]'));
9090
assert.ok(stdout.includes('remote [name]'));
9191
});
92+
93+
it('lists --agent flag in help', () => {
94+
const { stdout } = run('--help');
95+
assert.ok(stdout.includes('--agent'));
96+
assert.ok(stdout.includes('opencode'));
97+
});
98+
99+
it('exits 1 on unknown --agent value', () => {
100+
const { stderr, exitCode } = run('status', '--agent', 'nonexistent');
101+
assert.strictEqual(exitCode, 1);
102+
assert.ok(stderr.includes('Unknown agent'));
103+
});
104+
105+
it('errors on remote when agent does not support it (opencode)', () => {
106+
const { stderr, exitCode } = run('remote', 'demo', '--agent', 'opencode');
107+
assert.strictEqual(exitCode, 1);
108+
assert.ok(stderr.includes('does not support remote-control'));
109+
});
110+
111+
it('errors on restart when agent does not support remote-control (opencode)', () => {
112+
const { stderr, exitCode } = run('restart', 'anything', '--agent', 'opencode');
113+
assert.strictEqual(exitCode, 1);
114+
assert.ok(stderr.includes('does not support remote-control'));
115+
});
92116
});

0 commit comments

Comments
 (0)