Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
edd86ec
feat(terminal-multiplexer): add interface and adapters
mrdavidlaing Jan 28, 2026
6f15a4e
feat(terminal-multiplexer): integrate with existing codebase
mrdavidlaing Jan 28, 2026
3bc9d21
feat(terminal-multiplexer): complete integration and documentation
mrdavidlaing Jan 28, 2026
e105046
fix(terminal-multiplexer): improve zellij detection and stacking
mrdavidlaing Jan 28, 2026
9f6bd6a
fix(zellij-adapter): implement pane auto-close matching tmux behavior
mrdavidlaing Jan 28, 2026
bb194ec
fix(zellij-adapter): add retry loop to fix pane ID race condition
mrdavidlaing Jan 28, 2026
abd25a1
fix(tests): fix test isolation issues in terminal-multiplexer tests
mrdavidlaing Jan 28, 2026
29c92d0
feat(zellij): add state persistence layer for anchor pane tracking
mrdavidlaing Jan 29, 2026
72345c1
feat(tmux-manager): thread OpenCode session context to zellij adapter
mrdavidlaing Jan 29, 2026
71e429c
feat(zellij): add session context and anchor validation to ZellijAdapter
mrdavidlaing Jan 29, 2026
bf711ef
feat(zellij): clean up state on session deletion
mrdavidlaing Jan 29, 2026
aeb0313
feat(zellij): wire session context integration
mrdavidlaing Jan 29, 2026
b0ddfdc
test(zellij): add edge case tests for state persistence
mrdavidlaing Jan 29, 2026
29765b7
fix(index): add null check for tmuxSessionManager cleanup
mrdavidlaing Jan 30, 2026
7d6400a
test: add cleanup hooks to prevent storage pollution
mrdavidlaing Jan 30, 2026
98019a7
refactor(zellij): implement dependency injection for storage
mrdavidlaing Jan 31, 2026
d20d4a0
fix(tmux): send Ctrl+C before respawn-pane to prevent race condition
mrdavidlaing Jan 31, 2026
56d9759
fix(zellij): synchronize concurrent pane stacking with Promise-based …
mrdavidlaing Jan 31, 2026
e41d9c9
fix(tmux-subagent): enforce MIN_AGE_MS before closing idle sessions
mrdavidlaing Jan 31, 2026
8e2bbe1
chore: post-rebase cleanup - regenerate schema/lockfile and fix syntax
mrdavidlaing Feb 4, 2026
eae7152
fix(tmux-subagent): prevent race condition causing duplicate pane spawns
mrdavidlaing Feb 4, 2026
e61e991
fix(index): remove duplicate tmuxSessionManager call from global sess…
mrdavidlaing Feb 4, 2026
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
90 changes: 44 additions & 46 deletions bun.lock

Large diffs are not rendered by default.

176 changes: 176 additions & 0 deletions src/config/schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -606,3 +606,179 @@ describe("OhMyOpenCodeConfigSchema - browser_automation_engine", () => {
expect(result.data?.browser_automation_engine).toBeUndefined()
})
})

describe("TerminalConfigSchema", () => {
test("accepts provider field with 'auto' value", () => {
// #given
const input = { provider: "auto" }

// #when
const result = OhMyOpenCodeConfigSchema.safeParse({ terminal: input })

// #then
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.terminal?.provider).toBe("auto")
}
})

test("accepts provider field with 'tmux' value", () => {
// #given
const input = { provider: "tmux" }

// #when
const result = OhMyOpenCodeConfigSchema.safeParse({ terminal: input })

// #then
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.terminal?.provider).toBe("tmux")
}
})

test("accepts provider field with 'zellij' value", () => {
// #given
const input = { provider: "zellij" }

// #when
const result = OhMyOpenCodeConfigSchema.safeParse({ terminal: input })

// #then
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.terminal?.provider).toBe("zellij")
}
})

test("defaults provider to 'auto' when not specified", () => {
// #given
const input = {}

// #when
const result = OhMyOpenCodeConfigSchema.parse({ terminal: input })

// #then
expect(result.terminal?.provider).toBe("auto")
})

test("accepts tmux config nested in terminal", () => {
// #given
const input = {
provider: "tmux",
tmux: {
enabled: true,
layout: "main-horizontal",
},
}

// #when
const result = OhMyOpenCodeConfigSchema.safeParse({ terminal: input })

// #then
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.terminal?.tmux?.enabled).toBe(true)
expect(result.data.terminal?.tmux?.layout).toBe("main-horizontal")
}
})

test("accepts zellij config nested in terminal", () => {
// #given
const input = {
provider: "zellij",
zellij: {
enabled: true,
session_prefix: "my-session",
},
}

// #when
const result = OhMyOpenCodeConfigSchema.safeParse({ terminal: input })

// #then
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.terminal?.zellij?.enabled).toBe(true)
expect(result.data.terminal?.zellij?.session_prefix).toBe("my-session")
}
})

test("rejects invalid provider value", () => {
// #given
const input = { provider: "invalid" }

// #when
const result = OhMyOpenCodeConfigSchema.safeParse({ terminal: input })

// #then
expect(result.success).toBe(false)
})
})

