Skip to content

Commit 92f6e1c

Browse files
authored
Merge pull request #1750 from Yeachan-Heo/fix/issue-1749-cmux-team-mode
fix(team): support cmux alongside tmux for team mode
2 parents fb577ff + 3fffdfb commit 92f6e1c

File tree

4 files changed

+86
-12
lines changed

4 files changed

+86
-12
lines changed

docs/REFERENCE.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,11 @@ omc team api claim-task --input '{"team_name":"auth-review","task_id":"1","worke
245245

246246
Supported entrypoints: direct start (`omc team [N:agent] "<task>"`), `status`, `shutdown`, and `api`.
247247

248+
Topology behavior:
249+
- inside classic tmux (`$TMUX` set): reuse the current tmux surface for split-pane or `--new-window` layouts
250+
- inside cmux (`CMUX_SURFACE_ID` without `$TMUX`): launch a detached tmux session for team workers
251+
- plain terminal: launch a detached tmux session for team workers
252+
248253
### `omc session search`
249254

250255
```bash

skills/omc-teams/SKILL.md

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ Spawn N CLI worker processes in tmux panes to execute tasks in parallel. Support
3636
## Requirements
3737

3838
- **tmux binary** must be installed and discoverable (`command -v tmux`)
39-
- **Active tmux session** required to launch worker panes (`$TMUX` set, or start/attach tmux first)
39+
- **Classic tmux session optional** for in-place pane splitting (`$TMUX` set). Inside cmux or a plain terminal, `omc team` falls back to a detached tmux session instead of splitting the current surface.
4040
- **claude** CLI: `npm install -g @anthropic-ai/claude-code`
4141
- **codex** CLI: `npm install -g @openai/codex`
4242
- **gemini** CLI: `npm install -g @google/gemini-cli`
@@ -52,8 +52,10 @@ command -v tmux >/dev/null 2>&1
5252
```
5353

5454
- If this fails, report that **tmux is not installed** and stop.
55-
- If `tmux` exists but `$TMUX` is empty, report that the user is **not currently inside an active tmux session**. Do **not** say tmux is missing; tell them to start or attach tmux, then rerun.
56-
- If you need to confirm the active session, use:
55+
- If `$TMUX` is set, `omc team` can reuse the current tmux window/panes directly.
56+
- If `$TMUX` is empty but `CMUX_SURFACE_ID` is set, report that the user is running inside **cmux**. Do **not** say tmux is missing or that they are "not inside tmux"; `omc team` will launch a **detached tmux session** for workers instead of splitting the cmux surface.
57+
- If neither `$TMUX` nor `CMUX_SURFACE_ID` is set, report that the user is in a **plain terminal**. `omc team` can still launch a **detached tmux session**, but if they specifically want in-place pane/window topology they should start from a classic tmux session first.
58+
- If you need to confirm the active tmux session, use:
5759

