Skip to content
Open
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
46 changes: 1 addition & 45 deletions backend/internal/adapters/runtime/zellij/process_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,13 @@ package zellij

import (
"os/exec"
"strings"
"syscall"

"golang.org/x/sys/windows"
)

func startBackgroundProcess(env []string, name string, args ...string) error {
script := "Start-Process -FilePath " + psQuote(name) + " -ArgumentList " + psQuote(windowsCommandLine(args)) + " -WindowStyle Hidden"
cmd := exec.Command("powershell.exe", "-NoLogo", "-NoProfile", "-EncodedCommand", powerShellEncodedCommand(script))
cmd := exec.Command(name, args...)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now that the PowerShell Start-Process wrapper is gone, windowsCommandLine (line 27) and windowsQuoteArg (line 35) are no longer referenced by anything except each other — they're dead code. psQuote/powerShellEncodedCommand are still used by commands.go, but these two should be removed. The unused linter is enabled in .golangci.yml; it won't fire on Linux CI because of the //go:build windows tag, but it would fail a GOOS=windows lint. Non-blocking, but worth deleting in this PR.

cmd.Env = env
cmd.SysProcAttr = &syscall.SysProcAttr{
CreationFlags: windows.CREATE_NEW_CONSOLE,
Expand All @@ -24,45 +22,3 @@ func startBackgroundProcess(env []string, name string, args ...string) error {
go func() { _ = cmd.Wait() }()
return nil
}

func windowsCommandLine(args []string) string {
quoted := make([]string, len(args))
for i, arg := range args {
quoted[i] = windowsQuoteArg(arg)
}
return strings.Join(quoted, " ")
}

func windowsQuoteArg(arg string) string {
if arg == "" {
return `""`
}
if !strings.ContainsAny(arg, " \t\"") {
return arg
}

var b strings.Builder
b.WriteByte('"')
backslashes := 0
for _, r := range arg {
switch r {
case '\\':
backslashes++
case '"':
b.WriteString(strings.Repeat(`\`, backslashes*2+1))
b.WriteRune(r)
backslashes = 0
default:
if backslashes > 0 {
b.WriteString(strings.Repeat(`\`, backslashes))
backslashes = 0
}
b.WriteRune(r)
}
}
if backslashes > 0 {
b.WriteString(strings.Repeat(`\`, backslashes*2))
}
b.WriteByte('"')
return b.String()
}
12 changes: 7 additions & 5 deletions backend/internal/adapters/runtime/zellij/zellij.go
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,7 @@ func (r *Runtime) Create(ctx context.Context, cfg ports.RuntimeConfig) (ports.Ru
}()

if err := r.createSession(ctx, id, layoutPath, launchEnv); err != nil {
_ = r.Destroy(context.Background(), ports.RuntimeHandle{ID: id})
return ports.RuntimeHandle{}, fmt.Errorf("zellij runtime: create session %s: %w", id, err)
}
paneID, err := r.findAgentPane(ctx, id)
Expand All @@ -263,9 +264,10 @@ func (r *Runtime) Create(ctx context.Context, cfg ports.RuntimeConfig) (ports.Ru
return handle, nil
}

// createSession runs `zellij attach --create-background`. On Windows we spawn
// it via runner.Start (fire-and-forget) because the inherited daemon stdio
// confuses zellij's own readiness probe; on Unix we keep the synchronous run.
// createSession runs `zellij attach --create-background`. Windows cannot use
// CombinedOutput here: zellij may keep stdout/stderr open after creating the
// background session, so we start it detached and let pane polling observe
// readiness. Non-Windows keeps the synchronous command path.
func (r *Runtime) createSession(ctx context.Context, id, layoutPath string, env map[string]string) error {
args := createSessionArgs(id, layoutPath)
if runtime.GOOS != "windows" {
Expand Down Expand Up @@ -530,9 +532,9 @@ func (r *Runtime) run(ctx context.Context, args ...string) ([]byte, error) {
return out, nil
}

// startWithEnv fires zellij in the background with extra env vars merged onto
// startWithEnv starts zellij in the background with extra env vars merged onto
// the runtime's base env. Used by the Windows createSession path so the daemon
// is not blocked waiting on zellij's `--create-background` to settle.
// is not blocked waiting on zellij's `--create-background` stdio handles.
func (r *Runtime) startWithEnv(extra map[string]string, args ...string) error {
fullArgs := append(r.baseArgs(), args...)
if err := r.runner.Start(r.envWith(extra), r.binary, fullArgs...); err != nil {
Expand Down
12 changes: 6 additions & 6 deletions backend/internal/session_manager/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -229,19 +229,19 @@ func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Sess
// post-create commands (e.g. `pnpm install`) before the agent launches.
if err := m.provisionWorkspace(ctx, project, ws.Path); err != nil {
_ = m.workspace.Destroy(ctx, ws)
m.markSpawnFailedTerminated(ctx, id)
m.rollbackSpawnSeedRow(ctx, id)
return domain.SessionRecord{}, fmt.Errorf("spawn %s: provision: %w", id, err)
}

agent, ok := m.agents.Agent(cfg.Harness)
if !ok {
_ = m.workspace.Destroy(ctx, ws)
m.markSpawnFailedTerminated(ctx, id)
m.rollbackSpawnSeedRow(ctx, id)
return domain.SessionRecord{}, fmt.Errorf("spawn %s: no agent adapter for harness %q", id, cfg.Harness)
}
if err := m.prepareWorkspace(ctx, agent, id, ws.Path); err != nil {
_ = m.workspace.Destroy(ctx, ws)
m.markSpawnFailedTerminated(ctx, id)
m.rollbackSpawnSeedRow(ctx, id)
return domain.SessionRecord{}, fmt.Errorf("spawn %s: %w", id, err)
}
agentConfig := effectiveAgentConfig(cfg.Kind, project.Config)
Expand All @@ -256,7 +256,7 @@ func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Sess
})
if err != nil {
_ = m.workspace.Destroy(ctx, ws)
m.markSpawnFailedTerminated(ctx, id)
m.rollbackSpawnSeedRow(ctx, id)
return domain.SessionRecord{}, fmt.Errorf("spawn %s: launch command: %w", id, err)
}
// Pre-flight: confirm argv[0] actually exists on PATH (or as an absolute
Expand All @@ -265,7 +265,7 @@ func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Sess
// unresolved binary would leak through as a "live" session that never ran.
if err := m.validateAgentBinary(argv); err != nil {
_ = m.workspace.Destroy(ctx, ws)
m.markSpawnFailedTerminated(ctx, id)
m.rollbackSpawnSeedRow(ctx, id)
return domain.SessionRecord{}, fmt.Errorf("spawn %s: %w", id, err)
}
handle, err := m.runtime.Create(ctx, ports.RuntimeConfig{
Expand All @@ -276,7 +276,7 @@ func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Sess
})
if err != nil {
_ = m.workspace.Destroy(ctx, ws)
m.markSpawnFailedTerminated(ctx, id)
m.rollbackSpawnSeedRow(ctx, id)
return domain.SessionRecord{}, fmt.Errorf("spawn %s: runtime: %w", id, err)
}

Expand Down
23 changes: 19 additions & 4 deletions backend/internal/session_manager/manager_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -350,7 +350,7 @@ func TestSpawn_StampsUTCTimestamps(t *testing.T) {
}
}

func TestSpawn_RollsBackOnRuntimeFailure(t *testing.T) {
func TestSpawn_DeletesSeedRowOnRuntimeFailure(t *testing.T) {
m, st, _, ws := newManager()
m.runtime = &fakeRuntime{createErr: errors.New("boom")}
if _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer"}); err == nil {
Expand All @@ -359,8 +359,23 @@ func TestSpawn_RollsBackOnRuntimeFailure(t *testing.T) {
if ws.destroyed != 1 {
t.Fatal("workspace should roll back")
}
if rec, present := st.sessions["mer-1"]; present {
t.Fatalf("spawn row without runtime handle must be deleted, got %+v", rec)
}
}

func TestSpawn_ParksRowTerminatedWhenRuntimeFailureDeleteFails(t *testing.T) {
m, st, _, ws := newManager()
m.runtime = &fakeRuntime{createErr: errors.New("boom")}
st.deleteErr = errors.New("db locked")
if _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer"}); err == nil {
t.Fatal("expected failure")
}
if ws.destroyed != 1 {
t.Fatal("workspace should roll back")
}
if !st.sessions["mer-1"].IsTerminated {
t.Fatal("orphaned spawn should be terminated")
t.Fatal("row must fall back to terminated when delete fails")
}
}

Expand Down Expand Up @@ -855,8 +870,8 @@ func TestSpawn_RejectsMissingAgentBinary(t *testing.T) {
if ws.destroyed != 1 {
t.Fatal("workspace must be torn down when the pre-launch binary check fails")
}
if !st.sessions["mer-1"].IsTerminated {
t.Fatal("the orphan row should be marked terminated after the failed spawn")
if rec, present := st.sessions["mer-1"]; present {
t.Fatalf("spawn row without runtime handle must be deleted, got %+v", rec)
}
}

Expand Down
6 changes: 3 additions & 3 deletions frontend/src/landing/content/docs/faq.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@ import { Accordions, Accordion } from "fumadocs-ui/components/accordion";
</Accordion>

<Accordion title="Does it work on Windows?">
Partially — see [Platforms › Windows](/docs/platforms#windows). You need `runtime: process` instead of tmux, and
desktop notifications are a no-op. Everything else (spawning, PR flow, review loop, CI recovery) works the same.
Partially — see [Platforms › Windows](/docs/platforms#windows). The current Go rewrite requires `zellij` 0.44.3+ on
`PATH`; `runtime: process` is not currently wired. Desktop notifications are a no-op.
</Accordion>

<Accordion title="Where does session state live?">
Expand All @@ -41,7 +41,7 @@ import { Accordions, Accordion } from "fumadocs-ui/components/accordion";

<Accordion title="Can I run AO on a remote server?">
Yes. The dashboard is a plain Next.js app — port-forward or put it behind your reverse proxy. AO has no built-in auth,
so use your usual SSO / basic-auth layer. Use `runtime: process` in containers.
so use your usual SSO / basic-auth layer. Install `zellij` 0.44.3+ on `PATH` in containers or remote hosts.
</Accordion>

<Accordion title="How much does it cost?">
Expand Down
7 changes: 3 additions & 4 deletions frontend/src/landing/content/docs/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ AO is built from plugins. The default setup works out of the box for common GitH
| Plugin slot | What it controls | Examples |
| ----------- | --------------------------------- | ------------------------------------------- |
| Agent | Which coding tool writes changes | Claude Code, Codex, Cursor, Aider, OpenCode |
| Runtime | How the agent process runs | tmux, child process |
| Runtime | How the agent process runs | zellij |
| Workspace | Where code is checked out | git worktree, full clone |
| Tracker | Where issues come from | GitHub, GitLab, Linear |
| SCM | How PRs, reviews, and CI are read | GitHub, GitLab |
Expand All @@ -68,9 +68,8 @@ Most users start with the defaults and only edit `agent-orchestrator.yaml` when
windows="partial"
note={
<>
Windows support is actively improving. Use the process runtime instead of tmux by setting{" "}
<code>defaults.runtime: process</code> in <code>agent-orchestrator.yaml</code>. See{" "}
<a href="/docs/platforms">Platforms</a> for details.
Windows support is actively improving. The current Go rewrite requires <code>zellij</code> 0.44.3+ on{" "}
<code>PATH</code>. See <a href="/docs/platforms">Platforms</a> for details.
</>
}
/>
Expand Down
30 changes: 13 additions & 17 deletions frontend/src/landing/content/docs/installation.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -23,21 +23,21 @@ This page gets your machine ready to run Agent Orchestrator. By the end, the `ao
windows="partial"
note={
<>
Windows support is actively improving. Use <code>defaults.runtime: process</code> instead of tmux. See{" "}
<a href="/docs/platforms#windows">Platforms</a>.
Windows support is actively improving. The current Go rewrite requires <code>zellij</code> 0.44.3+ on{" "}
<code>PATH</code>. See <a href="/docs/platforms#windows">Platforms</a>.
</>
}
/>

Install these before running AO:

| Tool | Required | Why AO needs it |
| --------------- | ---------------------- | ------------------------------------------------------------------------------------ |
| Node.js 20+ | Yes | Runs the AO CLI, dashboard, and plugins. |
| Git | Yes | Creates worktrees, branches, commits, and cleanup operations. |
| GitHub CLI `gh` | For GitHub projects | Reads issues, PRs, reviews, and CI status as your user. |
| tmux | Default on macOS/Linux | Keeps long-running agent sessions attachable and recoverable. |
| An agent CLI | Yes | The worker that writes code, such as Claude Code, Codex, Cursor, Aider, or OpenCode. |
| Tool | Required | Why AO needs it |
| --------------- | ------------------- | ------------------------------------------------------------------------------------ |
| Node.js 20+ | Yes | Runs the AO CLI, dashboard, and plugins. |
| Git | Yes | Creates worktrees, branches, commits, and cleanup operations. |
| GitHub CLI `gh` | For GitHub projects | Reads issues, PRs, reviews, and CI status as your user. |
| Zellij 0.44.3+ | Yes | Current Go runtime for long-running agent sessions. Must be on `PATH`. |
| An agent CLI | Yes | The worker that writes code, such as Claude Code, Codex, Cursor, Aider, or OpenCode. |

If you plan to use GitLab or Linear, install and authenticate their CLIs or credentials as described on the related plugin pages. A GitHub-only setup only needs `gh`.

Expand Down Expand Up @@ -131,12 +131,7 @@ ao doctor --fix

## Windows Setup

Windows support is in progress. Use the process runtime instead of tmux:

```yaml title="agent-orchestrator.yaml"
defaults:
runtime: process
```
Windows support is in progress. The current Go rewrite requires `zellij` 0.44.3+ on `PATH`; `runtime: process` is not currently wired.

Use Slack, Discord, webhook, or another network notifier for alerts. Desktop notifications and iTerm2 integration are not available on Windows.

Expand All @@ -151,8 +146,9 @@ Use Slack, Discord, webhook, or another network notifier for alerts. Desktop not
Run `gh auth login`, then `gh auth status`. AO cannot read GitHub issues, PRs, reviews, or CI checks until `gh` is
authenticated.
</Accordion>
<Accordion title="tmux is missing">
Install tmux on macOS or Linux, or set `defaults.runtime: process` if you are on Windows or running in a container.
<Accordion title="zellij is missing">
Install `zellij` 0.44.3 or newer and make sure it is on `PATH`. The current Go rewrite does not wire `runtime:
process`.
</Accordion>
<Accordion title="No agent runtime is detected">
Install one supported agent CLI and run it once outside AO so it can complete its own sign-in flow.
Expand Down
Loading
Loading