describe("OhMyOpenCodeConfigSchema - backward compatibility with tmux key", () => {
test("still accepts top-level tmux config key (backward compat)", () => {
// #given
const input = {
tmux: {
enabled: true,
layout: "main-vertical",
},
}

// #when
const result = OhMyOpenCodeConfigSchema.safeParse(input)

// #then
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.tmux?.enabled).toBe(true)
expect(result.data.tmux?.layout).toBe("main-vertical")
}
})

test("accepts both tmux and terminal keys together", () => {
// #given
const input = {
tmux: {
enabled: true,
},
terminal: {
provider: "zellij",
zellij: {
enabled: true,
},
},
}

// #when
const result = OhMyOpenCodeConfigSchema.safeParse(input)

// #then
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.tmux?.enabled).toBe(true)
expect(result.data.terminal?.provider).toBe("zellij")
expect(result.data.terminal?.zellij?.enabled).toBe(true)
}
})

test("accepts config with only tmux key (no terminal key)", () => {
// #given
const input = {
tmux: {
enabled: true,
session_prefix: "my-prefix",
},
}

// #when
const result = OhMyOpenCodeConfigSchema.safeParse(input)

// #then
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.tmux?.enabled).toBe(true)
expect(result.data.terminal).toBeUndefined()
}
})
})
14 changes: 14 additions & 0 deletions src/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,17 @@ export const TmuxConfigSchema = z.object({
agent_pane_min_width: z.number().min(20).default(40),
})

export const ZellijConfigSchema = z.object({
enabled: z.boolean().default(false),
session_prefix: z.string().optional(),
})

export const TerminalConfigSchema = z.object({
provider: z.enum(["auto", "tmux", "zellij"]).default("auto"),
tmux: TmuxConfigSchema.optional(),
zellij: ZellijConfigSchema.optional(),
})

export const SisyphusTasksConfigSchema = z.object({
/** Absolute or relative storage path override. When set, bypasses global config dir. */
storage_path: z.string().optional(),
Expand Down Expand Up @@ -408,6 +419,7 @@ export const OhMyOpenCodeConfigSchema = z.object({
browser_automation_engine: BrowserAutomationConfigSchema.optional(),
websearch: WebsearchConfigSchema.optional(),
tmux: TmuxConfigSchema.optional(),
terminal: TerminalConfigSchema.optional(),
sisyphus: SisyphusConfigSchema.optional(),
})

Expand Down Expand Up @@ -438,6 +450,8 @@ export type WebsearchProvider = z.infer<typeof WebsearchProviderSchema>
export type WebsearchConfig = z.infer<typeof WebsearchConfigSchema>
export type TmuxConfig = z.infer<typeof TmuxConfigSchema>
export type TmuxLayout = z.infer<typeof TmuxLayoutSchema>
export type ZellijConfig = z.infer<typeof ZellijConfigSchema>
export type TerminalConfig = z.infer<typeof TerminalConfigSchema>
export type SisyphusTasksConfig = z.infer<typeof SisyphusTasksConfigSchema>
export type SisyphusConfig = z.infer<typeof SisyphusConfigSchema>

Expand Down
38 changes: 37 additions & 1 deletion src/features/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ features/
├── hook-message-injector/ # Message injection
├── task-toast-manager/ # Background task notifications
├── skill-mcp-manager/ # MCP client lifecycle (617 lines)
├── tmux-subagent/ # Tmux session management
├── tmux-subagent/ # Terminal multiplexer session management (tmux/zellij)
├── mcp-oauth/ # MCP OAuth handling
├── sisyphus-swarm/ # Swarm coordination
├── sisyphus-tasks/ # Task tracking
Expand Down Expand Up @@ -56,9 +56,45 @@ features/
- **Transports**: stdio, http (SSE/Streamable)
- **Lifecycle**: 5m idle cleanup

## TMUX SESSION MANAGER

**Purpose**: Manages background agent sessions in terminal multiplexer panes.

### Architecture

- **Multiplexer Abstraction**: Uses `Multiplexer` interface (supports tmux and zellij)
- **Capability-Based Behavior**: Checks `adapter.capabilities.manualLayout` for layout strategy
- **Dual State Tracking**: Maintains both `TrackedSession` and `PaneHandle` maps

### Layout Strategies

| Capability | Strategy | Multiplexer |
|------------|----------|-------------|
| `manualLayout: true` | Decision engine with grid algorithm | tmux |
| `manualLayout: false` | Simple spawn, auto-layout | zellij |

### Usage

```typescript
// Create with detected multiplexer
const adapter = createMultiplexer(detectedType, config)
const manager = new TmuxSessionManager(ctx, adapter, tmuxConfig)

// Manager handles capability branching internally
manager.onSessionCreated(session) // Uses appropriate strategy
```

### Key Features

- **Auto-detection**: Detects tmux or zellij via environment variables
- **Graceful degradation**: Plugin works without multiplexer
- **Backward compatible**: Existing tmux functionality unchanged
- **Clean separation**: Capability-based branching is explicit

## ANTI-PATTERNS

- **Sequential delegation**: Use `delegate_task` parallel
- **Trust self-reports**: ALWAYS verify
- **Main thread blocks**: No heavy I/O in loader init
- **Direct state mutation**: Use managers for boulder/session state
- **Hardcoded multiplexer**: Use `terminal-multiplexer` abstraction
Loading