5860
```bash
5961
tmux display-message -p '#S'
@@ -143,7 +145,8 @@ If encountered, switch to `omc team ...` CLI commands.
143145

144146
| Error | Cause | Fix |
145147
| ---------------------------- | ----------------------------------- | ----------------------------------------------------------------------------------- |
146-
| `not inside tmux` | Shell not running inside tmux | Start tmux and rerun |
148+
| `not inside tmux` | Requested in-place pane topology from a non-tmux surface | Start tmux and rerun, or let `omc team` use its detached-session fallback |
149+
| `cmux surface detected` | Running inside cmux without `$TMUX` | Use the normal `omc team ...` flow; OMC will launch a detached tmux session |
147150
| `Unsupported agent type` | Requested agent is not claude/codex/gemini | Use `claude`, `codex`, or `gemini`; for native Claude Code agents use `/oh-my-claudecode:team` |
148151
| `codex: command not found` | Codex CLI not installed | `npm install -g @openai/codex` |
149152
| `gemini: command not found` | Gemini CLI not installed | `npm install -g @google/gemini-cli` |

src/team/__tests__/tmux-session.create-team.test.ts

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,34 @@ vi.mock('child_process', async (importOriginal) => {
8282
};
8383
});
8484

85-
import { createTeamSession } from '../tmux-session.js';
85+
import { createTeamSession, detectTeamMultiplexerContext } from '../tmux-session.js';
86+
87+
describe('detectTeamMultiplexerContext', () => {
88+
afterEach(() => {
89+
vi.unstubAllEnvs();
90+
});
91+
92+
it('returns tmux when TMUX is present', () => {
93+
vi.stubEnv('TMUX', '/tmp/tmux-1000/default,1,1');
94+
vi.stubEnv('CMUX_SURFACE_ID', 'cmux-surface');
95+
96+
expect(detectTeamMultiplexerContext()).toBe('tmux');
97+
});
98+
99+
it('returns cmux when CMUX_SURFACE_ID is present without TMUX', () => {
100+
vi.stubEnv('TMUX', '');
101+
vi.stubEnv('CMUX_SURFACE_ID', 'cmux-surface');
102+
103+
expect(detectTeamMultiplexerContext()).toBe('cmux');
104+
});
105+
106+
it('returns none when neither tmux nor cmux markers are present', () => {
107+
vi.stubEnv('TMUX', '');
108+
vi.stubEnv('CMUX_SURFACE_ID', '');
109+
110+
expect(detectTeamMultiplexerContext()).toBe('none');
111+
});
112+
});
86113

87114
describe('createTeamSession context resolution', () => {
88115
beforeEach(() => {
@@ -98,6 +125,7 @@ describe('createTeamSession context resolution', () => {
98125
it('creates a detached session when running outside tmux', async () => {
99126
vi.stubEnv('TMUX', '');
100127
vi.stubEnv('TMUX_PANE', '');
128+
vi.stubEnv('CMUX_SURFACE_ID', '');
101129

102130
const session = await createTeamSession('race-team', 0, '/tmp');
103131

@@ -111,6 +139,27 @@ describe('createTeamSession context resolution', () => {
111139
expect(session.sessionMode).toBe('detached-session');
112140
});
113141

142+
it('uses a detached tmux session when running inside cmux', async () => {
143+
vi.stubEnv('TMUX', '');
144+
vi.stubEnv('TMUX_PANE', '');
145+
vi.stubEnv('CMUX_SURFACE_ID', 'cmux-surface');
146+
147+
const session = await createTeamSession('race-team', 1, '/tmp', { newWindow: true });
148+
149+
expect(mockedCalls.execFileArgs.some((args) => args[0] === 'new-window')).toBe(false);
150+
const detachedCreateCall = mockedCalls.execFileArgs.find((args) =>
151+
args[0] === 'new-session' && args.includes('-d') && args.includes('-P'),
152+
);
153+
expect(detachedCreateCall).toBeDefined();
154+
155+
const firstSplitCall = mockedCalls.execFileArgs.find((args) => args[0] === 'split-window');
156+
expect(firstSplitCall).toEqual(expect.arrayContaining(['split-window', '-h', '-t', '%91']));
157+
expect(session.leaderPaneId).toBe('%91');
158+
expect(session.sessionName).toBe('omc-team-race-team-detached:0');
159+
expect(session.workerPaneIds).toEqual(['%501']);
160+
expect(session.sessionMode).toBe('detached-session');
161+
});
162+
114163
it('anchors context to TMUX_PANE to avoid focus races', async () => {
115164
vi.stubEnv('TMUX', '/tmp/tmux-1000/default,1,1');
116165
vi.stubEnv('TMUX_PANE', '%732');

src/team/tmux-session.ts

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,16 @@ const TMUX_SESSION_PREFIX = 'omc-team';
2121
const promisifiedExec = promisify(exec);
2222
const promisifiedExecFile = promisify(execFile);
2323

24+
export type TeamMultiplexerContext = 'tmux' | 'cmux' | 'none';
25+
26+
export function detectTeamMultiplexerContext(
27+
env: NodeJS.ProcessEnv = process.env,
28+
): TeamMultiplexerContext {
29+
if (env.TMUX) return 'tmux';
30+
if (env.CMUX_SURFACE_ID) return 'cmux';
31+
return 'none';
32+
}
33+
2434
/**
2535
* True when running on Windows under MSYS2/Git Bash.
2636
* Tmux panes run bash in this environment, not cmd.exe.
@@ -409,11 +419,15 @@ export function spawnBridgeInSession(
409419
/**
410420
* Create a tmux team topology for a team leader/worker layout.
411421
*
412-
* Must be run inside an existing tmux session ($TMUX must be set).
413-
* By default, creates splits in the CURRENT window so panes appear immediately
414-
* in the user's view. When options.newWindow is true, creates a detached
415-
* dedicated tmux window first and then splits worker panes there.
416-
* Returns sessionName in "session:window" form.
422+
* When running inside a classic tmux session, creates splits in the CURRENT
423+
* window so panes appear immediately in the user's view. When options.newWindow
424+
* is true, creates a detached dedicated tmux window first and then splits worker
425+
* panes there.
426+
*
427+
* When running inside cmux (CMUX_SURFACE_ID without TMUX) or a plain terminal,
428+
* falls back to a detached tmux session because the current surface cannot be
429+
* targeted as a normal tmux pane/window. Returns sessionName in "session:window"
430+
* form.
417431
*
418432
* Layout: leader pane on the left, worker panes stacked vertically on the right.
419433
* IMPORTANT: Uses pane IDs (%N format) not pane indices for stable targeting.
@@ -428,7 +442,8 @@ export async function createTeamSession(
428442
const { promisify } = await import('util');
429443
const execFileAsync = promisify(execFile);
430444

431-
const inTmux = Boolean(process.env.TMUX);
445+
const multiplexerContext = detectTeamMultiplexerContext();
446+
const inTmux = multiplexerContext === 'tmux';
432447
const useDedicatedWindow = Boolean(options.newWindow && inTmux);
433448

434449
// Prefer the invoking pane from environment to avoid focus races when users
@@ -441,7 +456,9 @@ export async function createTeamSession(
441456

442457
if (!inTmux) {
443458
// Backward-compatible fallback: create an isolated detached tmux session
444-
// so workflows can run when launched outside an attached tmux client.
459+
// so workflows can run when launched outside an attached tmux client. This
460+
// also covers cmux, which exposes its own surface metadata without a tmux
461+
// pane/window that OMC can split directly.
445462
const detachedSessionName = `${TMUX_SESSION_PREFIX}-${sanitizeName(teamName)}-${Date.now().toString(36)}`;
446463
const detachedResult = await execFileAsync('tmux', [
447464
'new-session', '-d', '-P', '-F', '#S:0 #{pane_id}',

0 commit comments

Comments
 (0)